├── .github ├── CODEOWNERS └── workflows │ └── deploy.yml ├── .gitignore ├── .idea └── checkstyle-idea.xml ├── LICENSE.md ├── README-en.md ├── README-ja.md ├── README-zhcn.md ├── README.md ├── build.gradle ├── deploy ├── api │ └── Dockerfile └── filebeat │ ├── Dockerfile │ └── filebeat.yml ├── docs ├── bbibbi.svg ├── cat.svg ├── cheese-cat-collaborator.svg ├── cheese-cat.svg ├── dessert-fox-collaborator.svg ├── dessert-fox-rudolph.svg ├── dessert-fox.svg ├── fishman-glasses.svg ├── fishman.svg ├── flamingo.svg ├── galchi-cat.svg ├── ghost-collaborator.svg ├── ghost-king.svg ├── ghost.svg ├── goblin-bag.svg ├── goblin.svg ├── goose-java.svg ├── goose-js.svg ├── goose-kotlin.svg ├── goose-linux.svg ├── goose-node.svg ├── goose-spring.svg ├── goose-sunglasses.svg ├── goose-swift.svg ├── goose.svg ├── hamster-collaborator.svg ├── hamster-java.svg ├── hamster-js.svg ├── hamster-kotlin.svg ├── hamster-santa.svg ├── hamster-spring.svg ├── hamster-tube.svg ├── hamster.svg ├── little-chick-java.svg ├── little-chick-js.svg ├── little-chick-kotlin.svg ├── little-chick-linux.svg ├── little-chick-node.svg ├── little-chick-santa.svg ├── little-chick-spring.svg ├── little-chick-sunglasses.svg ├── little-chick-swift.svg ├── little-chick-tube.svg ├── little-chick.svg ├── logo.svg ├── maltese-collaborator.svg ├── maltese-king.svg ├── maltese.svg ├── mole-grass.svg ├── mole.svg ├── penguin-java.svg ├── penguin-js.svg ├── penguin-kotlin.svg ├── penguin-linux.svg ├── penguin-node.svg ├── penguin-spring.svg ├── penguin-sunglasses.svg ├── penguin-swift.svg ├── penguin.svg ├── pets.svg ├── pig-collaborator.svg ├── pig-java.svg ├── pig-js.svg ├── pig-kotlin.svg ├── pig-linux.svg ├── pig-node.svg ├── pig-spring.svg ├── pig-sunglasses.svg ├── pig-swift.svg ├── pig.svg ├── quokka-leaf.svg ├── quokka-sunglasses.svg ├── quokka.svg ├── rabbit-brown-rudolph.svg ├── rabbit-collaborator.svg ├── rabbit-tube.svg ├── rabbit.svg ├── sample.svg ├── scream-ghost.svg ├── scream.svg ├── shiba-king.svg ├── shiba.svg ├── slime-blue.svg ├── slime-green.svg ├── slime-pumpkin-1.svg ├── slime-pumpkin-2.svg ├── slime-red-java.svg ├── slime-red-js.svg ├── slime-red-kotlin.svg ├── slime-red-linux.svg ├── slime-red-node.svg ├── slime-red-swift.svg ├── slime-red.svg ├── sloth-king.svg ├── sloth-sunglasses.svg ├── sloth.svg ├── snowman-melt.svg ├── snowman.svg ├── tenmm.svg ├── turtle.svg ├── unicorn.svg ├── white-cat-collaborator.svg └── white-cat.svg ├── gradle.properties ├── gradle ├── core.gradle ├── db.gradle ├── jetbrains.gradle ├── logging.gradle ├── monitor.gradle ├── slack.gradle ├── spring.gradle ├── test.gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle └── src ├── main ├── kotlin │ └── org │ │ └── gitanimals │ │ ├── Application.kt │ │ ├── core │ │ ├── AggregateRoot.kt │ │ ├── DomainEventPublisher.kt │ │ ├── ErrorResponse.kt │ │ ├── Exception.kt │ │ ├── FieldType.kt │ │ ├── GracefulShutdownDispatcher.kt │ │ ├── HttpClientErrorHandler.kt │ │ ├── IdGenerator.kt │ │ ├── JacksonConfig.kt │ │ ├── Mode.kt │ │ ├── OrchestratorExtension.kt │ │ ├── PersonaType.kt │ │ ├── Svgs.kt │ │ ├── UpdateUserOrchestrator.kt │ │ ├── advice │ │ │ └── GlobalExceptionHandler.kt │ │ ├── appender │ │ │ └── SlackAppender.kt │ │ ├── auth │ │ │ ├── InternalAuth.kt │ │ │ ├── InternalAuthClient.kt │ │ │ ├── InternalAuthRequestInterceptor.kt │ │ │ ├── RequiredUserEntryPointAspect.kt │ │ │ ├── RequiredUserEntryPoints.kt │ │ │ └── UserEntryPointValidationExtension.kt │ │ ├── clock.kt │ │ ├── extension │ │ │ ├── HttpResponseExtension.kt │ │ │ └── StringExtension.kt │ │ ├── filter │ │ │ ├── CorsFilter.kt │ │ │ └── MDCFilter.kt │ │ ├── interceptor │ │ │ ├── InterceptorConfigurer.kt │ │ │ └── InternalApiInterceptor.kt │ │ ├── lock │ │ │ ├── DistributedLock.kt │ │ │ ├── LockAcquireFailException.kt │ │ │ └── RedisDistributedLockService.kt │ │ └── redis │ │ │ ├── AsyncRedisPubSubEvent.kt │ │ │ ├── RedisConfiguration.kt │ │ │ ├── RedisPubSubChannel.kt │ │ │ ├── RedisPubSubEventListener.kt │ │ │ ├── TraceableMessageListener.kt │ │ │ ├── TransactionCommitRedisPubSubEvent.kt │ │ │ └── TransactionCommitRedisPubSubEventListener.kt │ │ ├── guild │ │ ├── app │ │ │ ├── AcceptJoinGuildFacade.kt │ │ │ ├── ChangeGuildFacade.kt │ │ │ ├── ChangeMainPersonaFacade.kt │ │ │ ├── CreateGuildFacade.kt │ │ │ ├── DenyJoinGuildFacade.kt │ │ │ ├── DrawGuildFacade.kt │ │ │ ├── GetJoinedGuildFacade.kt │ │ │ ├── IdentityApi.kt │ │ │ ├── JoinGuildFacade.kt │ │ │ ├── KickGuildFacade.kt │ │ │ ├── LeaveGuildFacade.kt │ │ │ ├── RenderApi.kt │ │ │ ├── SearchGuildFacade.kt │ │ │ ├── event │ │ │ │ └── InboxInputEvent.kt │ │ │ ├── request │ │ │ │ └── CreateGuildRequest.kt │ │ │ └── response │ │ │ │ ├── GuildBackgroundResponse.kt │ │ │ │ ├── GuildIconsResponse.kt │ │ │ │ ├── GuildPagingResponse.kt │ │ │ │ ├── GuildResponse.kt │ │ │ │ └── GuildsResponse.kt │ │ ├── controller │ │ │ ├── GuildController.kt │ │ │ └── request │ │ │ │ └── JoinGuildRequest.kt │ │ ├── domain │ │ │ ├── AbstractTime.kt │ │ │ ├── Guild.kt │ │ │ ├── GuildIcons.kt │ │ │ ├── GuildRepository.kt │ │ │ ├── GuildService.kt │ │ │ ├── Leader.kt │ │ │ ├── Member.kt │ │ │ ├── RandomGuildCache.kt │ │ │ ├── SearchFilter.kt │ │ │ ├── WaitMember.kt │ │ │ ├── event │ │ │ │ └── GuildContributionUpdated.kt │ │ │ ├── extension │ │ │ │ └── GuildFieldTypeExtension.kt │ │ │ └── request │ │ │ │ ├── ChangeGuildRequest.kt │ │ │ │ └── CreateLeaderRequest.kt │ │ ├── infra │ │ │ ├── GuildHttpClientConfigurer.kt │ │ │ ├── GuildRedisEventSubscriber.kt │ │ │ ├── GuildUpdateGuildContributionMessageListener.kt │ │ │ ├── HttpClientErrorHandler.kt │ │ │ ├── InMemoryRandomGuildCache.kt │ │ │ └── event │ │ │ │ └── UserContributionUpdated.kt │ │ └── saga │ │ │ ├── PersonaDeletedSagaHandler.kt │ │ │ └── event │ │ │ └── PersonaDeleted.kt │ │ ├── rank │ │ ├── app │ │ │ ├── GetRankByUsernameFacade.kt │ │ │ ├── GivePointToGuildFacade.kt │ │ │ ├── GivePointToUsersFacade.kt │ │ │ ├── GuildApi.kt │ │ │ ├── IdentityApi.kt │ │ │ ├── RankQueryFacade.kt │ │ │ └── RenderApi.kt │ │ ├── controller │ │ │ ├── RankController.kt │ │ │ └── response │ │ │ │ ├── RankHistoryResponse.kt │ │ │ │ ├── RankResponses.kt │ │ │ │ └── RankTotalCountResponse.kt │ │ ├── domain │ │ │ ├── AbstractTime.kt │ │ │ ├── GuildContributionRank.kt │ │ │ ├── GuildContributionRankRepository.kt │ │ │ ├── GuildContributionRankService.kt │ │ │ ├── Rank.kt │ │ │ ├── RankQueryRepository.kt │ │ │ ├── UserContributionRank.kt │ │ │ ├── UserContributionRankRepository.kt │ │ │ ├── UserContributionRankService.kt │ │ │ ├── event │ │ │ │ └── RankUpdated.kt │ │ │ ├── history │ │ │ │ ├── RankHistory.kt │ │ │ │ ├── RankHistoryRepository.kt │ │ │ │ ├── RankHistoryService.kt │ │ │ │ ├── Winner.kt │ │ │ │ └── request │ │ │ │ │ └── InitRankHistoryRequest.kt │ │ │ └── response │ │ │ │ └── RankResponse.kt │ │ └── infra │ │ │ ├── HttpClientErrorHandler.kt │ │ │ ├── RankCacheConfigurer.kt │ │ │ ├── RankHttpClientConfigurer.kt │ │ │ ├── RankRedisEventSubscriber.kt │ │ │ ├── RankUpdateGuildContributionMessageListener.kt │ │ │ ├── RedisRankQueryRepository.kt │ │ │ ├── UpdateUserContributionMessageListener.kt │ │ │ └── event │ │ │ ├── GuildContributionUpdated.kt │ │ │ └── UserContributionUpdated.kt │ │ ├── render │ │ ├── app │ │ │ ├── AnimationFacade.kt │ │ │ ├── ContributionApi.kt │ │ │ ├── GithubRestApi.kt │ │ │ ├── IdentityApi.kt │ │ │ ├── UserFacade.kt │ │ │ ├── UserStatisticSchedule.kt │ │ │ └── request │ │ │ │ └── MergePersonaRequest.kt │ │ ├── controller │ │ │ ├── AnimationController.kt │ │ │ ├── BackgroundController.kt │ │ │ ├── InternalAnimationController.kt │ │ │ ├── InternalPersonaController.kt │ │ │ ├── PersonaController.kt │ │ │ ├── PersonaStatisticController.kt │ │ │ ├── UserStatisticController.kt │ │ │ ├── request │ │ │ │ ├── AddMultiplyPersonaRequest.kt │ │ │ │ ├── AddPersonaRequest.kt │ │ │ │ ├── ChangeFieldRequest.kt │ │ │ │ └── UsernameAndPersonaIdRequest.kt │ │ │ └── response │ │ │ │ ├── BackgroundResponse.kt │ │ │ │ ├── PersonaEnumResponse.kt │ │ │ │ ├── PersonaResponse.kt │ │ │ │ ├── TotalPersonaResponse.kt │ │ │ │ ├── TotalUserResponse.kt │ │ │ │ └── UserResponse.kt │ │ ├── domain │ │ │ ├── AbstractTime.kt │ │ │ ├── EntryPoint.kt │ │ │ ├── Field.kt │ │ │ ├── Idempotency.kt │ │ │ ├── IdempotencyRepository.kt │ │ │ ├── Persona.kt │ │ │ ├── PersonaStatisticRepository.kt │ │ │ ├── PersonaStatisticService.kt │ │ │ ├── User.kt │ │ │ ├── UserAuthInfo.kt │ │ │ ├── UserRepository.kt │ │ │ ├── UserService.kt │ │ │ ├── UserStatisticRepository.kt │ │ │ ├── UserStatisticService.kt │ │ │ ├── event │ │ │ │ ├── NewUserCreated.kt │ │ │ │ ├── PersonaDeleted.kt │ │ │ │ ├── UserContributionUpdated.kt │ │ │ │ ├── UserYesterdayReport.kt │ │ │ │ ├── UsernameChanged.kt │ │ │ │ └── Visited.kt │ │ │ ├── extension │ │ │ │ └── RenderFieldTypeExtension.kt │ │ │ ├── request │ │ │ │ └── PersonaChangeRequest.kt │ │ │ ├── response │ │ │ │ ├── NewPetDropRateDistribution.kt │ │ │ │ └── PersonaResponse.kt │ │ │ └── value │ │ │ │ ├── Contribution.kt │ │ │ │ └── Level.kt │ │ ├── infra │ │ │ ├── CacheConfigurer.kt │ │ │ ├── CustomExecutorConfigurer.kt │ │ │ ├── GithubContributionApi.kt │ │ │ ├── HttpClientErrorHandler.kt │ │ │ ├── NewPetDropRateDistributionReport.kt │ │ │ ├── PersonaDeletedEventHandler.kt │ │ │ ├── RenderHttpClientConfigurer.kt │ │ │ ├── VisitedEventListener.kt │ │ │ └── event │ │ │ │ └── NewPetDropRateDistributionEvent.kt │ │ └── saga │ │ │ ├── UsedCouponSagaHandlers.kt │ │ │ └── event │ │ │ ├── CouponUsed.kt │ │ │ └── GavePoint.kt │ │ ├── star │ │ ├── controller │ │ │ └── StargazerController.kt │ │ ├── domain │ │ │ ├── Stargazer.kt │ │ │ ├── StargazerService.kt │ │ │ └── StargazersRepository.kt │ │ └── infra │ │ │ ├── GithubStargazerApi.kt │ │ │ └── StargazerBatchJob.kt │ │ └── supports │ │ ├── deadletter │ │ ├── DeadLetterEvent.kt │ │ ├── DeadLetterEventPublisher.kt │ │ ├── DeadLetterListenConfigurer.kt │ │ ├── DeadLetterRedisMessageListenerConfiguration.kt │ │ └── DeadLetterRelayEventListener.kt │ │ ├── event │ │ ├── SlackInteracted.kt │ │ └── SlackReplied.kt │ │ └── orchestrate │ │ ├── IdentityApi.kt │ │ ├── NetxUserOrchestrator.kt │ │ └── SupportsOrchestrateHttpClientConfigurer.kt └── resources │ ├── application.properties │ ├── github-graphql │ ├── contribution-count-by-year.graphql │ ├── contribution-year.graphql │ ├── schema.docs.graphql │ └── stargazer.graphql │ ├── logback.xml │ └── persona │ ├── animal │ ├── bbibbi.svg │ ├── cat.svg │ ├── cheese-cat-collaborator.svg │ ├── cheese-cat.svg │ ├── dessert-fox-collaborator.svg │ ├── dessert-fox-rudolph.svg │ ├── dessert-fox.svg │ ├── fishman-glasses.svg │ ├── fishman.svg │ ├── flamingo.svg │ ├── galchi-cat.svg │ ├── ghost-collaborator.svg │ ├── ghost-king.svg │ ├── ghost.svg │ ├── goblin-bag.svg │ ├── goblin.svg │ ├── goose-java.svg │ ├── goose-js.svg │ ├── goose-kotlin.svg │ ├── goose-linux.svg │ ├── goose-node.svg │ ├── goose-spring.svg │ ├── goose-sunglasses.svg │ ├── goose-swift.svg │ ├── goose.svg │ ├── hamster-collaborator.svg │ ├── hamster-java.svg │ ├── hamster-js.svg │ ├── hamster-kotlin.svg │ ├── hamster-santa.svg │ ├── hamster-spring.svg │ ├── hamster-tube.svg │ ├── hamster.svg │ ├── little-chick-java.svg │ ├── little-chick-js.svg │ ├── little-chick-kotlin.svg │ ├── little-chick-linux.svg │ ├── little-chick-node.svg │ ├── little-chick-santa.svg │ ├── little-chick-spring.svg │ ├── little-chick-sunglasses.svg │ ├── little-chick-swift.svg │ ├── little-chick-tube.svg │ ├── little-chick.svg │ ├── maltese-collaborator.svg │ ├── maltese-king.svg │ ├── maltese.svg │ ├── mole-grass.svg │ ├── mole.svg │ ├── penguin-java.svg │ ├── penguin-js.svg │ ├── penguin-kotlin.svg │ ├── penguin-linux.svg │ ├── penguin-node.svg │ ├── penguin-spring.svg │ ├── penguin-sunglasses.svg │ ├── penguin-swift.svg │ ├── penguin.svg │ ├── pig-collaborator.svg │ ├── pig-java.svg │ ├── pig-js.svg │ ├── pig-kotlin.svg │ ├── pig-linux.svg │ ├── pig-node.svg │ ├── pig-spring.svg │ ├── pig-sunglasses.svg │ ├── pig-swift.svg │ ├── pig.svg │ ├── quokka-leaf.svg │ ├── quokka-sunglasses.svg │ ├── quokka.svg │ ├── rabbit-brown-rudolph.svg │ ├── rabbit-collaborator.svg │ ├── rabbit-tube.svg │ ├── rabbit.svg │ ├── scream-ghost.svg │ ├── scream.svg │ ├── shiba-king.svg │ ├── shiba.svg │ ├── slime-blue.svg │ ├── slime-green.svg │ ├── slime-pumpkin-1.svg │ ├── slime-pumpkin-2.svg │ ├── slime-red-java.svg │ ├── slime-red-js.svg │ ├── slime-red-kotlin.svg │ ├── slime-red-linux.svg │ ├── slime-red-node.svg │ ├── slime-red-swift.svg │ ├── slime-red.svg │ ├── sloth-king.svg │ ├── sloth-sunglasses.svg │ ├── sloth.svg │ ├── snowman-melt.svg │ ├── snowman.svg │ ├── tenmm.svg │ ├── turtle.svg │ ├── unicorn.svg │ ├── white-cat-collaborator.svg │ └── white-cat.svg │ ├── field │ ├── carrot-and-coin.svg │ ├── folder.svg │ ├── grass-christmastree-background.svg │ ├── grass-christmastree-field.svg │ ├── grass-field.svg │ ├── halloween-field.svg │ ├── logo-showing.svg │ ├── red-computer.svg │ ├── red-sofa.svg │ ├── snow-grass-background.svg │ ├── snow-grass-field.svg │ ├── snow-house-background.svg │ ├── snow-house-field.svg │ ├── snowy-field.svg │ └── white-field.svg │ └── text │ ├── large │ ├── 0.svg │ ├── 1.svg │ ├── 2.svg │ ├── 3.svg │ ├── 4.svg │ ├── 5.svg │ ├── 6.svg │ ├── 7.svg │ ├── 8.svg │ ├── 9.svg │ ├── A.svg │ ├── B.svg │ ├── C.svg │ ├── D.svg │ ├── E.svg │ ├── F.svg │ ├── G.svg │ ├── H.svg │ ├── I.svg │ ├── J.svg │ ├── K.svg │ ├── L.svg │ ├── M.svg │ ├── N.svg │ ├── O.svg │ ├── P.svg │ ├── Q.svg │ ├── R.svg │ ├── S.svg │ ├── T.svg │ ├── U.svg │ ├── V.svg │ ├── W.svg │ ├── X.svg │ ├── Y.svg │ ├── Z.svg │ ├── _a.svg │ ├── _b.svg │ ├── _c.svg │ ├── _d.svg │ ├── _e.svg │ ├── _f.svg │ ├── _g.svg │ ├── _h.svg │ ├── _i.svg │ ├── _j.svg │ ├── _k.svg │ ├── _l.svg │ ├── _m.svg │ ├── _n.svg │ ├── _o.svg │ ├── _p.svg │ ├── _q.svg │ ├── _r.svg │ ├── _s.svg │ ├── _t.svg │ ├── _u.svg │ ├── _v.svg │ ├── _w.svg │ ├── _x.svg │ ├── _y.svg │ ├── _z.svg │ └── hyphens.svg │ ├── medium │ ├── 0.svg │ ├── 1.svg │ ├── 2.svg │ ├── 3.svg │ ├── 4.svg │ ├── 5.svg │ ├── 6.svg │ ├── 7.svg │ ├── 8.svg │ └── 9.svg │ └── small │ ├── 0.svg │ ├── 1.svg │ ├── 2.svg │ ├── 3.svg │ ├── 4.svg │ ├── 5.svg │ ├── 6.svg │ ├── 7.svg │ ├── 8.svg │ └── 9.svg └── test ├── kotlin └── org │ └── gitanimals │ ├── core │ ├── auth │ │ └── InternalAuthTest.kt │ └── extension │ │ └── StringExtensionTest.kt │ ├── guild │ ├── app │ │ └── CreateGuildFacadeTest.kt │ ├── domain │ │ ├── Fixture.kt │ │ └── GuildServiceTest.kt │ ├── saga │ │ └── PersonaDeletedSagaHandlerTest.kt │ └── supports │ │ ├── GuildSagaCapture.kt │ │ ├── MockApiConfiguration.kt │ │ └── RedisContainer.kt │ ├── render │ ├── app │ │ ├── AnimationFacadeTest.kt │ │ └── UserStatisticScheduleTest.kt │ ├── domain │ │ ├── PersonaStatisticServiceTest.kt │ │ ├── UserFixture.kt │ │ ├── UserServiceTest.kt │ │ ├── UserStatisticsServiceTest.kt │ │ └── UserTest.kt │ ├── saga │ │ └── UsedCouponSagaHandlerTest.kt │ └── supports │ │ ├── DomainEventHolder.kt │ │ ├── IntegrationTest.kt │ │ ├── RedisContainer.kt │ │ └── SagaCapture.kt │ └── supports │ └── orchestrate │ └── NetxUserOrchestratorTest.kt └── resources ├── application.properties └── persona └── goose ├── test.html └── test.svg /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @devxb 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | build/ 3 | !gradle/wrapper/gradle-wrapper.jar 4 | !**/src/main/**/build/ 5 | !**/src/test/**/build/ 6 | 7 | ### IntelliJ IDEA ### 8 | .idea/modules.xml 9 | .idea/jarRepositories.xml 10 | .idea/compiler.xml 11 | .idea/libraries/ 12 | *.iws 13 | *.iml 14 | *.ipr 15 | out/ 16 | !**/src/main/**/out/ 17 | !**/src/test/**/out/ 18 | 19 | ### Eclipse ### 20 | .apt_generated 21 | .classpath 22 | .factorypath 23 | .project 24 | .settings 25 | .springBeans 26 | .sts4-cache 27 | bin/ 28 | !**/src/main/**/bin/ 29 | !**/src/test/**/bin/ 30 | 31 | ### NetBeans ### 32 | /nbproject/private/ 33 | /nbbuild/ 34 | /dist/ 35 | /nbdist/ 36 | /.nb-gradle/ 37 | 38 | ### VS Code ### 39 | .vscode/ 40 | 41 | ### Mac OS ### 42 | .DS_Store 43 | 44 | .idea 45 | logs 46 | -------------------------------------------------------------------------------- /.idea/checkstyle-idea.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10.10.0 5 | JavaOnly 6 | 14 | 15 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # LICENSE 2 | 3 | ## Restrictions on Use 4 | 5 | Unauthorized use, reproduction, or distribution of GitAnimals' designs is strictly prohibited. 6 | 7 | All designs, including but not limited to illustrations, icons, logos, and characters, are the exclusive property of the creator and GitAnimals. 8 | 9 | Any commercial or non-commercial use of GitAnimals' designs without prior written permission is not allowed. 10 | 11 | ## Copyright Notice 12 | 13 | All designs and visual assets associated with GitAnimals are copyrighted and belong to their respective creators and GitAnimals. Any violation of these terms may result in legal action. 14 | 15 | For inquiries regarding usage permissions, please contact us at xb@gitanimals.org 16 | 17 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "application" 3 | id "org.jetbrains.kotlin.jvm" version "${jetbrainKotlinVersion}" 4 | id "org.jetbrains.kotlin.plugin.spring" version "${jetbrainKotlinVersion}" 5 | id 'org.jetbrains.kotlin.plugin.jpa' version "${jetbrainKotlinVersion}" 6 | id "org.springframework.boot" version "${springbootVersion}" 7 | id "io.spring.dependency-management" version "${springDependencyManagementVersion}" 8 | id "io.sentry.jvm.gradle" version "${sentryVersion}" 9 | } 10 | 11 | group = "${group}" 12 | version = "${version}" 13 | 14 | repositories { 15 | mavenCentral() 16 | maven { url "https://s01.oss.sonatype.org/content/repositories/snapshots/" } 17 | maven { url "https://oss.sonatype.org/content/repositories/snapshots" } 18 | maven { url "https://s01.oss.sonatype.org/content/groups/staging/" } 19 | } 20 | 21 | test { 22 | useJUnitPlatform() 23 | } 24 | 25 | application { 26 | mainClassName = 'org.gitanimals.Application' 27 | } 28 | 29 | sentry { 30 | includeSourceContext = true 31 | 32 | org = "devxb" 33 | projectName = "gitanimals-render" 34 | authToken = System.getProperty("SENTRY_AUTH_TOKEN") 35 | } 36 | 37 | apply from: "gradle/db.gradle" 38 | apply from: "gradle/core.gradle" 39 | apply from: "gradle/test.gradle" 40 | apply from: "gradle/slack.gradle" 41 | apply from: "gradle/spring.gradle" 42 | apply from: "gradle/monitor.gradle" 43 | apply from: "gradle/logging.gradle" 44 | apply from: "gradle/jetbrains.gradle" 45 | -------------------------------------------------------------------------------- /deploy/api/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM arm64v8/openjdk:21 2 | 3 | ARG DB_URL 4 | ARG DB_USERNAME 5 | ARG DB_PASSWORD 6 | ARG GITHUB_TOKEN 7 | ARG REDIS_HOST 8 | ARG REDIS_PORT 9 | ARG INTERNAL_SECRET 10 | ARG SLACK_TOKEN 11 | ARG RELAY_APPROVE_TOKEN 12 | ARG INTERNAL_AUTH_SECRET 13 | ARG INTERNAL_IMAGE_SECRET 14 | 15 | ARG JAR_FILE=./*.jar 16 | COPY ${JAR_FILE} gitanimals-render.jar 17 | 18 | ENV db_url=${DB_URL} \ 19 | db_username=${DB_USERNAME} \ 20 | db_password=${DB_PASSWORD} \ 21 | github_token=${GITHUB_TOKEN} \ 22 | redis_host=${REDIS_HOST} \ 23 | redis_port=${REDIS_PORT} \ 24 | internal_secret=${INTERNAL_SECRET} \ 25 | slack_token=${SLACK_TOKEN} \ 26 | relay_approve_token=${RELAY_APPROVE_TOKEN} \ 27 | internal_auth_secret=${INTERNAL_AUTH_SECRET} \ 28 | internal_image_secret=${INTERNAL_IMAGE_SECRET} 29 | 30 | ENTRYPOINT java -jar gitanimals-render.jar \ 31 | --spring.datasource.url=${db_url} \ 32 | --spring.datasource.username=${db_username} \ 33 | --spring.datasource.password=${db_password} \ 34 | --netx.host=${redis_host} \ 35 | --netx.port=${redis_port} \ 36 | --github.token=${github_token} \ 37 | --internal.secret=${internal_secret} \ 38 | --slack.token=${slack_token} \ 39 | --relay.approve.token=${relay_approve_token} \ 40 | --internal.auth.secret=${internal_auth_secret} \ 41 | --internal.image.secret=${internal_image_secret} 42 | -------------------------------------------------------------------------------- /deploy/filebeat/Dockerfile: -------------------------------------------------------------------------------- 1 | # Filebeat Base Image 2 | FROM docker.elastic.co/beats/filebeat:8.10.0 3 | 4 | # Copy custom Filebeat configuration 5 | COPY filebeat.yml /usr/share/filebeat/filebeat.yml 6 | 7 | # Set permissions 8 | USER root 9 | RUN chmod go-w /usr/share/filebeat/filebeat.yml 10 | 11 | # Set working directory 12 | WORKDIR /usr/share/filebeat 13 | 14 | # Entry point 15 | ENTRYPOINT ["filebeat"] 16 | CMD ["-e", "-c", "/usr/share/filebeat/filebeat.yml"] 17 | -------------------------------------------------------------------------------- /deploy/filebeat/filebeat.yml: -------------------------------------------------------------------------------- 1 | filebeat.inputs: 2 | - type: log 3 | enabled: true 4 | paths: 5 | - /logs/*.json 6 | json: 7 | keys_under_root: true 8 | add_error_key: true 9 | 10 | output.elasticsearch: 11 | hosts: ["192.168.0.31:9200"] 12 | username: "${FILEBEAT_USERNAME}" 13 | password: "${FILEBEAT_PASSWORD}" 14 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official 2 | 3 | ### Project ### 4 | group=org.gitanimals 5 | version=1.0 6 | 7 | ### Spring ### 8 | springDependencyManagementVersion=1.1.1 9 | springbootVersion=3.2.1 10 | springMockkVersion=4.0.0 11 | 12 | ### Jetbrain ### 13 | jetbrainKotlinVersion=1.9.22 14 | jvmTarget=21 15 | 16 | ### Database ### 17 | mysqlConnectorVersion=8.0.33 18 | 19 | ### Kotest ### 20 | kotestVersion=5.7.2 21 | kotestExtensionSpringVersion=1.1.3 22 | 23 | ### TestContainer ### 24 | testContainerVersion=1.19.3 25 | 26 | ### Netx ### 27 | netxVersion=0.5.0 28 | 29 | ### Sentry ### 30 | sentryVersion=4.4.0 31 | 32 | ### RestAssured ### 33 | restAssuredVersion=5.4.0 34 | 35 | ### H2version ### 36 | h2Version=1.4.200 37 | 38 | ### Snowflake ### 39 | snowflakeVersion=5.2.5 40 | 41 | ### Caffeine cache ### 42 | caffeineCacheVersion=3.1.8 43 | 44 | ### Ecs logback encoder ### 45 | logbackEcsEncoderVersion=1.6.0 46 | 47 | ### Slack ### 48 | slackVersion=1.40.2 49 | 50 | ### Jackson version ### 51 | jacksonVersion=2.15.3 52 | 53 | ### Redisson ### 54 | redissonVersion=3.47.0 55 | -------------------------------------------------------------------------------- /gradle/core.gradle: -------------------------------------------------------------------------------- 1 | dependencies { 2 | implementation "org.rooftopmsa:netx:${netxVersion}" 3 | implementation "com.github.ben-manes.caffeine:caffeine:${caffeineCacheVersion}" 4 | implementation "com.fasterxml.jackson.module:jackson-module-kotlin:${jacksonVersion}" 5 | implementation "org.redisson:redisson-spring-boot-starter:${redissonVersion}" 6 | } 7 | -------------------------------------------------------------------------------- /gradle/db.gradle: -------------------------------------------------------------------------------- 1 | dependencies { 2 | implementation "mysql:mysql-connector-java:${mysqlConnectorVersion}" 3 | implementation "com.github.f4b6a3:tsid-creator:${snowflakeVersion}" 4 | 5 | testRuntimeOnly "com.h2database:h2:${h2Version}" 6 | } 7 | -------------------------------------------------------------------------------- /gradle/jetbrains.gradle: -------------------------------------------------------------------------------- 1 | compileKotlin { 2 | kotlinOptions.jvmTarget = "${jvmTarget}" 3 | } 4 | 5 | compileTestKotlin { 6 | kotlinOptions.jvmTarget = "${jvmTarget}" 7 | } 8 | 9 | dependencies { 10 | implementation "org.jetbrains.kotlin:kotlin-reflect" 11 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-reactor" 12 | 13 | testImplementation "org.jetbrains.kotlin:kotlin-test" 14 | } 15 | -------------------------------------------------------------------------------- /gradle/logging.gradle: -------------------------------------------------------------------------------- 1 | dependencies { 2 | implementation "co.elastic.logging:logback-ecs-encoder:${logbackEcsEncoderVersion}" 3 | } 4 | -------------------------------------------------------------------------------- /gradle/monitor.gradle: -------------------------------------------------------------------------------- 1 | dependencies { 2 | runtimeOnly 'io.micrometer:micrometer-registry-prometheus' 3 | } 4 | -------------------------------------------------------------------------------- /gradle/slack.gradle: -------------------------------------------------------------------------------- 1 | dependencies { 2 | implementation "com.slack.api:bolt:${slackVersion}" 3 | implementation "com.slack.api:bolt-servlet:${slackVersion}" 4 | implementation "com.slack.api:bolt-jetty:${slackVersion}" 5 | } 6 | -------------------------------------------------------------------------------- /gradle/spring.gradle: -------------------------------------------------------------------------------- 1 | jar { 2 | enabled = false 3 | } 4 | 5 | dependencies { 6 | implementation "org.springframework.boot:spring-boot-starter" 7 | implementation "org.springframework.boot:spring-boot-starter-web" 8 | implementation "org.springframework.boot:spring-boot-starter-aop" 9 | implementation "org.springframework.boot:spring-boot-starter-cache" 10 | implementation "org.springframework.boot:spring-boot-starter-data-jpa" 11 | implementation "org.springframework.boot:spring-boot-starter-actuator" 12 | implementation "org.springframework.boot:spring-boot-starter-data-redis" 13 | implementation 'org.springframework.retry:spring-retry' 14 | 15 | testImplementation "org.springframework.boot:spring-boot-starter-test" 16 | 17 | testImplementation "com.ninja-squad:springmockk:${springMockkVersion}" 18 | } 19 | -------------------------------------------------------------------------------- /gradle/test.gradle: -------------------------------------------------------------------------------- 1 | test { 2 | useJUnitPlatform() 3 | } 4 | 5 | dependencies { 6 | testImplementation "io.kotest:kotest-runner-junit5:${kotestVersion}" 7 | testImplementation "io.kotest:kotest-assertions-core:${kotestVersion}" 8 | testImplementation "io.kotest.extensions:kotest-extensions-spring:${kotestExtensionSpringVersion}" 9 | 10 | testImplementation "org.testcontainers:testcontainers:${testContainerVersion}" 11 | 12 | testImplementation "io.rest-assured:rest-assured:${restAssuredVersion}" 13 | 14 | } 15 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/git-goods/gitanimals/bb981af8fff02bed5f850027a17c7539ba7f44a3/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.7-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | 2 | rootProject.name = 'gitanimals' 3 | 4 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/Application.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals 2 | 3 | import org.rooftop.netx.meta.EnableSaga 4 | import org.springframework.boot.SpringApplication 5 | import org.springframework.boot.autoconfigure.SpringBootApplication 6 | import org.springframework.cache.annotation.EnableCaching 7 | import org.springframework.data.jpa.repository.config.EnableJpaAuditing 8 | import org.springframework.retry.annotation.EnableRetry 9 | import org.springframework.scheduling.annotation.EnableAsync 10 | import org.springframework.scheduling.annotation.EnableScheduling 11 | 12 | @EnableSaga 13 | @EnableRetry 14 | @EnableAsync 15 | @EnableCaching 16 | @EnableScheduling 17 | @EnableJpaAuditing 18 | @SpringBootApplication 19 | class Application { 20 | 21 | companion object { 22 | 23 | @JvmStatic 24 | fun main(args: Array) { 25 | SpringApplication.run(Application::class.java, *args) 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/core/AggregateRoot.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.core 2 | 3 | @Target(AnnotationTarget.CLASS) 4 | @Retention(AnnotationRetention.RUNTIME) 5 | annotation class AggregateRoot 6 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/core/DomainEventPublisher.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.core 2 | 3 | import org.slf4j.LoggerFactory 4 | import org.springframework.context.ApplicationEventPublisher 5 | import org.springframework.stereotype.Component 6 | 7 | object DomainEventPublisher { 8 | 9 | private lateinit var applicationEventPublisher: ApplicationEventPublisher 10 | private val logger = LoggerFactory.getLogger(this::class.simpleName) 11 | 12 | fun publish(event: T) { 13 | runCatching { 14 | applicationEventPublisher.publishEvent(event) 15 | }.onSuccess { 16 | logger.info("Publish event success. event: \"$event\"") 17 | }.onFailure { 18 | logger.error("Publish event fail. event: \"$event\"") 19 | } 20 | } 21 | 22 | @Component 23 | class EventPublisherInjector(applicationEventPublisher: ApplicationEventPublisher) { 24 | 25 | init { 26 | DomainEventPublisher.applicationEventPublisher = applicationEventPublisher 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/core/ErrorResponse.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.core 2 | 3 | data class ErrorResponse( 4 | val message: String, 5 | ) { 6 | 7 | companion object { 8 | fun from(exception: Exception): ErrorResponse = 9 | ErrorResponse(exception.message ?: exception.localizedMessage) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/core/Exception.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.core 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties 4 | 5 | @JsonIgnoreProperties(ignoreUnknown = true) 6 | class AuthorizationException(message: String) : IllegalArgumentException(message) 7 | 8 | val AUTHORIZATION_EXCEPTION = AuthorizationException("Authorization fail") 9 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/core/GracefulShutdownDispatcher.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.core 2 | 3 | import jakarta.annotation.PreDestroy 4 | import kotlinx.coroutines.CoroutineDispatcher 5 | import kotlinx.coroutines.asCoroutineDispatcher 6 | import org.gitanimals.core.GracefulShutdownDispatcher.executorService 7 | import org.slf4j.LoggerFactory 8 | import org.springframework.stereotype.Component 9 | import java.util.concurrent.Executors 10 | import java.util.concurrent.TimeUnit 11 | 12 | object GracefulShutdownDispatcher { 13 | 14 | val executorService = Executors.newFixedThreadPool(10) { runnable -> 15 | Thread(runnable, "gitanimals-gracefulshutdown").apply { isDaemon = false } 16 | } 17 | 18 | val dispatcher: CoroutineDispatcher = executorService.asCoroutineDispatcher() 19 | } 20 | 21 | @Component 22 | class GracefulShutdownHook { 23 | 24 | private val logger = LoggerFactory.getLogger(this::class.simpleName) 25 | 26 | @PreDestroy 27 | fun tryGracefulShutdown() { 28 | logger.info("Shutting down dispatcher...") 29 | executorService.shutdown() 30 | runCatching { 31 | if (!executorService.awaitTermination(60, TimeUnit.SECONDS)) { 32 | logger.warn("Forcing shutdown...") 33 | executorService.shutdownNow() 34 | } else { 35 | logger.info("Shutdown completed gracefully.") 36 | } 37 | }.onFailure { 38 | if (it is InterruptedException) { 39 | logger.warn("Shutdown interrupted. Forcing shutdown...") 40 | executorService.shutdownNow() 41 | Thread.currentThread().interrupt() 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/core/HttpClientErrorHandler.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.core 2 | 3 | import org.springframework.http.HttpStatus 4 | import org.springframework.http.client.ClientHttpResponse 5 | import org.springframework.web.client.ResponseErrorHandler 6 | 7 | class HttpClientErrorHandler : ResponseErrorHandler { 8 | 9 | override fun hasError(response: ClientHttpResponse): Boolean { 10 | return response.statusCode.isError 11 | } 12 | 13 | override fun handleError(response: ClientHttpResponse) { 14 | val body = response.body.bufferedReader().use { it.readText() } 15 | when { 16 | response.statusCode.isSameCodeAs(HttpStatus.UNAUTHORIZED) -> 17 | throw AuthorizationException(body) 18 | 19 | response.statusCode.is4xxClientError -> 20 | throw IllegalArgumentException(body) 21 | 22 | response.statusCode.is5xxServerError -> 23 | throw IllegalStateException(body) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/core/IdGenerator.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.core 2 | 3 | import com.github.f4b6a3.tsid.TsidFactory 4 | 5 | object IdGenerator { 6 | 7 | private val tsidFactory = TsidFactory.newInstance256() 8 | 9 | fun generate(): Long = tsidFactory.create().toLong() 10 | } 11 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/core/JacksonConfig.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.core 2 | 3 | import com.fasterxml.jackson.annotation.JsonAutoDetect 4 | import com.fasterxml.jackson.annotation.JsonCreator 5 | import com.fasterxml.jackson.annotation.PropertyAccessor 6 | import com.fasterxml.jackson.databind.DeserializationFeature 7 | import com.fasterxml.jackson.databind.ObjectMapper 8 | import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule 9 | import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper 10 | import com.fasterxml.jackson.module.paramnames.ParameterNamesModule 11 | import org.springframework.context.annotation.Bean 12 | import org.springframework.context.annotation.Configuration 13 | import org.springframework.context.annotation.Primary 14 | 15 | @Configuration 16 | class JacksonConfig { 17 | 18 | @Bean 19 | @Primary 20 | fun objectMapper(): ObjectMapper { 21 | return jacksonObjectMapper() 22 | .setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY) 23 | .registerModule(ParameterNamesModule(JsonCreator.Mode.PROPERTIES)) 24 | .registerModule(JavaTimeModule()) 25 | .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) 26 | .configure(DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES, false) 27 | .configure(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES, false) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/core/Mode.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.core 2 | 3 | enum class Mode { 4 | FARM, 5 | LINE, 6 | LINE_NO_CONTRIBUTION, 7 | NAME_WITH_LEVEL, 8 | NONE, 9 | ; 10 | } 11 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/core/UpdateUserOrchestrator.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.core 2 | 3 | import org.gitanimals.core.auth.UserEntryPoint 4 | 5 | fun interface UpdateUserOrchestrator { 6 | 7 | fun updateUsername(request: UpdateUserNameRequest) 8 | 9 | data class UpdateUserNameRequest( 10 | val id: Long, 11 | val authenticationId: String, 12 | val entryPoint: UserEntryPoint, 13 | val previousName: String, 14 | val changeName: String, 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/core/appender/SlackAppender.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.core.appender 2 | 3 | import ch.qos.logback.classic.Level 4 | import ch.qos.logback.classic.spi.ILoggingEvent 5 | import ch.qos.logback.core.AppenderBase 6 | import com.slack.api.Slack 7 | import com.slack.api.methods.MethodsClient 8 | import com.slack.api.methods.request.chat.ChatPostMessageRequest 9 | 10 | class SlackAppender : AppenderBase() { 11 | 12 | private lateinit var slack: MethodsClient 13 | 14 | private val errorChannel = "C080GR85WM9" 15 | private val warnChannel = "C08977RL38C" 16 | 17 | override fun append(eventObject: ILoggingEvent) { 18 | runCatching { 19 | val channel = when (eventObject.level) { 20 | Level.WARN -> warnChannel 21 | Level.ERROR -> errorChannel 22 | else -> NOT_LOGGING 23 | } 24 | 25 | if (channel == NOT_LOGGING) { 26 | return 27 | } 28 | 29 | val request: ChatPostMessageRequest = ChatPostMessageRequest.builder() 30 | .channel(channel) 31 | .text(eventObject.formattedMessage) 32 | .build(); 33 | slack.chatPostMessage(request) 34 | } 35 | } 36 | 37 | fun setToken(token: String) { 38 | this.slack = Slack.getInstance().methods(token) 39 | } 40 | 41 | companion object { 42 | private const val NOT_LOGGING = "NOT LOGGING" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/core/auth/InternalAuthRequestInterceptor.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.core.auth 2 | 3 | import org.gitanimals.core.filter.MDCFilter.Companion.USER_ENTRY_POINT 4 | import org.gitanimals.core.filter.MDCFilter.Companion.USER_ID 5 | import org.slf4j.MDC 6 | import org.springframework.http.HttpRequest 7 | import org.springframework.http.client.ClientHttpRequestExecution 8 | import org.springframework.http.client.ClientHttpRequestInterceptor 9 | import org.springframework.http.client.ClientHttpResponse 10 | import org.springframework.stereotype.Component 11 | import java.util.* 12 | 13 | @Component 14 | class InternalAuthRequestInterceptor( 15 | private val internalAuth: InternalAuth, 16 | ) : ClientHttpRequestInterceptor { 17 | 18 | override fun intercept( 19 | request: HttpRequest, 20 | body: ByteArray, 21 | execution: ClientHttpRequestExecution 22 | ): ClientHttpResponse { 23 | val userId = runCatching { 24 | MDC.get(USER_ID).toLong() 25 | }.getOrNull() 26 | 27 | if (userId != null) { 28 | val encrypt = internalAuth.encrypt(userId = userId) 29 | 30 | request.headers.add( 31 | InternalAuth.INTERNAL_AUTH_SECRET_KEY, 32 | Base64.getEncoder().encodeToString(encrypt.secret), 33 | ) 34 | request.headers.add( 35 | InternalAuth.INTERNAL_AUTH_IV_KEY, 36 | Base64.getEncoder().encodeToString(encrypt.iv), 37 | ) 38 | } 39 | 40 | val userEntryPoint = runCatching { 41 | MDC.get(USER_ENTRY_POINT) 42 | }.getOrNull() 43 | 44 | if (userEntryPoint != null) { 45 | request.headers.add( 46 | InternalAuth.INTERNAL_ENTRY_POINT_KEY, 47 | userEntryPoint, 48 | ) 49 | } 50 | 51 | return execution.execute(request, body) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/core/auth/RequiredUserEntryPointAspect.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.core.auth 2 | 3 | import org.aspectj.lang.ProceedingJoinPoint 4 | import org.aspectj.lang.annotation.Around 5 | import org.aspectj.lang.annotation.Aspect 6 | import org.gitanimals.core.auth.UserEntryPointValidationExtension.withUserEntryPointValidation 7 | import org.slf4j.LoggerFactory 8 | import org.springframework.stereotype.Component 9 | 10 | @Aspect 11 | @Component 12 | class RequiredUserEntryPointAspect { 13 | 14 | private val logger = LoggerFactory.getLogger(this::class.simpleName) 15 | 16 | @Around("@annotation(requiredUserEntryPoints)") 17 | fun validate( 18 | proceedingJoinPoint: ProceedingJoinPoint, 19 | requiredUserEntryPoints: RequiredUserEntryPoints, 20 | ): Any? { 21 | return withUserEntryPointValidation( 22 | expectedUserEntryPoints = requiredUserEntryPoints.expected, 23 | onSuccess = { proceedingJoinPoint.proceed() }, 24 | failMessage = { 25 | val message = 26 | "ExpectedUserEntryPoints: \"${requiredUserEntryPoints.expected}\" but request user entryPoint is \"$it\"" 27 | logger.info("[RequiredUserEntryPointAspect] $message") 28 | message 29 | } 30 | ) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/core/auth/RequiredUserEntryPoints.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.core.auth 2 | 3 | @Target(AnnotationTarget.FUNCTION) 4 | @Retention(AnnotationRetention.RUNTIME) 5 | annotation class RequiredUserEntryPoints( 6 | val expected: Array, 7 | ) 8 | 9 | enum class UserEntryPoint { 10 | ANY, 11 | GITHUB, 12 | APPLE, 13 | ; 14 | } 15 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/core/auth/UserEntryPointValidationExtension.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.core.auth 2 | 3 | import org.springframework.stereotype.Component 4 | 5 | object UserEntryPointValidationExtension { 6 | 7 | private lateinit var internalAuth: InternalAuth 8 | 9 | fun withUserEntryPointValidation( 10 | expectedUserEntryPoints: Array, 11 | onSuccess: () -> T, 12 | failMessage: (UserEntryPoint?) -> String = { userEntryPoint -> 13 | "ExpectedUserEntryPoints: \"$expectedUserEntryPoints\" but request user entryPoint is \"$userEntryPoint\"" 14 | } 15 | ): T { 16 | val userEntryPoint = runCatching { 17 | val userEntryPointString = internalAuth.getUserEntryPoint { 18 | throw IllegalArgumentException(failMessage.invoke(null)) 19 | } 20 | UserEntryPoint.valueOf(userEntryPointString) 21 | }.getOrElse { 22 | throw IllegalArgumentException(failMessage.invoke(null)) 23 | } 24 | 25 | require( 26 | expectedUserEntryPoints.contains(UserEntryPoint.ANY) 27 | || userEntryPoint in expectedUserEntryPoints 28 | ) { failMessage.invoke(userEntryPoint) } 29 | 30 | return onSuccess.invoke() 31 | } 32 | 33 | @Component 34 | class UserEntryPointValidationExtensionBeanInjector( 35 | internalAuth: InternalAuth, 36 | ) { 37 | 38 | init { 39 | UserEntryPointValidationExtension.internalAuth = internalAuth 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/core/clock.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.core 2 | 3 | import java.time.Clock 4 | import java.time.Instant 5 | import java.time.ZoneId 6 | import java.time.ZonedDateTime 7 | 8 | var clock: Clock = Clock.systemUTC() 9 | 10 | fun instant() = Instant.now(clock) 11 | 12 | fun Instant.toZonedDateTime() = ZonedDateTime.ofInstant(this, clock.zone) 13 | 14 | fun Instant.toZonedDateTime(zoneId: ZoneId) = ZonedDateTime.ofInstant(this, zoneId) 15 | 16 | fun Instant.toKr() = ZonedDateTime.ofInstant(this, ZoneId.of("Asia/Seoul")) 17 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/core/extension/HttpResponseExtension.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.core.extension 2 | 3 | import jakarta.servlet.http.HttpServletResponse 4 | import org.springframework.http.HttpHeaders 5 | 6 | object HttpResponseExtension { 7 | 8 | fun HttpServletResponse.cacheControl(maxAgeSeconds: Int): HttpServletResponse { 9 | this.setHeader( 10 | HttpHeaders.CACHE_CONTROL, 11 | "no-cache, no-store, must-revalidate, max-age=$maxAgeSeconds" 12 | ) 13 | this.setHeader(HttpHeaders.PRAGMA, "no-cache") 14 | return this 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/core/extension/StringExtension.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.core.extension 2 | 3 | object StringExtension { 4 | 5 | fun String.trimNotDigitCharacters(): String { 6 | var trimed = this.trim() 7 | 8 | if (trimed.first().isDigit().not()) { 9 | trimed = trimed.drop(1) 10 | } 11 | if (trimed.last().isDigit().not()) { 12 | trimed = trimed.dropLast(1) 13 | } 14 | 15 | return trimed 16 | } 17 | 18 | fun String.deleteBrackets(): String { 19 | val start = when (this[0]) { 20 | '{' -> 1 21 | else -> 0 22 | } 23 | 24 | val end = when (this.last()) { 25 | '}' -> this.length - 1 26 | else -> this.length 27 | } 28 | 29 | return this.substring(start, end) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/core/filter/CorsFilter.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.core.filter 2 | 3 | import jakarta.servlet.Filter 4 | import jakarta.servlet.FilterChain 5 | import jakarta.servlet.ServletRequest 6 | import jakarta.servlet.ServletResponse 7 | import jakarta.servlet.http.HttpServletResponse 8 | import org.springframework.http.HttpHeaders 9 | import org.springframework.stereotype.Component 10 | 11 | @Component 12 | class CorsFilter : Filter { 13 | 14 | override fun doFilter( 15 | request: ServletRequest, 16 | response: ServletResponse, 17 | chain: FilterChain 18 | ) { 19 | (response as HttpServletResponse).allowCors() 20 | chain.doFilter(request, response) 21 | } 22 | 23 | private fun HttpServletResponse.allowCors(): ServletResponse { 24 | this.addHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "*") 25 | this.addHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, "*") 26 | this.addHeader(HttpHeaders.ACCESS_CONTROL_MAX_AGE, "3600") 27 | this.addHeader( 28 | HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, 29 | "Origin, X-Requested-With, Content-Type, Accept, Authorization, Api-Version, Image-Secret" 30 | ) 31 | return this 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/core/interceptor/InterceptorConfigurer.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.core.interceptor 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.web.servlet.config.annotation.InterceptorRegistry 7 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer 8 | 9 | @Configuration 10 | class InterceptorConfigurer( 11 | @Value("\${internal.secret}") private val internalSecret: String, 12 | ) : WebMvcConfigurer { 13 | 14 | override fun addInterceptors(registry: InterceptorRegistry) { 15 | registry.addInterceptor(internalApiInterceptor()) 16 | .addPathPatterns("/internals/**") 17 | .excludePathPatterns() 18 | } 19 | 20 | @Bean 21 | fun internalApiInterceptor(): InternalApiInterceptor = InternalApiInterceptor(internalSecret) 22 | } 23 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/core/interceptor/InternalApiInterceptor.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.core.interceptor 2 | 3 | import jakarta.servlet.http.HttpServletRequest 4 | import jakarta.servlet.http.HttpServletResponse 5 | import org.springframework.web.servlet.HandlerInterceptor 6 | 7 | class InternalApiInterceptor( 8 | private val internalSecret: String, 9 | ) : HandlerInterceptor { 10 | 11 | override fun preHandle( 12 | request: HttpServletRequest, 13 | response: HttpServletResponse, 14 | handler: Any, 15 | ): Boolean { 16 | val requestSecret = request.getHeader("Internal-Secret") 17 | 18 | if (requestSecret == internalSecret) { 19 | return true 20 | } 21 | 22 | response.sendError(403, "Cannot call internal API") 23 | return false 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/core/lock/DistributedLock.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.core.lock 2 | 3 | import org.springframework.stereotype.Component 4 | import kotlin.time.Duration.Companion.seconds 5 | 6 | object LOCK_KEY_PREFIX { 7 | const val CREATE_NEW_USER = "CREATE_NEW_USER" 8 | const val SET_AUTH = "SET_AUTH" 9 | } 10 | 11 | object DistributedLock { 12 | 13 | private lateinit var lockService: DistributedLockService 14 | 15 | fun withLock( 16 | key: String, 17 | leaseMillis: Long = 10.seconds.inWholeMilliseconds, 18 | waitMillis: Long = 3.seconds.inWholeMilliseconds, 19 | action: () -> T, 20 | ): T { 21 | return lockService.withLock( 22 | key = key, 23 | leaseMillis = leaseMillis, 24 | waitMillis = waitMillis, 25 | action = action, 26 | ) 27 | } 28 | 29 | interface DistributedLockService { 30 | 31 | fun withLock( 32 | key: String, 33 | leaseMillis: Long = 10.seconds.inWholeMilliseconds, 34 | waitMillis: Long = 3.seconds.inWholeMilliseconds, 35 | action: () -> T, 36 | ): T 37 | } 38 | 39 | @Component 40 | class DistributedLockServiceInjector(distributedLockService: DistributedLockService) { 41 | 42 | init { 43 | lockService = distributedLockService 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/core/lock/LockAcquireFailException.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.core.lock 2 | 3 | class LockAcquireFailException( 4 | override val message: String, 5 | ) : RuntimeException(message) 6 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/core/lock/RedisDistributedLockService.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.core.lock 2 | 3 | import org.redisson.Redisson 4 | import org.redisson.api.RedissonClient 5 | import org.redisson.config.Config 6 | import org.springframework.beans.factory.annotation.Value 7 | import org.springframework.context.annotation.Bean 8 | import org.springframework.context.annotation.Configuration 9 | import org.springframework.stereotype.Component 10 | import java.util.concurrent.TimeUnit 11 | 12 | @Component 13 | class RedisDistributedLockService( 14 | private val redissonClient: RedissonClient, 15 | ) : DistributedLock.DistributedLockService { 16 | 17 | override fun withLock( 18 | key: String, 19 | leaseMillis: Long, 20 | waitMillis: Long, 21 | action: () -> T 22 | ): T { 23 | val lock = redissonClient.getLock(key) 24 | val acquired = lock.tryLock(waitMillis, leaseMillis, TimeUnit.MILLISECONDS) 25 | 26 | return if (acquired) { 27 | runCatching { 28 | action.invoke() 29 | }.getOrElse { 30 | throw it 31 | }.also { 32 | lock.unlock() 33 | } 34 | } else { 35 | throw LockAcquireFailException(message = "Cannot acquire lock") 36 | } 37 | } 38 | } 39 | 40 | @Configuration 41 | class RedissonConfig( 42 | @Value("\${netx.host}") private val host: String, 43 | @Value("\${netx.port}") private val port: String, 44 | @Value("\${netx.password:0000}") private val password: String, 45 | ) { 46 | 47 | @Bean 48 | fun redissonClient(): RedissonClient { 49 | val config = Config() 50 | 51 | config.useSingleServer() 52 | .setAddress("redis://$host:$port") 53 | // .setPassword(password) 54 | 55 | return Redisson.create(config) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/core/redis/AsyncRedisPubSubEvent.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.core.redis 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnore 4 | import org.gitanimals.core.filter.MDCFilter 5 | import org.slf4j.MDC 6 | 7 | abstract class AsyncRedisPubSubEvent( 8 | val apiUserId: String? = runCatching { MDC.get(MDCFilter.USER_ID) }.getOrNull(), 9 | val traceId: String, 10 | @JsonIgnore 11 | val channel: String, 12 | ) 13 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/core/redis/RedisConfiguration.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.core.redis 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.RedisConnectionFactory 7 | import org.springframework.data.redis.connection.RedisPassword 8 | import org.springframework.data.redis.connection.RedisStandaloneConfiguration 9 | import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory 10 | import org.springframework.data.redis.core.StringRedisTemplate 11 | 12 | @Configuration 13 | class RedisConfiguration( 14 | @Value("\${netx.host}") private val host: String, 15 | @Value("\${netx.port}") private val port: String, 16 | @Value("\${netx.password:0000}") private val password: String, 17 | ) { 18 | 19 | @Bean 20 | fun gitanimalsRedisTemplate(): StringRedisTemplate = 21 | StringRedisTemplate(redisConnectionFactory()) 22 | 23 | @Bean 24 | fun redisConnectionFactory(): RedisConnectionFactory { 25 | val port: String = System.getProperty("netx.port") ?: port 26 | 27 | val redisStandaloneConfiguration = RedisStandaloneConfiguration() 28 | redisStandaloneConfiguration.hostName = host 29 | redisStandaloneConfiguration.port = port.toInt() 30 | redisStandaloneConfiguration.password = RedisPassword.of(password) 31 | 32 | return LettuceConnectionFactory(redisStandaloneConfiguration) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/core/redis/RedisPubSubChannel.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.core.redis 2 | 3 | object RedisPubSubChannel { 4 | 5 | const val USER_CONTRIBUTION_UPDATED = "USER_CONTRIBUTION_UPDATED" 6 | const val GUILD_CONTRIBUTION_UPDATED = "GUILD_CONTRIBUTION_UPDATED" 7 | 8 | const val SLACK_INTERACTED = "SLACK_INTERACTED" 9 | const val SLACK_REPLIED = "SLACK_REPLIED" 10 | 11 | const val DEAD_LETTER_OCCURRED = "DEAD_LETTER_OCCURRED" 12 | 13 | const val NEW_PET_DROP_RATE_DISTRIBUTION = "NEW_PET_DROP_RATE_DISTRIBUTION" 14 | } 15 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/core/redis/RedisPubSubEventListener.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.core.redis 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper 4 | import org.slf4j.LoggerFactory 5 | import org.springframework.beans.factory.annotation.Qualifier 6 | import org.springframework.context.event.EventListener 7 | import org.springframework.data.redis.core.StringRedisTemplate 8 | import org.springframework.scheduling.annotation.Async 9 | import org.springframework.stereotype.Component 10 | 11 | @Component 12 | class RedisPubSubEventListener( 13 | private val objectMapper: ObjectMapper, 14 | @Qualifier("gitanimalsRedisTemplate") private val redisTemplate: StringRedisTemplate, 15 | ) { 16 | 17 | private val logger = LoggerFactory.getLogger(this::class.simpleName) 18 | 19 | @Async 20 | @EventListener(value = [AsyncRedisPubSubEvent::class]) 21 | fun handleAsyncRedisPubSubEvent(event: AsyncRedisPubSubEvent) { 22 | runCatching { 23 | val eventBody = objectMapper.writeValueAsString(event) 24 | redisTemplate.convertAndSend( 25 | event.channel, 26 | eventBody, 27 | ) 28 | eventBody 29 | }.onSuccess { 30 | logger.info("Publish event: \"$it\" to channel: \"${event.channel}\"") 31 | }.onFailure { 32 | logger.error( 33 | "Cannot publish event to redis. event: $event, channel: ${event.channel}", 34 | it 35 | ) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/core/redis/TraceableMessageListener.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.core.redis 2 | 3 | import com.fasterxml.jackson.core.type.TypeReference 4 | import com.fasterxml.jackson.databind.ObjectMapper 5 | import org.gitanimals.core.filter.MDCFilter 6 | import org.gitanimals.core.filter.MDCFilter.Companion.TRACE_ID 7 | import org.slf4j.LoggerFactory 8 | import org.slf4j.MDC 9 | import org.springframework.data.redis.connection.Message 10 | import org.springframework.data.redis.connection.MessageListener 11 | import org.springframework.data.redis.core.StringRedisTemplate 12 | 13 | abstract class TraceableMessageListener( 14 | private val redisTemplate: StringRedisTemplate, 15 | private val objectMapper: ObjectMapper, 16 | ) : MessageListener { 17 | 18 | private val logger = LoggerFactory.getLogger(this::class.simpleName) 19 | 20 | override fun onMessage(message: Message, pattern: ByteArray?) { 21 | runCatching { 22 | val request = objectMapper.readValue( 23 | redisTemplate.stringSerializer.deserialize(message.body), 24 | object : TypeReference>() {}, 25 | ) 26 | 27 | runCatching { 28 | request["traceId"] as String 29 | }.onSuccess { 30 | MDC.put(TRACE_ID, it) 31 | } 32 | runCatching { 33 | request["apiUserId"] as String 34 | }.onSuccess { 35 | MDC.put(MDCFilter.USER_ID, it) 36 | } 37 | 38 | onMessage(message) 39 | }.onFailure { 40 | logger.error("Fail to listen message: $message, error: $it", it) 41 | }.also { 42 | MDC.remove(TRACE_ID) 43 | } 44 | } 45 | 46 | abstract fun onMessage(message: Message) 47 | } 48 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/core/redis/TransactionCommitRedisPubSubEvent.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.core.redis 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnore 4 | import org.gitanimals.core.filter.MDCFilter 5 | import org.slf4j.MDC 6 | import org.springframework.context.ApplicationEvent 7 | 8 | abstract class TransactionCommitRedisPubSubEvent( 9 | val apiUserId: String? = runCatching { MDC.get(MDCFilter.USER_ID) }.getOrNull(), 10 | val traceId: String, 11 | @JsonIgnore 12 | val channel: String, 13 | source: Any, 14 | ) : ApplicationEvent(source) { 15 | 16 | @JsonIgnore 17 | override fun getSource(): Any { 18 | return super.source 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/core/redis/TransactionCommitRedisPubSubEventListener.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.core.redis 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper 4 | import org.slf4j.LoggerFactory 5 | import org.springframework.beans.factory.annotation.Qualifier 6 | import org.springframework.data.redis.core.StringRedisTemplate 7 | import org.springframework.stereotype.Component 8 | import org.springframework.transaction.event.TransactionPhase 9 | import org.springframework.transaction.event.TransactionalEventListener 10 | 11 | @Component 12 | class TransactionCommitRedisPubSubEventListener( 13 | private val objectMapper: ObjectMapper, 14 | @Qualifier("gitanimalsRedisTemplate") private val redisTemplate: StringRedisTemplate, 15 | ) { 16 | 17 | private val logger = LoggerFactory.getLogger(this::class.simpleName) 18 | 19 | @TransactionalEventListener( 20 | value = [TransactionCommitRedisPubSubEvent::class], 21 | phase = TransactionPhase.AFTER_COMMIT, 22 | ) 23 | fun handleTransactionCommitRedisPubSubEvent(event: TransactionCommitRedisPubSubEvent) { 24 | runCatching { 25 | val eventBody = objectMapper.writeValueAsString(event) 26 | redisTemplate.convertAndSend( 27 | event.channel, 28 | eventBody, 29 | ) 30 | eventBody 31 | }.onSuccess { 32 | logger.info("Publish event: \"$it\" to channel: \"${event.channel}\"") 33 | }.onFailure { 34 | logger.error("Cannot publish event to redis. event: $event, channel: ${event.channel}, source: ${event.source}", it) 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/guild/app/AcceptJoinGuildFacade.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.guild.app 2 | 3 | import org.gitanimals.core.auth.InternalAuth 4 | import org.gitanimals.guild.domain.GuildService 5 | import org.springframework.stereotype.Service 6 | 7 | @Service 8 | class AcceptJoinGuildFacade( 9 | private val internalAuth: InternalAuth, 10 | private val guildService: GuildService, 11 | ) { 12 | 13 | fun acceptJoin(guildId: Long, acceptUserId: Long) { 14 | val userId = internalAuth.getUserId() 15 | 16 | guildService.acceptJoin( 17 | acceptorId = userId, 18 | guildId = guildId, 19 | acceptUserId = acceptUserId, 20 | ) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/guild/app/ChangeGuildFacade.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.guild.app 2 | 3 | import org.gitanimals.core.auth.InternalAuth 4 | import org.gitanimals.guild.domain.GuildService 5 | import org.gitanimals.guild.domain.request.ChangeGuildRequest 6 | import org.springframework.stereotype.Service 7 | 8 | @Service 9 | class ChangeGuildFacade( 10 | private val internalAuth: InternalAuth, 11 | private val guildService: GuildService, 12 | ) { 13 | 14 | fun changeGuild(guildId: Long, changeGuildRequest: ChangeGuildRequest) { 15 | changeGuildRequest.requireValidTitle() 16 | 17 | val userId = internalAuth.getUserId() 18 | 19 | guildService.changeGuild( 20 | changeRequesterId = userId, 21 | guildId = guildId, 22 | request = changeGuildRequest, 23 | ) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/guild/app/ChangeMainPersonaFacade.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.guild.app 2 | 3 | import org.gitanimals.guild.domain.GuildService 4 | import org.springframework.stereotype.Component 5 | 6 | @Component 7 | class ChangeMainPersonaFacade( 8 | private val renderApi: RenderApi, 9 | private val identityApi: IdentityApi, 10 | private val guildService: GuildService, 11 | ) { 12 | 13 | fun changeMainPersona(token: String, guildId: Long, personaId: Long) { 14 | val user = identityApi.getUserByToken(token) 15 | val personas = renderApi.getUserByName(user.username).personas 16 | 17 | val changedPersona = personas.firstOrNull { it.id.toLong() == personaId } 18 | ?: throw IllegalArgumentException("Cannot change persona to \"$personaId\" from user \"${user.username}\"") 19 | 20 | guildService.changeMainPersona( 21 | guildId = guildId, 22 | userId = user.id.toLong(), 23 | personaId = changedPersona.id.toLong(), 24 | personaType = changedPersona.type, 25 | ) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/guild/app/DenyJoinGuildFacade.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.guild.app 2 | 3 | import org.gitanimals.core.auth.InternalAuth 4 | import org.gitanimals.guild.domain.GuildService 5 | import org.springframework.stereotype.Service 6 | 7 | @Service 8 | class DenyJoinGuildFacade( 9 | private val internalAuth: InternalAuth, 10 | private val guildService: GuildService, 11 | ) { 12 | 13 | fun denyJoin(guildId: Long, denyUserId: Long) { 14 | val userId = internalAuth.getUserId() 15 | 16 | guildService.denyJoin(denierId = userId, guildId = guildId, denyUserId = denyUserId) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/guild/app/GetJoinedGuildFacade.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.guild.app 2 | 3 | import org.gitanimals.core.auth.InternalAuth 4 | import org.gitanimals.guild.domain.Guild 5 | import org.gitanimals.guild.domain.GuildService 6 | import org.springframework.stereotype.Service 7 | 8 | @Service 9 | class GetJoinedGuildFacade( 10 | private val internalAuth: InternalAuth, 11 | private val guildService: GuildService, 12 | ) { 13 | 14 | fun getJoinedGuilds(): List { 15 | val userId = internalAuth.getUserId() 16 | 17 | return guildService.findAllGuildByUserId(userId) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/guild/app/IdentityApi.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.guild.app 2 | 3 | import org.springframework.http.HttpHeaders 4 | import org.springframework.web.bind.annotation.PathVariable 5 | import org.springframework.web.bind.annotation.RequestHeader 6 | import org.springframework.web.bind.annotation.RequestParam 7 | import org.springframework.web.service.annotation.GetExchange 8 | import org.springframework.web.service.annotation.PostExchange 9 | 10 | interface IdentityApi { 11 | 12 | @GetExchange("/users") 13 | fun getUserByToken(@RequestHeader(HttpHeaders.AUTHORIZATION) token: String): UserResponse 14 | 15 | @PostExchange("/internals/users/points/decreases") 16 | fun decreasePoint( 17 | @RequestHeader(HttpHeaders.AUTHORIZATION) token: String, 18 | @RequestHeader(INTERNAL_SECRET_KEY) internalSecret: String, 19 | @RequestParam("idempotency-key") idempotencyKey: String, 20 | @RequestParam("point") point: String, 21 | ) 22 | 23 | @PostExchange("/internals/users/points/increases") 24 | fun increasePoint( 25 | @RequestHeader(HttpHeaders.AUTHORIZATION) token: String, 26 | @RequestHeader(INTERNAL_SECRET_KEY) internalSecret: String, 27 | @RequestParam("idempotency-key") idempotencyKey: String, 28 | @RequestParam("point") point: String, 29 | ) 30 | 31 | @GetExchange("/internals/users/by-name/{name}") 32 | fun getUserByName( 33 | @PathVariable("name") name: String, 34 | @RequestHeader(INTERNAL_SECRET_KEY) internalSecret: String, 35 | ): UserResponse 36 | 37 | data class UserResponse( 38 | val id: String, 39 | val username: String, 40 | val points: String, 41 | val profileImage: String, 42 | ) 43 | 44 | private companion object { 45 | private const val INTERNAL_SECRET_KEY = "Internal-Secret" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/guild/app/KickGuildFacade.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.guild.app 2 | 3 | import org.gitanimals.core.auth.InternalAuth 4 | import org.gitanimals.guild.domain.GuildService 5 | import org.springframework.stereotype.Service 6 | 7 | @Service 8 | class KickGuildFacade( 9 | private val internalAuth: InternalAuth, 10 | private val guildService: GuildService, 11 | ) { 12 | 13 | fun kickMember(guildId: Long, kickUserId: Long) { 14 | val userId = internalAuth.getUserId() 15 | 16 | guildService.kickMember( 17 | kickerId = userId, 18 | guildId = guildId, 19 | kickUserId = kickUserId, 20 | ) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/guild/app/LeaveGuildFacade.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.guild.app 2 | 3 | import org.gitanimals.core.auth.InternalAuth 4 | import org.gitanimals.guild.domain.GuildService 5 | import org.springframework.stereotype.Component 6 | 7 | @Component 8 | class LeaveGuildFacade( 9 | private val internalAuth: InternalAuth, 10 | private val guildService: GuildService, 11 | ) { 12 | 13 | fun leave(guildId: Long) { 14 | val userId = internalAuth.getUserId() 15 | 16 | guildService.leave(guildId, userId) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/guild/app/RenderApi.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.guild.app 2 | 3 | import org.gitanimals.core.PersonaType 4 | import org.springframework.web.bind.annotation.PathVariable 5 | import org.springframework.web.bind.annotation.RequestBody 6 | import org.springframework.web.bind.annotation.RequestHeader 7 | import org.springframework.web.service.annotation.GetExchange 8 | 9 | interface RenderApi { 10 | 11 | @GetExchange("/users/{username}") 12 | fun getUserByName(@PathVariable("username") username: String): UserResponse 13 | 14 | @GetExchange("/internals/personas/all") 15 | fun getAllPersonasByUserIdsAndPersonaIds( 16 | @RequestHeader(INTERNAL_SECRET_KEY) internalSecret: String, 17 | @RequestBody usernameAndPersonaIdRequests: List, 18 | ): List 19 | 20 | data class UserResponse( 21 | val id: String, 22 | val name: String, 23 | val totalContributions: String, 24 | val personas: List, 25 | ) { 26 | 27 | data class PersonaResponse( 28 | val id: String, 29 | val level: String, 30 | val type: PersonaType, 31 | ) 32 | } 33 | 34 | data class UsernameAndPersonaIdRequest( 35 | val username: String, 36 | val personaId: Long, 37 | ) 38 | 39 | private companion object { 40 | private const val INTERNAL_SECRET_KEY = "Internal-Secret" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/guild/app/SearchGuildFacade.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.guild.app 2 | 3 | import org.gitanimals.guild.domain.Guild 4 | import org.gitanimals.guild.domain.GuildService 5 | import org.gitanimals.guild.domain.RandomGuildCache 6 | import org.gitanimals.guild.domain.SearchFilter 7 | import org.springframework.data.domain.Page 8 | import org.springframework.stereotype.Service 9 | 10 | @Service 11 | class SearchGuildFacade( 12 | private val randomGuildCache: RandomGuildCache, 13 | private val guildService: GuildService, 14 | ) { 15 | 16 | fun search(key: Int, text: String, pageNumber: Int, filter: SearchFilter): Page { 17 | if (filter == SearchFilter.RANDOM) { 18 | return randomGuildCache.get( 19 | key = key, 20 | text = text, 21 | pageNumber = pageNumber, 22 | filter = filter, 23 | ) 24 | } 25 | 26 | return guildService.search( 27 | text = text, 28 | pageNumber = pageNumber, 29 | filter = filter, 30 | ) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/guild/app/request/CreateGuildRequest.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.guild.app.request 2 | 3 | import org.gitanimals.core.FieldType 4 | import org.gitanimals.core.largetTextAcceptableChars 5 | 6 | data class CreateGuildRequest( 7 | val title: String, 8 | val body: String, 9 | val guildIcon: String, 10 | val autoJoin: Boolean, 11 | val farmType: FieldType, 12 | val personaId: String, 13 | ) { 14 | 15 | fun requireValidTitle() { 16 | title.forEach { 17 | require(it in largetTextAcceptableChars) { "Cannot accept title \"$it\"" } 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/guild/app/response/GuildBackgroundResponse.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.guild.app.response 2 | 3 | import org.gitanimals.core.FieldType 4 | 5 | data class GuildBackgroundResponse( 6 | val backgrounds: List 7 | ) 8 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/guild/app/response/GuildIconsResponse.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.guild.app.response 2 | 3 | data class GuildIconsResponse( 4 | val icons: List, 5 | ) 6 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/guild/app/response/GuildPagingResponse.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.guild.app.response 2 | 3 | import org.gitanimals.guild.domain.Guild 4 | import org.springframework.data.domain.Page 5 | 6 | data class GuildPagingResponse( 7 | val guilds: List, 8 | val pagination: Pagination, 9 | ) { 10 | 11 | data class Pagination( 12 | val totalRecords: Int, 13 | val currentPage: Int, 14 | val totalPages: Int, 15 | val nextPage: Int?, 16 | val prevPage: Int?, 17 | ) 18 | 19 | companion object { 20 | 21 | fun from(guilds: Page, forceCurrentPage: Int): GuildPagingResponse { 22 | return GuildPagingResponse( 23 | guilds = guilds.map { GuildResponse.from(it) }.toList(), 24 | pagination = Pagination( 25 | totalRecords = guilds.count(), 26 | currentPage = forceCurrentPage, 27 | totalPages = guilds.totalPages, 28 | nextPage = when (guilds.hasNext()) { 29 | true -> guilds.number + 1 30 | false -> null 31 | }, 32 | prevPage = when (guilds.hasPrevious()) { 33 | true -> guilds.number - 1 34 | false -> null 35 | }, 36 | ) 37 | ) 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/guild/app/response/GuildsResponse.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.guild.app.response 2 | 3 | import org.gitanimals.guild.domain.Guild 4 | 5 | data class GuildsResponse( 6 | val guilds: List, 7 | ) { 8 | 9 | companion object { 10 | 11 | fun from(guilds: List): GuildsResponse { 12 | return GuildsResponse(guilds.map { GuildResponse.from(it) }) 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/guild/controller/request/JoinGuildRequest.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.guild.controller.request 2 | 3 | data class JoinGuildRequest( 4 | val personaId: String, 5 | ) 6 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/guild/domain/AbstractTime.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.guild.domain 2 | 3 | import jakarta.persistence.Column 4 | import jakarta.persistence.EntityListeners 5 | import jakarta.persistence.MappedSuperclass 6 | import jakarta.persistence.PrePersist 7 | import org.springframework.data.annotation.CreatedDate 8 | import org.springframework.data.annotation.LastModifiedDate 9 | import org.springframework.data.jpa.domain.support.AuditingEntityListener 10 | import java.time.Instant 11 | 12 | @MappedSuperclass 13 | @EntityListeners(AuditingEntityListener::class) 14 | abstract class AbstractTime( 15 | @CreatedDate 16 | @Column(name = "created_at") 17 | var createdAt: Instant = Instant.now(), 18 | 19 | @LastModifiedDate 20 | @Column(name = "modified_at") 21 | var modifiedAt: Instant? = null, 22 | ) { 23 | 24 | @PrePersist 25 | fun prePersist() { 26 | modifiedAt = when (modifiedAt == null) { 27 | true -> createdAt 28 | false -> return 29 | } 30 | } 31 | } 32 | 33 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/guild/domain/GuildIcons.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.guild.domain 2 | 3 | enum class GuildIcons( 4 | private val imageName: String, 5 | ) { 6 | CAT("GUILD-1"), 7 | CHICK("GUILD-2"), 8 | FLAMINGO("GUILD-3"), 9 | RABBIT("GUILD-4"), 10 | DESSERT_FOX("GUILD-5"), 11 | GHOST("GUILD-6"), 12 | HAMSTER("GUILD-7"), 13 | SLIME("GUILD-8"), 14 | PIG("GUILD-9"), 15 | PENGUIN("GUILD-10"), 16 | ; 17 | 18 | fun getImagePath() = "$IMAGE_PATH_PREFIX$imageName" 19 | 20 | companion object { 21 | private const val IMAGE_PATH_PREFIX = "https://static.gitanimals.org/guilds/icons/" 22 | 23 | fun requireExistImagePath(imagePath: String) { 24 | require(entries.any { it.getImagePath() == imagePath }) { 25 | "Cannot find matched image by imagePath \"$imagePath\"" 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/guild/domain/Leader.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.guild.domain 2 | 3 | import jakarta.persistence.Column 4 | import jakarta.persistence.Embeddable 5 | import jakarta.persistence.EnumType 6 | import jakarta.persistence.Enumerated 7 | import org.gitanimals.core.PersonaType 8 | 9 | @Embeddable 10 | data class Leader( 11 | @Column(name = "leader_id", nullable = false) 12 | val userId: Long, 13 | 14 | @Column(name = "name", nullable = false, columnDefinition = "VARCHAR(255)") 15 | var name: String, 16 | 17 | @Column(name = "persona_id", nullable = false) 18 | var personaId: Long, 19 | 20 | @Enumerated(EnumType.STRING) 21 | @Column(name = "persona_type", nullable = false, columnDefinition = "VARCHAR(255)") 22 | var personaType: PersonaType, 23 | 24 | @Column(name = "contributions", nullable = false) 25 | var contributions: Long, 26 | ) 27 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/guild/domain/Member.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.guild.domain 2 | 3 | import jakarta.persistence.* 4 | import org.gitanimals.core.IdGenerator 5 | import org.gitanimals.core.PersonaType 6 | 7 | @Entity 8 | @Table( 9 | name = "member", 10 | indexes = [ 11 | Index(name = "member_idx_name", columnList = "user_name"), 12 | ] 13 | ) 14 | class Member( 15 | @Id 16 | @Column(name = "id") 17 | val id: Long, 18 | 19 | @Column(name = "user_id", nullable = false) 20 | val userId: Long, 21 | 22 | @Column(name = "user_name", nullable = false) 23 | var name: String, 24 | 25 | @Column(name = "persona_id", nullable = false) 26 | var personaId: Long, 27 | 28 | @Enumerated(EnumType.STRING) 29 | @Column(name = "persona_type", nullable = false, columnDefinition = "VARCHAR(255)") 30 | var personaType: PersonaType, 31 | 32 | @Column(name = "contributions", nullable = false) 33 | private var contributions: Long, 34 | 35 | @ManyToOne(fetch = FetchType.LAZY) 36 | @JoinColumn(name = "guild_id") 37 | val guild: Guild, 38 | ) : AbstractTime() { 39 | 40 | fun getContributions() = contributions 41 | 42 | fun setContributions(contributions: Long) { 43 | this.contributions = contributions 44 | } 45 | 46 | override fun equals(other: Any?): Boolean { 47 | if (this === other) return true 48 | if (other !is Member) return false 49 | 50 | return userId == other.userId 51 | } 52 | 53 | override fun hashCode(): Int { 54 | return userId.hashCode() 55 | } 56 | 57 | companion object { 58 | 59 | fun create( 60 | guild: Guild, 61 | userId: Long, 62 | name: String, 63 | personaId: Long, 64 | personaType: PersonaType, 65 | contributions: Long, 66 | ): Member { 67 | return Member( 68 | id = IdGenerator.generate(), 69 | userId = userId, 70 | name = name, 71 | personaId = personaId, 72 | personaType = personaType, 73 | guild = guild, 74 | contributions = contributions, 75 | ) 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/guild/domain/RandomGuildCache.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.guild.domain 2 | 3 | import org.springframework.data.domain.Page 4 | 5 | interface RandomGuildCache { 6 | 7 | fun get(key: Int, text: String, pageNumber: Int, filter: SearchFilter): Page 8 | 9 | fun updateForce() 10 | } 11 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/guild/domain/SearchFilter.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.guild.domain 2 | 3 | enum class SearchFilter { 4 | 5 | RANDOM { 6 | override fun sort(guilds: List) = guilds 7 | }, 8 | 9 | PEOPLE_ASC { 10 | override fun sort(guilds: List): List { 11 | return guilds.sortedBy { it.getMembers().size } 12 | } 13 | }, 14 | 15 | PEOPLE_DESC { 16 | override fun sort(guilds: List): List { 17 | return guilds.sortedByDescending { it.getMembers().size } 18 | 19 | } 20 | }, 21 | 22 | CONTRIBUTION_ASC { 23 | override fun sort(guilds: List): List { 24 | return guilds.sortedBy { it.getTotalContributions() } 25 | } 26 | }, 27 | 28 | CONTRIBUTION_DESC { 29 | override fun sort(guilds: List): List { 30 | return guilds.sortedByDescending { it.getTotalContributions() } 31 | } 32 | }, 33 | ; 34 | 35 | abstract fun sort(guilds: List): List 36 | } 37 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/guild/domain/event/GuildContributionUpdated.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.guild.domain.event 2 | 3 | import org.gitanimals.core.DomainEventPublisher 4 | import org.gitanimals.core.IdGenerator 5 | import org.gitanimals.core.filter.MDCFilter 6 | import org.gitanimals.core.redis.RedisPubSubChannel 7 | import org.gitanimals.core.redis.TransactionCommitRedisPubSubEvent 8 | import org.slf4j.MDC 9 | 10 | data class GuildContributionUpdated( 11 | val guildId: Long, 12 | val guildTitle: String, 13 | val guildImage: String, 14 | val contributions: Long, 15 | val updatedContributions: Long, 16 | ) : TransactionCommitRedisPubSubEvent( 17 | traceId = runCatching { MDC.get(MDCFilter.TRACE_ID) }.getOrElse { 18 | IdGenerator.generate().toString() 19 | }, 20 | channel = RedisPubSubChannel.GUILD_CONTRIBUTION_UPDATED, 21 | source = DomainEventPublisher::class, 22 | ) 23 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/guild/domain/extension/GuildFieldTypeExtension.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.guild.domain.extension 2 | 3 | import org.gitanimals.core.FieldType 4 | 5 | object GuildFieldTypeExtension { 6 | 7 | fun FieldType.isGuildField(): Boolean { 8 | return this in guildFields 9 | } 10 | 11 | private val guildFields = setOf( 12 | FieldType.FOLDER, 13 | FieldType.RED_COMPUTER, 14 | FieldType.RED_SOFA, 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/guild/domain/request/ChangeGuildRequest.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.guild.domain.request 2 | 3 | import org.gitanimals.core.FieldType 4 | import org.gitanimals.core.largetTextAcceptableChars 5 | 6 | data class ChangeGuildRequest( 7 | val title: String, 8 | val body: String, 9 | val farmType: FieldType, 10 | val guildIcon: String, 11 | val autoJoin: Boolean, 12 | ) { 13 | 14 | fun requireValidTitle() { 15 | title.forEach { 16 | require(it in largetTextAcceptableChars) { "Cannot accept title \"$it\"" } 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/guild/domain/request/CreateLeaderRequest.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.guild.domain.request 2 | 3 | import org.gitanimals.core.PersonaType 4 | import org.gitanimals.guild.domain.Leader 5 | 6 | data class CreateLeaderRequest( 7 | val userId: Long, 8 | val name: String, 9 | val personaId: Long, 10 | val contributions: Long, 11 | val personaType: PersonaType, 12 | ) { 13 | 14 | fun toDomain(): Leader { 15 | return Leader( 16 | userId = userId, 17 | name = name, 18 | personaId = personaId, 19 | contributions = contributions, 20 | personaType = personaType, 21 | ) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/guild/infra/GuildRedisEventSubscriber.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.guild.infra 2 | 3 | import org.gitanimals.core.redis.RedisPubSubChannel 4 | import org.springframework.context.annotation.Bean 5 | import org.springframework.context.annotation.Configuration 6 | import org.springframework.data.redis.connection.RedisConnectionFactory 7 | import org.springframework.data.redis.listener.ChannelTopic 8 | import org.springframework.data.redis.listener.RedisMessageListenerContainer 9 | 10 | @Configuration 11 | class GuildRedisEventSubscriber( 12 | private val redisConnectionFactory: RedisConnectionFactory, 13 | private val guildUpdateGuildContributionMessageListener: GuildUpdateGuildContributionMessageListener, 14 | ) { 15 | 16 | @Bean 17 | fun guildRedisListenerContainer(): RedisMessageListenerContainer { 18 | return RedisMessageListenerContainer().apply { 19 | this.connectionFactory = redisConnectionFactory 20 | this.addMessageListener( 21 | guildUpdateGuildContributionMessageListener, 22 | ChannelTopic(RedisPubSubChannel.USER_CONTRIBUTION_UPDATED), 23 | ) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/guild/infra/GuildUpdateGuildContributionMessageListener.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.guild.infra 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper 4 | import org.gitanimals.core.redis.TraceableMessageListener 5 | import org.gitanimals.guild.domain.GuildService 6 | import org.gitanimals.guild.infra.event.UserContributionUpdated 7 | import org.slf4j.LoggerFactory 8 | import org.springframework.beans.factory.annotation.Qualifier 9 | import org.springframework.data.redis.connection.Message 10 | import org.springframework.data.redis.core.StringRedisTemplate 11 | import org.springframework.stereotype.Component 12 | 13 | @Component 14 | class GuildUpdateGuildContributionMessageListener( 15 | private val guildService: GuildService, 16 | private val objectMapper: ObjectMapper, 17 | @Qualifier("gitanimalsRedisTemplate") private val redisTemplate: StringRedisTemplate, 18 | ) : TraceableMessageListener(objectMapper = objectMapper, redisTemplate = redisTemplate) { 19 | 20 | private val logger = LoggerFactory.getLogger(this::class.simpleName) 21 | 22 | override fun onMessage(message: Message) { 23 | runCatching { 24 | val userContributionUpdated = objectMapper.readValue( 25 | redisTemplate.stringSerializer.deserialize(message.body), 26 | UserContributionUpdated::class.java, 27 | ) 28 | updateGuildContributions(userContributionUpdated) 29 | }.onFailure { 30 | logger.error("Cannot update guild contributions message: $message", it) 31 | throw it 32 | } 33 | } 34 | 35 | private fun updateGuildContributions(userContributionUpdated: UserContributionUpdated) { 36 | guildService.updateContribution( 37 | username = userContributionUpdated.username, 38 | contributions = userContributionUpdated.contributions, 39 | ) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/guild/infra/HttpClientErrorHandler.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.guild.infra 2 | 3 | import org.gitanimals.core.AuthorizationException 4 | import org.springframework.http.HttpStatus 5 | import org.springframework.http.client.ClientHttpResponse 6 | import org.springframework.web.client.ResponseErrorHandler 7 | 8 | 9 | class HttpClientErrorHandler : ResponseErrorHandler { 10 | 11 | override fun hasError(response: ClientHttpResponse): Boolean { 12 | return response.statusCode.isError 13 | } 14 | 15 | override fun handleError(response: ClientHttpResponse) { 16 | val body = response.body.bufferedReader().use { it.readText() } 17 | when { 18 | response.statusCode.isSameCodeAs(HttpStatus.UNAUTHORIZED) -> 19 | throw AuthorizationException(body) 20 | 21 | response.statusCode.is4xxClientError -> 22 | throw IllegalArgumentException(body) 23 | 24 | response.statusCode.is5xxServerError -> 25 | throw IllegalStateException(body) 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/guild/infra/event/UserContributionUpdated.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.guild.infra.event 2 | 3 | import java.time.Instant 4 | 5 | data class UserContributionUpdated( 6 | val traceId: String, 7 | val username: String, 8 | val contributions: Long, 9 | val updatedContributions: Long, 10 | val userContributionUpdated: Boolean, 11 | val contributionUpdatedAt: Instant, 12 | val timestamp: Long, 13 | ) 14 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/guild/saga/PersonaDeletedSagaHandler.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.guild.saga 2 | 3 | import org.gitanimals.guild.app.RenderApi 4 | import org.gitanimals.guild.domain.GuildService 5 | import org.gitanimals.guild.saga.event.PersonaDeleted 6 | import org.rooftop.netx.api.SagaStartEvent 7 | import org.rooftop.netx.api.SagaStartListener 8 | import org.rooftop.netx.api.SuccessWith 9 | import org.rooftop.netx.meta.SagaHandler 10 | 11 | @SagaHandler 12 | class PersonaDeletedSagaHandler( 13 | private val guildService: GuildService, 14 | private val renderApi: RenderApi, 15 | ) { 16 | 17 | @SagaStartListener( 18 | event = PersonaDeleted::class, 19 | noRollbackFor = [IllegalStateException::class], 20 | successWith = SuccessWith.END, 21 | ) 22 | fun handlePersonaDeletedEvent(sagaStartEvent: SagaStartEvent) { 23 | val personaDeleted = sagaStartEvent.decodeEvent(PersonaDeleted::class) 24 | 25 | val changePersona = 26 | renderApi.getUserByName(personaDeleted.username).personas.maxByOrNull { it.level } 27 | ?: throw IllegalStateException("Cannot find any persona by username \"${personaDeleted.username}\"") 28 | 29 | guildService.deletePersonaSync( 30 | userId = personaDeleted.userId, 31 | deletedPersonaId = personaDeleted.personaId, 32 | changePersonaId = changePersona.id.toLong(), 33 | changePersonaType = changePersona.type, 34 | ) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/guild/saga/event/PersonaDeleted.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.guild.saga.event 2 | 3 | import org.gitanimals.core.clock 4 | import java.time.Instant 5 | 6 | data class PersonaDeleted( 7 | val userId: Long, 8 | val username: String, 9 | val personaId: Long, 10 | val personaDeletedAt: Instant = clock.instant(), 11 | ) 12 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/rank/app/GetRankByUsernameFacade.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.rank.app 2 | 3 | import org.gitanimals.rank.domain.RankQueryRepository 4 | import org.gitanimals.rank.domain.UserContributionRankService 5 | import org.gitanimals.rank.domain.response.RankResponse 6 | import org.springframework.stereotype.Component 7 | 8 | @Component 9 | class GetRankByUsernameFacade( 10 | private val userContributionRankService: UserContributionRankService, 11 | private val rankQueryRepository: RankQueryRepository, 12 | ) { 13 | 14 | fun invoke(username: String): RankResponse { 15 | val userContributionRank = userContributionRankService.findUserRankByUsername(username) 16 | 17 | checkNotNull(userContributionRank) { 18 | "UserContributionRank is null username: $username" 19 | } 20 | 21 | val rankQueryResponse = rankQueryRepository.getRankByRankId( 22 | rankType = RankQueryRepository.RankType.WEEKLY_USER_CONTRIBUTIONS, 23 | rankId = userContributionRank.id, 24 | ) 25 | 26 | return RankResponse( 27 | id = userContributionRank.userId.toString(), 28 | rank = rankQueryResponse.rank, 29 | image = userContributionRank.image, 30 | name = userContributionRank.username, 31 | contributions = userContributionRank.weeklyContributions, 32 | ) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/rank/app/GuildApi.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.rank.app 2 | 3 | import com.fasterxml.jackson.annotation.JsonFormat 4 | import org.springframework.web.bind.annotation.PathVariable 5 | import org.springframework.web.service.annotation.GetExchange 6 | import java.time.Instant 7 | 8 | interface GuildApi { 9 | 10 | @GetExchange("/internals/guilds/by-title/{title}") 11 | fun getGuildByTitle(@PathVariable("title") title: String): GuildResponse 12 | 13 | @GetExchange("/guilds/{guildId}") 14 | fun getGuildById(@PathVariable("guildId") guildId: Long): GuildResponse 15 | 16 | data class GuildResponse( 17 | val id: String, 18 | val title: String, 19 | val body: String, 20 | val guildIcon: String, 21 | val leader: org.gitanimals.guild.app.response.GuildResponse.Leader, 22 | val farmType: String, 23 | val totalContributions: String, 24 | val members: List, 25 | val waitMembers: List, 26 | val autoJoin: Boolean, 27 | @JsonFormat( 28 | shape = JsonFormat.Shape.STRING, 29 | pattern = "yyyy-MM-dd HH:mm:ss", 30 | timezone = "UTC" 31 | ) 32 | val createdAt: Instant, 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/rank/app/IdentityApi.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.rank.app 2 | 3 | import org.springframework.web.bind.annotation.PathVariable 4 | import org.springframework.web.bind.annotation.RequestParam 5 | import org.springframework.web.service.annotation.GetExchange 6 | import org.springframework.web.service.annotation.PostExchange 7 | 8 | interface IdentityApi { 9 | 10 | @GetExchange("/internals/users/by-name/{name}") 11 | fun getUserByName(@PathVariable("name") name: String): UserResponse 12 | 13 | @PostExchange("/internals/users/points/increases/by-username/{username}") 14 | fun increaseUserPointsByUsername( 15 | @PathVariable("username") username: String, 16 | @RequestParam("point") point: Int, 17 | @RequestParam("idempotency-key") idempotencyKey: String, 18 | ) 19 | 20 | data class UserResponse( 21 | val id: String, 22 | val username: String, 23 | val points: String, 24 | val profileImage: String, 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/rank/app/RenderApi.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.rank.app 2 | 3 | import org.gitanimals.core.PersonaType 4 | import org.springframework.web.bind.annotation.PathVariable 5 | import org.springframework.web.service.annotation.GetExchange 6 | 7 | fun interface RenderApi { 8 | 9 | @GetExchange("/internals/users/{username}/top-level-personas") 10 | fun getUserWithTopLevelPersona(@PathVariable("username") username: String): UserResponse 11 | 12 | data class UserResponse( 13 | val id: String, 14 | val name: String, 15 | val totalContributions: String, 16 | val personas: List, 17 | ) 18 | 19 | data class PersonaResponse( 20 | val id: String, 21 | val type: PersonaType, 22 | val level: String, 23 | val visible: Boolean, 24 | val dropRate: String, 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/rank/controller/response/RankHistoryResponse.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.rank.controller.response 2 | 3 | import org.gitanimals.rank.domain.RankQueryRepository 4 | import org.gitanimals.rank.domain.history.RankHistory 5 | 6 | data class RankHistoryResponse( 7 | val winner: List 8 | ) { 9 | data class Result( 10 | val id: String, 11 | val name: String, 12 | val rank: Int, 13 | val prize: Int, 14 | val rankType: RankQueryRepository.RankType, 15 | ) 16 | 17 | companion object { 18 | fun from(rankHistories: List): RankHistoryResponse { 19 | return RankHistoryResponse( 20 | winner = rankHistories.map { 21 | Result( 22 | id = it.winner.id.toString(), 23 | name = it.winner.name, 24 | rank = it.ranks, 25 | prize = it.prize, 26 | rankType = it.rankType, 27 | ) 28 | }.sortedBy { it.rank } 29 | ) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/rank/controller/response/RankResponses.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.rank.controller.response 2 | 3 | import org.gitanimals.rank.domain.response.RankResponse 4 | 5 | data class RankResponses( 6 | val ranks: List, 7 | ) 8 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/rank/controller/response/RankTotalCountResponse.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.rank.controller.response 2 | 3 | data class RankTotalCountResponse( 4 | val count: Int, 5 | ) 6 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/rank/domain/AbstractTime.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.rank.domain 2 | 3 | import jakarta.persistence.Column 4 | import jakarta.persistence.EntityListeners 5 | import jakarta.persistence.MappedSuperclass 6 | import jakarta.persistence.PrePersist 7 | import org.springframework.data.annotation.CreatedDate 8 | import org.springframework.data.annotation.LastModifiedDate 9 | import org.springframework.data.jpa.domain.support.AuditingEntityListener 10 | import java.time.Instant 11 | 12 | @MappedSuperclass 13 | @EntityListeners(AuditingEntityListener::class) 14 | abstract class AbstractTime( 15 | @CreatedDate 16 | @Column(name = "created_at") 17 | var createdAt: Instant = Instant.now(), 18 | 19 | @LastModifiedDate 20 | @Column(name = "modified_at") 21 | var modifiedAt: Instant? = null, 22 | ) { 23 | 24 | @PrePersist 25 | fun prePersist() { 26 | modifiedAt = when (modifiedAt == null) { 27 | true -> createdAt 28 | false -> return 29 | } 30 | } 31 | } 32 | 33 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/rank/domain/GuildContributionRank.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.rank.domain 2 | 3 | import jakarta.persistence.Column 4 | import jakarta.persistence.Entity 5 | import jakarta.persistence.Table 6 | import org.gitanimals.core.IdGenerator 7 | 8 | @Entity 9 | @Table(name = "guild_contribution_rank") 10 | class GuildContributionRank( 11 | id: Long, 12 | image: String, 13 | 14 | @Column(name = "guild_id", nullable = false, unique = true) 15 | val guildId: Long, 16 | @Column(name = "guild_name", nullable = false, unique = true) 17 | val guildName: String, 18 | @Column(name = "weekly_contributions", nullable = false) 19 | var weeklyContributions: Long, 20 | ) : Rank(id, image) { 21 | 22 | companion object { 23 | fun create( 24 | image: String, 25 | guildId: Long, 26 | guildName: String, 27 | weeklyContributions: Long, 28 | ): GuildContributionRank { 29 | return GuildContributionRank( 30 | id = IdGenerator.generate(), 31 | image = image, 32 | guildId = guildId, 33 | guildName = guildName, 34 | weeklyContributions = weeklyContributions, 35 | ) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/rank/domain/GuildContributionRankRepository.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.rank.domain 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository 4 | import org.springframework.data.jpa.repository.Modifying 5 | import org.springframework.data.jpa.repository.Query 6 | 7 | interface GuildContributionRankRepository : JpaRepository { 8 | 9 | fun findByGuildId(guildId: Long): GuildContributionRank? 10 | 11 | @Modifying 12 | @Query("update GuildContributionRank as g set g.weeklyContributions = 0") 13 | fun initialWeeklyRanks() 14 | } 15 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/rank/domain/Rank.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.rank.domain 2 | 3 | import jakarta.persistence.* 4 | 5 | @MappedSuperclass 6 | @Inheritance(strategy = InheritanceType.TABLE_PER_CLASS) 7 | abstract class Rank( 8 | @Id 9 | @Column(name = "id") 10 | val id: Long, 11 | 12 | @Column(name = "image", nullable = false) 13 | val image: String, 14 | ) : AbstractTime() 15 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/rank/domain/RankQueryRepository.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.rank.domain 2 | 3 | interface RankQueryRepository { 4 | 5 | fun findAllRank(rankStartedAt: Int, rankType: RankType, limit: Int): Set 6 | 7 | fun getRankByRankId(rankType: RankType, rankId: Long): RankQueryResponse 8 | 9 | fun updateRank(rankType: RankType, rankId: RankId, score: Long) 10 | 11 | fun initialRank(rankType: RankType) 12 | 13 | fun getTotalRankCount(rankType: RankType): Int 14 | 15 | enum class RankType { 16 | WEEKLY_GUILD_CONTRIBUTIONS, 17 | WEEKLY_USER_CONTRIBUTIONS, 18 | ; 19 | } 20 | 21 | data class RankQueryResponse( 22 | val id: Long, 23 | val rank: Int, 24 | ) 25 | 26 | } 27 | 28 | @JvmInline 29 | value class RankId(val value: Long) 30 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/rank/domain/UserContributionRank.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.rank.domain 2 | 3 | import jakarta.persistence.Column 4 | import jakarta.persistence.Entity 5 | import jakarta.persistence.Table 6 | import org.gitanimals.core.IdGenerator 7 | 8 | @Entity 9 | @Table(name = "user_contribution_rank") 10 | class UserContributionRank( 11 | id: Long, 12 | image: String, 13 | 14 | @Column(name = "user_id", nullable = false, unique = true) 15 | val userId: Long, 16 | @Column(name = "username", nullable = false, unique = true) 17 | var username: String, 18 | @Column(name = "weekly_contributions", nullable = false) 19 | var weeklyContributions: Long, 20 | ) : Rank(id, image) { 21 | 22 | companion object { 23 | 24 | fun create( 25 | image: String, 26 | userId: Long, 27 | username: String, 28 | weeklyContributions: Long, 29 | ): UserContributionRank { 30 | return UserContributionRank( 31 | id = IdGenerator.generate(), 32 | image = image, 33 | userId = userId, 34 | username = username, 35 | weeklyContributions = weeklyContributions, 36 | ) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/rank/domain/UserContributionRankRepository.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.rank.domain 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository 4 | import org.springframework.data.jpa.repository.Modifying 5 | import org.springframework.data.jpa.repository.Query 6 | import org.springframework.data.repository.query.Param 7 | 8 | interface UserContributionRankRepository : JpaRepository { 9 | fun findByUserId(userId: Long): UserContributionRank? 10 | 11 | @Query("select u from UserContributionRank as u where u.username = :username") 12 | fun findByUsername(@Param("username") username: String): UserContributionRank? 13 | 14 | @Modifying 15 | @Query("update UserContributionRank as u set u.weeklyContributions = 0") 16 | fun initialWeeklyRanks() 17 | } 18 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/rank/domain/event/RankUpdated.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.rank.domain.event 2 | 3 | import org.gitanimals.rank.domain.RankId 4 | import org.gitanimals.rank.domain.RankQueryRepository 5 | 6 | data class RankUpdated( 7 | val type: RankQueryRepository.RankType, 8 | val rankId: RankId, 9 | val score: Long, 10 | ) 11 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/rank/domain/history/RankHistory.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.rank.domain.history 2 | 3 | import jakarta.persistence.* 4 | import org.gitanimals.rank.domain.AbstractTime 5 | import org.gitanimals.rank.domain.RankQueryRepository.RankType 6 | 7 | @Entity 8 | @Table(name = "rank_history") 9 | class RankHistory( 10 | @Id 11 | val id: Long, 12 | @Column(name = "ranks") 13 | val ranks: Int, 14 | @Column(name = "prize") 15 | val prize: Int, 16 | @Enumerated(EnumType.STRING) 17 | @Column(name = "rank_type") 18 | val rankType: RankType, 19 | @Embedded 20 | val winner: Winner, 21 | ) : AbstractTime() 22 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/rank/domain/history/RankHistoryRepository.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.rank.domain.history 2 | 3 | import org.gitanimals.rank.domain.RankQueryRepository.RankType 4 | import org.springframework.data.jpa.repository.JpaRepository 5 | 6 | interface RankHistoryRepository: JpaRepository { 7 | 8 | fun findTop3ByRankTypeOrderByCreatedAtDesc(rankType: RankType): List 9 | } 10 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/rank/domain/history/RankHistoryService.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.rank.domain.history 2 | 3 | import org.gitanimals.core.IdGenerator 4 | import org.gitanimals.rank.domain.RankQueryRepository.RankType 5 | import org.gitanimals.rank.domain.history.request.InitRankHistoryRequest 6 | import org.springframework.cache.annotation.Cacheable 7 | import org.springframework.stereotype.Service 8 | import org.springframework.transaction.annotation.Transactional 9 | 10 | @Service 11 | class RankHistoryService( 12 | private val rankHistoryRepository: RankHistoryRepository, 13 | ) { 14 | 15 | @Transactional 16 | fun initTop3Rank( 17 | initRankHistoryRequests: List, 18 | ) { 19 | rankHistoryRepository.saveAll(initRankHistoryRequests.map { 20 | RankHistory( 21 | id = IdGenerator.generate(), 22 | ranks = it.rank, 23 | prize = it.prize, 24 | rankType = it.rankType, 25 | winner = Winner( 26 | id = it.winnerId, 27 | name = it.winnerName, 28 | ) 29 | ) 30 | }) 31 | } 32 | 33 | @Cacheable(cacheNames = ["find_top3_history_by_rank_type"]) 34 | fun findTop3HistoryByRankType(rankType: RankType): List { 35 | return rankHistoryRepository.findTop3ByRankTypeOrderByCreatedAtDesc(rankType) 36 | .sortedBy { it.ranks } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/rank/domain/history/Winner.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.rank.domain.history 2 | 3 | import jakarta.persistence.Column 4 | import jakarta.persistence.Embeddable 5 | 6 | @Embeddable 7 | class Winner( 8 | @Column(name = "winner_id") 9 | val id: Long, 10 | @Column(name = "winner_name") 11 | val name: String, 12 | ) 13 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/rank/domain/history/request/InitRankHistoryRequest.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.rank.domain.history.request 2 | 3 | import org.gitanimals.rank.domain.RankQueryRepository.RankType 4 | 5 | data class InitRankHistoryRequest( 6 | val rank: Int, 7 | val prize: Int, 8 | val rankType: RankType, 9 | val winnerId: Long, 10 | val winnerName: String, 11 | ) 12 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/rank/domain/response/RankResponse.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.rank.domain.response 2 | 3 | data class RankResponse( 4 | val id: String, 5 | val rank: Int, 6 | val image: String, 7 | val name: String, 8 | val contributions: Long, 9 | ) 10 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/rank/infra/HttpClientErrorHandler.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.rank.infra 2 | 3 | import org.gitanimals.core.AuthorizationException 4 | import org.springframework.http.HttpStatus 5 | import org.springframework.http.client.ClientHttpResponse 6 | import org.springframework.web.client.ResponseErrorHandler 7 | 8 | 9 | class HttpClientErrorHandler : ResponseErrorHandler { 10 | 11 | override fun hasError(response: ClientHttpResponse): Boolean { 12 | return response.statusCode.isError 13 | } 14 | 15 | override fun handleError(response: ClientHttpResponse) { 16 | val body = response.body.bufferedReader().use { it.readText() } 17 | when { 18 | response.statusCode.isSameCodeAs(HttpStatus.UNAUTHORIZED) -> 19 | throw AuthorizationException(body) 20 | 21 | response.statusCode.is4xxClientError -> 22 | throw IllegalArgumentException(body) 23 | 24 | response.statusCode.is5xxServerError -> 25 | throw IllegalStateException(body) 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/rank/infra/RankCacheConfigurer.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.rank.infra 2 | 3 | import com.github.benmanes.caffeine.cache.Caffeine 4 | import org.springframework.cache.CacheManager 5 | import org.springframework.cache.caffeine.CaffeineCacheManager 6 | import org.springframework.context.annotation.Bean 7 | import org.springframework.context.annotation.Configuration 8 | import kotlin.time.Duration.Companion.minutes 9 | import kotlin.time.toJavaDuration 10 | 11 | @Configuration 12 | class RankCacheConfigurer { 13 | 14 | @Bean 15 | fun rankTotalCountCacheManager(): CacheManager { 16 | val caffeineConfig = Caffeine.newBuilder().expireAfterWrite(10.minutes.toJavaDuration()) 17 | 18 | val caffeineCacheManager = CaffeineCacheManager() 19 | caffeineCacheManager.setCaffeine(caffeineConfig) 20 | 21 | return caffeineCacheManager 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/rank/infra/RankRedisEventSubscriber.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.rank.infra 2 | 3 | import org.gitanimals.core.redis.RedisPubSubChannel 4 | import org.springframework.context.annotation.Bean 5 | import org.springframework.context.annotation.Configuration 6 | import org.springframework.data.redis.connection.RedisConnectionFactory 7 | import org.springframework.data.redis.listener.ChannelTopic 8 | import org.springframework.data.redis.listener.RedisMessageListenerContainer 9 | 10 | @Configuration 11 | class RankRedisEventSubscriber( 12 | private val redisConnectionFactory: RedisConnectionFactory, 13 | private val rankUpdateGuildContributionMessageListener: RankUpdateGuildContributionMessageListener, 14 | private val updateUserContributionMessageListener: UpdateUserContributionMessageListener, 15 | ) { 16 | 17 | @Bean 18 | fun rankRedisListenerContainer(): RedisMessageListenerContainer { 19 | return RedisMessageListenerContainer().apply { 20 | this.connectionFactory = redisConnectionFactory 21 | this.addMessageListener( 22 | updateUserContributionMessageListener, 23 | ChannelTopic(RedisPubSubChannel.USER_CONTRIBUTION_UPDATED), 24 | ) 25 | this.addMessageListener( 26 | rankUpdateGuildContributionMessageListener, 27 | ChannelTopic(RedisPubSubChannel.GUILD_CONTRIBUTION_UPDATED), 28 | ) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/rank/infra/RankUpdateGuildContributionMessageListener.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.rank.infra 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper 4 | import org.gitanimals.core.redis.TraceableMessageListener 5 | import org.gitanimals.rank.domain.GuildContributionRank 6 | import org.gitanimals.rank.domain.GuildContributionRankService 7 | import org.gitanimals.rank.infra.event.GuildContributionUpdated 8 | import org.slf4j.LoggerFactory 9 | import org.springframework.beans.factory.annotation.Qualifier 10 | import org.springframework.data.redis.connection.Message 11 | import org.springframework.data.redis.core.StringRedisTemplate 12 | import org.springframework.stereotype.Component 13 | 14 | @Component 15 | class RankUpdateGuildContributionMessageListener( 16 | private val objectMapper: ObjectMapper, 17 | private val guildContributionService: GuildContributionRankService, 18 | @Qualifier("gitanimalsRedisTemplate") private val redisTemplate: StringRedisTemplate, 19 | ) : TraceableMessageListener(objectMapper = objectMapper, redisTemplate = redisTemplate) { 20 | 21 | private val logger = LoggerFactory.getLogger(this::class.simpleName) 22 | 23 | override fun onMessage(message: Message) { 24 | runCatching { 25 | val guildContributionUpdated = objectMapper.readValue( 26 | redisTemplate.stringSerializer.deserialize(message.body), 27 | GuildContributionUpdated::class.java, 28 | ) 29 | 30 | val updatedGuildContributionRank = GuildContributionRank.create( 31 | image = guildContributionUpdated.guildImage, 32 | guildName = guildContributionUpdated.guildTitle, 33 | guildId = guildContributionUpdated.guildId, 34 | weeklyContributions = guildContributionUpdated.updatedContributions, 35 | ) 36 | 37 | guildContributionService.updateContribution(updatedGuildContributionRank) 38 | }.onFailure { 39 | logger.error("Cannot update guild contributions by message: $message", it) 40 | throw it 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/rank/infra/event/GuildContributionUpdated.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.rank.infra.event 2 | 3 | data class GuildContributionUpdated( 4 | val traceId: String, 5 | val guildId: Long, 6 | val guildTitle: String, 7 | val guildImage: String, 8 | val contributions: Long, 9 | val updatedContributions: Long, 10 | val timestamp: Long, 11 | ) 12 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/rank/infra/event/UserContributionUpdated.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.rank.infra.event 2 | 3 | import java.time.Instant 4 | 5 | data class UserContributionUpdated( 6 | val traceId: String, 7 | val username: String, 8 | val contributions: Long, 9 | val updatedContributions: Long, 10 | val userContributionUpdated: Boolean, 11 | val contributionUpdatedAt: Instant, 12 | val timestamp: Long, 13 | ) 14 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/app/ContributionApi.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.app 2 | 3 | interface ContributionApi { 4 | 5 | fun getContributionCount(username: String, years: List): Map 6 | 7 | fun getAllContributionYears(username: String): List 8 | } 9 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/app/GithubRestApi.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.app 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty 4 | import org.springframework.web.bind.annotation.PathVariable 5 | import org.springframework.web.service.annotation.GetExchange 6 | 7 | fun interface GithubRestApi { 8 | 9 | @GetExchange("/users/{username}") 10 | fun getGithubUser(@PathVariable("username") username: String): GithubUserResponse 11 | 12 | data class GithubUserResponse( 13 | @JsonProperty("login") 14 | val name: String, 15 | val id: String, 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/app/IdentityApi.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.app 2 | 3 | import org.springframework.http.HttpHeaders 4 | import org.springframework.web.bind.annotation.PathVariable 5 | import org.springframework.web.bind.annotation.RequestHeader 6 | import org.springframework.web.bind.annotation.RequestParam 7 | import org.springframework.web.service.annotation.GetExchange 8 | import org.springframework.web.service.annotation.PostExchange 9 | 10 | interface IdentityApi { 11 | 12 | @GetExchange("/users") 13 | fun getUserByToken(@RequestHeader(HttpHeaders.AUTHORIZATION) token: String): UserResponse 14 | 15 | @PostExchange("/internals/users/points/increases/by-username/{username}") 16 | fun increaseUserPointsByUsername( 17 | @PathVariable("username") username: String, 18 | @RequestParam("point") point: Int, 19 | @RequestParam("idempotency-key") idempotencyKey: String, 20 | ) 21 | 22 | data class UserResponse( 23 | val id: String, 24 | val username: String, 25 | val points: String, 26 | val profileImage: String, 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/app/UserStatisticSchedule.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.app 2 | 3 | import org.gitanimals.core.instant 4 | import org.gitanimals.core.toKr 5 | import org.gitanimals.render.domain.UserStatisticService 6 | import org.gitanimals.render.domain.event.UserYesterdayReport 7 | import org.rooftop.netx.api.SagaManager 8 | import org.springframework.scheduling.annotation.Scheduled 9 | import org.springframework.stereotype.Service 10 | 11 | @Service 12 | class UserStatisticSchedule( 13 | private val sagaManager: SagaManager, 14 | private val userStatisticService: UserStatisticService, 15 | ) { 16 | 17 | @Scheduled(cron = EVERY_9AM, zone = "Asia/Seoul") 18 | fun sendYesterdayNewUserReport() { 19 | val yesterday = instant().toKr().minusDays(1) 20 | val yesterdayUserCount = userStatisticService.getYesterdayUserCount() 21 | val totalUserCount = userStatisticService.getTotalUserCount() 22 | 23 | val userYesterdayReport = UserYesterdayReport( 24 | date = yesterday, 25 | yesterdayNewUserCount = yesterdayUserCount, 26 | totalUserCount = totalUserCount, 27 | serverName = SERVER_NAME, 28 | ) 29 | 30 | sagaManager.startSync(userYesterdayReport) 31 | } 32 | 33 | private companion object { 34 | private const val EVERY_9AM = "0 0 9 * * ?" 35 | private const val SERVER_NAME = "RENDER" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/app/request/MergePersonaRequest.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.app.request 2 | 3 | data class MergePersonaRequest( 4 | val increasePersonaId: String, 5 | val deletePersonaId: String, 6 | ) 7 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/controller/AnimationController.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.controller 2 | 3 | import jakarta.servlet.http.HttpServletResponse 4 | import org.gitanimals.core.Mode 5 | import org.gitanimals.core.extension.HttpResponseExtension.cacheControl 6 | import org.gitanimals.core.extension.StringExtension.deleteBrackets 7 | import org.gitanimals.core.extension.StringExtension.trimNotDigitCharacters 8 | import org.gitanimals.render.app.AnimationFacade 9 | import org.springframework.web.bind.annotation.GetMapping 10 | import org.springframework.web.bind.annotation.PathVariable 11 | import org.springframework.web.bind.annotation.RequestParam 12 | import org.springframework.web.bind.annotation.RestController 13 | 14 | @RestController 15 | class AnimationController( 16 | private val animationFacade: AnimationFacade, 17 | ) { 18 | 19 | @GetMapping(value = ["/farms/{username}"], produces = ["image/svg+xml"]) 20 | fun getFarmSvgAnimation( 21 | @PathVariable("username") username: String, 22 | response: HttpServletResponse 23 | ): String { 24 | response.cacheControl(3600) 25 | return animationFacade.getFarmAnimation(username.deleteBrackets()) 26 | } 27 | 28 | @GetMapping(value = ["/lines/{username}"], produces = ["image/svg+xml"]) 29 | fun getLineSvgAnimation( 30 | @PathVariable("username") username: String, 31 | @RequestParam(name = "pet-id", defaultValue = "0") personaId: String, 32 | @RequestParam(name = "contribution-view", defaultValue = "true") contributionView: Boolean, 33 | response: HttpServletResponse, 34 | ): String { 35 | response.cacheControl(3600) 36 | 37 | val mode = when (contributionView) { 38 | true -> Mode.LINE 39 | false -> Mode.LINE_NO_CONTRIBUTION 40 | } 41 | 42 | return animationFacade.getLineAnimation( 43 | username = username.deleteBrackets(), 44 | personaId = personaId.trimNotDigitCharacters().toLong(), 45 | mode = mode, 46 | ) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/controller/InternalAnimationController.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.controller 2 | 3 | import jakarta.servlet.http.HttpServletResponse 4 | import org.gitanimals.core.Mode 5 | import org.gitanimals.core.extension.HttpResponseExtension.cacheControl 6 | import org.gitanimals.core.extension.StringExtension.deleteBrackets 7 | import org.gitanimals.core.extension.StringExtension.trimNotDigitCharacters 8 | import org.gitanimals.render.app.AnimationFacade 9 | import org.springframework.beans.factory.annotation.Value 10 | import org.springframework.web.bind.annotation.GetMapping 11 | import org.springframework.web.bind.annotation.PathVariable 12 | import org.springframework.web.bind.annotation.RequestHeader 13 | import org.springframework.web.bind.annotation.RequestParam 14 | import org.springframework.web.bind.annotation.RestController 15 | 16 | @RestController 17 | class InternalAnimationController( 18 | private val animationFacade: AnimationFacade, 19 | @Value("\${internal.image.secret}") private val internalImageSecret: String, 20 | ) { 21 | 22 | @GetMapping(value = ["/lines/{username}/only-pets"], produces = ["image/svg+xml"]) 23 | fun getOnlyPetAnimation( 24 | @PathVariable("username") username: String, 25 | @RequestParam(name = "pet-id", defaultValue = "0") personaId: String, 26 | @RequestHeader("Image-Secret") renderSecret: String, 27 | response: HttpServletResponse, 28 | ): String { 29 | require(renderSecret == this.internalImageSecret) 30 | response.cacheControl(3600) 31 | 32 | return animationFacade.getLineAnimation( 33 | username = username.deleteBrackets(), 34 | personaId = personaId.trimNotDigitCharacters().toLong(), 35 | mode = Mode.NONE, 36 | ) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/controller/PersonaStatisticController.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.controller 2 | 3 | import org.gitanimals.render.controller.response.TotalPersonaResponse 4 | import org.gitanimals.render.domain.PersonaStatisticService 5 | import org.springframework.web.bind.annotation.GetMapping 6 | import org.springframework.web.bind.annotation.RestController 7 | 8 | @RestController 9 | class PersonaStatisticController( 10 | private val personaStatisticService: PersonaStatisticService, 11 | ) { 12 | 13 | @GetMapping("/personas/statistics/total") 14 | fun totalAdaptedPersonas(): TotalPersonaResponse = 15 | TotalPersonaResponse.from(personaStatisticService.getTotalPersonaCount()) 16 | } 17 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/controller/UserStatisticController.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.controller 2 | 3 | import org.gitanimals.render.controller.response.TotalUserResponse 4 | import org.gitanimals.render.domain.UserStatisticService 5 | import org.springframework.web.bind.annotation.GetMapping 6 | import org.springframework.web.bind.annotation.RestController 7 | 8 | @RestController 9 | class UserStatisticController( 10 | private val userStatisticService: UserStatisticService, 11 | ) { 12 | 13 | @GetMapping("/users/statistics/total") 14 | fun totalUsers(): TotalUserResponse = 15 | TotalUserResponse.from(userStatisticService.getTotalUserCount()) 16 | } 17 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/controller/request/AddMultiplyPersonaRequest.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.controller.request 2 | 3 | data class AddMultiplyPersonaRequest( 4 | val idempotencyKey: String, 5 | val personaName: String, 6 | ) 7 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/controller/request/AddPersonaRequest.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.controller.request 2 | 3 | data class AddPersonaRequest( 4 | val id: Long, 5 | val name: String, 6 | val level: Int, 7 | ) 8 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/controller/request/ChangeFieldRequest.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.controller.request 2 | 3 | data class ChangeFieldRequest( 4 | val type: String, 5 | ) 6 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/controller/request/UsernameAndPersonaIdRequest.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.controller.request 2 | 3 | data class UsernameAndPersonaIdRequest( 4 | val username: String, 5 | val personaId: Long, 6 | ) 7 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/controller/response/BackgroundResponse.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.controller.response 2 | 3 | import org.gitanimals.core.FieldType 4 | import org.gitanimals.render.domain.User 5 | 6 | data class BackgroundResponse( 7 | val id: String, 8 | val name: String, 9 | val backgrounds: List, 10 | ) { 11 | 12 | data class Background( 13 | val type: FieldType, 14 | ) 15 | 16 | companion object { 17 | fun from(user: User): BackgroundResponse { 18 | return BackgroundResponse( 19 | id = user.id.toString(), 20 | name = user.getName(), 21 | backgrounds = user.fields.map { Background(it.fieldType) }.ifEmpty { 22 | listOf(Background(FieldType.WHITE_FIELD)) 23 | }, 24 | ) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/controller/response/PersonaEnumResponse.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.controller.response 2 | 3 | import org.gitanimals.core.PersonaType 4 | 5 | data class PersonaEnumResponse( 6 | val type: PersonaType, 7 | val dropRate: String, 8 | ) { 9 | 10 | companion object { 11 | fun from(personaType: PersonaType): PersonaEnumResponse { 12 | return PersonaEnumResponse(personaType, personaType.getDropRate()) 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/controller/response/PersonaResponse.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.controller.response 2 | 3 | import org.gitanimals.core.PersonaType 4 | import org.gitanimals.render.domain.response.PersonaResponse 5 | 6 | data class PersonaResponse( 7 | val id: String, 8 | val type: PersonaType, 9 | val level: String, 10 | val visible: Boolean, 11 | val dropRate: String, 12 | ) { 13 | 14 | companion object { 15 | fun from(personaResponse: PersonaResponse): org.gitanimals.render.controller.response.PersonaResponse { 16 | return org.gitanimals.render.controller.response.PersonaResponse( 17 | id = personaResponse.id, 18 | type = personaResponse.type, 19 | level = personaResponse.level, 20 | visible = personaResponse.visible, 21 | dropRate = personaResponse.dropRate, 22 | ) 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/controller/response/TotalPersonaResponse.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.controller.response 2 | 3 | data class TotalPersonaResponse( 4 | private val personaCount: String, 5 | ) { 6 | 7 | companion object { 8 | fun from(totalPersonaCount: Long): TotalPersonaResponse = 9 | TotalPersonaResponse(totalPersonaCount.toString()) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/controller/response/TotalUserResponse.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.controller.response 2 | 3 | data class TotalUserResponse( 4 | val userCount: String, 5 | ) { 6 | 7 | companion object { 8 | fun from(totalUserCount: Long): TotalUserResponse = 9 | TotalUserResponse(totalUserCount.toString()) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/domain/AbstractTime.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.domain 2 | 3 | import jakarta.persistence.Column 4 | import jakarta.persistence.EntityListeners 5 | import jakarta.persistence.MappedSuperclass 6 | import jakarta.persistence.PrePersist 7 | import org.springframework.data.annotation.CreatedDate 8 | import org.springframework.data.annotation.LastModifiedDate 9 | import org.springframework.data.jpa.domain.support.AuditingEntityListener 10 | import java.time.Instant 11 | 12 | @MappedSuperclass 13 | @EntityListeners(AuditingEntityListener::class) 14 | abstract class AbstractTime( 15 | @CreatedDate 16 | @Column(name = "created_at") 17 | var createdAt: Instant = Instant.now(), 18 | 19 | @LastModifiedDate 20 | @Column(name = "modified_at") 21 | var modifiedAt: Instant? = null, 22 | ) { 23 | 24 | @PrePersist 25 | fun prePersist() { 26 | modifiedAt = when (modifiedAt == null) { 27 | true -> createdAt 28 | false -> return 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/domain/EntryPoint.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.domain 2 | 3 | enum class EntryPoint { 4 | GITHUB, 5 | } 6 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/domain/Field.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.domain 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnore 4 | import jakarta.persistence.* 5 | import org.gitanimals.core.FieldType 6 | import org.gitanimals.core.IdGenerator 7 | 8 | @Entity 9 | @Table(name = "field") 10 | class Field( 11 | @Id 12 | @Column(name = "id") 13 | private val id: Long, 14 | 15 | @Column(name = "field_type") 16 | @Enumerated(value = EnumType.STRING) 17 | val fieldType: FieldType, 18 | 19 | @Column(name = "is_choose", nullable = false) 20 | private var isChoose: Boolean, 21 | 22 | @JsonIgnore 23 | @ManyToOne(fetch = FetchType.LAZY) 24 | @JoinColumn(name = "user_id") 25 | val user: User, 26 | ) { 27 | 28 | fun isChoose(): Boolean = this.isChoose 29 | 30 | fun choose() { 31 | this.isChoose = true 32 | } 33 | 34 | fun unChoose() { 35 | this.isChoose = false 36 | } 37 | 38 | override fun equals(other: Any?): Boolean { 39 | if (this === other) return true 40 | if (other !is Field) return false 41 | 42 | return fieldType == other.fieldType 43 | } 44 | 45 | override fun hashCode(): Int { 46 | return fieldType.hashCode() 47 | } 48 | 49 | fun fillBackground(): String = this.fieldType.fillBackground() 50 | 51 | fun loadComponent(name: String, totalCount: Long): String = 52 | this.fieldType.loadComponent(name, totalCount) 53 | 54 | fun drawBorder(): String = this.fieldType.drawBorder() 55 | 56 | companion object { 57 | fun from(user: User, fieldType: FieldType): Field { 58 | return Field( 59 | id = IdGenerator.generate(), 60 | fieldType = fieldType, 61 | isChoose = false, 62 | user = user, 63 | ) 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/domain/Idempotency.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.domain 2 | 3 | import jakarta.persistence.Column 4 | import jakarta.persistence.Entity 5 | import jakarta.persistence.Id 6 | import jakarta.persistence.Table 7 | 8 | @Entity(name="idempotency") 9 | @Table(name="idempotency") 10 | class Idempotency( 11 | @Id 12 | @Column(name="id") 13 | val id: String 14 | ) 15 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/domain/IdempotencyRepository.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.domain 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository 4 | 5 | interface IdempotencyRepository : JpaRepository 6 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/domain/PersonaStatisticRepository.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.domain 2 | 3 | import org.springframework.data.domain.Pageable 4 | import org.springframework.data.domain.Slice 5 | import org.springframework.data.jpa.repository.JpaRepository 6 | import org.springframework.data.jpa.repository.Query 7 | import org.springframework.data.repository.query.Param 8 | import java.time.Instant 9 | 10 | interface PersonaStatisticRepository : JpaRepository { 11 | 12 | @Query("select p from persona p where p.createdAt >= :createdAt") 13 | fun findAllPersonaByCreatedAtAfter( 14 | @Param("createdAt") createdAt: Instant, 15 | pageable: Pageable, 16 | ): Slice 17 | } 18 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/domain/PersonaStatisticService.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.domain 2 | 3 | import org.gitanimals.core.PersonaType 4 | import org.gitanimals.render.domain.response.NewPetDropRateDistribution 5 | import org.springframework.cache.annotation.Cacheable 6 | import org.springframework.data.domain.PageRequest 7 | import org.springframework.stereotype.Service 8 | import java.time.Instant 9 | 10 | @Service 11 | class PersonaStatisticService( 12 | private val personaStatisticRepository: PersonaStatisticRepository, 13 | ) { 14 | 15 | @Cacheable(value = ["total_persona_count_cache"]) 16 | fun getTotalPersonaCount() = personaStatisticRepository.count() 17 | 18 | fun getAggregatedPersonaDistributionByCreatedAtAfter(createdAt: Instant): List { 19 | var pageable = PageRequest.of(0, 100) 20 | var personas = personaStatisticRepository.findAllPersonaByCreatedAtAfter(createdAt, pageable) 21 | 22 | val newPetDropRateDistributionMap: MutableMap = 23 | PersonaType.entries.associateTo(mutableMapOf()) { 24 | it.weight to 0 25 | } 26 | 27 | personas.content.forEach { 28 | newPetDropRateDistributionMap.getOrPut(it.type.weight) { 0 } 29 | newPetDropRateDistributionMap[it.type.weight] = (newPetDropRateDistributionMap[it.type.weight] ?: 0) + 1 30 | } 31 | 32 | while (personas.hasNext()) { 33 | pageable = pageable.next() 34 | personas = personaStatisticRepository.findAllPersonaByCreatedAtAfter(createdAt, pageable) 35 | personas.content.forEach { 36 | newPetDropRateDistributionMap.getOrPut(it.type.weight) { 0 } 37 | newPetDropRateDistributionMap[it.type.weight] = (newPetDropRateDistributionMap[it.type.weight] ?: 0) + 1 38 | } 39 | } 40 | 41 | return newPetDropRateDistributionMap.map { 42 | val dropRate = it.key 43 | val count = it.value 44 | 45 | NewPetDropRateDistribution(dropRate, count) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/domain/UserAuthInfo.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.domain 2 | 3 | import jakarta.persistence.Column 4 | import jakarta.persistence.Embeddable 5 | import jakarta.persistence.EnumType 6 | import jakarta.persistence.Enumerated 7 | 8 | @Embeddable 9 | class UserAuthInfo( 10 | 11 | @Enumerated(EnumType.STRING) 12 | @Column(name = "entry_point", nullable = true) 13 | val entryPoint: EntryPoint, 14 | 15 | @Column(name = "authentication_id", nullable = true) 16 | val authenticationId: String, 17 | ) 18 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/domain/UserRepository.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.domain 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository 4 | import org.springframework.data.jpa.repository.Query 5 | import org.springframework.data.repository.query.Param 6 | 7 | interface UserRepository : JpaRepository { 8 | 9 | fun findByName(name: String): User? 10 | 11 | @Query( 12 | """ 13 | select u from user as u 14 | left join fetch u.contributions 15 | where u.name = :name 16 | """ 17 | ) 18 | fun findByNameWithContributions(@Param("name") name: String): User? 19 | 20 | @Query( 21 | """ 22 | select u from user as u 23 | left join fetch u.contributions 24 | where u.name in :usernames 25 | """ 26 | ) 27 | fun findAllByIdsWithContributions(@Param("usernames") usernames: Set): List 28 | 29 | @Query( 30 | """ 31 | select u from user as u 32 | where u.authInfo.entryPoint = :entryPoint 33 | and u.authInfo.authenticationId = :authenticationId 34 | """ 35 | ) 36 | fun findByEntryPointAndAuthenticationId( 37 | @Param("entryPoint") entryPoint: EntryPoint, 38 | @Param("authenticationId") authenticationId: String, 39 | ): User? 40 | 41 | fun existsByName(name: String): Boolean 42 | 43 | fun deleteByName(name: String) 44 | } 45 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/domain/UserStatisticRepository.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.domain 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository 4 | import org.springframework.data.jpa.repository.Query 5 | import org.springframework.data.repository.query.Param 6 | import java.time.Instant 7 | 8 | interface UserStatisticRepository : JpaRepository { 9 | 10 | @Query("select count(u.id) from user u where u.createdAt between :startDay and :endDay") 11 | fun getDailyUserCount( 12 | @Param("startDay") startDay: Instant, 13 | @Param("endDay") endDay: Instant, 14 | ): Int 15 | } 16 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/domain/UserStatisticService.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.domain 2 | 3 | import org.springframework.cache.annotation.Cacheable 4 | import org.springframework.stereotype.Service 5 | import java.time.LocalDate 6 | import java.time.ZoneOffset 7 | 8 | @Service 9 | class UserStatisticService( 10 | private val userStatisticRepository: UserStatisticRepository, 11 | ) { 12 | 13 | fun getYesterdayUserCount(): Int { 14 | val yesterday = LocalDate.now().minusDays(1) 15 | val startDay = yesterday.atTime(0, 0, 0).toInstant(ZoneOffset.UTC) 16 | val endDay = yesterday.atTime(23, 59, 59).toInstant(ZoneOffset.UTC) 17 | return userStatisticRepository.getDailyUserCount(startDay, endDay) 18 | } 19 | 20 | @Cacheable(value = ["total_user_count_cache"]) 21 | fun getTotalUserCount(): Long = userStatisticRepository.count() 22 | } 23 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/domain/event/NewUserCreated.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.domain.event 2 | 3 | data class NewUserCreated( 4 | val userId: Long, 5 | val username: String, 6 | val newUserCreated: Boolean = true, 7 | ) 8 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/domain/event/PersonaDeleted.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.domain.event 2 | 3 | import org.gitanimals.core.clock 4 | import java.time.Instant 5 | 6 | data class PersonaDeleted( 7 | val userId: Long, 8 | val username: String, 9 | val personaId: Long, 10 | val personaDeletedAt: Instant = clock.instant(), 11 | ) 12 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/domain/event/UserContributionUpdated.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.domain.event 2 | 3 | import org.gitanimals.core.DomainEventPublisher 4 | import org.gitanimals.core.IdGenerator 5 | import org.gitanimals.core.filter.MDCFilter 6 | import org.gitanimals.core.instant 7 | import org.gitanimals.core.redis.RedisPubSubChannel 8 | import org.gitanimals.core.redis.TransactionCommitRedisPubSubEvent 9 | import org.slf4j.MDC 10 | import java.time.Instant 11 | 12 | data class UserContributionUpdated( 13 | val username: String, 14 | val contributions: Long, 15 | val updatedContributions: Long, 16 | val userContributionUpdated: Boolean = true, 17 | val contributionUpdatedAt: Instant = instant(), 18 | ) : TransactionCommitRedisPubSubEvent( 19 | traceId = runCatching { MDC.get(MDCFilter.TRACE_ID) }.getOrElse { 20 | IdGenerator.generate().toString() 21 | }, 22 | channel = RedisPubSubChannel.USER_CONTRIBUTION_UPDATED, 23 | source = DomainEventPublisher::class, 24 | ) 25 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/domain/event/UserYesterdayReport.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.domain.event 2 | 3 | import java.time.ZonedDateTime 4 | 5 | data class UserYesterdayReport( 6 | val date: ZonedDateTime, 7 | val yesterdayNewUserCount: Int, 8 | val totalUserCount: Long, 9 | val serverName: String, 10 | ) 11 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/domain/event/UsernameChanged.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.domain.event 2 | 3 | data class UsernameChanged( 4 | val previousName: String, 5 | val changedName: String, 6 | ) 7 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/domain/event/Visited.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.domain.event 2 | 3 | data class Visited( 4 | val username: String, 5 | val traceId: String, 6 | ) 7 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/domain/extension/RenderFieldTypeExtension.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.domain.extension 2 | 3 | import org.gitanimals.core.FieldType 4 | 5 | object RenderFieldTypeExtension { 6 | 7 | fun FieldType.isRenderField(): Boolean { 8 | return this in renderFields 9 | } 10 | 11 | private val renderFields = FieldType.entries.asSequence() 12 | .filter { it != FieldType.LOGO_SHOWING } 13 | .filter { it != FieldType.FOLDER } 14 | .filter { it != FieldType.RED_SOFA } 15 | .filter { it != FieldType.RED_COMPUTER } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/domain/request/PersonaChangeRequest.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.domain.request 2 | 3 | data class PersonaChangeRequest( 4 | val personaId: String, 5 | val visible: Boolean, 6 | ) 7 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/domain/response/NewPetDropRateDistribution.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.domain.response 2 | 3 | data class NewPetDropRateDistribution( 4 | val dropRate: Double, 5 | val count: Int, 6 | ) 7 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/domain/response/PersonaResponse.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.domain.response 2 | 3 | import org.gitanimals.render.domain.Persona 4 | import org.gitanimals.core.PersonaType 5 | 6 | data class PersonaResponse( 7 | val id: String, 8 | val type: PersonaType, 9 | val level: String, 10 | val visible: Boolean, 11 | val dropRate: String, 12 | ) { 13 | companion object { 14 | fun from(persona: Persona): PersonaResponse { 15 | return PersonaResponse( 16 | persona.id.toString(), 17 | persona.type, 18 | persona.level.value.toString(), 19 | persona.visible, 20 | persona.type.getDropRate() 21 | ) 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/domain/value/Contribution.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.domain.value 2 | 3 | import jakarta.persistence.Column 4 | import jakarta.persistence.Embeddable 5 | import java.time.Instant 6 | 7 | @Embeddable 8 | data class Contribution( 9 | @Column(name = "year", nullable = false) 10 | val year: Int, 11 | @Column(name = "contribution", nullable = false) 12 | var contribution: Int, 13 | @Column(name = "last_updated_contributions",) 14 | var lastUpdatedContribution: Instant, 15 | ) 16 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/domain/value/Level.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.domain.value 2 | 3 | import jakarta.persistence.Column 4 | import jakarta.persistence.Embeddable 5 | 6 | 7 | @Embeddable 8 | data class Level( 9 | @Column(name = "level", nullable = false) 10 | var value: Long, 11 | ) 12 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/infra/CacheConfigurer.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.infra 2 | 3 | import com.github.benmanes.caffeine.cache.Caffeine 4 | import org.springframework.cache.CacheManager 5 | import org.springframework.cache.caffeine.CaffeineCacheManager 6 | import org.springframework.context.annotation.Bean 7 | import org.springframework.context.annotation.Configuration 8 | import org.springframework.context.annotation.Primary 9 | import kotlin.time.Duration.Companion.hours 10 | import kotlin.time.toJavaDuration 11 | 12 | @Configuration 13 | class CacheConfigurer { 14 | 15 | @Bean 16 | @Primary 17 | fun cacheManager(caffeine: Caffeine): CacheManager { 18 | val caffeineCacheManager = CaffeineCacheManager() 19 | caffeineCacheManager.setCaffeine(caffeine) 20 | return caffeineCacheManager 21 | } 22 | 23 | @Bean 24 | fun caffeineConfig(): Caffeine = 25 | Caffeine.newBuilder().expireAfterWrite(1.hours.toJavaDuration()) 26 | } 27 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/infra/CustomExecutorConfigurer.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.infra 2 | 3 | import org.springframework.context.annotation.Bean 4 | import org.springframework.context.annotation.Configuration 5 | import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor 6 | import java.util.concurrent.Executor 7 | import kotlin.time.Duration.Companion.minutes 8 | 9 | @Configuration 10 | class CustomExecutorConfigurer { 11 | 12 | @Bean(GRACEFUL_SHUTDOWN_EXECUTOR) 13 | fun taskExecutor(): Executor { 14 | val executor = ThreadPoolTaskExecutor() 15 | executor.corePoolSize = 2 16 | executor.maxPoolSize = 20 17 | executor.threadNamePrefix = "$GRACEFUL_SHUTDOWN_EXECUTOR-" 18 | executor.setWaitForTasksToCompleteOnShutdown(true) 19 | executor.setAwaitTerminationSeconds(2.minutes.inWholeSeconds.toInt()) 20 | executor.initialize() 21 | 22 | return executor 23 | } 24 | 25 | companion object { 26 | const val GRACEFUL_SHUTDOWN_EXECUTOR = "gracefulShutdownExecutor" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/infra/HttpClientErrorHandler.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.infra 2 | 3 | import org.gitanimals.core.AuthorizationException 4 | import org.springframework.http.HttpStatus 5 | import org.springframework.http.client.ClientHttpResponse 6 | import org.springframework.web.client.ResponseErrorHandler 7 | 8 | 9 | class HttpClientErrorHandler : ResponseErrorHandler { 10 | 11 | override fun hasError(response: ClientHttpResponse): Boolean { 12 | return response.statusCode.isError 13 | } 14 | 15 | override fun handleError(response: ClientHttpResponse) { 16 | val body = response.body.bufferedReader().use { it.readText() } 17 | when { 18 | response.statusCode.isSameCodeAs(HttpStatus.UNAUTHORIZED) -> 19 | throw AuthorizationException(body) 20 | 21 | response.statusCode.is4xxClientError -> 22 | throw IllegalArgumentException(body) 23 | 24 | response.statusCode.is5xxServerError -> 25 | throw IllegalStateException(body) 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/infra/PersonaDeletedEventHandler.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.infra 2 | 3 | import org.gitanimals.render.domain.event.PersonaDeleted 4 | import org.gitanimals.render.infra.CustomExecutorConfigurer.Companion.GRACEFUL_SHUTDOWN_EXECUTOR 5 | import org.rooftop.netx.api.SagaManager 6 | import org.springframework.context.event.EventListener 7 | import org.springframework.scheduling.annotation.Async 8 | import org.springframework.stereotype.Component 9 | 10 | @Component 11 | class PersonaDeletedEventHandler( 12 | private val sagaManager: SagaManager, 13 | ) { 14 | 15 | @Async(GRACEFUL_SHUTDOWN_EXECUTOR) 16 | @EventListener 17 | fun handlePersonaDeletedEvent(personaDeleted: PersonaDeleted) { 18 | sagaManager.startSync(personaDeleted) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/infra/event/NewPetDropRateDistributionEvent.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.infra.event 2 | 3 | import org.gitanimals.core.filter.MDCFilter.Companion.TRACE_ID 4 | import org.gitanimals.core.redis.AsyncRedisPubSubEvent 5 | import org.gitanimals.core.redis.RedisPubSubChannel.NEW_PET_DROP_RATE_DISTRIBUTION 6 | import org.slf4j.MDC 7 | 8 | data class NewPetDropRateDistributionEvent( 9 | val type: Type, 10 | val distributions: List, 11 | ) : AsyncRedisPubSubEvent( 12 | traceId = MDC.get(TRACE_ID), 13 | channel = NEW_PET_DROP_RATE_DISTRIBUTION, 14 | ) { 15 | data class Distribution( 16 | val dropRate: Double, 17 | val count: Int, 18 | ) 19 | } 20 | 21 | enum class Type { 22 | DAILY, 23 | WEEKLY, 24 | MONTHLY, 25 | } 26 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/saga/UsedCouponSagaHandlers.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.saga 2 | 3 | import org.gitanimals.render.domain.UserService 4 | import org.gitanimals.render.saga.event.CouponUsed 5 | import org.rooftop.netx.api.SagaCommitEvent 6 | import org.rooftop.netx.api.SagaCommitListener 7 | import org.rooftop.netx.meta.SagaHandler 8 | 9 | @SagaHandler 10 | class UsedCouponSagaHandlers( 11 | private val userService: UserService, 12 | ) { 13 | 14 | @SagaCommitListener(event = CouponUsed::class) 15 | fun handleCouponUsedCommitEvent(sagaCommitEvent: SagaCommitEvent) { 16 | val couponUsed = sagaCommitEvent.decodeEvent(CouponUsed::class) 17 | if ((couponUsed.code in acceptableCouponCodes).not()) { 18 | return 19 | } 20 | sagaCommitEvent.setNextEvent(couponUsed) 21 | 22 | userService.givePersonaByCoupon(couponUsed.username, couponUsed.dynamic, couponUsed.code) 23 | } 24 | 25 | private companion object { 26 | private val acceptableCouponCodes = listOf( 27 | "NEW_USER_BONUS_PET", 28 | ) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/saga/event/CouponUsed.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.saga.event 2 | 3 | data class CouponUsed( 4 | val userId: Long, 5 | val username: String, 6 | val code: String, 7 | val dynamic: String, 8 | ) 9 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/render/saga/event/GavePoint.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.saga.event 2 | 3 | data class GavePoint( 4 | val username: String, 5 | val point: Long, 6 | val contributions: Int, 7 | ) 8 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/star/controller/StargazerController.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.star.controller 2 | 3 | import org.gitanimals.star.domain.StargazerService 4 | import org.springframework.web.bind.annotation.GetMapping 5 | import org.springframework.web.bind.annotation.PathVariable 6 | import org.springframework.web.bind.annotation.RestController 7 | 8 | @RestController 9 | class StargazerController( 10 | private val stargazerService: StargazerService, 11 | ) { 12 | 13 | @GetMapping("/stargazers/{login}/press") 14 | fun isPressStar(@PathVariable("login") login: String): Map { 15 | val isPressStar = stargazerService.existsByLogin(login) 16 | 17 | return mapOf("isPressStar" to isPressStar) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/star/domain/Stargazer.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.star.domain 2 | 3 | import jakarta.persistence.Column 4 | import jakarta.persistence.Entity 5 | import jakarta.persistence.Id 6 | 7 | @Entity 8 | class Stargazer( 9 | @Id 10 | @Column(name = "login") 11 | val login: String, 12 | ) 13 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/star/domain/StargazerService.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.star.domain 2 | 3 | import jakarta.persistence.EntityManager 4 | import org.springframework.cache.annotation.Cacheable 5 | import org.springframework.stereotype.Service 6 | import org.springframework.transaction.annotation.Transactional 7 | 8 | @Service 9 | @Transactional(readOnly = true) 10 | class StargazerService( 11 | private val stargazersRepository: StargazersRepository, 12 | private val entityManager: EntityManager, 13 | ) { 14 | 15 | @Cacheable(value = ["exists_by_login_cache"]) 16 | fun existsByLogin(login: String): Boolean = stargazersRepository.existsById(login) 17 | 18 | @Transactional 19 | fun updateAll(logins: List) { 20 | stargazersRepository.deleteAllInBatch() 21 | 22 | logins.map { entityManager.persist(Stargazer(it)) } 23 | entityManager.flush() 24 | entityManager.clear() 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/star/domain/StargazersRepository.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.star.domain 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository 4 | 5 | interface StargazersRepository : JpaRepository 6 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/star/infra/StargazerBatchJob.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.star.infra 2 | 3 | import org.gitanimals.star.domain.StargazerService 4 | import org.slf4j.LoggerFactory 5 | import org.springframework.boot.context.event.ApplicationStartedEvent 6 | import org.springframework.context.annotation.Profile 7 | import org.springframework.context.event.EventListener 8 | import org.springframework.scheduling.annotation.Scheduled 9 | import org.springframework.stereotype.Service 10 | 11 | @Service 12 | @Profile("!test") 13 | class StargazerBatchJob( 14 | private val githubStargazerApi: GithubStargazerApi, 15 | private val stargazerService: StargazerService, 16 | ) { 17 | 18 | private val logger = LoggerFactory.getLogger(this::class.simpleName) 19 | 20 | @EventListener(ApplicationStartedEvent::class) 21 | fun initStargazer() { 22 | updateStargazer() 23 | } 24 | 25 | @Scheduled(cron = EVERY_DAY) 26 | fun updateStargazer() { 27 | runCatching { 28 | val stargazers = githubStargazerApi.getStargazers() 29 | stargazerService.updateAll( 30 | stargazers.flatMap { stargazer -> 31 | stargazer.edges.map { edge -> 32 | edge.node.login 33 | } 34 | } 35 | ) 36 | }.onSuccess { 37 | logger.info("[StargazerBatchJob] Success to aggregation stargazer counts.") 38 | }.onFailure { 39 | logger.error( 40 | "[StargazerBatchJob] Fail to aggregation stargazer counts. cause: ${it.message}", 41 | it 42 | ) 43 | } 44 | } 45 | 46 | 47 | companion object { 48 | private const val EVERY_DAY = "0 0 * * * ?" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/supports/deadletter/DeadLetterEvent.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.supports.deadletter 2 | 3 | import org.gitanimals.core.IdGenerator 4 | import org.gitanimals.core.filter.MDCFilter 5 | import org.gitanimals.core.redis.AsyncRedisPubSubEvent 6 | import org.gitanimals.core.redis.RedisPubSubChannel.DEAD_LETTER_OCCURRED 7 | import org.slf4j.MDC 8 | 9 | data class DeadLetterEvent( 10 | val deadLetterId: String, 11 | val sagaId: String, 12 | val nodeName: String, 13 | val group: String, 14 | val deadLetter: Map, 15 | ) : AsyncRedisPubSubEvent( 16 | traceId = runCatching { MDC.get(MDCFilter.TRACE_ID) } 17 | .getOrElse { IdGenerator.generate().toString() }, 18 | channel = DEAD_LETTER_OCCURRED, 19 | ) 20 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/supports/deadletter/DeadLetterEventPublisher.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.supports.deadletter 2 | 3 | import org.gitanimals.core.IdGenerator 4 | import org.gitanimals.core.filter.MDCFilter.Companion.TRACE_ID 5 | import org.rooftop.netx.api.SagaEvent 6 | import org.rooftop.netx.api.TypeReference 7 | import org.rooftop.netx.spi.DeadLetterListener 8 | import org.slf4j.LoggerFactory 9 | import org.slf4j.MDC 10 | import org.springframework.context.ApplicationEventPublisher 11 | 12 | class DeadLetterEventPublisher( 13 | private val applicationEventPublisher: ApplicationEventPublisher, 14 | ) : DeadLetterListener { 15 | 16 | private val logger = LoggerFactory.getLogger(this::class.simpleName) 17 | 18 | override fun listen(deadLetterId: String, sagaEvent: SagaEvent) { 19 | runCatching { 20 | MDC.put(TRACE_ID, IdGenerator.generate().toString()) 21 | applicationEventPublisher.publishEvent( 22 | DeadLetterEvent( 23 | deadLetterId = deadLetterId, 24 | sagaId = sagaEvent.id, 25 | group = sagaEvent.group, 26 | nodeName = sagaEvent.nodeName, 27 | deadLetter = sagaEvent.decodeEvent(object: TypeReference>(){}), 28 | ) 29 | ) 30 | }.onFailure { 31 | logger.error("Fail to publish dead letter event deadLetterId: \"$deadLetterId\", event: \"$sagaEvent\"", it) 32 | }.also { 33 | MDC.remove(TRACE_ID) 34 | } 35 | } 36 | } 37 | 38 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/supports/deadletter/DeadLetterListenConfigurer.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.supports.deadletter 2 | 3 | import org.rooftop.netx.spi.DeadLetterRegistry 4 | import org.springframework.context.ApplicationEventPublisher 5 | import org.springframework.context.annotation.Bean 6 | import org.springframework.context.annotation.Configuration 7 | 8 | @Configuration 9 | class DeadLetterListenConfigurer( 10 | private val deadLetterRegistry: DeadLetterRegistry, 11 | private val applicationEventPublisher: ApplicationEventPublisher, 12 | ) { 13 | 14 | @Bean 15 | fun deadLetterEventPublisher(): DeadLetterEventPublisher { 16 | return DeadLetterEventPublisher(applicationEventPublisher).apply { 17 | deadLetterRegistry.addListener(this) 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/supports/deadletter/DeadLetterRedisMessageListenerConfiguration.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.supports.deadletter 2 | 3 | import org.gitanimals.core.redis.RedisPubSubChannel 4 | import org.springframework.context.annotation.Bean 5 | import org.springframework.context.annotation.Configuration 6 | import org.springframework.data.redis.connection.RedisConnectionFactory 7 | import org.springframework.data.redis.listener.ChannelTopic 8 | import org.springframework.data.redis.listener.RedisMessageListenerContainer 9 | 10 | @Configuration 11 | class DeadLetterRedisMessageListenerConfiguration( 12 | private val redisConnectionFactory: RedisConnectionFactory, 13 | private val deadLetterRelayEventListener: DeadLetterRelayEventListener, 14 | ) { 15 | 16 | @Bean 17 | fun deadLetterListenerContainer(): RedisMessageListenerContainer { 18 | return RedisMessageListenerContainer().apply { 19 | this.connectionFactory = redisConnectionFactory 20 | this.addMessageListener( 21 | deadLetterRelayEventListener, 22 | ChannelTopic(RedisPubSubChannel.SLACK_INTERACTED) 23 | ) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/supports/event/SlackInteracted.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.supports.event 2 | 3 | data class SlackInteracted( 4 | val userId: String, 5 | val username: String, 6 | val slackChannel: String, 7 | val threadTs: String, 8 | val sourceKey: String, 9 | val payload: String, 10 | val traceId: String, 11 | ) 12 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/supports/event/SlackReplied.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.supports.event 2 | 3 | import org.gitanimals.core.IdGenerator 4 | import org.gitanimals.core.filter.MDCFilter.Companion.TRACE_ID 5 | import org.gitanimals.core.redis.AsyncRedisPubSubEvent 6 | import org.gitanimals.core.redis.RedisPubSubChannel 7 | import org.slf4j.MDC 8 | 9 | data class SlackReplied( 10 | val slackChannel: String, 11 | val threadTs: String, 12 | val message: String, 13 | ) : AsyncRedisPubSubEvent( 14 | channel = RedisPubSubChannel.SLACK_REPLIED, 15 | traceId = runCatching { MDC.get(TRACE_ID) } 16 | .getOrElse { IdGenerator.generate().toString() }, 17 | ) 18 | -------------------------------------------------------------------------------- /src/main/kotlin/org/gitanimals/supports/orchestrate/IdentityApi.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.supports.orchestrate 2 | 3 | import org.gitanimals.core.auth.UserEntryPoint 4 | import org.springframework.web.bind.annotation.RequestBody 5 | import org.springframework.web.bind.annotation.RequestParam 6 | import org.springframework.web.service.annotation.PatchExchange 7 | 8 | fun interface IdentityApi { 9 | 10 | @PatchExchange("/internals/users") 11 | fun updateUserByAuthInfo( 12 | @RequestParam("entry-point") entryPoint: UserEntryPoint, 13 | @RequestParam("authentication-id") authenticationId: String, 14 | @RequestBody request: UsernameUpdateRequest, 15 | ) 16 | } 17 | 18 | data class UsernameUpdateRequest( 19 | val changedName: String, 20 | ) 21 | -------------------------------------------------------------------------------- /src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.profiles.active=prod 2 | 3 | server.name=gitanimals-render 4 | server.port=8080 5 | 6 | spring.datasource.url=jdbc:mysql://localhost:3306/gitanimalsrender 7 | spring.datasource.username=root 8 | spring.datasource.password=0000 9 | spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver 10 | 11 | spring.jpa.hibernate.ddl-auto=update 12 | spring.jpa.open-in-view=false 13 | 14 | netx.mode=redis 15 | netx.host=localhost 16 | netx.port=6379 17 | netx.group=render 18 | netx.node-id=1 19 | netx.node-name=render-1 20 | netx.recovery-milli=1000 21 | netx.orphan-milli=60000 22 | netx.backpressure=40 23 | netx.logging.level=info 24 | 25 | slack.token=xoxb- 26 | github.token=a 27 | 28 | sentry.dsn=https://fe1aaf784ec135343909a4a0dfe4f0eb@o4505051656486912.ingest.us.sentry.io/4507088960684032 29 | sentry.traces-sample-rate=1.0 30 | 31 | internal.secret=a 32 | internal.auth.secret=b 33 | internal.image.secret=c 34 | 35 | spring.application.name=render.gitanimals 36 | management.endpoints.web.exposure.include=health,prometheus 37 | management.metrics.tags.application=${spring.application.name} 38 | management.endpoint.health.show-details=never 39 | management.health.defaults.enabled=false 40 | management.health.ping.enabled=true 41 | 42 | logging.level.root=INFO 43 | 44 | logging.level.org.springframework.web.servlet.PageNotFound=ERROR 45 | spring.mvc.throw-exception-if-no-handler-found=true 46 | spring.web.resources.add-mappings=false 47 | 48 | relay.approve.token=foo 49 | -------------------------------------------------------------------------------- /src/main/resources/github-graphql/contribution-count-by-year.graphql: -------------------------------------------------------------------------------- 1 | query { 2 | user(login: "*{name}") { 3 | contributionsCollection(from: "*{year}-01-01T00:00:00Z", to: "*{year}-12-31T23:59:59Z") { 4 | contributionCalendar { 5 | totalContributions 6 | } 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/main/resources/github-graphql/contribution-year.graphql: -------------------------------------------------------------------------------- 1 | query { 2 | user(login: "*{name}") { 3 | contributionsCollection { 4 | contributionYears 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/main/resources/github-graphql/stargazer.graphql: -------------------------------------------------------------------------------- 1 | query { 2 | repository(owner: "git-goods", name: "gitanimals") { 3 | stargazers(first: 100, after: "*{endCursor}") { 4 | edges { 5 | starredAt 6 | node { 7 | login 8 | name 9 | url 10 | } 11 | } 12 | pageInfo { 13 | endCursor 14 | hasNextPage 15 | } 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main/resources/persona/field/carrot-and-coin.svg: -------------------------------------------------------------------------------- 1 | 2 | *{username} 3 | 4 | 5 | 6 | 7 | 8 | *{commit-count} 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/main/resources/persona/field/grass-field.svg: -------------------------------------------------------------------------------- 1 | 2 | *{username} 3 | 4 | 5 | 6 | 7 | 8 | *{commit-count} 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/main/resources/persona/field/halloween-field.svg: -------------------------------------------------------------------------------- 1 | 2 | *{username} 3 | 4 | 5 | 6 | 7 | 8 | *{commit-count} 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/main/resources/persona/field/snowy-field.svg: -------------------------------------------------------------------------------- 1 | 2 | *{username} 3 | 4 | 5 | 6 | 7 | 8 | *{commit-count} 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/main/resources/persona/field/white-field.svg: -------------------------------------------------------------------------------- 1 | 2 | *{username} 3 | 4 | 5 | 6 | 7 | 8 | *{commit-count} 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/0.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/3.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/4.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/5.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/6.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/7.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/8.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/9.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/A.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/B.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/C.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/D.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/E.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/F.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/G.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/H.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/I.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/J.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/K.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/L.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/M.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/N.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/O.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/P.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/Q.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/R.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/S.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/T.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/U.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/V.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/W.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/X.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/Y.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/Z.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/_a.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/_b.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/_c.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/_d.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/_e.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/_f.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/_g.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/_h.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/_i.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/_j.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/_k.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/_l.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/_m.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/_n.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/_o.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/_p.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/_q.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/_r.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/_s.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/_t.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/_u.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/_v.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/_w.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/_x.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/_y.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/_z.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/large/hyphens.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/medium/0.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/medium/1.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/medium/2.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/medium/3.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/medium/4.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/medium/5.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/medium/6.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/medium/7.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/medium/8.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/medium/9.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/small/0.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/small/1.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/small/2.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/small/3.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/small/4.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/small/5.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/small/6.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/small/7.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/small/8.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/main/resources/persona/text/small/9.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/test/kotlin/org/gitanimals/core/extension/StringExtensionTest.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.core.extension 2 | 3 | import io.kotest.core.annotation.DisplayName 4 | import io.kotest.core.spec.style.DescribeSpec 5 | import io.kotest.matchers.shouldBe 6 | import org.gitanimals.core.extension.StringExtension.deleteBrackets 7 | import org.gitanimals.core.extension.StringExtension.trimNotDigitCharacters 8 | 9 | @DisplayName("StringExtension class의") 10 | internal class StringExtensionTest : DescribeSpec({ 11 | 12 | describe("trimNotDigitCharacters 메소드는") { 13 | context("입력받은 문자의 앞 뒤로 숫자가 아닌 문자가 있다면") { 14 | val text = "\"12345\"" 15 | 16 | it("앞 뒤 문자를 삭제한다.") { 17 | val result = text.trimNotDigitCharacters() 18 | 19 | result shouldBe "12345" 20 | } 21 | } 22 | } 23 | 24 | describe("deleteBrackets 메소드는") { 25 | val text = "{devxb}" 26 | context("입력받은 문자의 앞 뒤로 bracket이 있다면, 삭제한다") { 27 | val result = text.deleteBrackets() 28 | 29 | result shouldBe "devxb" 30 | } 31 | } 32 | }) 33 | -------------------------------------------------------------------------------- /src/test/kotlin/org/gitanimals/guild/domain/Fixture.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.guild.domain 2 | 3 | import org.gitanimals.core.FieldType 4 | import org.gitanimals.core.PersonaType 5 | 6 | fun guild( 7 | id: Long = 1L, 8 | guildIcon: String = "default_icon.png", 9 | title: String = "Default Guild Title", 10 | body: String = "Default guild description.", 11 | leader: Leader = leader(), 12 | members: MutableSet = mutableSetOf(), 13 | waitMembers: MutableSet = mutableSetOf(), 14 | farmType: FieldType = FieldType.LOGO_SHOWING, 15 | autoJoin: Boolean = true, 16 | ): Guild { 17 | return Guild( 18 | id = id, 19 | guildIcon = guildIcon, 20 | title = title, 21 | body = body, 22 | leader = leader, 23 | members = members, 24 | waitMembers = waitMembers, 25 | farmType = farmType, 26 | autoJoin = autoJoin, 27 | ) 28 | } 29 | 30 | fun leader( 31 | userId: Long = 1L, 32 | name: String = "Default Leader", 33 | personaId: Long = 1L, 34 | contributions: Long = 0L, 35 | personaType: PersonaType = PersonaType.GOOSE, 36 | ): Leader { 37 | return Leader( 38 | userId = userId, 39 | name = name, 40 | personaId = personaId, 41 | contributions = contributions, 42 | personaType = personaType, 43 | ) 44 | } 45 | 46 | fun member( 47 | guild: Guild, 48 | userId: Long = 2L, 49 | name: String = "DefaultName", 50 | personaId: Long = 200L, 51 | contributions: Long = 500L, 52 | personaType: PersonaType = PersonaType.GOOSE, 53 | ): Member { 54 | return Member.create( 55 | guild = guild, 56 | userId = userId, 57 | name = name, 58 | personaId = personaId, 59 | contributions = contributions, 60 | personaType = personaType, 61 | ) 62 | } 63 | -------------------------------------------------------------------------------- /src/test/kotlin/org/gitanimals/guild/supports/GuildSagaCapture.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.guild.supports 2 | 3 | import io.kotest.matchers.equals.shouldBeEqual 4 | import org.rooftop.netx.api.* 5 | import org.rooftop.netx.meta.SagaHandler 6 | 7 | @SagaHandler 8 | class GuildSagaCapture { 9 | 10 | val storage = mutableMapOf() 11 | 12 | fun clear() { 13 | storage.clear() 14 | } 15 | 16 | fun countShouldBe( 17 | start: Int = 0, 18 | join: Int = 0, 19 | commit: Int = 0, 20 | rollback: Int = 0, 21 | ) { 22 | startCountShouldBe(start) 23 | joinCountShouldBe(join) 24 | commitCountShouldBe(commit) 25 | rollbackCountShouldBe(rollback) 26 | } 27 | 28 | fun startCountShouldBe(count: Int) { 29 | (storage["start"] ?: 0) shouldBeEqual count 30 | } 31 | 32 | fun joinCountShouldBe(count: Int) { 33 | (storage["join"] ?: 0) shouldBeEqual count 34 | } 35 | 36 | fun commitCountShouldBe(count: Int) { 37 | (storage["commit"] ?: 0) shouldBeEqual count 38 | } 39 | 40 | fun rollbackCountShouldBe(count: Int) { 41 | (storage["rollback"] ?: 0) shouldBeEqual count 42 | } 43 | 44 | @SagaStartListener(successWith = SuccessWith.END) 45 | fun captureStart(startEvent: SagaStartEvent) { 46 | storage["start"] = (storage["start"] ?: 0) + 1 47 | } 48 | 49 | @SagaJoinListener(successWith = SuccessWith.END) 50 | fun captureJoin(joinEvent: SagaJoinEvent) { 51 | storage["join"] = (storage["join"] ?: 0) + 1 52 | } 53 | 54 | @SagaCommitListener 55 | fun captureCommit(commitEvent: SagaCommitEvent) { 56 | storage["commit"] = (storage["commit"] ?: 0) + 1 57 | } 58 | 59 | @SagaRollbackListener 60 | fun captureRollback(rollbackEvent: SagaRollbackEvent) { 61 | storage["rollback"] = (storage["rollback"] ?: 0) + 1 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/test/kotlin/org/gitanimals/guild/supports/MockApiConfiguration.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.guild.supports 2 | 3 | import io.mockk.Runs 4 | import io.mockk.every 5 | import io.mockk.just 6 | import io.mockk.mockk 7 | import org.gitanimals.core.PersonaType 8 | import org.gitanimals.guild.app.IdentityApi 9 | import org.gitanimals.guild.app.RenderApi 10 | import org.springframework.boot.test.context.TestConfiguration 11 | import org.springframework.context.annotation.Bean 12 | 13 | @TestConfiguration 14 | class MockApiConfiguration { 15 | 16 | @Bean 17 | fun identityApi(): IdentityApi = mockk().apply { 18 | val api = this 19 | every { api.increasePoint(any(), any(), any(), any()) } just Runs 20 | every { api.decreasePoint(any(), any(), any(), any()) } just Runs 21 | every { api.getUserByToken(any()) } returns identityUserResponse 22 | } 23 | 24 | @Bean 25 | fun renderApi(): RenderApi = mockk().apply { 26 | val api = this 27 | every { api.getUserByName(any()) } returns renderUserResponse 28 | } 29 | 30 | companion object { 31 | val identityUserResponse = IdentityApi.UserResponse( 32 | id = "1", 33 | username = "devxb", 34 | points = "30000", 35 | profileImage = "https://gitanimals.org" 36 | ) 37 | 38 | val renderUserResponse = RenderApi.UserResponse( 39 | id = "2", 40 | name = "devxb", 41 | totalContributions = "9999", 42 | personas = listOf( 43 | RenderApi.UserResponse.PersonaResponse( 44 | id = "3", 45 | level = "99", 46 | type = PersonaType.GOOSE, 47 | ), 48 | RenderApi.UserResponse.PersonaResponse( 49 | id = "4", 50 | level = "98", 51 | type = PersonaType.GOOSE, 52 | ), 53 | ) 54 | ) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/test/kotlin/org/gitanimals/guild/supports/RedisContainer.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.guild.supports 2 | 3 | 4 | import org.springframework.boot.test.context.TestConfiguration 5 | import org.testcontainers.containers.GenericContainer 6 | import org.testcontainers.utility.DockerImageName 7 | 8 | @TestConfiguration 9 | internal class RedisContainer { 10 | init { 11 | val redis: GenericContainer<*> = GenericContainer(DockerImageName.parse("redis:7.2.3")) 12 | .withExposedPorts(6379) 13 | 14 | runCatching { 15 | redis.start() 16 | }.onFailure { 17 | if (it is com.github.dockerjava.api.exception.InternalServerErrorException) { 18 | redis.start() 19 | } 20 | } 21 | 22 | System.setProperty( 23 | "netx.port", 24 | redis.getMappedPort(6379).toString() 25 | ) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/test/kotlin/org/gitanimals/render/app/UserStatisticScheduleTest.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.app 2 | 3 | import io.kotest.assertions.nondeterministic.eventually 4 | import io.kotest.core.annotation.DisplayName 5 | import io.kotest.core.spec.style.DescribeSpec 6 | import org.gitanimals.render.domain.UserStatisticService 7 | import org.gitanimals.render.supports.RedisContainer 8 | import org.gitanimals.render.supports.SagaCapture 9 | import org.rooftop.netx.meta.EnableSaga 10 | import org.springframework.boot.autoconfigure.domain.EntityScan 11 | import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest 12 | import org.springframework.data.jpa.repository.config.EnableJpaRepositories 13 | import org.springframework.test.context.ContextConfiguration 14 | import org.springframework.test.context.TestPropertySource 15 | import kotlin.time.Duration.Companion.seconds 16 | 17 | @EnableSaga 18 | @DataJpaTest 19 | @ContextConfiguration( 20 | classes = [ 21 | RedisContainer::class, 22 | SagaCapture::class, 23 | UserStatisticSchedule::class, 24 | UserStatisticService::class, 25 | ] 26 | ) 27 | @TestPropertySource("classpath:application.properties") 28 | @DisplayName("UserStatisticSchedule 클래스의") 29 | @EntityScan(basePackages = ["org.gitanimals.render.domain"]) 30 | @EnableJpaRepositories(basePackages = ["org.gitanimals.render.domain"]) 31 | internal class UserStatisticScheduleTest( 32 | private val userStatisticSchedule: UserStatisticSchedule, 33 | private val sagaCapture: SagaCapture, 34 | ) : DescribeSpec({ 35 | 36 | describe("sendYesterdayNewUserReport 메소드는") { 37 | context("호출되면,") { 38 | it("UserYesterdayReport 를 담은 이벤트를 발행한다.") { 39 | userStatisticSchedule.sendYesterdayNewUserReport() 40 | 41 | eventually(5.seconds) { 42 | sagaCapture.startCountShouldBe(1) 43 | } 44 | } 45 | } 46 | } 47 | }) 48 | -------------------------------------------------------------------------------- /src/test/kotlin/org/gitanimals/render/domain/UserFixture.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.domain 2 | 3 | import org.gitanimals.core.IdGenerator 4 | import org.gitanimals.core.PersonaType 5 | import org.gitanimals.render.domain.value.Contribution 6 | import org.gitanimals.render.domain.value.Level 7 | 8 | fun user( 9 | id: Long = IdGenerator.generate(), 10 | name: String = "devxb", 11 | personas: MutableList = mutableListOf(), 12 | contributions: MutableList = mutableListOf(), 13 | authInfo: UserAuthInfo? = UserAuthInfo(EntryPoint.GITHUB, id.toString()), 14 | visit: Long = 0L, 15 | ): User { 16 | return User( 17 | id = id, 18 | name = name, 19 | personas = personas, 20 | contributions = contributions, 21 | visit = visit, 22 | version = 0L, 23 | lastPersonaGivePoint = 0, 24 | authInfo = authInfo, 25 | ) 26 | } 27 | 28 | fun persona( 29 | id: Long = IdGenerator.generate(), 30 | type: PersonaType = PersonaType.CAT, 31 | user: User, 32 | ) = Persona( 33 | id = id, 34 | type = type, 35 | level = Level(0), 36 | visible = false, 37 | user = user, 38 | ) 39 | -------------------------------------------------------------------------------- /src/test/kotlin/org/gitanimals/render/domain/UserServiceTest.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.domain 2 | 3 | import io.kotest.core.annotation.DisplayName 4 | import io.kotest.core.spec.style.DescribeSpec 5 | import io.kotest.matchers.nulls.shouldBeNull 6 | import io.kotest.matchers.shouldBe 7 | import org.gitanimals.core.DomainEventPublisher 8 | import org.gitanimals.render.supports.IntegrationTest 9 | 10 | @IntegrationTest( 11 | classes = [ 12 | UserService::class, 13 | DomainEventPublisher.EventPublisherInjector::class, 14 | ] 15 | ) 16 | @DisplayName("UserService 클래스의") 17 | internal class UserServiceTest( 18 | private val userService: UserService, 19 | private val userRepository: UserRepository, 20 | ) : DescribeSpec({ 21 | 22 | beforeEach { 23 | userRepository.save(user) 24 | } 25 | 26 | afterEach { 27 | userRepository.deleteAll() 28 | } 29 | 30 | describe("deletePersona 메소드는") { 31 | context("userId와 personaId를 받으면,") { 32 | val personaId = user.personas[0].id 33 | 34 | it("persona를 삭제한다.") { 35 | val response = userService.deletePersona(user.getName(), personaId) 36 | val user = userService.getUserByName(user.getName()) 37 | 38 | user.personas 39 | .find { it.id == response.id.toLong() } 40 | .shouldBeNull() 41 | } 42 | } 43 | } 44 | }) { 45 | 46 | private companion object { 47 | val user = User.newUser( 48 | name = "devxb", 49 | contributions = mapOf(2025 to 1000), 50 | ) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/test/kotlin/org/gitanimals/render/domain/UserStatisticsServiceTest.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.domain 2 | 3 | import io.kotest.core.annotation.DisplayName 4 | import io.kotest.core.spec.style.DescribeSpec 5 | import io.kotest.matchers.shouldNotBe 6 | import org.gitanimals.render.infra.CacheConfigurer 7 | import org.springframework.boot.autoconfigure.domain.EntityScan 8 | import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest 9 | import org.springframework.cache.CacheManager 10 | import org.springframework.cache.annotation.EnableCaching 11 | import org.springframework.cache.interceptor.SimpleKey 12 | import org.springframework.data.jpa.repository.config.EnableJpaRepositories 13 | import org.springframework.test.context.ContextConfiguration 14 | 15 | @DataJpaTest 16 | @EnableCaching 17 | @ContextConfiguration( 18 | classes = [ 19 | CacheConfigurer::class, 20 | UserStatisticService::class, 21 | ] 22 | ) 23 | @DisplayName("UserStatisticsService 클래스의") 24 | @EntityScan(basePackages = ["org.gitanimals.render.domain"]) 25 | @EnableJpaRepositories(basePackages = ["org.gitanimals.render.domain"]) 26 | class UserStatisticsServiceTest( 27 | private val cacheManager: CacheManager, 28 | private val userStatisticService: UserStatisticService, 29 | ) : DescribeSpec({ 30 | 31 | describe("getTotalUserCount 메소드는") { 32 | context("호출되면,") { 33 | it("total_user_count 이름의 캐시를 저장한다.") { 34 | val totalUserCount = userStatisticService.getTotalUserCount() 35 | 36 | totalUserCount shouldNotBe null 37 | cacheManager.getCache("total_user_count_cache")!![SimpleKey.EMPTY] shouldNotBe null 38 | } 39 | } 40 | } 41 | }) 42 | -------------------------------------------------------------------------------- /src/test/kotlin/org/gitanimals/render/supports/DomainEventHolder.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.supports 2 | 3 | import io.kotest.matchers.shouldBe 4 | import org.gitanimals.render.domain.event.PersonaDeleted 5 | import org.springframework.boot.test.context.TestComponent 6 | import org.springframework.context.event.EventListener 7 | import kotlin.reflect.KClass 8 | 9 | @TestComponent 10 | class DomainEventHolder { 11 | 12 | private val events = mutableMapOf, Int>() 13 | 14 | @EventListener(PersonaDeleted::class) 15 | fun handlePersonaDeleted(personaDeleted: PersonaDeleted) { 16 | events[personaDeleted::class] = events.getOrDefault(personaDeleted::class, 0) + 1 17 | } 18 | 19 | fun eventsShouldBe(kClass: KClass<*>, count: Int) { 20 | events[kClass] shouldBe count 21 | } 22 | 23 | fun deleteAll() = events.clear() 24 | } 25 | -------------------------------------------------------------------------------- /src/test/kotlin/org/gitanimals/render/supports/IntegrationTest.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.supports 2 | 3 | import org.springframework.boot.autoconfigure.domain.EntityScan 4 | import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest 5 | import org.springframework.core.annotation.AliasFor 6 | import org.springframework.data.jpa.repository.config.EnableJpaRepositories 7 | import org.springframework.test.context.ContextConfiguration 8 | import org.springframework.test.context.event.RecordApplicationEvents 9 | import kotlin.reflect.KClass 10 | 11 | @DataJpaTest 12 | @ContextConfiguration 13 | @RecordApplicationEvents 14 | @EntityScan(basePackages = ["org.gitanimals.render.domain"]) 15 | @EnableJpaRepositories(basePackages = ["org.gitanimals.render.domain"]) 16 | annotation class IntegrationTest( 17 | @get:AliasFor(annotation = ContextConfiguration::class, value = "classes") 18 | val classes: Array>, 19 | ) 20 | -------------------------------------------------------------------------------- /src/test/kotlin/org/gitanimals/render/supports/RedisContainer.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.supports 2 | 3 | 4 | import org.springframework.boot.test.context.TestConfiguration 5 | import org.testcontainers.containers.GenericContainer 6 | import org.testcontainers.utility.DockerImageName 7 | 8 | @TestConfiguration 9 | internal class RedisContainer { 10 | init { 11 | val redis: GenericContainer<*> = GenericContainer(DockerImageName.parse("redis:7.2.3")) 12 | .withExposedPorts(6379) 13 | 14 | runCatching { 15 | redis.start() 16 | }.onFailure { 17 | if (it is com.github.dockerjava.api.exception.InternalServerErrorException) { 18 | redis.start() 19 | } 20 | } 21 | 22 | System.setProperty( 23 | "netx.port", 24 | redis.getMappedPort(6379).toString() 25 | ) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/test/kotlin/org/gitanimals/render/supports/SagaCapture.kt: -------------------------------------------------------------------------------- 1 | package org.gitanimals.render.supports 2 | 3 | import io.kotest.matchers.equals.shouldBeEqual 4 | import org.rooftop.netx.api.* 5 | import org.rooftop.netx.meta.SagaHandler 6 | 7 | @SagaHandler 8 | class SagaCapture { 9 | 10 | val storage = mutableMapOf() 11 | 12 | fun clear() { 13 | storage.clear() 14 | } 15 | 16 | fun startCountShouldBe(count: Int) { 17 | (storage["start"] ?: 0) shouldBeEqual count 18 | } 19 | 20 | fun joinCountShouldBe(count: Int) { 21 | (storage["join"] ?: 0) shouldBeEqual count 22 | } 23 | 24 | fun commitCountShouldBe(count: Int) { 25 | (storage["commit"] ?: 0) shouldBeEqual count 26 | } 27 | 28 | fun rollbackCountShouldBe(count: Int) { 29 | (storage["rollback"] ?: 0) shouldBeEqual count 30 | } 31 | 32 | @SagaStartListener(successWith = SuccessWith.END) 33 | fun captureStart(startEvent: SagaStartEvent) { 34 | storage["start"] = (storage["start"] ?: 0) + 1 35 | } 36 | 37 | @SagaJoinListener(successWith = SuccessWith.END) 38 | fun captureJoin(joinEvent: SagaJoinEvent) { 39 | storage["join"] = (storage["join"] ?: 0) + 1 40 | } 41 | 42 | @SagaCommitListener 43 | fun captureCommit(commitEvent: SagaCommitEvent) { 44 | storage["commit"] = (storage["commit"] ?: 0) + 1 45 | } 46 | 47 | @SagaRollbackListener 48 | fun captureRollback(rollbackEvent: SagaRollbackEvent) { 49 | storage["rollback"] = (storage["rollback"] ?: 0) + 1 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/test/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.profiles.active=test 2 | spring.datasource.driver-class-name = org.h2.Driver 3 | spring.datasource.url = jdbc:h2:mem:test;MODE=MySQL;DATABASE_TO_LOWER=TRUE 4 | 5 | spring.jpa.hibernated.ddl-auto = create-drop 6 | spring.jpa.database-platform = org.hibernate.dialect.H2Dialect 7 | 8 | spring.datasource.hikari.maximum-pool-size = 4 9 | spring.datasource.hikari.pool-name = H2_TEST_POOL 10 | 11 | ### FOR DEBUGGING ### 12 | logging.level.org.hibernate.SQL = debug 13 | logging.level.org.hibernate.type.descriptor.sql = trace 14 | 15 | spring.jpa.properties.hibernate.format_sql = true 16 | spring.jpa.properties.hibernate.highlight_sql = true 17 | spring.jpa.properties.hibernate.use_sql_comments = true 18 | 19 | ### NETX ### 20 | netx.mode=redis 21 | netx.host=localhost 22 | netx.port=6379 23 | netx.group=render 24 | netx.node-id=1 25 | netx.node-name=render-1 26 | netx.recovery-milli=1000 27 | netx.orphan-milli=60000 28 | netx.backpressure=40 29 | netx.logging.level=info 30 | 31 | ### GITHUB ### 32 | github.token=1234 33 | 34 | internal.secret=foo 35 | internal.auth.secret=p4K86j8sl9LDufoN/2rpj8df+gc2SAGHUKKup6l695o= 36 | -------------------------------------------------------------------------------- /src/test/resources/persona/goose/test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | --------------------------------------------------------------------------------