From 72b2b82e02dbe599285e1c9324a38190a99c1240 Mon Sep 17 00:00:00 2001 From: Tim Date: Thu, 18 Sep 2025 14:42:25 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=90=8E=E7=AB=AF=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/ISSUE_TEMPLATE/新功能建议.md | 7 +- .github/ISSUE_TEMPLATE/错误-bug报告.md | 21 +- .github/workflows/deploy-staging.yml | 3 +- .github/workflows/deploy.yml | 2 +- CODE_OF_CONDUCT.md | 20 +- CONTRIBUTING.md | 101 +- backend/.prettierrc | 22 +- .../com/openisle/OpenIsleApplication.java | 7 +- .../openisle/config/ActivityInitializer.java | 49 +- .../java/com/openisle/config/AsyncConfig.java | 24 +- .../com/openisle/config/CachingConfig.java | 184 +- .../openisle/config/ChannelInitializer.java | 39 +- .../config/CustomAccessDeniedHandler.java | 22 +- .../com/openisle/config/OpenApiConfig.java | 62 +- .../openisle/config/PointGoodInitializer.java | 35 +- .../com/openisle/config/RabbitMQConfig.java | 358 +-- .../config/RedisConnectionLogger.java | 32 +- .../com/openisle/config/SchedulerConfig.java | 19 +- .../com/openisle/config/SecurityConfig.java | 436 ++-- .../java/com/openisle/config/ShardInfo.java | 9 +- .../com/openisle/config/ShardingStrategy.java | 149 +- .../openisle/config/SpringDocProperties.java | 14 +- .../config/SystemUserInitializer.java | 40 +- .../controller/ActivityController.java | 98 +- .../controller/AdminCommentController.java | 43 +- .../controller/AdminConfigController.java | 86 +- .../openisle/controller/AdminController.java | 22 +- .../controller/AdminPostController.java | 163 +- .../controller/AdminTagController.java | 60 +- .../controller/AdminUserController.java | 88 +- .../com/openisle/controller/AiController.java | 65 +- .../openisle/controller/AuthController.java | 1087 +++++---- .../controller/CategoryController.java | 162 +- .../controller/ChannelController.java | 82 +- .../controller/CommentController.java | 285 ++- .../openisle/controller/ConfigController.java | 67 +- .../openisle/controller/DraftController.java | 80 +- .../controller/GlobalExceptionHandler.java | 49 +- .../openisle/controller/HelloController.java | 22 +- .../openisle/controller/InviteController.java | 35 +- .../openisle/controller/MedalController.java | 54 +- .../controller/MessageController.java | 311 +-- .../controller/NotificationController.java | 207 +- .../openisle/controller/OnlineController.java | 42 +- .../controller/PointHistoryController.java | 68 +- .../controller/PointMallController.java | 60 +- .../controller/PostChangeLogController.java | 31 +- .../openisle/controller/PostController.java | 463 ++-- .../PushSubscriptionController.java | 56 +- .../controller/ReactionController.java | 155 +- .../openisle/controller/RssController.java | 672 +++--- .../openisle/controller/SearchController.java | 159 +- .../controller/SitemapController.java | 89 +- .../openisle/controller/StatController.java | 192 +- .../controller/SubscriptionController.java | 93 +- .../openisle/controller/TagController.java | 199 +- .../openisle/controller/UploadController.java | 144 +- .../openisle/controller/UserController.java | 558 +++-- .../java/com/openisle/dto/ActivityDto.java | 20 +- .../main/java/com/openisle/dto/AuthorDto.java | 12 +- .../java/com/openisle/dto/CategoryDto.java | 14 +- .../com/openisle/dto/CategoryRequest.java | 9 +- .../java/com/openisle/dto/ChannelDto.java | 17 +- .../java/com/openisle/dto/CommentDto.java | 23 +- .../java/com/openisle/dto/CommentInfoDto.java | 14 +- .../com/openisle/dto/CommentMedalDto.java | 5 +- .../java/com/openisle/dto/CommentRequest.java | 5 +- .../main/java/com/openisle/dto/ConfigDto.java | 9 +- .../com/openisle/dto/ContributorMedalDto.java | 6 +- .../openisle/dto/ConversationDetailDto.java | 18 +- .../com/openisle/dto/ConversationDto.java | 24 +- .../dto/CreateConversationRequest.java | 5 +- .../dto/CreateConversationResponse.java | 5 +- .../com/openisle/dto/DiscordLoginRequest.java | 7 +- .../main/java/com/openisle/dto/DraftDto.java | 14 +- .../java/com/openisle/dto/DraftRequest.java | 12 +- .../com/openisle/dto/FeaturedMedalDto.java | 6 +- .../openisle/dto/ForgotPasswordRequest.java | 3 +- .../com/openisle/dto/GithubLoginRequest.java | 7 +- .../com/openisle/dto/GoogleLoginRequest.java | 5 +- .../java/com/openisle/dto/LoginRequest.java | 7 +- .../java/com/openisle/dto/LotteryDto.java | 19 +- .../com/openisle/dto/MakeReasonRequest.java | 5 +- .../main/java/com/openisle/dto/MedalDto.java | 13 +- .../com/openisle/dto/MedalSelectRequest.java | 3 +- .../java/com/openisle/dto/MessageDto.java | 19 +- .../dto/MessageNotificationPayload.java | 10 +- .../java/com/openisle/dto/MilkTeaInfoDto.java | 5 +- .../openisle/dto/MilkTeaRedeemRequest.java | 3 +- .../com/openisle/dto/NotificationDto.java | 26 +- .../dto/NotificationMarkReadRequest.java | 6 +- .../dto/NotificationPreferenceDto.java | 5 +- .../NotificationPreferenceUpdateRequest.java | 5 +- .../dto/NotificationUnreadCountDto.java | 3 +- .../com/openisle/dto/ParentCommentDto.java | 7 +- .../com/openisle/dto/PioneerMedalDto.java | 3 +- .../java/com/openisle/dto/PointGoodDto.java | 9 +- .../com/openisle/dto/PointHistoryDto.java | 26 +- .../com/openisle/dto/PointRedeemRequest.java | 5 +- .../main/java/com/openisle/dto/PollDto.java | 16 +- .../com/openisle/dto/PostChangeLogDto.java | 44 +- .../java/com/openisle/dto/PostDetailDto.java | 7 +- .../java/com/openisle/dto/PostMedalDto.java | 5 +- .../java/com/openisle/dto/PostMetaDto.java | 16 +- .../java/com/openisle/dto/PostRequest.java | 40 +- .../java/com/openisle/dto/PostSummaryDto.java | 49 +- .../com/openisle/dto/PushPublicKeyDto.java | 3 +- .../openisle/dto/PushSubscriptionRequest.java | 7 +- .../java/com/openisle/dto/ReactionDto.java | 16 +- .../com/openisle/dto/ReactionRequest.java | 3 +- .../com/openisle/dto/RegisterRequest.java | 11 +- .../openisle/dto/ResetPasswordRequest.java | 5 +- .../com/openisle/dto/SearchResultDto.java | 13 +- .../com/openisle/dto/SeedUserMedalDto.java | 3 +- .../java/com/openisle/dto/SiteConfigDto.java | 15 +- .../main/java/com/openisle/dto/TagDto.java | 19 +- .../java/com/openisle/dto/TagRequest.java | 9 +- .../openisle/dto/TelegramLoginRequest.java | 17 +- .../com/openisle/dto/TimelineItemDto.java | 11 +- .../com/openisle/dto/TwitterLoginRequest.java | 9 +- .../com/openisle/dto/UpdateProfileDto.java | 5 +- .../com/openisle/dto/UserAggregateDto.java | 10 +- .../main/java/com/openisle/dto/UserDto.java | 46 +- .../java/com/openisle/dto/UserSummaryDto.java | 9 +- .../com/openisle/dto/VerifyForgotRequest.java | 5 +- .../java/com/openisle/dto/VerifyRequest.java | 5 +- .../openisle/exception/FieldException.java | 12 +- .../openisle/exception/NotFoundException.java | 7 +- .../exception/RateLimitException.java | 7 +- .../com/openisle/mapper/ActivityMapper.java | 24 +- .../com/openisle/mapper/CategoryMapper.java | 26 +- .../com/openisle/mapper/CommentMapper.java | 59 +- .../java/com/openisle/mapper/DraftMapper.java | 29 +- .../openisle/mapper/NotificationMapper.java | 60 +- .../com/openisle/mapper/PointGoodMapper.java | 17 +- .../openisle/mapper/PointHistoryMapper.java | 49 +- .../openisle/mapper/PostChangeLogMapper.java | 139 +- .../java/com/openisle/mapper/PostMapper.java | 37 +- .../com/openisle/mapper/ReactionMapper.java | 32 +- .../java/com/openisle/mapper/TagMapper.java | 28 +- .../java/com/openisle/mapper/UserMapper.java | 168 +- .../java/com/openisle/model/Activity.java | 54 +- .../java/com/openisle/model/ActivityType.java | 6 +- .../com/openisle/model/AiFormatUsage.java | 30 +- .../java/com/openisle/model/Category.java | 23 +- .../main/java/com/openisle/model/Comment.java | 50 +- .../java/com/openisle/model/CommentSort.java | 6 +- .../openisle/model/CommentSubscription.java | 25 +- .../com/openisle/model/ContributorConfig.java | 20 +- .../main/java/com/openisle/model/Draft.java | 46 +- .../com/openisle/model/ExperienceLog.java | 38 +- .../main/java/com/openisle/model/Image.java | 15 +- .../java/com/openisle/model/InviteToken.java | 28 +- .../java/com/openisle/model/LotteryPost.java | 55 +- .../java/com/openisle/model/MedalType.java | 12 +- .../main/java/com/openisle/model/Message.java | 42 +- .../openisle/model/MessageConversation.java | 56 +- .../openisle/model/MessageParticipant.java | 30 +- .../java/com/openisle/model/Notification.java | 69 +- .../com/openisle/model/NotificationType.java | 92 +- .../com/openisle/model/PasswordStrength.java | 6 +- .../java/com/openisle/model/PointGood.java | 17 +- .../java/com/openisle/model/PointHistory.java | 56 +- .../com/openisle/model/PointHistoryType.java | 24 +- .../java/com/openisle/model/PointLog.java | 38 +- .../java/com/openisle/model/PollPost.java | 48 +- .../java/com/openisle/model/PollVote.java | 28 +- .../main/java/com/openisle/model/Post.java | 98 +- .../openisle/model/PostCategoryChangeLog.java | 5 +- .../com/openisle/model/PostChangeLog.java | 34 +- .../com/openisle/model/PostChangeType.java | 18 +- .../openisle/model/PostClosedChangeLog.java | 5 +- .../openisle/model/PostContentChangeLog.java | 9 +- .../openisle/model/PostFeaturedChangeLog.java | 5 +- .../model/PostLotteryResultChangeLog.java | 4 +- .../openisle/model/PostPinnedChangeLog.java | 8 +- .../java/com/openisle/model/PostRead.java | 32 +- .../java/com/openisle/model/PostStatus.java | 6 +- .../com/openisle/model/PostSubscription.java | 25 +- .../com/openisle/model/PostTagChangeLog.java | 9 +- .../openisle/model/PostTitleChangeLog.java | 5 +- .../java/com/openisle/model/PostType.java | 6 +- .../model/PostVoteResultChangeLog.java | 4 +- .../java/com/openisle/model/PublishMode.java | 4 +- .../com/openisle/model/PushSubscription.java | 34 +- .../java/com/openisle/model/Reaction.java | 62 +- .../java/com/openisle/model/ReactionType.java | 50 +- .../java/com/openisle/model/RegisterMode.java | 4 +- .../main/java/com/openisle/model/Role.java | 4 +- .../src/main/java/com/openisle/model/Tag.java | 51 +- .../main/java/com/openisle/model/User.java | 113 +- .../com/openisle/model/UserSubscription.java | 25 +- .../java/com/openisle/model/UserVisit.java | 26 +- .../repository/ActivityRepository.java | 2 +- .../repository/AiFormatUsageRepository.java | 5 +- .../repository/CategoryRepository.java | 7 +- .../repository/CommentRepository.java | 67 +- .../CommentSubscriptionRepository.java | 9 +- .../ContributorConfigRepository.java | 6 +- .../openisle/repository/DraftRepository.java | 7 +- .../repository/ExperienceLogRepository.java | 5 +- .../openisle/repository/ImageRepository.java | 5 +- .../repository/InviteTokenRepository.java | 9 +- .../repository/LotteryPostRepository.java | 7 +- .../MessageConversationRepository.java | 54 +- .../MessageParticipantRepository.java | 11 +- .../repository/MessageRepository.java | 26 +- .../repository/NotificationRepository.java | 68 +- .../repository/PointGoodRepository.java | 3 +- .../repository/PointHistoryRepository.java | 18 +- .../repository/PointLogRepository.java | 5 +- .../repository/PollPostRepository.java | 7 +- .../repository/PollVoteRepository.java | 5 +- .../repository/PostChangeLogRepository.java | 7 +- .../repository/PostReadRepository.java | 9 +- .../openisle/repository/PostRepository.java | 318 ++- .../PostSubscriptionRepository.java | 9 +- .../PushSubscriptionRepository.java | 7 +- .../repository/ReactionRepository.java | 106 +- .../openisle/repository/TagRepository.java | 19 +- .../openisle/repository/UserRepository.java | 30 +- .../UserSubscriptionRepository.java | 13 +- .../repository/UserVisitRepository.java | 17 +- .../scheduler/UserVisitScheduler.java | 44 +- .../com/openisle/service/ActivityService.java | 74 +- .../com/openisle/service/AiUsageService.java | 74 +- .../java/com/openisle/service/AuthResult.java | 6 +- .../com/openisle/service/AvatarGenerator.java | 25 +- .../com/openisle/service/CaptchaService.java | 15 +- .../com/openisle/service/CategoryService.java | 130 +- .../com/openisle/service/ChannelService.java | 156 +- .../com/openisle/service/CommentService.java | 733 +++--- .../openisle/service/ContributorService.java | 130 +- .../openisle/service/CosImageUploader.java | 192 +- .../openisle/service/DiscordAuthService.java | 188 +- .../com/openisle/service/DraftService.java | 115 +- .../com/openisle/service/EmailSender.java | 15 +- .../openisle/service/GithubAuthService.java | 233 +- .../openisle/service/GoogleAuthService.java | 120 +- .../com/openisle/service/ImageUploader.java | 183 +- .../com/openisle/service/InviteService.java | 127 +- .../java/com/openisle/service/JwtService.java | 193 +- .../com/openisle/service/LevelService.java | 152 +- .../com/openisle/service/MedalService.java | 337 +-- .../com/openisle/service/MessageService.java | 680 +++--- .../service/NotificationProducer.java | 84 +- .../openisle/service/NotificationService.java | 558 +++-- .../com/openisle/service/OpenAiService.java | 92 +- .../openisle/service/PasswordValidator.java | 109 +- .../openisle/service/PointMallService.java | 53 +- .../com/openisle/service/PointService.java | 478 ++-- .../service/PostChangeLogService.java | 197 +- .../com/openisle/service/PostReadService.java | 70 +- .../com/openisle/service/PostService.java | 1993 +++++++++-------- .../service/PushNotificationService.java | 77 +- .../service/PushSubscriptionService.java | 39 +- .../com/openisle/service/ReactionService.java | 238 +- .../openisle/service/RecaptchaService.java | 36 +- .../openisle/service/RegisterModeService.java | 21 +- .../openisle/service/ResendEmailSender.java | 47 +- .../com/openisle/service/SearchService.java | 307 +-- .../com/openisle/service/StatService.java | 92 +- .../openisle/service/SubscriptionService.java | 313 +-- .../java/com/openisle/service/TagService.java | 245 +- .../com/openisle/service/TagValidator.java | 20 +- .../openisle/service/TelegramAuthService.java | 168 +- .../openisle/service/TwitterAuthService.java | 253 ++- .../com/openisle/service/UserService.java | 393 ++-- .../openisle/service/UserVisitService.java | 137 +- .../openisle/service/UsernameValidator.java | 25 +- .../java/com/openisle/util/VerifyType.java | 19 +- .../controller/AdminControllerTest.java | 137 +- .../controller/AdminUserControllerTest.java | 98 +- .../controller/AuthControllerTest.java | 196 +- .../controller/CategoryControllerTest.java | 156 +- .../controller/CommentControllerTest.java | 151 +- .../controller/HelloControllerTest.java | 110 +- .../controller/MedalControllerTest.java | 107 +- .../NotificationControllerTest.java | 146 +- .../PointHistoryControllerTest.java | 91 +- .../controller/PostControllerTest.java | 593 ++--- .../PushSubscriptionControllerTest.java | 35 +- .../controller/ReactionControllerTest.java | 174 +- .../controller/SearchControllerTest.java | 166 +- .../controller/StatControllerTest.java | 230 +- .../controller/TagControllerTest.java | 168 +- .../controller/UserControllerTest.java | 431 ++-- .../ComplexFlowIntegrationTest.java | 311 +-- .../PublishModeIntegrationTest.java | 168 +- .../integration/SearchIntegrationTest.java | 168 +- .../openisle/service/CommentServiceTest.java | 72 +- .../service/CosImageUploaderTest.java | 34 +- .../openisle/service/MedalServiceTest.java | 220 +- .../service/NotificationServiceTest.java | 586 +++-- .../service/PasswordValidatorTest.java | 56 +- ...PointServiceRecalculateUserPointsTest.java | 79 +- .../service/PostCommentStatsTest.java | 118 +- .../com/openisle/service/PostServiceTest.java | 658 +++--- .../openisle/service/ReactionServiceTest.java | 84 +- .../openisle/service/SearchServiceTest.java | 95 +- .../service/UsernameValidatorTest.java | 26 +- docs/README.md | 1 + docs/app/(home)/[[...slug]]/page.tsx | 25 +- docs/app/(home)/layout.tsx | 26 +- docs/app/global.css | 8 +- docs/app/layout.tsx | 10 +- docs/app/provider.tsx | 6 +- docs/lib/layout.shared.tsx | 8 +- docs/lib/media-adapter.client.ts | 4 +- docs/lib/media-adapter.ts | 10 +- docs/lib/openapi.ts | 4 +- docs/lib/source.ts | 22 +- docs/mdx-components.tsx | 8 +- docs/next.config.mjs | 4 +- docs/package.json | 2 +- docs/postcss.config.mjs | 2 +- docs/scripts/generate-docs.ts | 6 +- docs/source.config.ts | 2 +- frontend_nuxt/assets/fonts.css | 64 +- frontend_nuxt/components/CommentEditor.vue | 12 +- .../composables/useChannelsUnreadCount.js | 68 +- frontend_nuxt/composables/useReactionTypes.js | 6 +- frontend_nuxt/composables/useUnreadCount.js | 78 +- frontend_nuxt/composables/useWebSocket.js | 49 +- package.json | 2 +- 325 files changed, 15341 insertions(+), 12370 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/新功能建议.md b/.github/ISSUE_TEMPLATE/新功能建议.md index 2151bd647..94035756f 100644 --- a/.github/ISSUE_TEMPLATE/新功能建议.md +++ b/.github/ISSUE_TEMPLATE/新功能建议.md @@ -1,10 +1,9 @@ --- name: 新功能建议 about: 请为该项目提出一个想法 -title: '' -labels: '' -assignees: '' - +title: "" +labels: "" +assignees: "" --- **你的功能请求是否与某个问题相关?请描述。** diff --git a/.github/ISSUE_TEMPLATE/错误-bug报告.md b/.github/ISSUE_TEMPLATE/错误-bug报告.md index 93a28d569..ba2f7bbf1 100644 --- a/.github/ISSUE_TEMPLATE/错误-bug报告.md +++ b/.github/ISSUE_TEMPLATE/错误-bug报告.md @@ -1,10 +1,9 @@ --- name: 错误/Bug报告 about: 创建报告以帮助我们改进 -title: '' -labels: '' -assignees: '' - +title: "" +labels: "" +assignees: "" --- **描述 Bug** @@ -26,16 +25,16 @@ assignees: '' **桌面端(请完成以下信息):** -* 操作系统:\[例如 iOS] -* 浏览器:\[例如 Chrome、Safari] -* 版本:\[例如 22] +- 操作系统:\[例如 iOS] +- 浏览器:\[例如 Chrome、Safari] +- 版本:\[例如 22] **移动端(请完成以下信息):** -* 设备:\[例如 iPhone6] -* 操作系统:\[例如 iOS8.1] -* 浏览器:\[例如 系统自带浏览器、Safari] -* 版本:\[例如 22] +- 设备:\[例如 iPhone6] +- 操作系统:\[例如 iOS8.1] +- 浏览器:\[例如 系统自带浏览器、Safari] +- 版本:\[例如 22] **附加上下文** 在此添加与问题相关的其他上下文信息。 diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index f62be188d..dde70459b 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -12,7 +12,7 @@ jobs: build-and-deploy: runs-on: ubuntu-latest environment: Deploy - if: ${{ !github.event.repository.fork }} # 只有非 fork 才执行 + if: ${{ !github.event.repository.fork }} # 只有非 fork 才执行 steps: - uses: actions/checkout@v4 @@ -32,4 +32,3 @@ jobs: secrets: inherit with: build-id: ${{ github.run_id }} - diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 22109dea6..5bfb41eb7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -3,7 +3,7 @@ name: CI & CD on: workflow_dispatch: schedule: - - cron: "0 19 * * *" # 每天 UTC 19:00,相当于北京时间凌晨3点 + - cron: "0 19 * * *" # 每天 UTC 19:00,相当于北京时间凌晨3点 jobs: build-and-deploy: diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 95b73b22b..b70f3308a 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -8,21 +8,21 @@ This isn’t an exhaustive list of things that you can’t do. Rather, take it i This code of conduct applies to all spaces managed by the OpenIsle project or . This includes IRC, the mailing lists, the issue tracker, DSF events, and any other forums created by the project team which the community uses for communication. In addition, violations of this code outside these spaces may affect a person's ability to participate within them. -If you believe someone is violating the code of conduct, we ask that you report it by emailing [](mailto:). For more details please see our +If you believe someone is violating the code of conduct, we ask that you report it by emailing [](mailto:). For more details please see our - **Be friendly and patient.** - **Be welcoming.** We strive to be a community that welcomes and supports people of all backgrounds and identities. This includes, but is not limited to members of any race, ethnicity, culture, national origin, colour, immigration status, social and economic class, educational level, sex, sexual orientation, gender identity and expression, age, size, family status, political belief, religion, and mental and physical ability. - **Be considerate.** Your work will be used by other people, and you in turn will depend on the work of others. Any decision you take will affect users and colleagues, and you should take those consequences into account when making decisions. Remember that we're a world-wide community, so you might not be communicating in someone else's primary language. - **Be respectful.** Not all of us will agree all the time, but disagreement is no excuse for poor behavior and poor manners. We might all experience some frustration now and then, but we cannot allow that frustration to turn into a personal attack. It’s important to remember that a community where people feel uncomfortable or threatened is not a productive one. Members of the OpenIsle community should be respectful when dealing with other members as well as with people outside the OpenIsle community. -- **Be careful in the words that you choose.** We are a community of professionals, and we conduct ourselves professionally. Be kind to others. Do not insult or put down other participants. Harassment and other exclusionary behavior aren't acceptable. This includes, but is not limited to: - - Violent threats or language directed against another person. - - Discriminatory jokes and language. - - Posting sexually explicit or violent material. - - Posting (or threatening to post) other people's personally identifying information ("doxing"). - - Personal insults, especially those using racist or sexist terms. - - Unwelcome sexual attention. - - Advocating for, or encouraging, any of the above behavior. - - Repeated harassment of others. In general, if someone asks you to stop, then stop. +- **Be careful in the words that you choose.** We are a community of professionals, and we conduct ourselves professionally. Be kind to others. Do not insult or put down other participants. Harassment and other exclusionary behavior aren't acceptable. This includes, but is not limited to: +- Violent threats or language directed against another person. +- Discriminatory jokes and language. +- Posting sexually explicit or violent material. +- Posting (or threatening to post) other people's personally identifying information ("doxing"). +- Personal insults, especially those using racist or sexist terms. +- Unwelcome sexual attention. +- Advocating for, or encouraging, any of the above behavior. +- Repeated harassment of others. In general, if someone asks you to stop, then stop. - **When we disagree, try to understand why.** Disagreements, both social and technical, happen all the time and OpenIsle is no exception. It is important that we resolve disagreements and differing views constructively. Remember that we’re different. The strength of OpenIsle comes from its varied community, people from a wide range of backgrounds. Different people have different perspectives on issues. Being unable to understand why someone holds a viewpoint doesn’t mean that they’re wrong. Don’t forget that it is human to err and blaming each other doesn’t get us anywhere. Instead, focus on helping to resolve issues and learning from mistakes. Original text courtesy of the [Speak Up! project](http://web.archive.org/web/20141109123859/http://speakup.io/coc.html). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9e837f2da..b5eb8c925 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,15 +1,15 @@ - [前置工作](#前置工作) - [启动后端服务](#启动后端服务) - - [本地 IDEA](#本地-idea) - - [配置环境变量](#配置环境变量) - - [配置 IDEA 参数](#配置-idea-参数) - - [配置 MySQL](#配置-mysql) - - [Docker 环境](#docker-环境) - - [配置环境变量](#配置环境变量-1) - - [构建并启动镜像](#构建并启动镜像) + - [本地 IDEA](#本地-idea) + - [配置环境变量](#配置环境变量) + - [配置 IDEA 参数](#配置-idea-参数) + - [配置 MySQL](#配置-mysql) + - [Docker 环境](#docker-环境) + - [配置环境变量](#配置环境变量-1) + - [构建并启动镜像](#构建并启动镜像) - [启动前端服务](#启动前端服务) - - [配置环境变量](#配置环境变量-2) - - [安装依赖和运行](#安装依赖和运行) + - [配置环境变量](#配置环境变量-2) + - [安装依赖和运行](#安装依赖和运行) - [其他配置](#其他配置) ## 前置工作 @@ -22,9 +22,9 @@ cd OpenIsle ``` - 后端开发环境 - - JDK 17+ + - JDK 17+ - 前端开发环境 - - Node.JS 20+ + - Node.JS 20+ ## 启动后端服务 @@ -45,15 +45,15 @@ IDEA 打开 `backend/` 文件夹。 1. 生成环境变量文件 - ```shell - cp open-isle.env.example open-isle.env - ``` + ```shell + cp open-isle.env.example open-isle.env + ``` - `open-isle.env.example` 是环境变量模板,`open-isle.env` 才是真正读取的内容 + `open-isle.env.example` 是环境变量模板,`open-isle.env` 才是真正读取的内容 2. 修改环境变量,留下需要的,比如你要开发 Google 登录业务,就需要谷歌相关的变量,数据库是一定要的 - ![环境变量](assets/contributing/backend_img_7.png) + ![环境变量](assets/contributing/backend_img_7.png) 3. 应用环境文件,选择刚刚的 `open-isle.env` @@ -72,11 +72,11 @@ SERVER_PORT=8082 - 设置 JDK 版本为 java 17 - 设置 VM Option,最好运行在其他端口,非 `8080`,这里设置 `8081` - 若上面在环境变量中设置了端口,那这里就不需要再额外设置 + 若上面在环境变量中设置了端口,那这里就不需要再额外设置 - ```shell - -Dserver.port=8081 - ``` + ```shell + -Dserver.port=8081 + ``` ![配置1](assets/contributing/backend_img_3.png) @@ -88,26 +88,25 @@ SERVER_PORT=8082 > 如果不知道怎么配置数据库可以参考 [Docker 环境](#docker-环境) 章节 1. 本机配置 MySQL 服务(网上很多教程,忽略) - - + 可以用 Laragon,自带 MySQL 包括 Nodejs,版本建议 `6.x`,`7` 以后需要 Lisence - + [下载地址](https://github.com/leokhoa/laragon/releases) + - 可以用 Laragon,自带 MySQL 包括 Nodejs,版本建议 `6.x`,`7` 以后需要 Lisence + - [下载地址](https://github.com/leokhoa/laragon/releases) 2. 填写环境变量 - ![环境变量](assets/contributing/backend_img_6.png) + ![环境变量](assets/contributing/backend_img_6.png) - ```ini - MYSQL_URL=jdbc:mysql://<数据库地址>:<端口>/<数据库名>?useUnicode=yes&characterEncoding=UTF-8&useInformationSchema=true&useSSL=false&serverTimezone=UTC - MYSQL_USER=<数据库用户名> - MYSQL_PASSWORD=<数据库密码> - ``` + ```ini + MYSQL_URL=jdbc:mysql://<数据库地址>:<端口>/<数据库名>?useUnicode=yes&characterEncoding=UTF-8&useInformationSchema=true&useSSL=false&serverTimezone=UTC + MYSQL_USER=<数据库用户名> + MYSQL_PASSWORD=<数据库密码> + ``` 3. 执行 [`db/init/init_script.sql`](backend/src/main/resources/db/init/init_script.sql) 脚本,导入基本的数据 - 管理员:**admin/123456** - 普通用户1:**user1/123456** - 普通用户2:**user2/123456** - - ![初始化脚本](assets/contributing/resources_img.png) + 管理员:**admin/123456** + 普通用户1:**user1/123456** + 普通用户2:**user2/123456** + + ![初始化脚本](assets/contributing/resources_img.png) #### 配置 Redis @@ -134,9 +133,9 @@ cd docker/ - `backend/open-isle.env`:后端环境变量,配置同上,见 [配置环境变量](#配置环境变量)。 - `docker/.env`:Docker Compose 环境变量,主要配置 MySQL 相关 - ```shell - cp .env.example .env - ``` + ```shell + cp .env.example .env + ``` > [!TIP] > 使用单独的 `.env` 文件是为了兼容线上环境或已启用 MySQL 服务的情况,如果只是想快速体验或者启动统一的环境,则推荐使用本方式。 @@ -176,21 +175,21 @@ cd frontend_nuxt/ - 利用预发环境:**(⚠️ 强烈推荐只开发前端的朋友使用该环境)** - ```shell - cp .env.staging.example .env - ``` + ```shell + cp .env.staging.example .env + ``` - 利用生产环境 - ```shell - cp .env.production.example .env - ``` + ```shell + cp .env.production.example .env + ``` - 利用本地环境 - ```shell - cp .env.dev.example .env - ``` + ```shell + cp .env.dev.example .env + ``` 若依赖本机部署的后端,需要修改 `.env` 中的 `NUXT_PUBLIC_API_BASE_URL` 值与后端服务端口一致 @@ -214,17 +213,17 @@ npm run dev - 修改 `application.properties` 配置 - ![后端配置](assets/contributing/backend_img.png) + ![后端配置](assets/contributing/backend_img.png) - 修改 `.env` 配置 - ![前端](assets/contributing/fontend_img.png) + ![前端](assets/contributing/fontend_img.png) - 配置第三方登录回调地址 - ![github配置](assets/contributing/github_img.png) + ![github配置](assets/contributing/github_img.png) - ![github配置2](assets/contributing/github_img_2.png) + ![github配置2](assets/contributing/github_img_2.png) ### 配置 Resend 邮箱服务 @@ -250,5 +249,3 @@ https://resend.com/emails 创建账号并登录 ## 开源共建和API文档 - API文档: https://docs.open-isle.com/openapi - - diff --git a/backend/.prettierrc b/backend/.prettierrc index 981186770..0df1fdc55 100644 --- a/backend/.prettierrc +++ b/backend/.prettierrc @@ -1,3 +1,23 @@ { - "plugins": ["prettier-plugin-java"] + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "semi": false, + "singleQuote": true, + "trailingComma": "all", + "endOfLine": "lf", + "proseWrap": "preserve", + "plugins": ["prettier-plugin-java"], + "overrides": [ + { + "files": "*.java", + "options": { + "printWidth": 100, + "tabWidth": 2, + "semi": true, + "singleQuote": false, + "trailingComma": "es5" + } + } + ] } diff --git a/backend/src/main/java/com/openisle/OpenIsleApplication.java b/backend/src/main/java/com/openisle/OpenIsleApplication.java index e0c86581f..d05e5cec4 100644 --- a/backend/src/main/java/com/openisle/OpenIsleApplication.java +++ b/backend/src/main/java/com/openisle/OpenIsleApplication.java @@ -7,7 +7,8 @@ import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableScheduling public class OpenIsleApplication { - public static void main(String[] args) { - SpringApplication.run(OpenIsleApplication.class, args); - } + + public static void main(String[] args) { + SpringApplication.run(OpenIsleApplication.class, args); + } } diff --git a/backend/src/main/java/com/openisle/config/ActivityInitializer.java b/backend/src/main/java/com/openisle/config/ActivityInitializer.java index 229eef3ad..84f8e42e2 100644 --- a/backend/src/main/java/com/openisle/config/ActivityInitializer.java +++ b/backend/src/main/java/com/openisle/config/ActivityInitializer.java @@ -3,37 +3,40 @@ package com.openisle.config; import com.openisle.model.Activity; import com.openisle.model.ActivityType; import com.openisle.repository.ActivityRepository; +import java.time.LocalDate; +import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; import org.springframework.boot.CommandLineRunner; import org.springframework.stereotype.Component; -import java.time.LocalDate; -import java.time.LocalDateTime; @Component @RequiredArgsConstructor public class ActivityInitializer implements CommandLineRunner { - private final ActivityRepository activityRepository; - @Override - public void run(String... args) { - if (activityRepository.findByType(ActivityType.MILK_TEA) == null) { - Activity a = new Activity(); - a.setTitle("🎡建站送奶茶活动"); - a.setType(ActivityType.MILK_TEA); - a.setIcon("https://icons.veryicon.com/png/o/food--drinks/delicious-food-1/coffee-36.png"); - a.setContent("为了有利于建站推广以及激励发布内容,我们推出了建站送奶茶的活动,前50名达到level 1的用户,可以联系站长获取奶茶/咖啡一杯"); - activityRepository.save(a); - } + private final ActivityRepository activityRepository; - if (activityRepository.findByType(ActivityType.INVITE_POINTS) == null) { - Activity a = new Activity(); - a.setTitle("🎁邀请码送积分活动"); - a.setType(ActivityType.INVITE_POINTS); - a.setIcon("https://img.icons8.com/color/96/gift.png"); - a.setContent("使用邀请码注册或邀请好友即可获得积分奖励,快来参与吧!"); - a.setStartTime(LocalDateTime.now()); - a.setEndTime(LocalDate.of(LocalDate.now().getYear(), 10, 1).atStartOfDay()); - activityRepository.save(a); - } + @Override + public void run(String... args) { + if (activityRepository.findByType(ActivityType.MILK_TEA) == null) { + Activity a = new Activity(); + a.setTitle("🎡建站送奶茶活动"); + a.setType(ActivityType.MILK_TEA); + a.setIcon("https://icons.veryicon.com/png/o/food--drinks/delicious-food-1/coffee-36.png"); + a.setContent( + "为了有利于建站推广以及激励发布内容,我们推出了建站送奶茶的活动,前50名达到level 1的用户,可以联系站长获取奶茶/咖啡一杯" + ); + activityRepository.save(a); } + + if (activityRepository.findByType(ActivityType.INVITE_POINTS) == null) { + Activity a = new Activity(); + a.setTitle("🎁邀请码送积分活动"); + a.setType(ActivityType.INVITE_POINTS); + a.setIcon("https://img.icons8.com/color/96/gift.png"); + a.setContent("使用邀请码注册或邀请好友即可获得积分奖励,快来参与吧!"); + a.setStartTime(LocalDateTime.now()); + a.setEndTime(LocalDate.of(LocalDate.now().getYear(), 10, 1).atStartOfDay()); + activityRepository.save(a); + } + } } diff --git a/backend/src/main/java/com/openisle/config/AsyncConfig.java b/backend/src/main/java/com/openisle/config/AsyncConfig.java index a8806f64d..2b9f75d4b 100644 --- a/backend/src/main/java/com/openisle/config/AsyncConfig.java +++ b/backend/src/main/java/com/openisle/config/AsyncConfig.java @@ -1,23 +1,23 @@ package com.openisle.config; +import java.util.concurrent.Executor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; -import java.util.concurrent.Executor; - @Configuration @EnableAsync public class AsyncConfig { - @Bean(name = "notificationExecutor") - public Executor notificationExecutor() { - ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); - executor.setCorePoolSize(2); - executor.setMaxPoolSize(10); - executor.setQueueCapacity(100); - executor.setThreadNamePrefix("notification-"); - executor.initialize(); - return executor; - } + + @Bean(name = "notificationExecutor") + public Executor notificationExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(10); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("notification-"); + executor.initialize(); + return executor; + } } diff --git a/backend/src/main/java/com/openisle/config/CachingConfig.java b/backend/src/main/java/com/openisle/config/CachingConfig.java index 70c14f593..b3b716359 100644 --- a/backend/src/main/java/com/openisle/config/CachingConfig.java +++ b/backend/src/main/java/com/openisle/config/CachingConfig.java @@ -7,6 +7,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator; import com.fasterxml.jackson.datatype.hibernate6.Hibernate6Module; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.EnableCaching; import org.springframework.context.annotation.Bean; @@ -21,10 +24,6 @@ import org.springframework.data.redis.serializer.RedisSerializationContext; import org.springframework.data.redis.serializer.RedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; -import java.time.Duration; -import java.util.HashMap; -import java.util.Map; - /** * Redis 缓存配置类 * @author smallclover @@ -34,96 +33,107 @@ import java.util.Map; @EnableCaching public class CachingConfig { - // 标签缓存名 - public static final String TAG_CACHE_NAME="openisle_tags"; - // 分类缓存名 - public static final String CATEGORY_CACHE_NAME="openisle_categories"; - // 在线人数缓存名 - public static final String ONLINE_CACHE_NAME="openisle_online"; - // 注册验证码 - public static final String VERIFY_CACHE_NAME="openisle_verify"; - // 发帖频率限制 - public static final String LIMIT_CACHE_NAME="openisle_limit"; - // 用户访问统计 - public static final String VISIT_CACHE_NAME="openisle_visit"; - // 文章缓存 - public static final String POST_CACHE_NAME="openisle_posts"; + // 标签缓存名 + public static final String TAG_CACHE_NAME = "openisle_tags"; + // 分类缓存名 + public static final String CATEGORY_CACHE_NAME = "openisle_categories"; + // 在线人数缓存名 + public static final String ONLINE_CACHE_NAME = "openisle_online"; + // 注册验证码 + public static final String VERIFY_CACHE_NAME = "openisle_verify"; + // 发帖频率限制 + public static final String LIMIT_CACHE_NAME = "openisle_limit"; + // 用户访问统计 + public static final String VISIT_CACHE_NAME = "openisle_visit"; + // 文章缓存 + public static final String POST_CACHE_NAME = "openisle_posts"; - /** - * 自定义Redis的序列化器 - * @return - */ - @Bean() - @Primary - public RedisSerializer redisSerializer() { - // 注册 JavaTimeModule 來支持 Java 8 的日期和时间 API,否则回报一下错误,同时还要引入jsr310 + /** + * 自定义Redis的序列化器 + * @return + */ + @Bean + @Primary + public RedisSerializer redisSerializer() { + // 注册 JavaTimeModule 來支持 Java 8 的日期和时间 API,否则回报一下错误,同时还要引入jsr310 - // org.springframework.data.redis.serializer.SerializationException: Could not write JSON: Java 8 date/time type `java.time.LocalDateTime` not supported by default: - // add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling - // (through reference chain: java.util.ArrayList[0]->com.openisle.dto.TagDto["createdAt"]) - // 设置可见性,允许序列化所有元素 - ObjectMapper objectMapper = new ObjectMapper(); - objectMapper.registerModule(new JavaTimeModule()); - // Hibernate6Module 可以自动处理懒加载代理对象。 - // Tag对象的creator是FetchType.LAZY - objectMapper.registerModule(new Hibernate6Module() - .disable(Hibernate6Module.Feature.USE_TRANSIENT_ANNOTATION) - // 将 Hibernate 特有的集合类型转换为标准 Java 集合类型 - // 避免序列化时出现 org.hibernate.collection.spi.PersistentSet 这样的类型信息 - .configure(Hibernate6Module.Feature.REPLACE_PERSISTENT_COLLECTIONS, true)); - // service的时候带上类型信息 - // 启用类型信息,避免 LinkedHashMap 问题 - objectMapper.activateDefaultTyping( - LaissezFaireSubTypeValidator.instance, - ObjectMapper.DefaultTyping.NON_FINAL, - JsonTypeInfo.As.PROPERTY - ); - objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); - return new GenericJackson2JsonRedisSerializer(objectMapper); - } + // org.springframework.data.redis.serializer.SerializationException: Could not write JSON: Java 8 date/time type `java.time.LocalDateTime` not supported by default: + // add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling + // (through reference chain: java.util.ArrayList[0]->com.openisle.dto.TagDto["createdAt"]) + // 设置可见性,允许序列化所有元素 + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + // Hibernate6Module 可以自动处理懒加载代理对象。 + // Tag对象的creator是FetchType.LAZY + objectMapper.registerModule( + new Hibernate6Module() + .disable(Hibernate6Module.Feature.USE_TRANSIENT_ANNOTATION) + // 将 Hibernate 特有的集合类型转换为标准 Java 集合类型 + // 避免序列化时出现 org.hibernate.collection.spi.PersistentSet 这样的类型信息 + .configure(Hibernate6Module.Feature.REPLACE_PERSISTENT_COLLECTIONS, true) + ); + // service的时候带上类型信息 + // 启用类型信息,避免 LinkedHashMap 问题 + objectMapper.activateDefaultTyping( + LaissezFaireSubTypeValidator.instance, + ObjectMapper.DefaultTyping.NON_FINAL, + JsonTypeInfo.As.PROPERTY + ); + objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); + return new GenericJackson2JsonRedisSerializer(objectMapper); + } - /** - * 配置 Spring Cache 使用 RedisCacheManager - */ - @Bean - public CacheManager cacheManager(RedisConnectionFactory connectionFactory, RedisSerializer redisSerializer) { + /** + * 配置 Spring Cache 使用 RedisCacheManager + */ + @Bean + public CacheManager cacheManager( + RedisConnectionFactory connectionFactory, + RedisSerializer redisSerializer + ) { + RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ZERO) // 默认缓存不过期 + .serializeKeysWith( + RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()) + ) + .serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer) + ) + .disableCachingNullValues(); // 禁止缓存 null 值 - RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() - .entryTtl(Duration.ZERO) // 默认缓存不过期 - .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) - .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer)) - .disableCachingNullValues(); // 禁止缓存 null 值 + // 个别缓存单独设置 TTL 时间 + Map cacheConfigs = new HashMap<>(); + RedisCacheConfiguration oneHourConfig = config.entryTtl(Duration.ofHours(1)); + RedisCacheConfiguration tenMinutesConfig = config.entryTtl(Duration.ofMinutes(10)); + cacheConfigs.put(TAG_CACHE_NAME, oneHourConfig); + cacheConfigs.put(CATEGORY_CACHE_NAME, oneHourConfig); + cacheConfigs.put(POST_CACHE_NAME, tenMinutesConfig); - // 个别缓存单独设置 TTL 时间 - Map cacheConfigs = new HashMap<>(); - RedisCacheConfiguration oneHourConfig = config.entryTtl(Duration.ofHours(1)); - RedisCacheConfiguration tenMinutesConfig = config.entryTtl(Duration.ofMinutes(10)); - cacheConfigs.put(TAG_CACHE_NAME, oneHourConfig); - cacheConfigs.put(CATEGORY_CACHE_NAME, oneHourConfig); - cacheConfigs.put(POST_CACHE_NAME, tenMinutesConfig); + return RedisCacheManager.builder(connectionFactory) + .cacheDefaults(config) + .withInitialCacheConfigurations(cacheConfigs) + .build(); + } - return RedisCacheManager.builder(connectionFactory) - .cacheDefaults(config) - .withInitialCacheConfigurations(cacheConfigs) - .build(); - } + /** + * 配置 RedisTemplate,支持直接操作 Redis + */ + @Bean + public RedisTemplate redisTemplate( + RedisConnectionFactory connectionFactory, + RedisSerializer redisSerializer + ) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); - /** - * 配置 RedisTemplate,支持直接操作 Redis - */ - @Bean - public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory, RedisSerializer redisSerializer) { - RedisTemplate template = new RedisTemplate<>(); - template.setConnectionFactory(connectionFactory); + // key 和 hashKey 使用 String 序列化 + template.setKeySerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); - // key 和 hashKey 使用 String 序列化 - template.setKeySerializer(new StringRedisSerializer()); - template.setHashKeySerializer(new StringRedisSerializer()); + // value 和 hashValue 使用 JSON 序列化 + template.setValueSerializer(redisSerializer); + template.setHashValueSerializer(redisSerializer); - // value 和 hashValue 使用 JSON 序列化 - template.setValueSerializer(redisSerializer); - template.setHashValueSerializer(redisSerializer); - - return template; - } + return template; + } } diff --git a/backend/src/main/java/com/openisle/config/ChannelInitializer.java b/backend/src/main/java/com/openisle/config/ChannelInitializer.java index ba034b49d..92e61f0ac 100644 --- a/backend/src/main/java/com/openisle/config/ChannelInitializer.java +++ b/backend/src/main/java/com/openisle/config/ChannelInitializer.java @@ -9,24 +9,29 @@ import org.springframework.stereotype.Component; @Component @RequiredArgsConstructor public class ChannelInitializer implements CommandLineRunner { - private final MessageConversationRepository conversationRepository; - @Override - public void run(String... args) { - if (conversationRepository.countByChannelTrue() == 0) { - MessageConversation chat = new MessageConversation(); - chat.setChannel(true); - chat.setName("吹水群"); - chat.setDescription("吹水聊天"); - chat.setAvatar("https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/32647273e2334d14adfd4a6ce9db0643.jpeg"); - conversationRepository.save(chat); + private final MessageConversationRepository conversationRepository; - MessageConversation tech = new MessageConversation(); - tech.setChannel(true); - tech.setName("技术讨论群"); - tech.setDescription("讨论技术相关话题"); - tech.setAvatar("https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/5edde9a5864e471caa32491dbcdaa8b2.png"); - conversationRepository.save(tech); - } + @Override + public void run(String... args) { + if (conversationRepository.countByChannelTrue() == 0) { + MessageConversation chat = new MessageConversation(); + chat.setChannel(true); + chat.setName("吹水群"); + chat.setDescription("吹水聊天"); + chat.setAvatar( + "https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/32647273e2334d14adfd4a6ce9db0643.jpeg" + ); + conversationRepository.save(chat); + + MessageConversation tech = new MessageConversation(); + tech.setChannel(true); + tech.setName("技术讨论群"); + tech.setDescription("讨论技术相关话题"); + tech.setAvatar( + "https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/5edde9a5864e471caa32491dbcdaa8b2.png" + ); + conversationRepository.save(tech); } + } } diff --git a/backend/src/main/java/com/openisle/config/CustomAccessDeniedHandler.java b/backend/src/main/java/com/openisle/config/CustomAccessDeniedHandler.java index 992b06f04..e31a2aade 100644 --- a/backend/src/main/java/com/openisle/config/CustomAccessDeniedHandler.java +++ b/backend/src/main/java/com/openisle/config/CustomAccessDeniedHandler.java @@ -3,23 +3,25 @@ package com.openisle.config; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.stereotype.Component; -import java.io.IOException; - /** * Returns 401 Unauthorized when an authenticated user lacks required privileges. */ @Component public class CustomAccessDeniedHandler implements AccessDeniedHandler { - @Override - public void handle(HttpServletRequest request, - HttpServletResponse response, - AccessDeniedException accessDeniedException) throws IOException, ServletException { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.setContentType("application/json"); - response.getWriter().write("{\"error\": \"Unauthorized\"}"); - } + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException, ServletException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json"); + response.getWriter().write("{\"error\": \"Unauthorized\"}"); + } } diff --git a/backend/src/main/java/com/openisle/config/OpenApiConfig.java b/backend/src/main/java/com/openisle/config/OpenApiConfig.java index aaccf2332..cbc15dca9 100644 --- a/backend/src/main/java/com/openisle/config/OpenApiConfig.java +++ b/backend/src/main/java/com/openisle/config/OpenApiConfig.java @@ -6,7 +6,6 @@ import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.security.SecurityRequirement; import io.swagger.v3.oas.models.security.SecurityScheme; import io.swagger.v3.oas.models.servers.Server; - import java.util.List; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; @@ -18,43 +17,42 @@ import org.springframework.context.annotation.Configuration; @RequiredArgsConstructor public class OpenApiConfig { - private final SpringDocProperties springDocProperties; + private final SpringDocProperties springDocProperties; - @Value("${springdoc.info.title}") - private String title; + @Value("${springdoc.info.title}") + private String title; - @Value("${springdoc.info.description}") - private String description; + @Value("${springdoc.info.description}") + private String description; - @Value("${springdoc.info.version}") - private String version; + @Value("${springdoc.info.version}") + private String version; - @Value("${springdoc.info.scheme}") - private String scheme; + @Value("${springdoc.info.scheme}") + private String scheme; - @Value("${springdoc.info.header}") - private String header; + @Value("${springdoc.info.header}") + private String header; - @Bean - public OpenAPI openAPI() { - SecurityScheme securityScheme = new SecurityScheme() - .type(SecurityScheme.Type.HTTP) - .scheme(scheme.toLowerCase()) - .bearerFormat("JWT") - .in(SecurityScheme.In.HEADER) - .name(header); + @Bean + public OpenAPI openAPI() { + SecurityScheme securityScheme = new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme(scheme.toLowerCase()) + .bearerFormat("JWT") + .in(SecurityScheme.In.HEADER) + .name(header); - List servers = springDocProperties.getServers().stream() - .map(s -> new Server().url(s.getUrl()).description(s.getDescription())) - .collect(Collectors.toList()); + List servers = springDocProperties + .getServers() + .stream() + .map(s -> new Server().url(s.getUrl()).description(s.getDescription())) + .collect(Collectors.toList()); - return new OpenAPI() - .servers(servers) - .info(new Info() - .title(title) - .description(description) - .version(version)) - .components(new Components().addSecuritySchemes("JWT", securityScheme)) - .addSecurityItem(new SecurityRequirement().addList("JWT")); - } + return new OpenAPI() + .servers(servers) + .info(new Info().title(title).description(description).version(version)) + .components(new Components().addSecuritySchemes("JWT", securityScheme)) + .addSecurityItem(new SecurityRequirement().addList("JWT")); + } } diff --git a/backend/src/main/java/com/openisle/config/PointGoodInitializer.java b/backend/src/main/java/com/openisle/config/PointGoodInitializer.java index f7f1e4cff..2da092e01 100644 --- a/backend/src/main/java/com/openisle/config/PointGoodInitializer.java +++ b/backend/src/main/java/com/openisle/config/PointGoodInitializer.java @@ -10,22 +10,27 @@ import org.springframework.stereotype.Component; @Component @RequiredArgsConstructor public class PointGoodInitializer implements CommandLineRunner { - private final PointGoodRepository pointGoodRepository; - @Override - public void run(String... args) { - if (pointGoodRepository.count() == 0) { - PointGood g1 = new PointGood(); - g1.setName("GPT Plus 1 个月"); - g1.setCost(20000); - g1.setImage("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/chatgpt.png"); - pointGoodRepository.save(g1); + private final PointGoodRepository pointGoodRepository; - PointGood g2 = new PointGood(); - g2.setName("奶茶"); - g2.setCost(5000); - g2.setImage("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/coffee.png"); - pointGoodRepository.save(g2); - } + @Override + public void run(String... args) { + if (pointGoodRepository.count() == 0) { + PointGood g1 = new PointGood(); + g1.setName("GPT Plus 1 个月"); + g1.setCost(20000); + g1.setImage( + "https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/chatgpt.png" + ); + pointGoodRepository.save(g1); + + PointGood g2 = new PointGood(); + g2.setName("奶茶"); + g2.setCost(5000); + g2.setImage( + "https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/coffee.png" + ); + pointGoodRepository.save(g2); } + } } diff --git a/backend/src/main/java/com/openisle/config/RabbitMQConfig.java b/backend/src/main/java/com/openisle/config/RabbitMQConfig.java index cd7e174f0..1b63f8484 100644 --- a/backend/src/main/java/com/openisle/config/RabbitMQConfig.java +++ b/backend/src/main/java/com/openisle/config/RabbitMQConfig.java @@ -1,5 +1,9 @@ package com.openisle.config; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import java.util.ArrayList; +import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.amqp.core.Binding; @@ -7,199 +11,209 @@ import org.springframework.amqp.core.BindingBuilder; import org.springframework.amqp.core.Queue; import org.springframework.amqp.core.TopicExchange; import org.springframework.amqp.rabbit.connection.ConnectionFactory; +import org.springframework.amqp.rabbit.core.RabbitAdmin; import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.CommandLineRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.springframework.amqp.rabbit.core.RabbitAdmin; -import org.springframework.boot.CommandLineRunner; import org.springframework.context.annotation.DependsOn; -import jakarta.annotation.PostConstruct; -import java.util.ArrayList; -import java.util.List; - @Configuration @RequiredArgsConstructor @Slf4j public class RabbitMQConfig { - public static final String EXCHANGE_NAME = "openisle-exchange"; - // 保持向后兼容的常量 - public static final String QUEUE_NAME = "notifications-queue"; - public static final String ROUTING_KEY = "notifications.routingkey"; - - // 硬编码为16以匹配ShardingStrategy中的十六进制分片逻辑 - private final int queueCount = 16; - - @Value("${rabbitmq.queue.durable}") - private boolean queueDurable; + public static final String EXCHANGE_NAME = "openisle-exchange"; + // 保持向后兼容的常量 + public static final String QUEUE_NAME = "notifications-queue"; + public static final String ROUTING_KEY = "notifications.routingkey"; - @PostConstruct - public void init() { - log.info("RabbitMQ配置初始化: 队列数量={}, 持久化={}", queueCount, queueDurable); + // 硬编码为16以匹配ShardingStrategy中的十六进制分片逻辑 + private final int queueCount = 16; + + @Value("${rabbitmq.queue.durable}") + private boolean queueDurable; + + @PostConstruct + public void init() { + log.info("RabbitMQ配置初始化: 队列数量={}, 持久化={}", queueCount, queueDurable); + } + + @Bean + public TopicExchange exchange() { + return new TopicExchange(EXCHANGE_NAME); + } + + /** + * 创建所有分片队列, 使用十六进制后缀 (0-f) + */ + @Bean + public List shardedQueues() { + log.info("开始创建分片队列 Bean..."); + + List queues = new ArrayList<>(); + for (int i = 0; i < queueCount; i++) { + String shardKey = Integer.toHexString(i); + String queueName = "notifications-queue-" + shardKey; + Queue queue = new Queue(queueName, queueDurable); + queues.add(queue); } - @Bean - public TopicExchange exchange() { - return new TopicExchange(EXCHANGE_NAME); + log.info("分片队列 Bean 创建完成,总数: {}", queues.size()); + return queues; + } + + /** + * 创建所有分片绑定, 使用十六进制路由键 (notifications.shard.0 - notifications.shard.f) + */ + @Bean + public List shardedBindings( + TopicExchange exchange, + @Qualifier("shardedQueues") List shardedQueues + ) { + log.info("开始创建分片绑定 Bean..."); + List bindings = new ArrayList<>(); + if (shardedQueues != null) { + for (Queue queue : shardedQueues) { + String queueName = queue.getName(); + String shardKey = queueName.substring("notifications-queue-".length()); + String routingKey = "notifications.shard." + shardKey; + Binding binding = BindingBuilder.bind(queue).to(exchange).with(routingKey); + bindings.add(binding); + } } - /** - * 创建所有分片队列, 使用十六进制后缀 (0-f) - */ - @Bean - public List shardedQueues() { - log.info("开始创建分片队列 Bean..."); - - List queues = new ArrayList<>(); - for (int i = 0; i < queueCount; i++) { - String shardKey = Integer.toHexString(i); - String queueName = "notifications-queue-" + shardKey; - Queue queue = new Queue(queueName, queueDurable); - queues.add(queue); - } - - log.info("分片队列 Bean 创建完成,总数: {}", queues.size()); - return queues; - } + log.info("分片绑定 Bean 创建完成,总数: {}", bindings.size()); + return bindings; + } - /** - * 创建所有分片绑定, 使用十六进制路由键 (notifications.shard.0 - notifications.shard.f) - */ - @Bean - public List shardedBindings(TopicExchange exchange, @Qualifier("shardedQueues") List shardedQueues) { - log.info("开始创建分片绑定 Bean..."); - List bindings = new ArrayList<>(); - if (shardedQueues != null) { - for (Queue queue : shardedQueues) { - String queueName = queue.getName(); - String shardKey = queueName.substring("notifications-queue-".length()); - String routingKey = "notifications.shard." + shardKey; - Binding binding = BindingBuilder.bind(queue).to(exchange).with(routingKey); - bindings.add(binding); + /** + * 保持向后兼容的单队列配置(可选) + */ + @Bean + public Queue legacyQueue() { + return new Queue(QUEUE_NAME, queueDurable); + } + + /** + * 保持向后兼容的单队列绑定(可选) + */ + @Bean + public Binding legacyBinding(Queue legacyQueue, TopicExchange exchange) { + return BindingBuilder.bind(legacyQueue).to(exchange).with(ROUTING_KEY); + } + + @Bean + public Jackson2JsonMessageConverter messageConverter() { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule()); + objectMapper.disable( + com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS + ); + return new Jackson2JsonMessageConverter(objectMapper); + } + + @Bean + public RabbitAdmin rabbitAdmin(ConnectionFactory connectionFactory) { + return new RabbitAdmin(connectionFactory); + } + + @Bean + public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) { + RabbitTemplate template = new RabbitTemplate(connectionFactory); + template.setMessageConverter(messageConverter()); + return template; + } + + /** + * 使用 CommandLineRunner 确保在应用完全启动后声明队列到 RabbitMQ + * 这样可以确保 RabbitAdmin 和所有 Bean 都已正确初始化 + */ + @Bean + @DependsOn({ "rabbitAdmin", "shardedQueues", "exchange" }) + public CommandLineRunner queueDeclarationRunner( + RabbitAdmin rabbitAdmin, + @Qualifier("shardedQueues") List shardedQueues, + TopicExchange exchange, + Queue legacyQueue, + @Qualifier("shardedBindings") List shardedBindings, + Binding legacyBinding + ) { + return args -> { + log.info("=== 开始主动声明 RabbitMQ 组件 ==="); + + try { + // 声明交换 + rabbitAdmin.declareExchange(exchange); + + // 声明分片队列 - 检查存在性 + log.info("开始检查并声明 {} 个分片队列...", shardedQueues.size()); + int successCount = 0; + int skippedCount = 0; + + for (Queue queue : shardedQueues) { + String queueName = queue.getName(); + try { + // 使用 declareQueue 的返回值判断队列是否已存在 + // 如果队列已存在且配置匹配,declareQueue 会返回现有队列信息 + // 如果不匹配或不存在,会创建新队列 + rabbitAdmin.declareQueue(queue); + successCount++; + } catch (org.springframework.amqp.AmqpIOException e) { + if ( + e.getMessage().contains("PRECONDITION_FAILED") && e.getMessage().contains("durable") + ) { + skippedCount++; } + } catch (Exception e) { + log.error("队列声明失败: {}, 错误: {}", queueName, e.getMessage()); + } } - - log.info("分片绑定 Bean 创建完成,总数: {}", bindings.size()); - return bindings; - } + log.info( + "分片队列处理完成: 成功 {}, 跳过 {}, 总数 {}", + successCount, + skippedCount, + shardedQueues.size() + ); - /** - * 保持向后兼容的单队列配置(可选) - */ - @Bean - public Queue legacyQueue() { - return new Queue(QUEUE_NAME, queueDurable); - } + // 声明分片绑定 + log.info("开始声明 {} 个分片绑定...", shardedBindings.size()); + int bindingSuccessCount = 0; + for (Binding binding : shardedBindings) { + try { + rabbitAdmin.declareBinding(binding); + bindingSuccessCount++; + } catch (Exception e) { + log.error("绑定声明失败: {}", e.getMessage()); + } + } + log.info("分片绑定声明完成: 成功 {}/{}", bindingSuccessCount, shardedBindings.size()); - /** - * 保持向后兼容的单队列绑定(可选) - */ - @Bean - public Binding legacyBinding(Queue legacyQueue, TopicExchange exchange) { - return BindingBuilder.bind(legacyQueue).to(exchange).with(ROUTING_KEY); - } + // 声明遗留队列和绑定 - 检查存在性 + try { + rabbitAdmin.declareQueue(legacyQueue); + rabbitAdmin.declareBinding(legacyBinding); + log.info("遗留队列和绑定就绪: {} (已存在或新创建)", QUEUE_NAME); + } catch (org.springframework.amqp.AmqpIOException e) { + if ( + e.getMessage().contains("PRECONDITION_FAILED") && e.getMessage().contains("durable") + ) { + log.warn("遗留队列已存在但 durable 设置不匹配: {}, 保持现有队列", QUEUE_NAME); + } else { + log.error("遗留队列声明失败: {}, 错误: {}", QUEUE_NAME, e.getMessage()); + } + } catch (Exception e) { + log.error("遗留队列声明失败: {}, 错误: {}", QUEUE_NAME, e.getMessage()); + } - @Bean - public Jackson2JsonMessageConverter messageConverter() { - ObjectMapper objectMapper = new ObjectMapper(); - objectMapper.registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule()); - objectMapper.disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); - return new Jackson2JsonMessageConverter(objectMapper); - } - - @Bean - public RabbitAdmin rabbitAdmin(ConnectionFactory connectionFactory) { - return new RabbitAdmin(connectionFactory); - } - - @Bean - public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) { - RabbitTemplate template = new RabbitTemplate(connectionFactory); - template.setMessageConverter(messageConverter()); - return template; - } - - /** - * 使用 CommandLineRunner 确保在应用完全启动后声明队列到 RabbitMQ - * 这样可以确保 RabbitAdmin 和所有 Bean 都已正确初始化 - */ - @Bean - @DependsOn({"rabbitAdmin", "shardedQueues", "exchange"}) - public CommandLineRunner queueDeclarationRunner(RabbitAdmin rabbitAdmin, - @Qualifier("shardedQueues") List shardedQueues, - TopicExchange exchange, - Queue legacyQueue, - @Qualifier("shardedBindings") List shardedBindings, - Binding legacyBinding) { - return args -> { - log.info("=== 开始主动声明 RabbitMQ 组件 ==="); - - try { - // 声明交换 - rabbitAdmin.declareExchange(exchange); - - // 声明分片队列 - 检查存在性 - log.info("开始检查并声明 {} 个分片队列...", shardedQueues.size()); - int successCount = 0; - int skippedCount = 0; - - for (Queue queue : shardedQueues) { - String queueName = queue.getName(); - try { - // 使用 declareQueue 的返回值判断队列是否已存在 - // 如果队列已存在且配置匹配,declareQueue 会返回现有队列信息 - // 如果不匹配或不存在,会创建新队列 - rabbitAdmin.declareQueue(queue); - successCount++; - } catch (org.springframework.amqp.AmqpIOException e) { - if (e.getMessage().contains("PRECONDITION_FAILED") && e.getMessage().contains("durable")) { - skippedCount++; - } - } catch (Exception e) { - log.error("队列声明失败: {}, 错误: {}", queueName, e.getMessage()); - } - } - log.info("分片队列处理完成: 成功 {}, 跳过 {}, 总数 {}", successCount, skippedCount, shardedQueues.size()); - - // 声明分片绑定 - log.info("开始声明 {} 个分片绑定...", shardedBindings.size()); - int bindingSuccessCount = 0; - for (Binding binding : shardedBindings) { - try { - rabbitAdmin.declareBinding(binding); - bindingSuccessCount++; - } catch (Exception e) { - log.error("绑定声明失败: {}", e.getMessage()); - } - } - log.info("分片绑定声明完成: 成功 {}/{}", bindingSuccessCount, shardedBindings.size()); - - // 声明遗留队列和绑定 - 检查存在性 - try { - rabbitAdmin.declareQueue(legacyQueue); - rabbitAdmin.declareBinding(legacyBinding); - log.info("遗留队列和绑定就绪: {} (已存在或新创建)", QUEUE_NAME); - } catch (org.springframework.amqp.AmqpIOException e) { - if (e.getMessage().contains("PRECONDITION_FAILED") && e.getMessage().contains("durable")) { - log.warn("遗留队列已存在但 durable 设置不匹配: {}, 保持现有队列", QUEUE_NAME); - } else { - log.error("遗留队列声明失败: {}, 错误: {}", QUEUE_NAME, e.getMessage()); - } - } catch (Exception e) { - log.error("遗留队列声明失败: {}, 错误: {}", QUEUE_NAME, e.getMessage()); - } - - log.info("=== RabbitMQ 组件声明完成 ==="); - log.info("请检查 RabbitMQ 管理界面确认队列已正确创建"); - - } catch (Exception e) { - log.error("RabbitMQ 组件声明过程中发生严重错误", e); - } - }; - } -} \ No newline at end of file + log.info("=== RabbitMQ 组件声明完成 ==="); + log.info("请检查 RabbitMQ 管理界面确认队列已正确创建"); + } catch (Exception e) { + log.error("RabbitMQ 组件声明过程中发生严重错误", e); + } + }; + } +} diff --git a/backend/src/main/java/com/openisle/config/RedisConnectionLogger.java b/backend/src/main/java/com/openisle/config/RedisConnectionLogger.java index 0f7fd305d..7b9531585 100644 --- a/backend/src/main/java/com/openisle/config/RedisConnectionLogger.java +++ b/backend/src/main/java/com/openisle/config/RedisConnectionLogger.java @@ -13,23 +13,23 @@ import org.springframework.stereotype.Component; @Slf4j public class RedisConnectionLogger implements InitializingBean { - private final RedisConnectionFactory connectionFactory; + private final RedisConnectionFactory connectionFactory; - public RedisConnectionLogger(RedisConnectionFactory connectionFactory) { - this.connectionFactory = connectionFactory; - } + public RedisConnectionLogger(RedisConnectionFactory connectionFactory) { + this.connectionFactory = connectionFactory; + } - @Override - public void afterPropertiesSet() { - try (var connection = connectionFactory.getConnection()) { - connection.ping(); - if (connectionFactory instanceof LettuceConnectionFactory lettuce) { - log.info("Redis connection established at {}:{}", lettuce.getHostName(), lettuce.getPort()); - } else { - log.info("Redis connection established"); - } - } catch (Exception e) { - log.error("Failed to connect to Redis", e); - } + @Override + public void afterPropertiesSet() { + try (var connection = connectionFactory.getConnection()) { + connection.ping(); + if (connectionFactory instanceof LettuceConnectionFactory lettuce) { + log.info("Redis connection established at {}:{}", lettuce.getHostName(), lettuce.getPort()); + } else { + log.info("Redis connection established"); + } + } catch (Exception e) { + log.error("Failed to connect to Redis", e); } + } } diff --git a/backend/src/main/java/com/openisle/config/SchedulerConfig.java b/backend/src/main/java/com/openisle/config/SchedulerConfig.java index c087ccd6b..8eec7445c 100644 --- a/backend/src/main/java/com/openisle/config/SchedulerConfig.java +++ b/backend/src/main/java/com/openisle/config/SchedulerConfig.java @@ -2,19 +2,20 @@ package com.openisle.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.TaskScheduler; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; -import org.springframework.scheduling.TaskScheduler; @Configuration @EnableScheduling public class SchedulerConfig { - @Bean - public TaskScheduler taskScheduler() { - ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); - scheduler.setPoolSize(2); - scheduler.setThreadNamePrefix("lottery-"); - scheduler.initialize(); - return scheduler; - } + + @Bean + public TaskScheduler taskScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(2); + scheduler.setThreadNamePrefix("lottery-"); + scheduler.initialize(); + return scheduler; + } } diff --git a/backend/src/main/java/com/openisle/config/SecurityConfig.java b/backend/src/main/java/com/openisle/config/SecurityConfig.java index ca2c930c3..23cb0c5a5 100644 --- a/backend/src/main/java/com/openisle/config/SecurityConfig.java +++ b/backend/src/main/java/com/openisle/config/SecurityConfig.java @@ -1,9 +1,17 @@ package com.openisle.config; +import com.openisle.repository.UserRepository; import com.openisle.service.JwtService; import com.openisle.service.UserVisitService; -import com.openisle.repository.UserRepository; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.time.LocalDate; +import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.core.RedisTemplate; @@ -22,202 +30,268 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import org.springframework.beans.factory.annotation.Value; - -import java.time.LocalDate; -import java.util.List; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import java.io.IOException; +import org.springframework.web.filter.OncePerRequestFilter; @Configuration @RequiredArgsConstructor public class SecurityConfig { - private final JwtService jwtService; - private final UserRepository userRepository; - private final AccessDeniedHandler customAccessDeniedHandler; - private final UserVisitService userVisitService; - @Value("${app.website-url}") - private String websiteUrl; - private final RedisTemplate redisTemplate; + private final JwtService jwtService; + private final UserRepository userRepository; + private final AccessDeniedHandler customAccessDeniedHandler; + private final UserVisitService userVisitService; - @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } + @Value("${app.website-url}") + private String websiteUrl; - @Bean - public UserDetailsService userDetailsService() { - return username -> userRepository.findByUsername(username) - .map(user -> org.springframework.security.core.userdetails.User - .withUsername(user.getUsername()) - .password(user.getPassword()) - .authorities(user.getRole().name()) - .build()) - .orElseThrow(() -> new UsernameNotFoundException("User not found")); - } + private final RedisTemplate redisTemplate; - @Bean - public AuthenticationManager authenticationManager(HttpSecurity http, PasswordEncoder passwordEncoder, UserDetailsService userDetailsService) throws Exception { - return http.getSharedObject(AuthenticationManagerBuilder.class) - .userDetailsService(userDetailsService) - .passwordEncoder(passwordEncoder) - .and() - .build(); - } + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } - @Bean - public CorsConfigurationSource corsConfigurationSource() { - CorsConfiguration cfg = new CorsConfiguration(); - cfg.setAllowedOrigins(List.of( - "http://127.0.0.1:8080", - "http://127.0.0.1:8081", - "http://127.0.0.1:8082", - "http://127.0.0.1:3000", - "http://127.0.0.1:3001", - "http://127.0.0.1", - "http://localhost:8080", - "http://localhost:8081", - "http://localhost:8082", - "http://localhost:3000", - "http://localhost:3001", - "http://localhost", - "http://30.211.97.238:3000", - "http://30.211.97.238", - "http://192.168.7.98", - "http://192.168.7.98:3000", - "https://petstore.swagger.io", - // 允许自建OpenAPI地址 - "https://docs.open-isle.com", - "https://www.docs.open-isle.com", - websiteUrl, - websiteUrl.replace("://www.", "://") - )); - cfg.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS")); - cfg.setAllowedHeaders(List.of("*")); - cfg.setAllowCredentials(true); - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/api/**", cfg); - return source; - } + @Bean + public UserDetailsService userDetailsService() { + return username -> + userRepository + .findByUsername(username) + .map(user -> + org.springframework.security.core.userdetails.User.withUsername(user.getUsername()) + .password(user.getPassword()) + .authorities(user.getRole().name()) + .build() + ) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); + } - @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - http.csrf(csrf -> csrf.disable()) - .cors(Customizer.withDefaults()) - .headers(h -> h.frameOptions(f -> f.sameOrigin())) - .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .exceptionHandling(eh -> eh.accessDeniedHandler(customAccessDeniedHandler)) - .authorizeHttpRequests(auth -> auth - .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() - .requestMatchers("/api/ws/**", "/api/sockjs/**").permitAll() - .requestMatchers("/api/v3/api-docs/**").permitAll() - .requestMatchers(HttpMethod.POST, "/api/auth/**").permitAll() - .requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll() - .requestMatchers(HttpMethod.GET, "/api/comments/**").permitAll() - .requestMatchers(HttpMethod.GET, "/api/categories/**").permitAll() - .requestMatchers(HttpMethod.GET, "/api/tags/**").permitAll() - .requestMatchers(HttpMethod.GET, "/api/config/**").permitAll() - .requestMatchers(HttpMethod.POST,"/api/auth/google").permitAll() - .requestMatchers(HttpMethod.POST,"/api/auth/reason").permitAll() - .requestMatchers(HttpMethod.GET, "/api/search/**").permitAll() - .requestMatchers(HttpMethod.GET, "/api/users/**").permitAll() - .requestMatchers(HttpMethod.GET, "/api/medals/**").permitAll() - .requestMatchers(HttpMethod.GET, "/api/push/public-key").permitAll() - .requestMatchers(HttpMethod.GET, "/api/reaction-types").permitAll() - .requestMatchers(HttpMethod.GET, "/api/activities/**").permitAll() - .requestMatchers(HttpMethod.GET, "/api/sitemap.xml").permitAll() - .requestMatchers(HttpMethod.GET, "/api/channels").permitAll() - .requestMatchers(HttpMethod.GET, "/api/rss").permitAll() - .requestMatchers(HttpMethod.GET, "/api/online/**").permitAll() - .requestMatchers(HttpMethod.POST, "/api/online/**").permitAll() - .requestMatchers(HttpMethod.GET, "/api/point-goods").permitAll() - .requestMatchers(HttpMethod.POST, "/api/point-goods").permitAll() - .requestMatchers(HttpMethod.POST, "/api/categories/**").hasAuthority("ADMIN") - .requestMatchers(HttpMethod.POST, "/api/tags/**").authenticated() - .requestMatchers(HttpMethod.DELETE, "/api/categories/**").hasAuthority("ADMIN") - .requestMatchers(HttpMethod.DELETE, "/api/tags/**").hasAuthority("ADMIN") - .requestMatchers(HttpMethod.GET, "/api/stats/**").hasAuthority("ADMIN") - .requestMatchers("/api/admin/**").hasAuthority("ADMIN") - .anyRequest().authenticated() - ) - .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) - .addFilterAfter(userVisitFilter(), UsernamePasswordAuthenticationFilter.class); - return http.build(); - } + @Bean + public AuthenticationManager authenticationManager( + HttpSecurity http, + PasswordEncoder passwordEncoder, + UserDetailsService userDetailsService + ) throws Exception { + return http + .getSharedObject(AuthenticationManagerBuilder.class) + .userDetailsService(userDetailsService) + .passwordEncoder(passwordEncoder) + .and() + .build(); + } - @Bean - public OncePerRequestFilter jwtAuthenticationFilter() { - return new OncePerRequestFilter() { - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - // 让预检请求直接通过 - if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { - filterChain.doFilter(request, response); - return; - } - String authHeader = request.getHeader("Authorization"); - String uri = request.getRequestURI(); + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration cfg = new CorsConfiguration(); + cfg.setAllowedOrigins( + List.of( + "http://127.0.0.1:8080", + "http://127.0.0.1:8081", + "http://127.0.0.1:8082", + "http://127.0.0.1:3000", + "http://127.0.0.1:3001", + "http://127.0.0.1", + "http://localhost:8080", + "http://localhost:8081", + "http://localhost:8082", + "http://localhost:3000", + "http://localhost:3001", + "http://localhost", + "http://30.211.97.238:3000", + "http://30.211.97.238", + "http://192.168.7.98", + "http://192.168.7.98:3000", + "https://petstore.swagger.io", + // 允许自建OpenAPI地址 + "https://docs.open-isle.com", + "https://www.docs.open-isle.com", + websiteUrl, + websiteUrl.replace("://www.", "://") + ) + ); + cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + cfg.setAllowedHeaders(List.of("*")); + cfg.setAllowCredentials(true); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/api/**", cfg); + return source; + } - boolean publicGet = "GET".equalsIgnoreCase(request.getMethod()) && - (uri.startsWith("/api/posts") || uri.startsWith("/api/comments") || - uri.startsWith("/api/categories") || uri.startsWith("/api/tags") || - uri.startsWith("/api/search") || uri.startsWith("/api/users") || - uri.startsWith("/api/reaction-types") || uri.startsWith("/api/config") || - uri.startsWith("/api/activities") || uri.startsWith("/api/push/public-key") || - uri.startsWith("/api/point-goods") || uri.startsWith("/api/channels") || - uri.startsWith("/api/sitemap.xml") || uri.startsWith("/api/medals") || - uri.startsWith("/api/rss")); + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .cors(Customizer.withDefaults()) + .headers(h -> h.frameOptions(f -> f.sameOrigin())) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(eh -> eh.accessDeniedHandler(customAccessDeniedHandler)) + .authorizeHttpRequests(auth -> + auth + .requestMatchers(HttpMethod.OPTIONS, "/**") + .permitAll() + .requestMatchers("/api/ws/**", "/api/sockjs/**") + .permitAll() + .requestMatchers("/api/v3/api-docs/**") + .permitAll() + .requestMatchers(HttpMethod.POST, "/api/auth/**") + .permitAll() + .requestMatchers(HttpMethod.GET, "/api/posts/**") + .permitAll() + .requestMatchers(HttpMethod.GET, "/api/comments/**") + .permitAll() + .requestMatchers(HttpMethod.GET, "/api/categories/**") + .permitAll() + .requestMatchers(HttpMethod.GET, "/api/tags/**") + .permitAll() + .requestMatchers(HttpMethod.GET, "/api/config/**") + .permitAll() + .requestMatchers(HttpMethod.POST, "/api/auth/google") + .permitAll() + .requestMatchers(HttpMethod.POST, "/api/auth/reason") + .permitAll() + .requestMatchers(HttpMethod.GET, "/api/search/**") + .permitAll() + .requestMatchers(HttpMethod.GET, "/api/users/**") + .permitAll() + .requestMatchers(HttpMethod.GET, "/api/medals/**") + .permitAll() + .requestMatchers(HttpMethod.GET, "/api/push/public-key") + .permitAll() + .requestMatchers(HttpMethod.GET, "/api/reaction-types") + .permitAll() + .requestMatchers(HttpMethod.GET, "/api/activities/**") + .permitAll() + .requestMatchers(HttpMethod.GET, "/api/sitemap.xml") + .permitAll() + .requestMatchers(HttpMethod.GET, "/api/channels") + .permitAll() + .requestMatchers(HttpMethod.GET, "/api/rss") + .permitAll() + .requestMatchers(HttpMethod.GET, "/api/online/**") + .permitAll() + .requestMatchers(HttpMethod.POST, "/api/online/**") + .permitAll() + .requestMatchers(HttpMethod.GET, "/api/point-goods") + .permitAll() + .requestMatchers(HttpMethod.POST, "/api/point-goods") + .permitAll() + .requestMatchers(HttpMethod.POST, "/api/categories/**") + .hasAuthority("ADMIN") + .requestMatchers(HttpMethod.POST, "/api/tags/**") + .authenticated() + .requestMatchers(HttpMethod.DELETE, "/api/categories/**") + .hasAuthority("ADMIN") + .requestMatchers(HttpMethod.DELETE, "/api/tags/**") + .hasAuthority("ADMIN") + .requestMatchers(HttpMethod.GET, "/api/stats/**") + .hasAuthority("ADMIN") + .requestMatchers("/api/admin/**") + .hasAuthority("ADMIN") + .anyRequest() + .authenticated() + ) + .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) + .addFilterAfter(userVisitFilter(), UsernamePasswordAuthenticationFilter.class); + return http.build(); + } - if (authHeader != null && authHeader.startsWith("Bearer ")) { - String token = authHeader.substring(7); - try { - String username = jwtService.validateAndGetSubject(token); - UserDetails userDetails = userDetailsService().loadUserByUsername(username); - UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( - userDetails, null, userDetails.getAuthorities()); - org.springframework.security.core.context.SecurityContextHolder.getContext().setAuthentication(authToken); - } catch (Exception e) { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.setContentType("application/json"); - response.getWriter().write("{\"error\": \"Invalid or expired token\"}"); - return; - } - } else if (!uri.startsWith("/api/auth") && !publicGet - && !uri.startsWith("/api/ws") && !uri.startsWith("/api/sockjs") - && !uri.startsWith("/api/v3/api-docs") - && !uri.startsWith("/api/online")) { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.setContentType("application/json"); - response.getWriter().write("{\"error\": \"Missing token\"}"); - return; - } + @Bean + public OncePerRequestFilter jwtAuthenticationFilter() { + return new OncePerRequestFilter() { + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + // 让预检请求直接通过 + if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { + filterChain.doFilter(request, response); + return; + } + String authHeader = request.getHeader("Authorization"); + String uri = request.getRequestURI(); - filterChain.doFilter(request, response); - } - }; - } + boolean publicGet = + "GET".equalsIgnoreCase(request.getMethod()) && + (uri.startsWith("/api/posts") || + uri.startsWith("/api/comments") || + uri.startsWith("/api/categories") || + uri.startsWith("/api/tags") || + uri.startsWith("/api/search") || + uri.startsWith("/api/users") || + uri.startsWith("/api/reaction-types") || + uri.startsWith("/api/config") || + uri.startsWith("/api/activities") || + uri.startsWith("/api/push/public-key") || + uri.startsWith("/api/point-goods") || + uri.startsWith("/api/channels") || + uri.startsWith("/api/sitemap.xml") || + uri.startsWith("/api/medals") || + uri.startsWith("/api/rss")); - @Bean - public OncePerRequestFilter userVisitFilter() { - return new OncePerRequestFilter() { - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - var auth = org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication(); - if (auth != null && auth.isAuthenticated() && !(auth instanceof org.springframework.security.authentication.AnonymousAuthenticationToken)) { - String key = CachingConfig.VISIT_CACHE_NAME+":"+ LocalDate.now(); - redisTemplate.opsForSet().add(key, auth.getName()); - } - filterChain.doFilter(request, response); - } - }; - } + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String token = authHeader.substring(7); + try { + String username = jwtService.validateAndGetSubject(token); + UserDetails userDetails = userDetailsService().loadUserByUsername(username); + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + org.springframework.security.core.context.SecurityContextHolder.getContext().setAuthentication( + authToken + ); + } catch (Exception e) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json"); + response.getWriter().write("{\"error\": \"Invalid or expired token\"}"); + return; + } + } else if ( + !uri.startsWith("/api/auth") && + !publicGet && + !uri.startsWith("/api/ws") && + !uri.startsWith("/api/sockjs") && + !uri.startsWith("/api/v3/api-docs") && + !uri.startsWith("/api/online") + ) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json"); + response.getWriter().write("{\"error\": \"Missing token\"}"); + return; + } + + filterChain.doFilter(request, response); + } + }; + } + + @Bean + public OncePerRequestFilter userVisitFilter() { + return new OncePerRequestFilter() { + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + var auth = + org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication(); + if ( + auth != null && + auth.isAuthenticated() && + !(auth instanceof + org.springframework.security.authentication.AnonymousAuthenticationToken) + ) { + String key = CachingConfig.VISIT_CACHE_NAME + ":" + LocalDate.now(); + redisTemplate.opsForSet().add(key, auth.getName()); + } + filterChain.doFilter(request, response); + } + }; + } } diff --git a/backend/src/main/java/com/openisle/config/ShardInfo.java b/backend/src/main/java/com/openisle/config/ShardInfo.java index feae640ff..74c2502c3 100644 --- a/backend/src/main/java/com/openisle/config/ShardInfo.java +++ b/backend/src/main/java/com/openisle/config/ShardInfo.java @@ -8,7 +8,8 @@ import lombok.NoArgsConstructor; @AllArgsConstructor @NoArgsConstructor public class ShardInfo { - private int shardIndex; - private String queueName; - private String routingKey; -} \ No newline at end of file + + private int shardIndex; + private String queueName; + private String routingKey; +} diff --git a/backend/src/main/java/com/openisle/config/ShardingStrategy.java b/backend/src/main/java/com/openisle/config/ShardingStrategy.java index a5be64640..7e894043d 100644 --- a/backend/src/main/java/com/openisle/config/ShardingStrategy.java +++ b/backend/src/main/java/com/openisle/config/ShardingStrategy.java @@ -1,84 +1,87 @@ package com.openisle.config; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicLong; -import java.util.Map; -import java.util.stream.Collectors; - @Component @Slf4j public class ShardingStrategy { - - // 固定为16以匹配RabbitMQConfig中的十六进制分片逻辑 - private static final int QUEUE_COUNT = 16; - - // 分片分布统计 - private final Map shardCounts = new ConcurrentHashMap<>(); - - /** - * 根据用户名获取分片信息(基于哈希值首字符) - */ - public ShardInfo getShardInfo(String username) { - if (username == null || username.isEmpty()) { - // 空用户名默认分到第0个分片 - return getShardInfoByIndex(0); - } - - // 计算用户名的哈希值并转为十六进制字符串 - String hash = Integer.toHexString(Math.abs(username.hashCode())); - - // 取哈希值的第一个字符 (0-9, a-f) - char firstChar = hash.charAt(0); - - // 十六进制字符映射到队列 - int shard = getShardFromHexChar(firstChar); - recordShardUsage(shard); - - log.debug("Username '{}' -> hash '{}' -> firstChar '{}' -> shard {}", - username, hash, firstChar, shard); - - return getShardInfoByIndex(shard); - } - - /** - * 将十六进制字符映射到分片索引 - */ - private int getShardFromHexChar(char hexChar) { - int charValue; - if (hexChar >= '0' && hexChar <= '9') { - charValue = hexChar - '0'; // 0-9 - } else if (hexChar >= 'a' && hexChar <= 'f') { - charValue = hexChar - 'a' + 10; // 10-15 - } else { - // 异常情况,默认为0 - charValue = 0; - } - - // 映射到队列数量范围内 - return charValue % QUEUE_COUNT; - } - - /** - * 根据分片索引获取分片信息 - */ - private ShardInfo getShardInfoByIndex(int shard) { - String shardKey = Integer.toHexString(shard); - return new ShardInfo( - shard, - "notifications-queue-" + shardKey, - "notifications.shard." + shardKey - ); - } - - /** - * 记录分片使用统计 - */ - private void recordShardUsage(int shard) { - shardCounts.computeIfAbsent(shard, k -> new AtomicLong(0)).incrementAndGet(); + + // 固定为16以匹配RabbitMQConfig中的十六进制分片逻辑 + private static final int QUEUE_COUNT = 16; + + // 分片分布统计 + private final Map shardCounts = new ConcurrentHashMap<>(); + + /** + * 根据用户名获取分片信息(基于哈希值首字符) + */ + public ShardInfo getShardInfo(String username) { + if (username == null || username.isEmpty()) { + // 空用户名默认分到第0个分片 + return getShardInfoByIndex(0); } -} \ No newline at end of file + // 计算用户名的哈希值并转为十六进制字符串 + String hash = Integer.toHexString(Math.abs(username.hashCode())); + + // 取哈希值的第一个字符 (0-9, a-f) + char firstChar = hash.charAt(0); + + // 十六进制字符映射到队列 + int shard = getShardFromHexChar(firstChar); + recordShardUsage(shard); + + log.debug( + "Username '{}' -> hash '{}' -> firstChar '{}' -> shard {}", + username, + hash, + firstChar, + shard + ); + + return getShardInfoByIndex(shard); + } + + /** + * 将十六进制字符映射到分片索引 + */ + private int getShardFromHexChar(char hexChar) { + int charValue; + if (hexChar >= '0' && hexChar <= '9') { + charValue = hexChar - '0'; // 0-9 + } else if (hexChar >= 'a' && hexChar <= 'f') { + charValue = hexChar - 'a' + 10; // 10-15 + } else { + // 异常情况,默认为0 + charValue = 0; + } + + // 映射到队列数量范围内 + return charValue % QUEUE_COUNT; + } + + /** + * 根据分片索引获取分片信息 + */ + private ShardInfo getShardInfoByIndex(int shard) { + String shardKey = Integer.toHexString(shard); + return new ShardInfo( + shard, + "notifications-queue-" + shardKey, + "notifications.shard." + shardKey + ); + } + + /** + * 记录分片使用统计 + */ + private void recordShardUsage(int shard) { + shardCounts.computeIfAbsent(shard, k -> new AtomicLong(0)).incrementAndGet(); + } +} diff --git a/backend/src/main/java/com/openisle/config/SpringDocProperties.java b/backend/src/main/java/com/openisle/config/SpringDocProperties.java index 2c1ceb9e1..a7c51dc0d 100644 --- a/backend/src/main/java/com/openisle/config/SpringDocProperties.java +++ b/backend/src/main/java/com/openisle/config/SpringDocProperties.java @@ -10,11 +10,13 @@ import org.springframework.stereotype.Component; @Component @ConfigurationProperties(prefix = "springdoc.api-docs") public class SpringDocProperties { - private List servers = new ArrayList<>(); - @Data - public static class ServerConfig { - private String url; - private String description; - } + private List servers = new ArrayList<>(); + + @Data + public static class ServerConfig { + + private String url; + private String description; + } } diff --git a/backend/src/main/java/com/openisle/config/SystemUserInitializer.java b/backend/src/main/java/com/openisle/config/SystemUserInitializer.java index 7dfd4cf39..218a0ea10 100644 --- a/backend/src/main/java/com/openisle/config/SystemUserInitializer.java +++ b/backend/src/main/java/com/openisle/config/SystemUserInitializer.java @@ -14,23 +14,27 @@ import org.springframework.stereotype.Component; @Component @RequiredArgsConstructor public class SystemUserInitializer implements CommandLineRunner { - private final UserRepository userRepository; - private final PasswordEncoder passwordEncoder; - @Override - public void run(String... args) { - userRepository.findByUsername("system").orElseGet(() -> { - User system = new User(); - system.setUsername("system"); - system.setEmail("system@openisle.local"); - // todo(tim): raw password 采用环境变量 - system.setPassword(passwordEncoder.encode("system")); - system.setRole(Role.USER); - system.setVerified(true); - system.setApproved(true); - system.setAvatar("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png"); - return userRepository.save(system); - }); - } + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + @Override + public void run(String... args) { + userRepository + .findByUsername("system") + .orElseGet(() -> { + User system = new User(); + system.setUsername("system"); + system.setEmail("system@openisle.local"); + // todo(tim): raw password 采用环境变量 + system.setPassword(passwordEncoder.encode("system")); + system.setRole(Role.USER); + system.setVerified(true); + system.setApproved(true); + system.setAvatar( + "https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png" + ); + return userRepository.save(system); + }); + } } - diff --git a/backend/src/main/java/com/openisle/controller/ActivityController.java b/backend/src/main/java/com/openisle/controller/ActivityController.java index 5aaf426e0..5fa0a1cf0 100644 --- a/backend/src/main/java/com/openisle/controller/ActivityController.java +++ b/backend/src/main/java/com/openisle/controller/ActivityController.java @@ -9,65 +9,75 @@ import com.openisle.model.ActivityType; import com.openisle.model.User; import com.openisle.service.ActivityService; import com.openisle.service.UserService; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.*; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; - import java.util.List; import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/activities") @RequiredArgsConstructor public class ActivityController { - private final ActivityService activityService; - private final UserService userService; - private final ActivityMapper activityMapper; - @GetMapping - @Operation(summary = "List activities", description = "Retrieve all activities") - @ApiResponse(responseCode = "200", description = "List of activities", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = ActivityDto.class)))) - public List list() { - return activityService.list().stream() - .map(activityMapper::toDto) - .collect(Collectors.toList()); - } + private final ActivityService activityService; + private final UserService userService; + private final ActivityMapper activityMapper; - @GetMapping("/milk-tea") - @Operation(summary = "Milk tea info", description = "Get milk tea activity information") - @ApiResponse(responseCode = "200", description = "Milk tea info", - content = @Content(schema = @Schema(implementation = MilkTeaInfoDto.class))) - public MilkTeaInfoDto milkTea() { - Activity a = activityService.getByType(ActivityType.MILK_TEA); - long count = activityService.countParticipants(a); - if (!a.isEnded() && count >= 50) { - activityService.end(a); - } - MilkTeaInfoDto info = new MilkTeaInfoDto(); - info.setRedeemCount(count); - info.setEnded(a.isEnded()); - return info; - } + @GetMapping + @Operation(summary = "List activities", description = "Retrieve all activities") + @ApiResponse( + responseCode = "200", + description = "List of activities", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ActivityDto.class))) + ) + public List list() { + return activityService.list().stream().map(activityMapper::toDto).collect(Collectors.toList()); + } - @PostMapping("/milk-tea/redeem") - @Operation(summary = "Redeem milk tea", description = "Redeem milk tea activity reward") - @ApiResponse(responseCode = "200", description = "Redeem result", - content = @Content(schema = @Schema(implementation = java.util.Map.class))) - @SecurityRequirement(name = "JWT") - public java.util.Map redeemMilkTea(@RequestBody MilkTeaRedeemRequest req, Authentication auth) { - User user = userService.findByIdentifier(auth.getName()).orElseThrow(); - Activity a = activityService.getByType(ActivityType.MILK_TEA); - boolean first = activityService.redeem(a, user, req.getContact()); - if (first) { - return java.util.Map.of("message", "redeemed"); - } - return java.util.Map.of("message", "updated"); + @GetMapping("/milk-tea") + @Operation(summary = "Milk tea info", description = "Get milk tea activity information") + @ApiResponse( + responseCode = "200", + description = "Milk tea info", + content = @Content(schema = @Schema(implementation = MilkTeaInfoDto.class)) + ) + public MilkTeaInfoDto milkTea() { + Activity a = activityService.getByType(ActivityType.MILK_TEA); + long count = activityService.countParticipants(a); + if (!a.isEnded() && count >= 50) { + activityService.end(a); } + MilkTeaInfoDto info = new MilkTeaInfoDto(); + info.setRedeemCount(count); + info.setEnded(a.isEnded()); + return info; + } + + @PostMapping("/milk-tea/redeem") + @Operation(summary = "Redeem milk tea", description = "Redeem milk tea activity reward") + @ApiResponse( + responseCode = "200", + description = "Redeem result", + content = @Content(schema = @Schema(implementation = java.util.Map.class)) + ) + @SecurityRequirement(name = "JWT") + public java.util.Map redeemMilkTea( + @RequestBody MilkTeaRedeemRequest req, + Authentication auth + ) { + User user = userService.findByIdentifier(auth.getName()).orElseThrow(); + Activity a = activityService.getByType(ActivityType.MILK_TEA); + boolean first = activityService.redeem(a, user, req.getContact()); + if (first) { + return java.util.Map.of("message", "redeemed"); + } + return java.util.Map.of("message", "updated"); + } } diff --git a/backend/src/main/java/com/openisle/controller/AdminCommentController.java b/backend/src/main/java/com/openisle/controller/AdminCommentController.java index 1f57be138..14b3a5698 100644 --- a/backend/src/main/java/com/openisle/controller/AdminCommentController.java +++ b/backend/src/main/java/com/openisle/controller/AdminCommentController.java @@ -19,24 +19,31 @@ import org.springframework.web.bind.annotation.*; @RequestMapping("/api/admin/comments") @RequiredArgsConstructor public class AdminCommentController { - private final CommentService commentService; - private final CommentMapper commentMapper; - @PostMapping("/{id}/pin") - @SecurityRequirement(name = "JWT") - @Operation(summary = "Pin comment", description = "Pin a comment by its id") - @ApiResponse(responseCode = "200", description = "Pinned comment", - content = @Content(schema = @Schema(implementation = CommentDto.class))) - public CommentDto pin(@PathVariable Long id, Authentication auth) { - return commentMapper.toDto(commentService.pinComment(auth.getName(), id)); - } + private final CommentService commentService; + private final CommentMapper commentMapper; - @PostMapping("/{id}/unpin") - @SecurityRequirement(name = "JWT") - @Operation(summary = "Unpin comment", description = "Remove pin from a comment") - @ApiResponse(responseCode = "200", description = "Unpinned comment", - content = @Content(schema = @Schema(implementation = CommentDto.class))) - public CommentDto unpin(@PathVariable Long id, Authentication auth) { - return commentMapper.toDto(commentService.unpinComment(auth.getName(), id)); - } + @PostMapping("/{id}/pin") + @SecurityRequirement(name = "JWT") + @Operation(summary = "Pin comment", description = "Pin a comment by its id") + @ApiResponse( + responseCode = "200", + description = "Pinned comment", + content = @Content(schema = @Schema(implementation = CommentDto.class)) + ) + public CommentDto pin(@PathVariable Long id, Authentication auth) { + return commentMapper.toDto(commentService.pinComment(auth.getName(), id)); + } + + @PostMapping("/{id}/unpin") + @SecurityRequirement(name = "JWT") + @Operation(summary = "Unpin comment", description = "Remove pin from a comment") + @ApiResponse( + responseCode = "200", + description = "Unpinned comment", + content = @Content(schema = @Schema(implementation = CommentDto.class)) + ) + public CommentDto unpin(@PathVariable Long id, Authentication auth) { + return commentMapper.toDto(commentService.unpinComment(auth.getName(), id)); + } } diff --git a/backend/src/main/java/com/openisle/controller/AdminConfigController.java b/backend/src/main/java/com/openisle/controller/AdminConfigController.java index b61954d3d..0dc834ec2 100644 --- a/backend/src/main/java/com/openisle/controller/AdminConfigController.java +++ b/backend/src/main/java/com/openisle/controller/AdminConfigController.java @@ -17,44 +17,56 @@ import org.springframework.web.bind.annotation.*; @RequestMapping("/api/admin/config") @RequiredArgsConstructor public class AdminConfigController { - private final PostService postService; - private final PasswordValidator passwordValidator; - private final AiUsageService aiUsageService; - private final RegisterModeService registerModeService; - @GetMapping - @SecurityRequirement(name = "JWT") - @Operation(summary = "Get configuration", description = "Retrieve application configuration settings") - @ApiResponse(responseCode = "200", description = "Current configuration", - content = @Content(schema = @Schema(implementation = ConfigDto.class))) - public ConfigDto getConfig() { - ConfigDto dto = new ConfigDto(); - dto.setPublishMode(postService.getPublishMode()); - dto.setPasswordStrength(passwordValidator.getStrength()); - dto.setAiFormatLimit(aiUsageService.getFormatLimit()); - dto.setRegisterMode(registerModeService.getRegisterMode()); - return dto; + private final PostService postService; + private final PasswordValidator passwordValidator; + private final AiUsageService aiUsageService; + private final RegisterModeService registerModeService; + + @GetMapping + @SecurityRequirement(name = "JWT") + @Operation( + summary = "Get configuration", + description = "Retrieve application configuration settings" + ) + @ApiResponse( + responseCode = "200", + description = "Current configuration", + content = @Content(schema = @Schema(implementation = ConfigDto.class)) + ) + public ConfigDto getConfig() { + ConfigDto dto = new ConfigDto(); + dto.setPublishMode(postService.getPublishMode()); + dto.setPasswordStrength(passwordValidator.getStrength()); + dto.setAiFormatLimit(aiUsageService.getFormatLimit()); + dto.setRegisterMode(registerModeService.getRegisterMode()); + return dto; + } + + @PostMapping + @SecurityRequirement(name = "JWT") + @Operation( + summary = "Update configuration", + description = "Update application configuration settings" + ) + @ApiResponse( + responseCode = "200", + description = "Updated configuration", + content = @Content(schema = @Schema(implementation = ConfigDto.class)) + ) + public ConfigDto updateConfig(@RequestBody ConfigDto dto) { + if (dto.getPublishMode() != null) { + postService.setPublishMode(dto.getPublishMode()); } - - @PostMapping - @SecurityRequirement(name = "JWT") - @Operation(summary = "Update configuration", description = "Update application configuration settings") - @ApiResponse(responseCode = "200", description = "Updated configuration", - content = @Content(schema = @Schema(implementation = ConfigDto.class))) - public ConfigDto updateConfig(@RequestBody ConfigDto dto) { - if (dto.getPublishMode() != null) { - postService.setPublishMode(dto.getPublishMode()); - } - if (dto.getPasswordStrength() != null) { - passwordValidator.setStrength(dto.getPasswordStrength()); - } - if (dto.getAiFormatLimit() != null) { - aiUsageService.setFormatLimit(dto.getAiFormatLimit()); - } - if (dto.getRegisterMode() != null) { - registerModeService.setRegisterMode(dto.getRegisterMode()); - } - return getConfig(); + if (dto.getPasswordStrength() != null) { + passwordValidator.setStrength(dto.getPasswordStrength()); } - + if (dto.getAiFormatLimit() != null) { + aiUsageService.setFormatLimit(dto.getAiFormatLimit()); + } + if (dto.getRegisterMode() != null) { + registerModeService.setRegisterMode(dto.getRegisterMode()); + } + return getConfig(); + } } diff --git a/backend/src/main/java/com/openisle/controller/AdminController.java b/backend/src/main/java/com/openisle/controller/AdminController.java index 968800dbb..d27a75b3e 100644 --- a/backend/src/main/java/com/openisle/controller/AdminController.java +++ b/backend/src/main/java/com/openisle/controller/AdminController.java @@ -5,21 +5,25 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import java.util.Map; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; -import java.util.Map; /** * Simple admin demo endpoint. */ @RestController public class AdminController { - @GetMapping("/api/admin/hello") - @SecurityRequirement(name = "JWT") - @Operation(summary = "Admin greeting", description = "Returns a greeting for admin users") - @ApiResponse(responseCode = "200", description = "Greeting payload", - content = @Content(schema = @Schema(implementation = Map.class))) - public Map adminHello() { - return Map.of("message", "Hello, Admin User"); - } + + @GetMapping("/api/admin/hello") + @SecurityRequirement(name = "JWT") + @Operation(summary = "Admin greeting", description = "Returns a greeting for admin users") + @ApiResponse( + responseCode = "200", + description = "Greeting payload", + content = @Content(schema = @Schema(implementation = Map.class)) + ) + public Map adminHello() { + return Map.of("message", "Hello, Admin User"); + } } diff --git a/backend/src/main/java/com/openisle/controller/AdminPostController.java b/backend/src/main/java/com/openisle/controller/AdminPostController.java index 6848aad39..17435096c 100644 --- a/backend/src/main/java/com/openisle/controller/AdminPostController.java +++ b/backend/src/main/java/com/openisle/controller/AdminPostController.java @@ -9,11 +9,10 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - import java.util.List; import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; /** * Endpoints for administrators to manage posts. @@ -22,71 +21,109 @@ import java.util.stream.Collectors; @RequestMapping("/api/admin/posts") @RequiredArgsConstructor public class AdminPostController { - private final PostService postService; - private final PostMapper postMapper; - @GetMapping("/pending") - @SecurityRequirement(name = "JWT") - @Operation(summary = "List pending posts", description = "Retrieve posts awaiting approval") - @ApiResponse(responseCode = "200", description = "Pending posts", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class)))) - public List pendingPosts() { - return postService.listPendingPosts().stream() - .map(postMapper::toSummaryDto) - .collect(Collectors.toList()); - } + private final PostService postService; + private final PostMapper postMapper; - @PostMapping("/{id}/approve") - @SecurityRequirement(name = "JWT") - @Operation(summary = "Approve post", description = "Approve a pending post") - @ApiResponse(responseCode = "200", description = "Approved post", - content = @Content(schema = @Schema(implementation = PostSummaryDto.class))) - public PostSummaryDto approve(@PathVariable Long id) { - return postMapper.toSummaryDto(postService.approvePost(id)); - } + @GetMapping("/pending") + @SecurityRequirement(name = "JWT") + @Operation(summary = "List pending posts", description = "Retrieve posts awaiting approval") + @ApiResponse( + responseCode = "200", + description = "Pending posts", + content = @Content( + array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class)) + ) + ) + public List pendingPosts() { + return postService + .listPendingPosts() + .stream() + .map(postMapper::toSummaryDto) + .collect(Collectors.toList()); + } - @PostMapping("/{id}/reject") - @SecurityRequirement(name = "JWT") - @Operation(summary = "Reject post", description = "Reject a pending post") - @ApiResponse(responseCode = "200", description = "Rejected post", - content = @Content(schema = @Schema(implementation = PostSummaryDto.class))) - public PostSummaryDto reject(@PathVariable Long id) { - return postMapper.toSummaryDto(postService.rejectPost(id)); - } + @PostMapping("/{id}/approve") + @SecurityRequirement(name = "JWT") + @Operation(summary = "Approve post", description = "Approve a pending post") + @ApiResponse( + responseCode = "200", + description = "Approved post", + content = @Content(schema = @Schema(implementation = PostSummaryDto.class)) + ) + public PostSummaryDto approve(@PathVariable Long id) { + return postMapper.toSummaryDto(postService.approvePost(id)); + } - @PostMapping("/{id}/pin") - @SecurityRequirement(name = "JWT") - @Operation(summary = "Pin post", description = "Pin a post to the top") - @ApiResponse(responseCode = "200", description = "Pinned post", - content = @Content(schema = @Schema(implementation = PostSummaryDto.class))) - public PostSummaryDto pin(@PathVariable Long id, org.springframework.security.core.Authentication auth) { - return postMapper.toSummaryDto(postService.pinPost(id, auth.getName())); - } + @PostMapping("/{id}/reject") + @SecurityRequirement(name = "JWT") + @Operation(summary = "Reject post", description = "Reject a pending post") + @ApiResponse( + responseCode = "200", + description = "Rejected post", + content = @Content(schema = @Schema(implementation = PostSummaryDto.class)) + ) + public PostSummaryDto reject(@PathVariable Long id) { + return postMapper.toSummaryDto(postService.rejectPost(id)); + } - @PostMapping("/{id}/unpin") - @SecurityRequirement(name = "JWT") - @Operation(summary = "Unpin post", description = "Remove a post from the top") - @ApiResponse(responseCode = "200", description = "Unpinned post", - content = @Content(schema = @Schema(implementation = PostSummaryDto.class))) - public PostSummaryDto unpin(@PathVariable Long id, org.springframework.security.core.Authentication auth) { - return postMapper.toSummaryDto(postService.unpinPost(id, auth.getName())); - } + @PostMapping("/{id}/pin") + @SecurityRequirement(name = "JWT") + @Operation(summary = "Pin post", description = "Pin a post to the top") + @ApiResponse( + responseCode = "200", + description = "Pinned post", + content = @Content(schema = @Schema(implementation = PostSummaryDto.class)) + ) + public PostSummaryDto pin( + @PathVariable Long id, + org.springframework.security.core.Authentication auth + ) { + return postMapper.toSummaryDto(postService.pinPost(id, auth.getName())); + } - @PostMapping("/{id}/rss-exclude") - @SecurityRequirement(name = "JWT") - @Operation(summary = "Exclude from RSS", description = "Exclude a post from RSS feed") - @ApiResponse(responseCode = "200", description = "Updated post", - content = @Content(schema = @Schema(implementation = PostSummaryDto.class))) - public PostSummaryDto excludeFromRss(@PathVariable Long id, org.springframework.security.core.Authentication auth) { - return postMapper.toSummaryDto(postService.excludeFromRss(id, auth.getName())); - } + @PostMapping("/{id}/unpin") + @SecurityRequirement(name = "JWT") + @Operation(summary = "Unpin post", description = "Remove a post from the top") + @ApiResponse( + responseCode = "200", + description = "Unpinned post", + content = @Content(schema = @Schema(implementation = PostSummaryDto.class)) + ) + public PostSummaryDto unpin( + @PathVariable Long id, + org.springframework.security.core.Authentication auth + ) { + return postMapper.toSummaryDto(postService.unpinPost(id, auth.getName())); + } - @PostMapping("/{id}/rss-include") - @SecurityRequirement(name = "JWT") - @Operation(summary = "Include in RSS", description = "Include a post in the RSS feed") - @ApiResponse(responseCode = "200", description = "Updated post", - content = @Content(schema = @Schema(implementation = PostSummaryDto.class))) - public PostSummaryDto includeInRss(@PathVariable Long id, org.springframework.security.core.Authentication auth) { - return postMapper.toSummaryDto(postService.includeInRss(id, auth.getName())); - } + @PostMapping("/{id}/rss-exclude") + @SecurityRequirement(name = "JWT") + @Operation(summary = "Exclude from RSS", description = "Exclude a post from RSS feed") + @ApiResponse( + responseCode = "200", + description = "Updated post", + content = @Content(schema = @Schema(implementation = PostSummaryDto.class)) + ) + public PostSummaryDto excludeFromRss( + @PathVariable Long id, + org.springframework.security.core.Authentication auth + ) { + return postMapper.toSummaryDto(postService.excludeFromRss(id, auth.getName())); + } + + @PostMapping("/{id}/rss-include") + @SecurityRequirement(name = "JWT") + @Operation(summary = "Include in RSS", description = "Include a post in the RSS feed") + @ApiResponse( + responseCode = "200", + description = "Updated post", + content = @Content(schema = @Schema(implementation = PostSummaryDto.class)) + ) + public PostSummaryDto includeInRss( + @PathVariable Long id, + org.springframework.security.core.Authentication auth + ) { + return postMapper.toSummaryDto(postService.includeInRss(id, auth.getName())); + } } diff --git a/backend/src/main/java/com/openisle/controller/AdminTagController.java b/backend/src/main/java/com/openisle/controller/AdminTagController.java index d0cbf4f8e..9f3373509 100644 --- a/backend/src/main/java/com/openisle/controller/AdminTagController.java +++ b/backend/src/main/java/com/openisle/controller/AdminTagController.java @@ -11,39 +11,47 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - import java.util.List; import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/admin/tags") @RequiredArgsConstructor public class AdminTagController { - private final TagService tagService; - private final PostService postService; - private final TagMapper tagMapper; - @GetMapping("/pending") - @SecurityRequirement(name = "JWT") - @Operation(summary = "List pending tags", description = "Retrieve tags awaiting approval") - @ApiResponse(responseCode = "200", description = "Pending tags", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = TagDto.class)))) - public List pendingTags() { - return tagService.listPendingTags().stream() - .map(t -> tagMapper.toDto(t, postService.countPostsByTag(t.getId()))) - .collect(Collectors.toList()); - } + private final TagService tagService; + private final PostService postService; + private final TagMapper tagMapper; - @PostMapping("/{id}/approve") - @SecurityRequirement(name = "JWT") - @Operation(summary = "Approve tag", description = "Approve a pending tag") - @ApiResponse(responseCode = "200", description = "Approved tag", - content = @Content(schema = @Schema(implementation = TagDto.class))) - public TagDto approve(@PathVariable Long id) { - Tag tag = tagService.approveTag(id); - long count = postService.countPostsByTag(tag.getId()); - return tagMapper.toDto(tag, count); - } + @GetMapping("/pending") + @SecurityRequirement(name = "JWT") + @Operation(summary = "List pending tags", description = "Retrieve tags awaiting approval") + @ApiResponse( + responseCode = "200", + description = "Pending tags", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = TagDto.class))) + ) + public List pendingTags() { + return tagService + .listPendingTags() + .stream() + .map(t -> tagMapper.toDto(t, postService.countPostsByTag(t.getId()))) + .collect(Collectors.toList()); + } + + @PostMapping("/{id}/approve") + @SecurityRequirement(name = "JWT") + @Operation(summary = "Approve tag", description = "Approve a pending tag") + @ApiResponse( + responseCode = "200", + description = "Approved tag", + content = @Content(schema = @Schema(implementation = TagDto.class)) + ) + public TagDto approve(@PathVariable Long id) { + Tag tag = tagService.approveTag(id); + long count = postService.countPostsByTag(tag.getId()); + return tagMapper.toDto(tag, count); + } } diff --git a/backend/src/main/java/com/openisle/controller/AdminUserController.java b/backend/src/main/java/com/openisle/controller/AdminUserController.java index b149d49ad..02d04696f 100644 --- a/backend/src/main/java/com/openisle/controller/AdminUserController.java +++ b/backend/src/main/java/com/openisle/controller/AdminUserController.java @@ -3,9 +3,9 @@ package com.openisle.controller; import com.openisle.model.Notification; import com.openisle.model.NotificationType; import com.openisle.model.User; -import com.openisle.service.EmailSender; import com.openisle.repository.NotificationRepository; import com.openisle.repository.UserRepository; +import com.openisle.service.EmailSender; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; @@ -18,46 +18,56 @@ import org.springframework.web.bind.annotation.*; @RequestMapping("/api/admin/users") @RequiredArgsConstructor public class AdminUserController { - private final UserRepository userRepository; - private final NotificationRepository notificationRepository; - private final EmailSender emailSender; - @Value("${app.website-url}") - private String websiteUrl; - @PostMapping("/{id}/approve") - @SecurityRequirement(name = "JWT") - @Operation(summary = "Approve user", description = "Approve a pending user registration") - @ApiResponse(responseCode = "200", description = "User approved") - public ResponseEntity approve(@PathVariable Long id) { - User user = userRepository.findById(id).orElseThrow(); - user.setApproved(true); - userRepository.save(user); - markRegisterRequestNotificationsRead(user); - emailSender.sendEmail(user.getEmail(), "您的注册已审核通过", - "🎉您的注册已经审核通过, 点击以访问网站: " + websiteUrl); - return ResponseEntity.ok().build(); - } + private final UserRepository userRepository; + private final NotificationRepository notificationRepository; + private final EmailSender emailSender; - @PostMapping("/{id}/reject") - @SecurityRequirement(name = "JWT") - @Operation(summary = "Reject user", description = "Reject a pending user registration") - @ApiResponse(responseCode = "200", description = "User rejected") - public ResponseEntity reject(@PathVariable Long id) { - User user = userRepository.findById(id).orElseThrow(); - user.setApproved(false); - userRepository.save(user); - markRegisterRequestNotificationsRead(user); - emailSender.sendEmail(user.getEmail(), "您的注册已被管理员拒绝", - "您的注册被管理员拒绝, 点击链接可以重新填写理由申请: " + websiteUrl); - return ResponseEntity.ok().build(); - } + @Value("${app.website-url}") + private String websiteUrl; - private void markRegisterRequestNotificationsRead(User applicant) { - java.util.List notifs = - notificationRepository.findByTypeAndFromUser(NotificationType.REGISTER_REQUEST, applicant); - for (Notification n : notifs) { - n.setRead(true); - } - notificationRepository.saveAll(notifs); + @PostMapping("/{id}/approve") + @SecurityRequirement(name = "JWT") + @Operation(summary = "Approve user", description = "Approve a pending user registration") + @ApiResponse(responseCode = "200", description = "User approved") + public ResponseEntity approve(@PathVariable Long id) { + User user = userRepository.findById(id).orElseThrow(); + user.setApproved(true); + userRepository.save(user); + markRegisterRequestNotificationsRead(user); + emailSender.sendEmail( + user.getEmail(), + "您的注册已审核通过", + "🎉您的注册已经审核通过, 点击以访问网站: " + websiteUrl + ); + return ResponseEntity.ok().build(); + } + + @PostMapping("/{id}/reject") + @SecurityRequirement(name = "JWT") + @Operation(summary = "Reject user", description = "Reject a pending user registration") + @ApiResponse(responseCode = "200", description = "User rejected") + public ResponseEntity reject(@PathVariable Long id) { + User user = userRepository.findById(id).orElseThrow(); + user.setApproved(false); + userRepository.save(user); + markRegisterRequestNotificationsRead(user); + emailSender.sendEmail( + user.getEmail(), + "您的注册已被管理员拒绝", + "您的注册被管理员拒绝, 点击链接可以重新填写理由申请: " + websiteUrl + ); + return ResponseEntity.ok().build(); + } + + private void markRegisterRequestNotificationsRead(User applicant) { + java.util.List notifs = notificationRepository.findByTypeAndFromUser( + NotificationType.REGISTER_REQUEST, + applicant + ); + for (Notification n : notifs) { + n.setRead(true); } + notificationRepository.saveAll(notifs); + } } diff --git a/backend/src/main/java/com/openisle/controller/AiController.java b/backend/src/main/java/com/openisle/controller/AiController.java index ff259cf79..83d27567d 100644 --- a/backend/src/main/java/com/openisle/controller/AiController.java +++ b/backend/src/main/java/com/openisle/controller/AiController.java @@ -1,7 +1,13 @@ package com.openisle.controller; -import com.openisle.service.OpenAiService; import com.openisle.service.AiUsageService; +import com.openisle.service.OpenAiService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; @@ -9,41 +15,40 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; - -import java.util.Map; @RestController @RequestMapping("/api/ai") @RequiredArgsConstructor public class AiController { - private final OpenAiService openAiService; - private final AiUsageService aiUsageService; + private final OpenAiService openAiService; + private final AiUsageService aiUsageService; - @PostMapping("/format") - @Operation(summary = "Format markdown", description = "Format text via AI") - @ApiResponse(responseCode = "200", description = "Formatted content", - content = @Content(schema = @Schema(implementation = Map.class))) - @SecurityRequirement(name = "JWT") - public ResponseEntity> format(@RequestBody Map req, - Authentication auth) { - String text = req.get("text"); - if (text == null) { - return ResponseEntity.badRequest().build(); - } - int limit = aiUsageService.getFormatLimit(); - int used = aiUsageService.getCount(auth.getName()); - if (limit > 0 && used >= limit) { - return ResponseEntity.status(429).build(); - } - aiUsageService.incrementAndGetCount(auth.getName()); - return openAiService.formatMarkdown(text) - .map(t -> ResponseEntity.ok(Map.of("content", t))) - .orElse(ResponseEntity.status(500).build()); + @PostMapping("/format") + @Operation(summary = "Format markdown", description = "Format text via AI") + @ApiResponse( + responseCode = "200", + description = "Formatted content", + content = @Content(schema = @Schema(implementation = Map.class)) + ) + @SecurityRequirement(name = "JWT") + public ResponseEntity> format( + @RequestBody Map req, + Authentication auth + ) { + String text = req.get("text"); + if (text == null) { + return ResponseEntity.badRequest().build(); } + int limit = aiUsageService.getFormatLimit(); + int used = aiUsageService.getCount(auth.getName()); + if (limit > 0 && used >= limit) { + return ResponseEntity.status(429).build(); + } + aiUsageService.incrementAndGetCount(auth.getName()); + return openAiService + .formatMarkdown(text) + .map(t -> ResponseEntity.ok(Map.of("content", t))) + .orElse(ResponseEntity.status(500).build()); + } } diff --git a/backend/src/main/java/com/openisle/controller/AuthController.java b/backend/src/main/java/com/openisle/controller/AuthController.java index 29e8a6348..1014eb72a 100644 --- a/backend/src/main/java/com/openisle/controller/AuthController.java +++ b/backend/src/main/java/com/openisle/controller/AuthController.java @@ -13,491 +13,698 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.TimeUnit; - @RestController @RequestMapping("/api/auth") @RequiredArgsConstructor public class AuthController { - private final UserService userService; - private final JwtService jwtService; - private final EmailSender emailService; - private final CaptchaService captchaService; - private final GoogleAuthService googleAuthService; - private final GithubAuthService githubAuthService; - private final DiscordAuthService discordAuthService; - private final TwitterAuthService twitterAuthService; - private final TelegramAuthService telegramAuthService; - private final RegisterModeService registerModeService; - private final NotificationService notificationService; - private final UserRepository userRepository; - private final InviteService inviteService; + private final UserService userService; + private final JwtService jwtService; + private final EmailSender emailService; + private final CaptchaService captchaService; + private final GoogleAuthService googleAuthService; + private final GithubAuthService githubAuthService; + private final DiscordAuthService discordAuthService; + private final TwitterAuthService twitterAuthService; + private final TelegramAuthService telegramAuthService; + private final RegisterModeService registerModeService; + private final NotificationService notificationService; + private final UserRepository userRepository; + private final InviteService inviteService; - @Value("${app.captcha.enabled:false}") - private boolean captchaEnabled; + @Value("${app.captcha.enabled:false}") + private boolean captchaEnabled; - @Value("${app.captcha.register-enabled:false}") - private boolean registerCaptchaEnabled; + @Value("${app.captcha.register-enabled:false}") + private boolean registerCaptchaEnabled; - @Value("${app.captcha.login-enabled:false}") - private boolean loginCaptchaEnabled; + @Value("${app.captcha.login-enabled:false}") + private boolean loginCaptchaEnabled; - @PostMapping("/register") - @Operation(summary = "Register user", description = "Register a new user account") - @ApiResponse(responseCode = "200", description = "Registration result", - content = @Content(schema = @Schema(implementation = Map.class))) - public ResponseEntity register(@RequestBody RegisterRequest req) { - if (captchaEnabled && registerCaptchaEnabled && !captchaService.verify(req.getCaptcha())) { - return ResponseEntity.badRequest().body(Map.of("error", "Invalid captcha")); - } - if (req.getInviteToken() != null && !req.getInviteToken().isEmpty()) { - InviteService.InviteValidateResult result = inviteService.validate(req.getInviteToken()); - if (!result.isValidate()) { - return ResponseEntity.badRequest().body(Map.of("error", "邀请码使用次数过多")); - } - try { - User user = userService.registerWithInvite( - req.getUsername(), req.getEmail(), req.getPassword()); - inviteService.consume(req.getInviteToken(), user.getUsername()); - // 发送确认邮件 - userService.sendVerifyMail(user, VerifyType.REGISTER); - return ResponseEntity.ok(Map.of( - "token", jwtService.generateToken(user.getUsername()), - "reason_code", "INVITE_APPROVED" - )); - } catch (FieldException e) { - return ResponseEntity.badRequest().body(Map.of( - "field", e.getField(), - "error", e.getMessage() - )); - } - } - User user = userService.register( - req.getUsername(), req.getEmail(), req.getPassword(), "", registerModeService.getRegisterMode()); + @PostMapping("/register") + @Operation(summary = "Register user", description = "Register a new user account") + @ApiResponse( + responseCode = "200", + description = "Registration result", + content = @Content(schema = @Schema(implementation = Map.class)) + ) + public ResponseEntity register(@RequestBody RegisterRequest req) { + if (captchaEnabled && registerCaptchaEnabled && !captchaService.verify(req.getCaptcha())) { + return ResponseEntity.badRequest().body(Map.of("error", "Invalid captcha")); + } + if (req.getInviteToken() != null && !req.getInviteToken().isEmpty()) { + InviteService.InviteValidateResult result = inviteService.validate(req.getInviteToken()); + if (!result.isValidate()) { + return ResponseEntity.badRequest().body(Map.of("error", "邀请码使用次数过多")); + } + try { + User user = userService.registerWithInvite( + req.getUsername(), + req.getEmail(), + req.getPassword() + ); + inviteService.consume(req.getInviteToken(), user.getUsername()); // 发送确认邮件 userService.sendVerifyMail(user, VerifyType.REGISTER); - if (!user.isApproved()) { - notificationService.createRegisterRequestNotifications(user, user.getRegisterReason()); + return ResponseEntity.ok( + Map.of( + "token", + jwtService.generateToken(user.getUsername()), + "reason_code", + "INVITE_APPROVED" + ) + ); + } catch (FieldException e) { + return ResponseEntity.badRequest().body( + Map.of("field", e.getField(), "error", e.getMessage()) + ); + } + } + User user = userService.register( + req.getUsername(), + req.getEmail(), + req.getPassword(), + "", + registerModeService.getRegisterMode() + ); + // 发送确认邮件 + userService.sendVerifyMail(user, VerifyType.REGISTER); + if (!user.isApproved()) { + notificationService.createRegisterRequestNotifications(user, user.getRegisterReason()); + } + return ResponseEntity.ok(Map.of("message", "Verification code sent")); + } + + @PostMapping("/verify") + @Operation(summary = "Verify account", description = "Verify registration code") + @ApiResponse( + responseCode = "200", + description = "Verification result", + content = @Content(schema = @Schema(implementation = Map.class)) + ) + public ResponseEntity verify(@RequestBody VerifyRequest req) { + Optional userOpt = userService.findByUsername(req.getUsername()); + if (userOpt.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("error", "Invalid credentials")); + } + boolean ok = userService.verifyCode(userOpt.get(), req.getCode(), VerifyType.REGISTER); + if (ok) { + User user = userOpt.get(); + + if (user.isApproved()) { + return ResponseEntity.ok( + Map.of( + "message", + "Verified and isApproved", + "reason_code", + "VERIFIED_AND_APPROVED", + "token", + jwtService.generateToken(req.getUsername()) + ) + ); + } else { + return ResponseEntity.ok( + Map.of( + "message", + "Verified", + "reason_code", + "VERIFIED", + "token", + jwtService.generateReasonToken(req.getUsername()) + ) + ); + } + } + return ResponseEntity.badRequest().body(Map.of("error", "Invalid verification code")); + } + + @PostMapping("/login") + @Operation(summary = "Login", description = "Authenticate with username/email and password") + @ApiResponse( + responseCode = "200", + description = "Authentication result", + content = @Content(schema = @Schema(implementation = Map.class)) + ) + public ResponseEntity login(@RequestBody LoginRequest req) { + if (captchaEnabled && loginCaptchaEnabled && !captchaService.verify(req.getCaptcha())) { + return ResponseEntity.badRequest().body(Map.of("error", "Invalid captcha")); + } + Optional userOpt = userService.findByUsername(req.getUsername()); + if (userOpt.isEmpty()) { + userOpt = userService.findByEmail(req.getUsername()); + } + if (userOpt.isEmpty() || !userService.matchesPassword(userOpt.get(), req.getPassword())) { + return ResponseEntity.badRequest().body( + Map.of("error", "Invalid credentials", "reason_code", "INVALID_CREDENTIALS") + ); + } + User user = userOpt.get(); + if (!user.isVerified()) { + user = userService.register( + user.getUsername(), + user.getEmail(), + user.getPassword(), + user.getRegisterReason(), + registerModeService.getRegisterMode() + ); + userService.sendVerifyMail(user, VerifyType.REGISTER); + return ResponseEntity.badRequest().body( + Map.of( + "error", + "User not verified", + "reason_code", + "NOT_VERIFIED", + "user_name", + user.getUsername() + ) + ); + } + if ( + RegisterMode.WHITELIST.equals(registerModeService.getRegisterMode()) && !user.isApproved() + ) { + if (user.getRegisterReason() != null && !user.getRegisterReason().isEmpty()) { + return ResponseEntity.badRequest().body( + Map.of("error", "Account awaiting approval", "reason_code", "IS_APPROVING") + ); + } + return ResponseEntity.badRequest().body( + Map.of( + "error", + "Register reason not approved", + "reason_code", + "NOT_APPROVED", + "token", + jwtService.generateReasonToken(user.getUsername()) + ) + ); + } + return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.getUsername()))); + } + + @PostMapping("/google") + @Operation(summary = "Login with Google", description = "Authenticate using Google account") + @ApiResponse( + responseCode = "200", + description = "Authentication result", + content = @Content(schema = @Schema(implementation = Map.class)) + ) + public ResponseEntity loginWithGoogle(@RequestBody GoogleLoginRequest req) { + boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty(); + InviteService.InviteValidateResult inviteValidateResult = inviteService.validate( + req.getInviteToken() + ); + if (viaInvite && !inviteValidateResult.isValidate()) { + return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token")); + } + Optional resultOpt = googleAuthService.authenticate( + req.getIdToken(), + registerModeService.getRegisterMode(), + viaInvite + ); + if (resultOpt.isPresent()) { + AuthResult result = resultOpt.get(); + if (viaInvite && result.isNewUser()) { + inviteService.consume( + req.getInviteToken(), + inviteValidateResult.getInviteToken().getInviter().getUsername() + ); + return ResponseEntity.ok( + Map.of( + "token", + jwtService.generateToken(result.getUser().getUsername()), + "reason_code", + "INVITE_APPROVED" + ) + ); + } + if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) { + return ResponseEntity.ok( + Map.of("token", jwtService.generateToken(result.getUser().getUsername())) + ); + } + if (!result.getUser().isApproved()) { + if ( + result.getUser().getRegisterReason() != null && + !result.getUser().getRegisterReason().isEmpty() + ) { + return ResponseEntity.badRequest().body( + Map.of( + "error", + "Account awaiting approval", + "reason_code", + "IS_APPROVING", + "token", + jwtService.generateReasonToken(result.getUser().getUsername()) + ) + ); } - return ResponseEntity.ok(Map.of("message", "Verification code sent")); + return ResponseEntity.badRequest().body( + Map.of( + "error", + "Account awaiting approval", + "reason_code", + "NOT_APPROVED", + "token", + jwtService.generateReasonToken(result.getUser().getUsername()) + ) + ); + } + + return ResponseEntity.ok( + Map.of("token", jwtService.generateToken(result.getUser().getUsername())) + ); + } + return ResponseEntity.badRequest().body( + Map.of("error", "Invalid google token", "reason_code", "INVALID_CREDENTIALS") + ); + } + + @PostMapping("/reason") + @Operation( + summary = "Submit register reason", + description = "Submit registration reason for approval" + ) + @ApiResponse( + responseCode = "200", + description = "Submission result", + content = @Content(schema = @Schema(implementation = Map.class)) + ) + public ResponseEntity reason(@RequestBody MakeReasonRequest req) { + String username = jwtService.validateAndGetSubjectForReason(req.getToken()); + Optional userOpt = userService.findByUsername(username); + if (userOpt.isEmpty()) { + return ResponseEntity.badRequest().body( + Map.of("error", "Invalid token, Please re-login", "reason_code", "INVALID_CREDENTIALS") + ); } - @PostMapping("/verify") - @Operation(summary = "Verify account", description = "Verify registration code") - @ApiResponse(responseCode = "200", description = "Verification result", - content = @Content(schema = @Schema(implementation = Map.class))) - public ResponseEntity verify(@RequestBody VerifyRequest req) { - Optional userOpt = userService.findByUsername(req.getUsername()); - if (userOpt.isEmpty()) { - return ResponseEntity.badRequest().body(Map.of("error", "Invalid credentials")); - } - boolean ok = userService.verifyCode(userOpt.get(), req.getCode(), VerifyType.REGISTER); - if (ok) { - User user = userOpt.get(); - - if (user.isApproved()) { - return ResponseEntity.ok(Map.of( - "message", "Verified and isApproved", - "reason_code", "VERIFIED_AND_APPROVED", - "token", jwtService.generateToken(req.getUsername()) - )); - } else { - return ResponseEntity.ok(Map.of( - "message", "Verified", - "reason_code", "VERIFIED", - "token", jwtService.generateReasonToken(req.getUsername()) - )); - } - } - return ResponseEntity.badRequest().body(Map.of("error", "Invalid verification code")); + if (req.getReason() == null || req.getReason().trim().length() <= 20) { + return ResponseEntity.badRequest().body( + Map.of("error", "Reason's length must longer than 20", "reason_code", "INVALID_CREDENTIALS") + ); } - @PostMapping("/login") - @Operation(summary = "Login", description = "Authenticate with username/email and password") - @ApiResponse(responseCode = "200", description = "Authentication result", - content = @Content(schema = @Schema(implementation = Map.class))) - public ResponseEntity login(@RequestBody LoginRequest req) { - if (captchaEnabled && loginCaptchaEnabled && !captchaService.verify(req.getCaptcha())) { - return ResponseEntity.badRequest().body(Map.of("error", "Invalid captcha")); - } - Optional userOpt = userService.findByUsername(req.getUsername()); - if (userOpt.isEmpty()) { - userOpt = userService.findByEmail(req.getUsername()); - } - if (userOpt.isEmpty() || !userService.matchesPassword(userOpt.get(), req.getPassword())) { - return ResponseEntity.badRequest().body(Map.of( - "error", "Invalid credentials", - "reason_code", "INVALID_CREDENTIALS")); - } - User user = userOpt.get(); - if (!user.isVerified()) { - user = userService.register(user.getUsername(), user.getEmail(), user.getPassword(), user.getRegisterReason(), registerModeService.getRegisterMode()); - userService.sendVerifyMail(user, VerifyType.REGISTER); - return ResponseEntity.badRequest().body(Map.of( - "error", "User not verified", - "reason_code", "NOT_VERIFIED", - "user_name", user.getUsername())); - } - if (RegisterMode.WHITELIST.equals(registerModeService.getRegisterMode()) && !user.isApproved()) { - if (user.getRegisterReason() != null && !user.getRegisterReason().isEmpty()) { - return ResponseEntity.badRequest().body(Map.of( - "error", "Account awaiting approval", - "reason_code", "IS_APPROVING" - )); - } - return ResponseEntity.badRequest().body(Map.of( - "error", "Register reason not approved", - "reason_code", "NOT_APPROVED", - "token", jwtService.generateReasonToken(user.getUsername()))); - } - return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.getUsername()))); + User user = userOpt.get(); + if (user.isApproved() || registerModeService.getRegisterMode() == RegisterMode.DIRECT) { + return ResponseEntity.ok().body(Map.of("valid", true)); } - @PostMapping("/google") - @Operation(summary = "Login with Google", description = "Authenticate using Google account") - @ApiResponse(responseCode = "200", description = "Authentication result", - content = @Content(schema = @Schema(implementation = Map.class))) - public ResponseEntity loginWithGoogle(@RequestBody GoogleLoginRequest req) { - boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty(); - InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken()); - if (viaInvite && !inviteValidateResult.isValidate()) { - return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token")); - } - Optional resultOpt = googleAuthService.authenticate( - req.getIdToken(), - registerModeService.getRegisterMode(), - viaInvite); - if (resultOpt.isPresent()) { - AuthResult result = resultOpt.get(); - if (viaInvite && result.isNewUser()) { - inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername()); - return ResponseEntity.ok(Map.of( - "token", jwtService.generateToken(result.getUser().getUsername()), - "reason_code", "INVITE_APPROVED" - )); - } - if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) { - return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername()))); - } - if (!result.getUser().isApproved()) { - if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) { - return ResponseEntity.badRequest().body(Map.of( - "error", "Account awaiting approval", - "reason_code", "IS_APPROVING", - "token", jwtService.generateReasonToken(result.getUser().getUsername()) - )); - } - return ResponseEntity.badRequest().body(Map.of( - "error", "Account awaiting approval", - "reason_code", "NOT_APPROVED", - "token", jwtService.generateReasonToken(result.getUser().getUsername()) - )); - } + user = userService.updateReason(user.getUsername(), req.getReason()); + notificationService.createRegisterRequestNotifications(user, req.getReason()); + return ResponseEntity.ok().body(Map.of("valid", true)); + } - return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername()))); - } - return ResponseEntity.badRequest().body(Map.of( - "error", "Invalid google token", - "reason_code", "INVALID_CREDENTIALS" - )); + @PostMapping("/github") + @Operation(summary = "Login with GitHub", description = "Authenticate using GitHub account") + @ApiResponse( + responseCode = "200", + description = "Authentication result", + content = @Content(schema = @Schema(implementation = Map.class)) + ) + public ResponseEntity loginWithGithub(@RequestBody GithubLoginRequest req) { + boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty(); + InviteService.InviteValidateResult inviteValidateResult = inviteService.validate( + req.getInviteToken() + ); + if (viaInvite && !inviteValidateResult.isValidate()) { + return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token")); } - - - @PostMapping("/reason") - @Operation(summary = "Submit register reason", description = "Submit registration reason for approval") - @ApiResponse(responseCode = "200", description = "Submission result", - content = @Content(schema = @Schema(implementation = Map.class))) - public ResponseEntity reason(@RequestBody MakeReasonRequest req) { - String username = jwtService.validateAndGetSubjectForReason(req.getToken()); - Optional userOpt = userService.findByUsername(username); - if (userOpt.isEmpty()) { - return ResponseEntity.badRequest().body(Map.of( - "error", "Invalid token, Please re-login", - "reason_code", "INVALID_CREDENTIALS" - )); + Optional resultOpt = githubAuthService.authenticate( + req.getCode(), + registerModeService.getRegisterMode(), + req.getRedirectUri(), + viaInvite + ); + if (resultOpt.isPresent()) { + AuthResult result = resultOpt.get(); + if (viaInvite && result.isNewUser()) { + inviteService.consume( + req.getInviteToken(), + inviteValidateResult.getInviteToken().getInviter().getUsername() + ); + return ResponseEntity.ok( + Map.of( + "token", + jwtService.generateToken(result.getUser().getUsername()), + "reason_code", + "INVITE_APPROVED" + ) + ); + } + if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) { + return ResponseEntity.ok( + Map.of("token", jwtService.generateToken(result.getUser().getUsername())) + ); + } + if (!result.getUser().isApproved()) { + if ( + result.getUser().getRegisterReason() != null && + !result.getUser().getRegisterReason().isEmpty() + ) { + // 已填写注册理由 + return ResponseEntity.badRequest().body( + Map.of( + "error", + "Account awaiting approval", + "reason_code", + "IS_APPROVING", + "token", + jwtService.generateReasonToken(result.getUser().getUsername()) + ) + ); } + return ResponseEntity.badRequest().body( + Map.of( + "error", + "Account awaiting approval", + "reason_code", + "NOT_APPROVED", + "token", + jwtService.generateReasonToken(result.getUser().getUsername()) + ) + ); + } - if (req.getReason() == null || req.getReason().trim().length() <= 20) { - return ResponseEntity.badRequest().body(Map.of( - "error", "Reason's length must longer than 20", - "reason_code", "INVALID_CREDENTIALS" - )); - } - - User user = userOpt.get(); - if (user.isApproved() || registerModeService.getRegisterMode() == RegisterMode.DIRECT) { - return ResponseEntity.ok().body(Map.of("valid", true)); - } - - user = userService.updateReason(user.getUsername(), req.getReason()); - notificationService.createRegisterRequestNotifications(user, req.getReason()); - return ResponseEntity.ok().body(Map.of("valid", true)); + return ResponseEntity.ok( + Map.of("token", jwtService.generateToken(result.getUser().getUsername())) + ); } + return ResponseEntity.badRequest().body( + Map.of("error", "Invalid github code", "reason_code", "INVALID_CREDENTIALS") + ); + } - @PostMapping("/github") - @Operation(summary = "Login with GitHub", description = "Authenticate using GitHub account") - @ApiResponse(responseCode = "200", description = "Authentication result", - content = @Content(schema = @Schema(implementation = Map.class))) - public ResponseEntity loginWithGithub(@RequestBody GithubLoginRequest req) { - boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty(); - InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken()); - if (viaInvite && !inviteValidateResult.isValidate()) { - return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token")); - } - Optional resultOpt = githubAuthService.authenticate( - req.getCode(), - registerModeService.getRegisterMode(), - req.getRedirectUri(), - viaInvite); - if (resultOpt.isPresent()) { - AuthResult result = resultOpt.get(); - if (viaInvite && result.isNewUser()) { - inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername()); - return ResponseEntity.ok(Map.of( - "token", jwtService.generateToken(result.getUser().getUsername()), - "reason_code", "INVITE_APPROVED" - )); - } - if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) { - return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername()))); - } - if (!result.getUser().isApproved()) { - if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) { - // 已填写注册理由 - return ResponseEntity.badRequest().body(Map.of( - "error", "Account awaiting approval", - "reason_code", "IS_APPROVING", - "token", jwtService.generateReasonToken(result.getUser().getUsername()) - )); - } - return ResponseEntity.badRequest().body(Map.of( - "error", "Account awaiting approval", - "reason_code", "NOT_APPROVED", - "token", jwtService.generateReasonToken(result.getUser().getUsername()) - )); - } - - return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername()))); - } - return ResponseEntity.badRequest().body(Map.of( - "error", "Invalid github code", - "reason_code", "INVALID_CREDENTIALS" - )); + @PostMapping("/discord") + @Operation(summary = "Login with Discord", description = "Authenticate using Discord account") + @ApiResponse( + responseCode = "200", + description = "Authentication result", + content = @Content(schema = @Schema(implementation = Map.class)) + ) + public ResponseEntity loginWithDiscord(@RequestBody DiscordLoginRequest req) { + boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty(); + InviteService.InviteValidateResult inviteValidateResult = inviteService.validate( + req.getInviteToken() + ); + if (viaInvite && !inviteValidateResult.isValidate()) { + return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token")); } - - @PostMapping("/discord") - @Operation(summary = "Login with Discord", description = "Authenticate using Discord account") - @ApiResponse(responseCode = "200", description = "Authentication result", - content = @Content(schema = @Schema(implementation = Map.class))) - public ResponseEntity loginWithDiscord(@RequestBody DiscordLoginRequest req) { - boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty(); - InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken()); - if (viaInvite && !inviteValidateResult.isValidate()) { - return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token")); + Optional resultOpt = discordAuthService.authenticate( + req.getCode(), + registerModeService.getRegisterMode(), + req.getRedirectUri(), + viaInvite + ); + if (resultOpt.isPresent()) { + AuthResult result = resultOpt.get(); + if (viaInvite && result.isNewUser()) { + inviteService.consume( + req.getInviteToken(), + inviteValidateResult.getInviteToken().getInviter().getUsername() + ); + return ResponseEntity.ok( + Map.of( + "token", + jwtService.generateToken(result.getUser().getUsername()), + "reason_code", + "INVITE_APPROVED" + ) + ); + } + if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) { + return ResponseEntity.ok( + Map.of("token", jwtService.generateToken(result.getUser().getUsername())) + ); + } + if (!result.getUser().isApproved()) { + if ( + result.getUser().getRegisterReason() != null && + !result.getUser().getRegisterReason().isEmpty() + ) { + return ResponseEntity.badRequest().body( + Map.of( + "error", + "Account awaiting approval", + "reason_code", + "IS_APPROVING", + "token", + jwtService.generateReasonToken(result.getUser().getUsername()) + ) + ); } - Optional resultOpt = discordAuthService.authenticate( - req.getCode(), - registerModeService.getRegisterMode(), - req.getRedirectUri(), - viaInvite); - if (resultOpt.isPresent()) { - AuthResult result = resultOpt.get(); - if (viaInvite && result.isNewUser()) { - inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername()); - return ResponseEntity.ok(Map.of( - "token", jwtService.generateToken(result.getUser().getUsername()), - "reason_code", "INVITE_APPROVED" - )); - } - if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) { - return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername()))); - } - if (!result.getUser().isApproved()) { - if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) { - return ResponseEntity.badRequest().body(Map.of( - "error", "Account awaiting approval", - "reason_code", "IS_APPROVING", - "token", jwtService.generateReasonToken(result.getUser().getUsername()) - )); - } - return ResponseEntity.badRequest().body(Map.of( - "error", "Account awaiting approval", - "reason_code", "NOT_APPROVED", - "token", jwtService.generateReasonToken(result.getUser().getUsername()) - )); - } + return ResponseEntity.badRequest().body( + Map.of( + "error", + "Account awaiting approval", + "reason_code", + "NOT_APPROVED", + "token", + jwtService.generateReasonToken(result.getUser().getUsername()) + ) + ); + } - return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername()))); - } - return ResponseEntity.badRequest().body(Map.of( - "error", "Invalid discord code", - "reason_code", "INVALID_CREDENTIALS" - )); + return ResponseEntity.ok( + Map.of("token", jwtService.generateToken(result.getUser().getUsername())) + ); } - - @PostMapping("/twitter") - @Operation(summary = "Login with Twitter", description = "Authenticate using Twitter account") - @ApiResponse(responseCode = "200", description = "Authentication result", - content = @Content(schema = @Schema(implementation = Map.class))) - public ResponseEntity loginWithTwitter(@RequestBody TwitterLoginRequest req) { - boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty(); - InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken()); - if (viaInvite && !inviteValidateResult.isValidate()) { - return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token")); - } - Optional resultOpt = twitterAuthService.authenticate( - req.getCode(), - req.getCodeVerifier(), - registerModeService.getRegisterMode(), - req.getRedirectUri(), - viaInvite); - if (resultOpt.isPresent()) { - AuthResult result = resultOpt.get(); - if (viaInvite && result.isNewUser()) { - inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername()); - return ResponseEntity.ok(Map.of( - "token", jwtService.generateToken(result.getUser().getUsername()), - "reason_code", "INVITE_APPROVED" - )); - } - if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) { - return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername()))); - } - if (!result.getUser().isApproved()) { - if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) { - return ResponseEntity.badRequest().body(Map.of( - "error", "Account awaiting approval", - "reason_code", "IS_APPROVING", - "token", jwtService.generateReasonToken(result.getUser().getUsername()) - )); - } - return ResponseEntity.badRequest().body(Map.of( - "error", "Account awaiting approval", - "reason_code", "NOT_APPROVED", - "token", jwtService.generateReasonToken(result.getUser().getUsername()) - )); - } + return ResponseEntity.badRequest().body( + Map.of("error", "Invalid discord code", "reason_code", "INVALID_CREDENTIALS") + ); + } - return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername()))); - } - return ResponseEntity.badRequest().body(Map.of( - "error", "Invalid twitter code", - "reason_code", "INVALID_CREDENTIALS" - )); + @PostMapping("/twitter") + @Operation(summary = "Login with Twitter", description = "Authenticate using Twitter account") + @ApiResponse( + responseCode = "200", + description = "Authentication result", + content = @Content(schema = @Schema(implementation = Map.class)) + ) + public ResponseEntity loginWithTwitter(@RequestBody TwitterLoginRequest req) { + boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty(); + InviteService.InviteValidateResult inviteValidateResult = inviteService.validate( + req.getInviteToken() + ); + if (viaInvite && !inviteValidateResult.isValidate()) { + return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token")); } + Optional resultOpt = twitterAuthService.authenticate( + req.getCode(), + req.getCodeVerifier(), + registerModeService.getRegisterMode(), + req.getRedirectUri(), + viaInvite + ); + if (resultOpt.isPresent()) { + AuthResult result = resultOpt.get(); + if (viaInvite && result.isNewUser()) { + inviteService.consume( + req.getInviteToken(), + inviteValidateResult.getInviteToken().getInviter().getUsername() + ); + return ResponseEntity.ok( + Map.of( + "token", + jwtService.generateToken(result.getUser().getUsername()), + "reason_code", + "INVITE_APPROVED" + ) + ); + } + if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) { + return ResponseEntity.ok( + Map.of("token", jwtService.generateToken(result.getUser().getUsername())) + ); + } + if (!result.getUser().isApproved()) { + if ( + result.getUser().getRegisterReason() != null && + !result.getUser().getRegisterReason().isEmpty() + ) { + return ResponseEntity.badRequest().body( + Map.of( + "error", + "Account awaiting approval", + "reason_code", + "IS_APPROVING", + "token", + jwtService.generateReasonToken(result.getUser().getUsername()) + ) + ); + } + return ResponseEntity.badRequest().body( + Map.of( + "error", + "Account awaiting approval", + "reason_code", + "NOT_APPROVED", + "token", + jwtService.generateReasonToken(result.getUser().getUsername()) + ) + ); + } - @PostMapping("/telegram") - @Operation(summary = "Login with Telegram", description = "Authenticate using Telegram data") - @ApiResponse(responseCode = "200", description = "Authentication result", - content = @Content(schema = @Schema(implementation = Map.class))) - public ResponseEntity loginWithTelegram(@RequestBody TelegramLoginRequest req) { - boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty(); - InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken()); - if (viaInvite && !inviteValidateResult.isValidate()) { - return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token")); - } - Optional resultOpt = telegramAuthService.authenticate( - req, - registerModeService.getRegisterMode(), - viaInvite); - if (resultOpt.isPresent()) { - AuthResult result = resultOpt.get(); - if (viaInvite && result.isNewUser()) { - inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername()); - return ResponseEntity.ok(Map.of( - "token", jwtService.generateToken(result.getUser().getUsername()), - "reason_code", "INVITE_APPROVED" - )); - } - if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) { - return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername()))); - } - if (!result.getUser().isApproved()) { - if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) { - return ResponseEntity.badRequest().body(Map.of( - "error", "Account awaiting approval", - "reason_code", "IS_APPROVING", - "token", jwtService.generateReasonToken(result.getUser().getUsername()) - )); - } - return ResponseEntity.badRequest().body(Map.of( - "error", "Account awaiting approval", - "reason_code", "NOT_APPROVED", - "token", jwtService.generateReasonToken(result.getUser().getUsername()) - )); - } - return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername()))); - } - return ResponseEntity.badRequest().body(Map.of( - "error", "Invalid telegram data", - "reason_code", "INVALID_CREDENTIALS" - )); + return ResponseEntity.ok( + Map.of("token", jwtService.generateToken(result.getUser().getUsername())) + ); } + return ResponseEntity.badRequest().body( + Map.of("error", "Invalid twitter code", "reason_code", "INVALID_CREDENTIALS") + ); + } - @GetMapping("/check") - @SecurityRequirement(name = "JWT") - @Operation(summary = "Check token", description = "Validate JWT token") - @ApiResponse(responseCode = "200", description = "Token valid", - content = @Content(schema = @Schema(implementation = Map.class))) - public ResponseEntity checkToken() { - return ResponseEntity.ok(Map.of("valid", true)); + @PostMapping("/telegram") + @Operation(summary = "Login with Telegram", description = "Authenticate using Telegram data") + @ApiResponse( + responseCode = "200", + description = "Authentication result", + content = @Content(schema = @Schema(implementation = Map.class)) + ) + public ResponseEntity loginWithTelegram(@RequestBody TelegramLoginRequest req) { + boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty(); + InviteService.InviteValidateResult inviteValidateResult = inviteService.validate( + req.getInviteToken() + ); + if (viaInvite && !inviteValidateResult.isValidate()) { + return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token")); } - - @PostMapping("/forgot/send") - @Operation(summary = "Send reset code", description = "Send verification code for password reset") - @ApiResponse(responseCode = "200", description = "Sending result", - content = @Content(schema = @Schema(implementation = Map.class))) - public ResponseEntity sendReset(@RequestBody ForgotPasswordRequest req) { - Optional userOpt = userService.findByEmail(req.getEmail()); - if (userOpt.isEmpty()) { - return ResponseEntity.badRequest().body(Map.of("error", "User not found")); + Optional resultOpt = telegramAuthService.authenticate( + req, + registerModeService.getRegisterMode(), + viaInvite + ); + if (resultOpt.isPresent()) { + AuthResult result = resultOpt.get(); + if (viaInvite && result.isNewUser()) { + inviteService.consume( + req.getInviteToken(), + inviteValidateResult.getInviteToken().getInviter().getUsername() + ); + return ResponseEntity.ok( + Map.of( + "token", + jwtService.generateToken(result.getUser().getUsername()), + "reason_code", + "INVITE_APPROVED" + ) + ); + } + if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) { + return ResponseEntity.ok( + Map.of("token", jwtService.generateToken(result.getUser().getUsername())) + ); + } + if (!result.getUser().isApproved()) { + if ( + result.getUser().getRegisterReason() != null && + !result.getUser().getRegisterReason().isEmpty() + ) { + return ResponseEntity.badRequest().body( + Map.of( + "error", + "Account awaiting approval", + "reason_code", + "IS_APPROVING", + "token", + jwtService.generateReasonToken(result.getUser().getUsername()) + ) + ); } - userService.sendVerifyMail(userOpt.get(), VerifyType.RESET_PASSWORD); - return ResponseEntity.ok(Map.of("message", "Verification code sent")); + return ResponseEntity.badRequest().body( + Map.of( + "error", + "Account awaiting approval", + "reason_code", + "NOT_APPROVED", + "token", + jwtService.generateReasonToken(result.getUser().getUsername()) + ) + ); + } + return ResponseEntity.ok( + Map.of("token", jwtService.generateToken(result.getUser().getUsername())) + ); } + return ResponseEntity.badRequest().body( + Map.of("error", "Invalid telegram data", "reason_code", "INVALID_CREDENTIALS") + ); + } - @PostMapping("/forgot/verify") - @Operation(summary = "Verify reset code", description = "Verify password reset code") - @ApiResponse(responseCode = "200", description = "Verification result", - content = @Content(schema = @Schema(implementation = Map.class))) - public ResponseEntity verifyReset(@RequestBody VerifyForgotRequest req) { - Optional userOpt = userService.findByEmail(req.getEmail()); - if (userOpt.isEmpty()) { - return ResponseEntity.badRequest().body(Map.of("error", "User not found")); - } - boolean ok = userService.verifyCode(userOpt.get(), req.getCode(), VerifyType.RESET_PASSWORD); - if (ok) { - String username = userService.findByEmail(req.getEmail()).get().getUsername(); - return ResponseEntity.ok(Map.of("token", jwtService.generateResetToken(username))); - } - return ResponseEntity.badRequest().body(Map.of("error", "Invalid verification code")); + @GetMapping("/check") + @SecurityRequirement(name = "JWT") + @Operation(summary = "Check token", description = "Validate JWT token") + @ApiResponse( + responseCode = "200", + description = "Token valid", + content = @Content(schema = @Schema(implementation = Map.class)) + ) + public ResponseEntity checkToken() { + return ResponseEntity.ok(Map.of("valid", true)); + } + + @PostMapping("/forgot/send") + @Operation(summary = "Send reset code", description = "Send verification code for password reset") + @ApiResponse( + responseCode = "200", + description = "Sending result", + content = @Content(schema = @Schema(implementation = Map.class)) + ) + public ResponseEntity sendReset(@RequestBody ForgotPasswordRequest req) { + Optional userOpt = userService.findByEmail(req.getEmail()); + if (userOpt.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("error", "User not found")); } + userService.sendVerifyMail(userOpt.get(), VerifyType.RESET_PASSWORD); + return ResponseEntity.ok(Map.of("message", "Verification code sent")); + } - @PostMapping("/forgot/reset") - @Operation(summary = "Reset password", description = "Reset user password after verification") - @ApiResponse(responseCode = "200", description = "Reset result", - content = @Content(schema = @Schema(implementation = Map.class))) - public ResponseEntity resetPassword(@RequestBody ResetPasswordRequest req) { - String username = jwtService.validateAndGetSubjectForReset(req.getToken()); - try { - userService.updatePassword(username, req.getPassword()); - return ResponseEntity.ok(Map.of("message", "Password updated")); - } catch (FieldException e) { - return ResponseEntity.badRequest().body(Map.of( - "field", e.getField(), - "error", e.getMessage() - )); - } + @PostMapping("/forgot/verify") + @Operation(summary = "Verify reset code", description = "Verify password reset code") + @ApiResponse( + responseCode = "200", + description = "Verification result", + content = @Content(schema = @Schema(implementation = Map.class)) + ) + public ResponseEntity verifyReset(@RequestBody VerifyForgotRequest req) { + Optional userOpt = userService.findByEmail(req.getEmail()); + if (userOpt.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("error", "User not found")); } + boolean ok = userService.verifyCode(userOpt.get(), req.getCode(), VerifyType.RESET_PASSWORD); + if (ok) { + String username = userService.findByEmail(req.getEmail()).get().getUsername(); + return ResponseEntity.ok(Map.of("token", jwtService.generateResetToken(username))); + } + return ResponseEntity.badRequest().body(Map.of("error", "Invalid verification code")); + } - // DTO classes moved to com.openisle.dto package + @PostMapping("/forgot/reset") + @Operation(summary = "Reset password", description = "Reset user password after verification") + @ApiResponse( + responseCode = "200", + description = "Reset result", + content = @Content(schema = @Schema(implementation = Map.class)) + ) + public ResponseEntity resetPassword(@RequestBody ResetPasswordRequest req) { + String username = jwtService.validateAndGetSubjectForReset(req.getToken()); + try { + userService.updatePassword(username, req.getPassword()); + return ResponseEntity.ok(Map.of("message", "Password updated")); + } catch (FieldException e) { + return ResponseEntity.badRequest().body( + Map.of("field", e.getField(), "error", e.getMessage()) + ); + } + } + + // DTO classes moved to com.openisle.dto package } diff --git a/backend/src/main/java/com/openisle/controller/CategoryController.java b/backend/src/main/java/com/openisle/controller/CategoryController.java index 02c8ed2bc..a10f963cb 100644 --- a/backend/src/main/java/com/openisle/controller/CategoryController.java +++ b/backend/src/main/java/com/openisle/controller/CategoryController.java @@ -8,88 +8,120 @@ import com.openisle.mapper.PostMapper; import com.openisle.model.Category; import com.openisle.service.CategoryService; import com.openisle.service.PostService; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; - import java.util.List; import java.util.Map; import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/categories") @RequiredArgsConstructor public class CategoryController { - private final CategoryService categoryService; - private final PostService postService; - private final PostMapper postMapper; - private final CategoryMapper categoryMapper; - @PostMapping - @Operation(summary = "Create category", description = "Create a new category") - @ApiResponse(responseCode = "200", description = "Created category", - content = @Content(schema = @Schema(implementation = CategoryDto.class))) - public CategoryDto create(@RequestBody CategoryRequest req) { - Category c = categoryService.createCategory(req.getName(), req.getDescription(), req.getIcon(), req.getSmallIcon()); - long count = postService.countPostsByCategory(c.getId()); - return categoryMapper.toDto(c, count); - } + private final CategoryService categoryService; + private final PostService postService; + private final PostMapper postMapper; + private final CategoryMapper categoryMapper; - @PutMapping("/{id}") - @Operation(summary = "Update category", description = "Update an existing category") - @ApiResponse(responseCode = "200", description = "Updated category", - content = @Content(schema = @Schema(implementation = CategoryDto.class))) - public CategoryDto update(@PathVariable Long id, @RequestBody CategoryRequest req) { - Category c = categoryService.updateCategory(id, req.getName(), req.getDescription(), req.getIcon(), req.getSmallIcon()); - long count = postService.countPostsByCategory(c.getId()); - return categoryMapper.toDto(c, count); - } + @PostMapping + @Operation(summary = "Create category", description = "Create a new category") + @ApiResponse( + responseCode = "200", + description = "Created category", + content = @Content(schema = @Schema(implementation = CategoryDto.class)) + ) + public CategoryDto create(@RequestBody CategoryRequest req) { + Category c = categoryService.createCategory( + req.getName(), + req.getDescription(), + req.getIcon(), + req.getSmallIcon() + ); + long count = postService.countPostsByCategory(c.getId()); + return categoryMapper.toDto(c, count); + } - @DeleteMapping("/{id}") - @Operation(summary = "Delete category", description = "Remove a category by id") - @ApiResponse(responseCode = "200", description = "Category deleted") - public void delete(@PathVariable Long id) { - categoryService.deleteCategory(id); - } + @PutMapping("/{id}") + @Operation(summary = "Update category", description = "Update an existing category") + @ApiResponse( + responseCode = "200", + description = "Updated category", + content = @Content(schema = @Schema(implementation = CategoryDto.class)) + ) + public CategoryDto update(@PathVariable Long id, @RequestBody CategoryRequest req) { + Category c = categoryService.updateCategory( + id, + req.getName(), + req.getDescription(), + req.getIcon(), + req.getSmallIcon() + ); + long count = postService.countPostsByCategory(c.getId()); + return categoryMapper.toDto(c, count); + } - @GetMapping - @Operation(summary = "List categories", description = "Get all categories") - @ApiResponse(responseCode = "200", description = "List of categories", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = CategoryDto.class)))) - public List list() { - List all = categoryService.listCategories(); - List ids = all.stream().map(Category::getId).toList(); - Map postsCntByCategoryIds = postService.countPostsByCategoryIds(ids); - return all.stream() - .map(c -> categoryMapper.toDto(c, postsCntByCategoryIds.getOrDefault(c.getId(), 0L))) - .sorted((a, b) -> Long.compare(b.getCount(), a.getCount())) - .collect(Collectors.toList()); - } + @DeleteMapping("/{id}") + @Operation(summary = "Delete category", description = "Remove a category by id") + @ApiResponse(responseCode = "200", description = "Category deleted") + public void delete(@PathVariable Long id) { + categoryService.deleteCategory(id); + } - @GetMapping("/{id}") - @Operation(summary = "Get category", description = "Get category by id") - @ApiResponse(responseCode = "200", description = "Category detail", - content = @Content(schema = @Schema(implementation = CategoryDto.class))) - public CategoryDto get(@PathVariable Long id) { - Category c = categoryService.getCategory(id); - long count = postService.countPostsByCategory(c.getId()); - return categoryMapper.toDto(c, count); - } + @GetMapping + @Operation(summary = "List categories", description = "Get all categories") + @ApiResponse( + responseCode = "200", + description = "List of categories", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = CategoryDto.class))) + ) + public List list() { + List all = categoryService.listCategories(); + List ids = all.stream().map(Category::getId).toList(); + Map postsCntByCategoryIds = postService.countPostsByCategoryIds(ids); + return all + .stream() + .map(c -> categoryMapper.toDto(c, postsCntByCategoryIds.getOrDefault(c.getId(), 0L))) + .sorted((a, b) -> Long.compare(b.getCount(), a.getCount())) + .collect(Collectors.toList()); + } - @GetMapping("/{id}/posts") - @Operation(summary = "List posts by category", description = "Get posts under a category") - @ApiResponse(responseCode = "200", description = "List of posts", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class)))) - public List listPostsByCategory(@PathVariable Long id, - @RequestParam(value = "page", required = false) Integer page, - @RequestParam(value = "pageSize", required = false) Integer pageSize) { - return postService.listPostsByCategories(java.util.List.of(id), page, pageSize) - .stream() - .map(postMapper::toSummaryDto) - .collect(Collectors.toList()); - } + @GetMapping("/{id}") + @Operation(summary = "Get category", description = "Get category by id") + @ApiResponse( + responseCode = "200", + description = "Category detail", + content = @Content(schema = @Schema(implementation = CategoryDto.class)) + ) + public CategoryDto get(@PathVariable Long id) { + Category c = categoryService.getCategory(id); + long count = postService.countPostsByCategory(c.getId()); + return categoryMapper.toDto(c, count); + } + + @GetMapping("/{id}/posts") + @Operation(summary = "List posts by category", description = "Get posts under a category") + @ApiResponse( + responseCode = "200", + description = "List of posts", + content = @Content( + array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class)) + ) + ) + public List listPostsByCategory( + @PathVariable Long id, + @RequestParam(value = "page", required = false) Integer page, + @RequestParam(value = "pageSize", required = false) Integer pageSize + ) { + return postService + .listPostsByCategories(java.util.List.of(id), page, pageSize) + .stream() + .map(postMapper::toSummaryDto) + .collect(Collectors.toList()); + } } diff --git a/backend/src/main/java/com/openisle/controller/ChannelController.java b/backend/src/main/java/com/openisle/controller/ChannelController.java index 41c680b43..7647ccd95 100644 --- a/backend/src/main/java/com/openisle/controller/ChannelController.java +++ b/backend/src/main/java/com/openisle/controller/ChannelController.java @@ -5,56 +5,66 @@ import com.openisle.model.User; import com.openisle.repository.UserRepository; import com.openisle.service.ChannelService; import com.openisle.service.MessageService; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.*; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; - import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/channels") @RequiredArgsConstructor public class ChannelController { - private final ChannelService channelService; - private final MessageService messageService; - private final UserRepository userRepository; - private Long getCurrentUserId(Authentication auth) { - User user = userRepository.findByUsername(auth.getName()) - .orElseThrow(() -> new IllegalArgumentException("User not found")); - return user.getId(); - } + private final ChannelService channelService; + private final MessageService messageService; + private final UserRepository userRepository; - @GetMapping - @Operation(summary = "List channels", description = "List channels for the current user") - @ApiResponse(responseCode = "200", description = "Channels", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = ChannelDto.class)))) - @SecurityRequirement(name = "JWT") - public List listChannels(Authentication auth) { - return channelService.listChannels(getCurrentUserId(auth)); - } + private Long getCurrentUserId(Authentication auth) { + User user = userRepository + .findByUsername(auth.getName()) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + return user.getId(); + } - @PostMapping("/{channelId}/join") - @Operation(summary = "Join channel", description = "Join a channel") - @ApiResponse(responseCode = "200", description = "Joined channel", - content = @Content(schema = @Schema(implementation = ChannelDto.class))) - @SecurityRequirement(name = "JWT") - public ChannelDto joinChannel(@PathVariable Long channelId, Authentication auth) { - return channelService.joinChannel(channelId, getCurrentUserId(auth)); - } + @GetMapping + @Operation(summary = "List channels", description = "List channels for the current user") + @ApiResponse( + responseCode = "200", + description = "Channels", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ChannelDto.class))) + ) + @SecurityRequirement(name = "JWT") + public List listChannels(Authentication auth) { + return channelService.listChannels(getCurrentUserId(auth)); + } - @GetMapping("/unread-count") - @Operation(summary = "Unread count", description = "Get unread channel count") - @ApiResponse(responseCode = "200", description = "Unread count", - content = @Content(schema = @Schema(implementation = Long.class))) - @SecurityRequirement(name = "JWT") - public long unreadCount(Authentication auth) { - return messageService.getUnreadChannelCount(getCurrentUserId(auth)); - } + @PostMapping("/{channelId}/join") + @Operation(summary = "Join channel", description = "Join a channel") + @ApiResponse( + responseCode = "200", + description = "Joined channel", + content = @Content(schema = @Schema(implementation = ChannelDto.class)) + ) + @SecurityRequirement(name = "JWT") + public ChannelDto joinChannel(@PathVariable Long channelId, Authentication auth) { + return channelService.joinChannel(channelId, getCurrentUserId(auth)); + } + + @GetMapping("/unread-count") + @Operation(summary = "Unread count", description = "Get unread channel count") + @ApiResponse( + responseCode = "200", + description = "Unread count", + content = @Content(schema = @Schema(implementation = Long.class)) + ) + @SecurityRequirement(name = "JWT") + public long unreadCount(Authentication auth) { + return messageService.getUnreadChannelCount(getCurrentUserId(auth)); + } } diff --git a/backend/src/main/java/com/openisle/controller/CommentController.java b/backend/src/main/java/com/openisle/controller/CommentController.java index fb53f35d2..d8fbe17ea 100644 --- a/backend/src/main/java/com/openisle/controller/CommentController.java +++ b/backend/src/main/java/com/openisle/controller/CommentController.java @@ -1,161 +1,198 @@ package com.openisle.controller; -import com.openisle.dto.PostChangeLogDto; -import com.openisle.dto.TimelineItemDto; -import com.openisle.mapper.PostChangeLogMapper; -import com.openisle.model.Comment; import com.openisle.dto.CommentDto; import com.openisle.dto.CommentRequest; +import com.openisle.dto.PostChangeLogDto; +import com.openisle.dto.TimelineItemDto; import com.openisle.mapper.CommentMapper; +import com.openisle.mapper.PostChangeLogMapper; +import com.openisle.model.Comment; import com.openisle.model.CommentSort; import com.openisle.service.*; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.*; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; - import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api") @RequiredArgsConstructor @Slf4j public class CommentController { - private final CommentService commentService; - private final LevelService levelService; - private final CaptchaService captchaService; - private final CommentMapper commentMapper; - private final PointService pointService; - private final PostChangeLogService changeLogService; - private final PostChangeLogMapper postChangeLogMapper; - @Value("${app.captcha.enabled:false}") - private boolean captchaEnabled; + private final CommentService commentService; + private final LevelService levelService; + private final CaptchaService captchaService; + private final CommentMapper commentMapper; + private final PointService pointService; + private final PostChangeLogService changeLogService; + private final PostChangeLogMapper postChangeLogMapper; - @Value("${app.captcha.comment-enabled:false}") - private boolean commentCaptchaEnabled; + @Value("${app.captcha.enabled:false}") + private boolean captchaEnabled; - @PostMapping("/posts/{postId}/comments") - @Operation(summary = "Create comment", description = "Add a comment to a post") - @ApiResponse(responseCode = "200", description = "Created comment", - content = @Content(schema = @Schema(implementation = CommentDto.class))) - @SecurityRequirement(name = "JWT") - public ResponseEntity createComment(@PathVariable Long postId, - @RequestBody CommentRequest req, - Authentication auth) { - log.debug("createComment called by user {} for post {}", auth.getName(), postId); - if (captchaEnabled && commentCaptchaEnabled && !captchaService.verify(req.getCaptcha())) { - log.debug("Captcha verification failed for user {} on post {}", auth.getName(), postId); - return ResponseEntity.badRequest().build(); - } - Comment comment = commentService.addComment(auth.getName(), postId, req.getContent()); - CommentDto dto = commentMapper.toDto(comment); - dto.setReward(levelService.awardForComment(auth.getName())); - dto.setPointReward(pointService.awardForComment(auth.getName(), postId, comment.getId())); - log.debug("createComment succeeded for comment {}", comment.getId()); - return ResponseEntity.ok(dto); + @Value("${app.captcha.comment-enabled:false}") + private boolean commentCaptchaEnabled; + + @PostMapping("/posts/{postId}/comments") + @Operation(summary = "Create comment", description = "Add a comment to a post") + @ApiResponse( + responseCode = "200", + description = "Created comment", + content = @Content(schema = @Schema(implementation = CommentDto.class)) + ) + @SecurityRequirement(name = "JWT") + public ResponseEntity createComment( + @PathVariable Long postId, + @RequestBody CommentRequest req, + Authentication auth + ) { + log.debug("createComment called by user {} for post {}", auth.getName(), postId); + if (captchaEnabled && commentCaptchaEnabled && !captchaService.verify(req.getCaptcha())) { + log.debug("Captcha verification failed for user {} on post {}", auth.getName(), postId); + return ResponseEntity.badRequest().build(); } + Comment comment = commentService.addComment(auth.getName(), postId, req.getContent()); + CommentDto dto = commentMapper.toDto(comment); + dto.setReward(levelService.awardForComment(auth.getName())); + dto.setPointReward(pointService.awardForComment(auth.getName(), postId, comment.getId())); + log.debug("createComment succeeded for comment {}", comment.getId()); + return ResponseEntity.ok(dto); + } - @PostMapping("/comments/{commentId}/replies") - @Operation(summary = "Reply to comment", description = "Reply to an existing comment") - @ApiResponse(responseCode = "200", description = "Reply created", - content = @Content(schema = @Schema(implementation = CommentDto.class))) - @SecurityRequirement(name = "JWT") - public ResponseEntity replyComment(@PathVariable Long commentId, - @RequestBody CommentRequest req, - Authentication auth) { - log.debug("replyComment called by user {} for comment {}", auth.getName(), commentId); - if (captchaEnabled && commentCaptchaEnabled && !captchaService.verify(req.getCaptcha())) { - log.debug("Captcha verification failed for user {} on comment {}", auth.getName(), commentId); - return ResponseEntity.badRequest().build(); - } - Comment comment = commentService.addReply(auth.getName(), commentId, req.getContent()); - CommentDto dto = commentMapper.toDto(comment); - dto.setReward(levelService.awardForComment(auth.getName())); - log.debug("replyComment succeeded for comment {}", comment.getId()); - return ResponseEntity.ok(dto); + @PostMapping("/comments/{commentId}/replies") + @Operation(summary = "Reply to comment", description = "Reply to an existing comment") + @ApiResponse( + responseCode = "200", + description = "Reply created", + content = @Content(schema = @Schema(implementation = CommentDto.class)) + ) + @SecurityRequirement(name = "JWT") + public ResponseEntity replyComment( + @PathVariable Long commentId, + @RequestBody CommentRequest req, + Authentication auth + ) { + log.debug("replyComment called by user {} for comment {}", auth.getName(), commentId); + if (captchaEnabled && commentCaptchaEnabled && !captchaService.verify(req.getCaptcha())) { + log.debug("Captcha verification failed for user {} on comment {}", auth.getName(), commentId); + return ResponseEntity.badRequest().build(); } + Comment comment = commentService.addReply(auth.getName(), commentId, req.getContent()); + CommentDto dto = commentMapper.toDto(comment); + dto.setReward(levelService.awardForComment(auth.getName())); + log.debug("replyComment succeeded for comment {}", comment.getId()); + return ResponseEntity.ok(dto); + } - @GetMapping("/posts/{postId}/comments") - @Operation(summary = "List comments", description = "List comments for a post") - @ApiResponse(responseCode = "200", description = "Comments", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = TimelineItemDto.class)))) - public List> listComments(@PathVariable Long postId, - @RequestParam(value = "sort", required = false, defaultValue = "OLDEST") CommentSort sort) { - log.debug("listComments called for post {} with sort {}", postId, sort); - List commentDtoList = commentService.getCommentsForPost(postId, sort).stream() - .map(commentMapper::toDtoWithReplies) - .collect(Collectors.toList()); - List postChangeLogDtoList = changeLogService.listLogs(postId).stream() - .map(postChangeLogMapper::toDto) - .collect(Collectors.toList()); - List> itemDtoList = new ArrayList<>(); + @GetMapping("/posts/{postId}/comments") + @Operation(summary = "List comments", description = "List comments for a post") + @ApiResponse( + responseCode = "200", + description = "Comments", + content = @Content( + array = @ArraySchema(schema = @Schema(implementation = TimelineItemDto.class)) + ) + ) + public List> listComments( + @PathVariable Long postId, + @RequestParam(value = "sort", required = false, defaultValue = "OLDEST") CommentSort sort + ) { + log.debug("listComments called for post {} with sort {}", postId, sort); + List commentDtoList = commentService + .getCommentsForPost(postId, sort) + .stream() + .map(commentMapper::toDtoWithReplies) + .collect(Collectors.toList()); + List postChangeLogDtoList = changeLogService + .listLogs(postId) + .stream() + .map(postChangeLogMapper::toDto) + .collect(Collectors.toList()); + List> itemDtoList = new ArrayList<>(); - itemDtoList.addAll(commentDtoList.stream() - .map(c -> new TimelineItemDto<>( - c.getId(), - "comment", - c.getCreatedAt(), - c // payload 是 CommentDto - )) - .toList()); + itemDtoList.addAll( + commentDtoList + .stream() + .map(c -> + new TimelineItemDto<>( + c.getId(), + "comment", + c.getCreatedAt(), + c // payload 是 CommentDto + ) + ) + .toList() + ); - itemDtoList.addAll(postChangeLogDtoList.stream() - .map(l -> new TimelineItemDto<>( - l.getId(), - "log", - l.getTime(), // 注意字段名不一样 - l // payload 是 PostChangeLogDto - )) - .toList()); - // 排序 - Comparator> comparator = Comparator.comparing(TimelineItemDto::getCreatedAt); - if (CommentSort.NEWEST.equals(sort)) { - comparator = comparator.reversed(); - } - itemDtoList.sort(comparator); - log.debug("listComments returning {} comments", itemDtoList.size()); - return itemDtoList; + itemDtoList.addAll( + postChangeLogDtoList + .stream() + .map(l -> + new TimelineItemDto<>( + l.getId(), + "log", + l.getTime(), // 注意字段名不一样 + l // payload 是 PostChangeLogDto + ) + ) + .toList() + ); + // 排序 + Comparator> comparator = Comparator.comparing(TimelineItemDto::getCreatedAt); + if (CommentSort.NEWEST.equals(sort)) { + comparator = comparator.reversed(); } + itemDtoList.sort(comparator); + log.debug("listComments returning {} comments", itemDtoList.size()); + return itemDtoList; + } - @DeleteMapping("/comments/{id}") - @Operation(summary = "Delete comment", description = "Delete a comment") - @ApiResponse(responseCode = "200", description = "Deleted") - @SecurityRequirement(name = "JWT") - public void deleteComment(@PathVariable Long id, Authentication auth) { - log.debug("deleteComment called by user {} for comment {}", auth.getName(), id); - commentService.deleteComment(auth.getName(), id); - log.debug("deleteComment completed for comment {}", id); - } + @DeleteMapping("/comments/{id}") + @Operation(summary = "Delete comment", description = "Delete a comment") + @ApiResponse(responseCode = "200", description = "Deleted") + @SecurityRequirement(name = "JWT") + public void deleteComment(@PathVariable Long id, Authentication auth) { + log.debug("deleteComment called by user {} for comment {}", auth.getName(), id); + commentService.deleteComment(auth.getName(), id); + log.debug("deleteComment completed for comment {}", id); + } - @PostMapping("/comments/{id}/pin") - @Operation(summary = "Pin comment", description = "Pin a comment") - @ApiResponse(responseCode = "200", description = "Pinned comment", - content = @Content(schema = @Schema(implementation = CommentDto.class))) - @SecurityRequirement(name = "JWT") - public CommentDto pinComment(@PathVariable Long id, Authentication auth) { - log.debug("pinComment called by user {} for comment {}", auth.getName(), id); - return commentMapper.toDto(commentService.pinComment(auth.getName(), id)); - } + @PostMapping("/comments/{id}/pin") + @Operation(summary = "Pin comment", description = "Pin a comment") + @ApiResponse( + responseCode = "200", + description = "Pinned comment", + content = @Content(schema = @Schema(implementation = CommentDto.class)) + ) + @SecurityRequirement(name = "JWT") + public CommentDto pinComment(@PathVariable Long id, Authentication auth) { + log.debug("pinComment called by user {} for comment {}", auth.getName(), id); + return commentMapper.toDto(commentService.pinComment(auth.getName(), id)); + } - @PostMapping("/comments/{id}/unpin") - @Operation(summary = "Unpin comment", description = "Unpin a comment") - @ApiResponse(responseCode = "200", description = "Unpinned comment", - content = @Content(schema = @Schema(implementation = CommentDto.class))) - @SecurityRequirement(name = "JWT") - public CommentDto unpinComment(@PathVariable Long id, Authentication auth) { - log.debug("unpinComment called by user {} for comment {}", auth.getName(), id); - return commentMapper.toDto(commentService.unpinComment(auth.getName(), id)); - } + @PostMapping("/comments/{id}/unpin") + @Operation(summary = "Unpin comment", description = "Unpin a comment") + @ApiResponse( + responseCode = "200", + description = "Unpinned comment", + content = @Content(schema = @Schema(implementation = CommentDto.class)) + ) + @SecurityRequirement(name = "JWT") + public CommentDto unpinComment(@PathVariable Long id, Authentication auth) { + log.debug("unpinComment called by user {} for comment {}", auth.getName(), id); + return commentMapper.toDto(commentService.unpinComment(auth.getName(), id)); + } } diff --git a/backend/src/main/java/com/openisle/controller/ConfigController.java b/backend/src/main/java/com/openisle/controller/ConfigController.java index 8139ebc58..fdec531cc 100644 --- a/backend/src/main/java/com/openisle/controller/ConfigController.java +++ b/backend/src/main/java/com/openisle/controller/ConfigController.java @@ -2,53 +2,56 @@ package com.openisle.controller; import com.openisle.dto.SiteConfigDto; import com.openisle.service.RegisterModeService; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api") @lombok.RequiredArgsConstructor public class ConfigController { - @Value("${app.captcha.enabled:false}") - private boolean captchaEnabled; + @Value("${app.captcha.enabled:false}") + private boolean captchaEnabled; - @Value("${app.captcha.register-enabled:false}") - private boolean registerCaptchaEnabled; + @Value("${app.captcha.register-enabled:false}") + private boolean registerCaptchaEnabled; - @Value("${app.captcha.login-enabled:false}") - private boolean loginCaptchaEnabled; + @Value("${app.captcha.login-enabled:false}") + private boolean loginCaptchaEnabled; - @Value("${app.captcha.post-enabled:false}") - private boolean postCaptchaEnabled; + @Value("${app.captcha.post-enabled:false}") + private boolean postCaptchaEnabled; - @Value("${app.captcha.comment-enabled:false}") - private boolean commentCaptchaEnabled; + @Value("${app.captcha.comment-enabled:false}") + private boolean commentCaptchaEnabled; - @Value("${app.ai.format-limit:3}") - private int aiFormatLimit; + @Value("${app.ai.format-limit:3}") + private int aiFormatLimit; - private final RegisterModeService registerModeService; + private final RegisterModeService registerModeService; - @GetMapping("/config") - @Operation(summary = "Site config", description = "Get site configuration") - @ApiResponse(responseCode = "200", description = "Site configuration", - content = @Content(schema = @Schema(implementation = SiteConfigDto.class))) - public SiteConfigDto getConfig() { - SiteConfigDto resp = new SiteConfigDto(); - resp.setCaptchaEnabled(captchaEnabled); - resp.setRegisterCaptchaEnabled(registerCaptchaEnabled); - resp.setLoginCaptchaEnabled(loginCaptchaEnabled); - resp.setPostCaptchaEnabled(postCaptchaEnabled); - resp.setCommentCaptchaEnabled(commentCaptchaEnabled); - resp.setAiFormatLimit(aiFormatLimit); - resp.setRegisterMode(registerModeService.getRegisterMode()); - return resp; - } + @GetMapping("/config") + @Operation(summary = "Site config", description = "Get site configuration") + @ApiResponse( + responseCode = "200", + description = "Site configuration", + content = @Content(schema = @Schema(implementation = SiteConfigDto.class)) + ) + public SiteConfigDto getConfig() { + SiteConfigDto resp = new SiteConfigDto(); + resp.setCaptchaEnabled(captchaEnabled); + resp.setRegisterCaptchaEnabled(registerCaptchaEnabled); + resp.setLoginCaptchaEnabled(loginCaptchaEnabled); + resp.setPostCaptchaEnabled(postCaptchaEnabled); + resp.setCommentCaptchaEnabled(commentCaptchaEnabled); + resp.setAiFormatLimit(aiFormatLimit); + resp.setRegisterMode(registerModeService.getRegisterMode()); + return resp; + } } diff --git a/backend/src/main/java/com/openisle/controller/DraftController.java b/backend/src/main/java/com/openisle/controller/DraftController.java index f0e481787..b4c1c66b4 100644 --- a/backend/src/main/java/com/openisle/controller/DraftController.java +++ b/backend/src/main/java/com/openisle/controller/DraftController.java @@ -5,50 +5,64 @@ import com.openisle.dto.DraftRequest; import com.openisle.mapper.DraftMapper; import com.openisle.model.Draft; import com.openisle.service.DraftService; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.*; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/drafts") @RequiredArgsConstructor public class DraftController { - private final DraftService draftService; - private final DraftMapper draftMapper; - @PostMapping - @Operation(summary = "Save draft", description = "Save a draft for current user") - @ApiResponse(responseCode = "200", description = "Draft saved", - content = @Content(schema = @Schema(implementation = DraftDto.class))) - @SecurityRequirement(name = "JWT") - public ResponseEntity saveDraft(@RequestBody DraftRequest req, Authentication auth) { - Draft draft = draftService.saveDraft(auth.getName(), req.getCategoryId(), req.getTitle(), req.getContent(), req.getTagIds()); - return ResponseEntity.ok(draftMapper.toDto(draft)); - } + private final DraftService draftService; + private final DraftMapper draftMapper; - @GetMapping("/me") - @Operation(summary = "Get my draft", description = "Get current user's draft") - @ApiResponse(responseCode = "200", description = "Draft details", - content = @Content(schema = @Schema(implementation = DraftDto.class))) - @SecurityRequirement(name = "JWT") - public ResponseEntity getMyDraft(Authentication auth) { - return draftService.getDraft(auth.getName()) - .map(d -> ResponseEntity.ok(draftMapper.toDto(d))) - .orElseGet(() -> ResponseEntity.noContent().build()); - } + @PostMapping + @Operation(summary = "Save draft", description = "Save a draft for current user") + @ApiResponse( + responseCode = "200", + description = "Draft saved", + content = @Content(schema = @Schema(implementation = DraftDto.class)) + ) + @SecurityRequirement(name = "JWT") + public ResponseEntity saveDraft(@RequestBody DraftRequest req, Authentication auth) { + Draft draft = draftService.saveDraft( + auth.getName(), + req.getCategoryId(), + req.getTitle(), + req.getContent(), + req.getTagIds() + ); + return ResponseEntity.ok(draftMapper.toDto(draft)); + } - @DeleteMapping("/me") - @Operation(summary = "Delete my draft", description = "Delete current user's draft") - @ApiResponse(responseCode = "200", description = "Draft deleted") - @SecurityRequirement(name = "JWT") - public ResponseEntity deleteMyDraft(Authentication auth) { - draftService.deleteDraft(auth.getName()); - return ResponseEntity.ok().build(); - } + @GetMapping("/me") + @Operation(summary = "Get my draft", description = "Get current user's draft") + @ApiResponse( + responseCode = "200", + description = "Draft details", + content = @Content(schema = @Schema(implementation = DraftDto.class)) + ) + @SecurityRequirement(name = "JWT") + public ResponseEntity getMyDraft(Authentication auth) { + return draftService + .getDraft(auth.getName()) + .map(d -> ResponseEntity.ok(draftMapper.toDto(d))) + .orElseGet(() -> ResponseEntity.noContent().build()); + } + + @DeleteMapping("/me") + @Operation(summary = "Delete my draft", description = "Delete current user's draft") + @ApiResponse(responseCode = "200", description = "Draft deleted") + @SecurityRequirement(name = "JWT") + public ResponseEntity deleteMyDraft(Authentication auth) { + draftService.deleteDraft(auth.getName()); + return ResponseEntity.ok().build(); + } } diff --git a/backend/src/main/java/com/openisle/controller/GlobalExceptionHandler.java b/backend/src/main/java/com/openisle/controller/GlobalExceptionHandler.java index 461486827..441a8084d 100644 --- a/backend/src/main/java/com/openisle/controller/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/openisle/controller/GlobalExceptionHandler.java @@ -1,40 +1,39 @@ package com.openisle.controller; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; import com.openisle.exception.FieldException; import com.openisle.exception.NotFoundException; import com.openisle.exception.RateLimitException; - import java.util.Map; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; @RestControllerAdvice public class GlobalExceptionHandler { - @ExceptionHandler(FieldException.class) - public ResponseEntity handleFieldException(FieldException ex) { - return ResponseEntity.badRequest() - .body(Map.of("error", ex.getMessage(), "field", ex.getField())); - } + @ExceptionHandler(FieldException.class) + public ResponseEntity handleFieldException(FieldException ex) { + return ResponseEntity.badRequest().body( + Map.of("error", ex.getMessage(), "field", ex.getField()) + ); + } - @ExceptionHandler(NotFoundException.class) - public ResponseEntity handleNotFoundException(NotFoundException ex) { - return ResponseEntity.status(404).body(Map.of("error", ex.getMessage())); - } + @ExceptionHandler(NotFoundException.class) + public ResponseEntity handleNotFoundException(NotFoundException ex) { + return ResponseEntity.status(404).body(Map.of("error", ex.getMessage())); + } - @ExceptionHandler(RateLimitException.class) - public ResponseEntity handleRateLimitException(RateLimitException ex) { - return ResponseEntity.status(429).body(Map.of("error", ex.getMessage())); - } + @ExceptionHandler(RateLimitException.class) + public ResponseEntity handleRateLimitException(RateLimitException ex) { + return ResponseEntity.status(429).body(Map.of("error", ex.getMessage())); + } - @ExceptionHandler(Exception.class) - public ResponseEntity handleException(Exception ex) { - String message = ex.getMessage(); - if (message == null) { - message = ex.getClass().getSimpleName(); - } - return ResponseEntity.badRequest().body(Map.of("error", message)); + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception ex) { + String message = ex.getMessage(); + if (message == null) { + message = ex.getClass().getSimpleName(); } + return ResponseEntity.badRequest().body(Map.of("error", message)); + } } - diff --git a/backend/src/main/java/com/openisle/controller/HelloController.java b/backend/src/main/java/com/openisle/controller/HelloController.java index e77d48876..256245f8c 100644 --- a/backend/src/main/java/com/openisle/controller/HelloController.java +++ b/backend/src/main/java/com/openisle/controller/HelloController.java @@ -5,18 +5,22 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import java.util.Map; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; -import java.util.Map; @RestController public class HelloController { - @GetMapping("/api/hello") - @SecurityRequirement(name = "JWT") - @Operation(summary = "Hello endpoint", description = "Returns a greeting for authenticated users") - @ApiResponse(responseCode = "200", description = "Greeting payload", - content = @Content(schema = @Schema(implementation = Map.class))) - public Map hello() { - return Map.of("message", "Hello, Authenticated User"); - } + + @GetMapping("/api/hello") + @SecurityRequirement(name = "JWT") + @Operation(summary = "Hello endpoint", description = "Returns a greeting for authenticated users") + @ApiResponse( + responseCode = "200", + description = "Greeting payload", + content = @Content(schema = @Schema(implementation = Map.class)) + ) + public Map hello() { + return Map.of("message", "Hello, Authenticated User"); + } } diff --git a/backend/src/main/java/com/openisle/controller/InviteController.java b/backend/src/main/java/com/openisle/controller/InviteController.java index 6d6e93c89..ba5a7b1ed 100644 --- a/backend/src/main/java/com/openisle/controller/InviteController.java +++ b/backend/src/main/java/com/openisle/controller/InviteController.java @@ -1,32 +1,35 @@ package com.openisle.controller; import com.openisle.service.InviteService; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; - import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/invite") @RequiredArgsConstructor public class InviteController { - private final InviteService inviteService; - @PostMapping("/generate") - @Operation(summary = "Generate invite", description = "Generate an invite token") - @ApiResponse(responseCode = "200", description = "Invite token", - content = @Content(schema = @Schema(implementation = Map.class))) - @SecurityRequirement(name = "JWT") - public Map generate(Authentication auth) { - String token = inviteService.generate(auth.getName()); - return Map.of("token", token); - } + private final InviteService inviteService; + + @PostMapping("/generate") + @Operation(summary = "Generate invite", description = "Generate an invite token") + @ApiResponse( + responseCode = "200", + description = "Invite token", + content = @Content(schema = @Schema(implementation = Map.class)) + ) + @SecurityRequirement(name = "JWT") + public Map generate(Authentication auth) { + String token = inviteService.generate(auth.getName()); + return Map.of("token", token); + } } diff --git a/backend/src/main/java/com/openisle/controller/MedalController.java b/backend/src/main/java/com/openisle/controller/MedalController.java index dfe113239..b3938017e 100644 --- a/backend/src/main/java/com/openisle/controller/MedalController.java +++ b/backend/src/main/java/com/openisle/controller/MedalController.java @@ -3,43 +3,49 @@ package com.openisle.controller; import com.openisle.dto.MedalDto; import com.openisle.dto.MedalSelectRequest; import com.openisle.service.MedalService; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.*; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; - import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/medals") @RequiredArgsConstructor public class MedalController { - private final MedalService medalService; - @GetMapping - @Operation(summary = "List medals", description = "List medals for user or globally") - @ApiResponse(responseCode = "200", description = "List of medals", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = MedalDto.class)))) - public List getMedals(@RequestParam(value = "userId", required = false) Long userId) { - return medalService.getMedals(userId); - } + private final MedalService medalService; - @PostMapping("/select") - @Operation(summary = "Select medal", description = "Select a medal for current user") - @ApiResponse(responseCode = "200", description = "Medal selected") - @SecurityRequirement(name = "JWT") - public ResponseEntity selectMedal(@RequestBody MedalSelectRequest req, Authentication auth) { - try { - medalService.selectMedal(auth.getName(), req.getType()); - return ResponseEntity.ok().build(); - } catch (IllegalArgumentException e) { - return ResponseEntity.badRequest().build(); - } + @GetMapping + @Operation(summary = "List medals", description = "List medals for user or globally") + @ApiResponse( + responseCode = "200", + description = "List of medals", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = MedalDto.class))) + ) + public List getMedals(@RequestParam(value = "userId", required = false) Long userId) { + return medalService.getMedals(userId); + } + + @PostMapping("/select") + @Operation(summary = "Select medal", description = "Select a medal for current user") + @ApiResponse(responseCode = "200", description = "Medal selected") + @SecurityRequirement(name = "JWT") + public ResponseEntity selectMedal( + @RequestBody MedalSelectRequest req, + Authentication auth + ) { + try { + medalService.selectMedal(auth.getName(), req.getType()); + return ResponseEntity.ok().build(); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().build(); } + } } diff --git a/backend/src/main/java/com/openisle/controller/MessageController.java b/backend/src/main/java/com/openisle/controller/MessageController.java index 8173b59ed..98569c5a1 100644 --- a/backend/src/main/java/com/openisle/controller/MessageController.java +++ b/backend/src/main/java/com/openisle/controller/MessageController.java @@ -10,6 +10,13 @@ import com.openisle.model.MessageConversation; import com.openisle.model.User; import com.openisle.repository.UserRepository; import com.openisle.service.MessageService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -18,153 +25,205 @@ import org.springframework.data.domain.Sort; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.ArraySchema; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; - -import java.util.List; @RestController @RequestMapping("/api/messages") @RequiredArgsConstructor public class MessageController { - private final MessageService messageService; - private final UserRepository userRepository; + private final MessageService messageService; + private final UserRepository userRepository; - // This is a placeholder for getting the current user's ID - private Long getCurrentUserId(Authentication auth) { - User user = userRepository.findByUsername(auth.getName()).orElseThrow(() -> new IllegalArgumentException("Sender not found")); - // In a real application, you would get this from the Authentication object - return user.getId(); + // This is a placeholder for getting the current user's ID + private Long getCurrentUserId(Authentication auth) { + User user = userRepository + .findByUsername(auth.getName()) + .orElseThrow(() -> new IllegalArgumentException("Sender not found")); + // In a real application, you would get this from the Authentication object + return user.getId(); + } + + @GetMapping("/conversations") + @Operation(summary = "List conversations", description = "Get all conversations of current user") + @ApiResponse( + responseCode = "200", + description = "List of conversations", + content = @Content( + array = @ArraySchema(schema = @Schema(implementation = ConversationDto.class)) + ) + ) + @SecurityRequirement(name = "JWT") + public ResponseEntity> getConversations(Authentication auth) { + List conversations = messageService.getConversations(getCurrentUserId(auth)); + return ResponseEntity.ok(conversations); + } + + @GetMapping("/conversations/{conversationId}") + @Operation(summary = "Get conversation", description = "Get messages of a conversation") + @ApiResponse( + responseCode = "200", + description = "Conversation detail", + content = @Content(schema = @Schema(implementation = ConversationDetailDto.class)) + ) + @SecurityRequirement(name = "JWT") + public ResponseEntity getMessages( + @PathVariable Long conversationId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + Authentication auth + ) { + Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending()); + ConversationDetailDto conversationDetails = messageService.getConversationDetails( + conversationId, + getCurrentUserId(auth), + pageable + ); + return ResponseEntity.ok(conversationDetails); + } + + @PostMapping + @Operation(summary = "Send message", description = "Send a direct message to a user") + @ApiResponse( + responseCode = "200", + description = "Message sent", + content = @Content(schema = @Schema(implementation = MessageDto.class)) + ) + @SecurityRequirement(name = "JWT") + public ResponseEntity sendMessage( + @RequestBody MessageRequest req, + Authentication auth + ) { + Message message = messageService.sendMessage( + getCurrentUserId(auth), + req.getRecipientId(), + req.getContent(), + req.getReplyToId() + ); + return ResponseEntity.ok(messageService.toDto(message)); + } + + @PostMapping("/conversations/{conversationId}/messages") + @Operation(summary = "Send message to conversation", description = "Reply within a conversation") + @ApiResponse( + responseCode = "200", + description = "Message sent", + content = @Content(schema = @Schema(implementation = MessageDto.class)) + ) + @SecurityRequirement(name = "JWT") + public ResponseEntity sendMessageToConversation( + @PathVariable Long conversationId, + @RequestBody ChannelMessageRequest req, + Authentication auth + ) { + Message message = messageService.sendMessageToConversation( + getCurrentUserId(auth), + conversationId, + req.getContent(), + req.getReplyToId() + ); + return ResponseEntity.ok(messageService.toDto(message)); + } + + @PostMapping("/conversations/{conversationId}/read") + @Operation( + summary = "Mark conversation read", + description = "Mark messages in conversation as read" + ) + @ApiResponse(responseCode = "200", description = "Marked as read") + @SecurityRequirement(name = "JWT") + public ResponseEntity markAsRead(@PathVariable Long conversationId, Authentication auth) { + messageService.markConversationAsRead(conversationId, getCurrentUserId(auth)); + return ResponseEntity.ok().build(); + } + + @PostMapping("/conversations") + @Operation( + summary = "Find or create conversation", + description = "Find existing or create new conversation with recipient" + ) + @ApiResponse( + responseCode = "200", + description = "Conversation id", + content = @Content(schema = @Schema(implementation = CreateConversationResponse.class)) + ) + @SecurityRequirement(name = "JWT") + public ResponseEntity findOrCreateConversation( + @RequestBody CreateConversationRequest req, + Authentication auth + ) { + MessageConversation conversation = messageService.findOrCreateConversation( + getCurrentUserId(auth), + req.getRecipientId() + ); + return ResponseEntity.ok(new CreateConversationResponse(conversation.getId())); + } + + @GetMapping("/unread-count") + @Operation( + summary = "Unread message count", + description = "Get unread message count for current user" + ) + @ApiResponse( + responseCode = "200", + description = "Unread count", + content = @Content(schema = @Schema(implementation = Long.class)) + ) + @SecurityRequirement(name = "JWT") + public ResponseEntity getUnreadCount(Authentication auth) { + return ResponseEntity.ok(messageService.getUnreadMessageCount(getCurrentUserId(auth))); + } + + // A simple request DTO + static class MessageRequest { + + private Long recipientId; + private String content; + private Long replyToId; + + public Long getRecipientId() { + return recipientId; } - @GetMapping("/conversations") - @Operation(summary = "List conversations", description = "Get all conversations of current user") - @ApiResponse(responseCode = "200", description = "List of conversations", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = ConversationDto.class)))) - @SecurityRequirement(name = "JWT") - public ResponseEntity> getConversations(Authentication auth) { - List conversations = messageService.getConversations(getCurrentUserId(auth)); - return ResponseEntity.ok(conversations); + public void setRecipientId(Long recipientId) { + this.recipientId = recipientId; } - @GetMapping("/conversations/{conversationId}") - @Operation(summary = "Get conversation", description = "Get messages of a conversation") - @ApiResponse(responseCode = "200", description = "Conversation detail", - content = @Content(schema = @Schema(implementation = ConversationDetailDto.class))) - @SecurityRequirement(name = "JWT") - public ResponseEntity getMessages(@PathVariable Long conversationId, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size, - Authentication auth) { - Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending()); - ConversationDetailDto conversationDetails = messageService.getConversationDetails(conversationId, getCurrentUserId(auth), pageable); - return ResponseEntity.ok(conversationDetails); + public String getContent() { + return content; } - @PostMapping - @Operation(summary = "Send message", description = "Send a direct message to a user") - @ApiResponse(responseCode = "200", description = "Message sent", - content = @Content(schema = @Schema(implementation = MessageDto.class))) - @SecurityRequirement(name = "JWT") - public ResponseEntity sendMessage(@RequestBody MessageRequest req, Authentication auth) { - Message message = messageService.sendMessage(getCurrentUserId(auth), req.getRecipientId(), req.getContent(), req.getReplyToId()); - return ResponseEntity.ok(messageService.toDto(message)); + public void setContent(String content) { + this.content = content; } - @PostMapping("/conversations/{conversationId}/messages") - @Operation(summary = "Send message to conversation", description = "Reply within a conversation") - @ApiResponse(responseCode = "200", description = "Message sent", - content = @Content(schema = @Schema(implementation = MessageDto.class))) - @SecurityRequirement(name = "JWT") - public ResponseEntity sendMessageToConversation(@PathVariable Long conversationId, - @RequestBody ChannelMessageRequest req, - Authentication auth) { - Message message = messageService.sendMessageToConversation(getCurrentUserId(auth), conversationId, req.getContent(), req.getReplyToId()); - return ResponseEntity.ok(messageService.toDto(message)); + public Long getReplyToId() { + return replyToId; } - @PostMapping("/conversations/{conversationId}/read") - @Operation(summary = "Mark conversation read", description = "Mark messages in conversation as read") - @ApiResponse(responseCode = "200", description = "Marked as read") - @SecurityRequirement(name = "JWT") - public ResponseEntity markAsRead(@PathVariable Long conversationId, Authentication auth) { - messageService.markConversationAsRead(conversationId, getCurrentUserId(auth)); - return ResponseEntity.ok().build(); + public void setReplyToId(Long replyToId) { + this.replyToId = replyToId; + } + } + + static class ChannelMessageRequest { + + private String content; + private Long replyToId; + + public String getContent() { + return content; } - @PostMapping("/conversations") - @Operation(summary = "Find or create conversation", description = "Find existing or create new conversation with recipient") - @ApiResponse(responseCode = "200", description = "Conversation id", - content = @Content(schema = @Schema(implementation = CreateConversationResponse.class))) - @SecurityRequirement(name = "JWT") - public ResponseEntity findOrCreateConversation(@RequestBody CreateConversationRequest req, Authentication auth) { - MessageConversation conversation = messageService.findOrCreateConversation(getCurrentUserId(auth), req.getRecipientId()); - return ResponseEntity.ok(new CreateConversationResponse(conversation.getId())); + public void setContent(String content) { + this.content = content; } - @GetMapping("/unread-count") - @Operation(summary = "Unread message count", description = "Get unread message count for current user") - @ApiResponse(responseCode = "200", description = "Unread count", - content = @Content(schema = @Schema(implementation = Long.class))) - @SecurityRequirement(name = "JWT") - public ResponseEntity getUnreadCount(Authentication auth) { - return ResponseEntity.ok(messageService.getUnreadMessageCount(getCurrentUserId(auth))); + public Long getReplyToId() { + return replyToId; } - // A simple request DTO - static class MessageRequest { - private Long recipientId; - private String content; - private Long replyToId; - - public Long getRecipientId() { - return recipientId; - } - - public void setRecipientId(Long recipientId) { - this.recipientId = recipientId; - } - - public String getContent() { - return content; - } - - public void setContent(String content) { - this.content = content; - } - - public Long getReplyToId() { - return replyToId; - } - - public void setReplyToId(Long replyToId) { - this.replyToId = replyToId; - } + public void setReplyToId(Long replyToId) { + this.replyToId = replyToId; } - - static class ChannelMessageRequest { - private String content; - private Long replyToId; - - public String getContent() { - return content; - } - - public void setContent(String content) { - this.content = content; - } - - public Long getReplyToId() { - return replyToId; - } - - public void setReplyToId(Long replyToId) { - this.replyToId = replyToId; - } - } -} \ No newline at end of file + } +} diff --git a/backend/src/main/java/com/openisle/controller/NotificationController.java b/backend/src/main/java/com/openisle/controller/NotificationController.java index 3371fcda6..03beffb8f 100644 --- a/backend/src/main/java/com/openisle/controller/NotificationController.java +++ b/backend/src/main/java/com/openisle/controller/NotificationController.java @@ -2,109 +2,158 @@ package com.openisle.controller; import com.openisle.dto.NotificationDto; import com.openisle.dto.NotificationMarkReadRequest; -import com.openisle.dto.NotificationUnreadCountDto; import com.openisle.dto.NotificationPreferenceDto; import com.openisle.dto.NotificationPreferenceUpdateRequest; +import com.openisle.dto.NotificationUnreadCountDto; import com.openisle.mapper.NotificationMapper; import com.openisle.service.NotificationService; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.*; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; - import java.util.List; import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; /** Endpoints for user notifications. */ @RestController @RequestMapping("/api/notifications") @RequiredArgsConstructor public class NotificationController { - private final NotificationService notificationService; - private final NotificationMapper notificationMapper; - @GetMapping - @Operation(summary = "List notifications", description = "Retrieve notifications for the current user") - @ApiResponse(responseCode = "200", description = "Notifications", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = NotificationDto.class)))) - @SecurityRequirement(name = "JWT") - public List list(@RequestParam(value = "page", defaultValue = "0") int page, - @RequestParam(value = "size", defaultValue = "30") int size, - Authentication auth) { - return notificationService.listNotifications(auth.getName(), null, page, size).stream() - .map(notificationMapper::toDto) - .collect(Collectors.toList()); - } + private final NotificationService notificationService; + private final NotificationMapper notificationMapper; - @GetMapping("/unread") - @Operation(summary = "List unread notifications", description = "Retrieve unread notifications for the current user") - @ApiResponse(responseCode = "200", description = "Unread notifications", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = NotificationDto.class)))) - @SecurityRequirement(name = "JWT") - public List listUnread(@RequestParam(value = "page", defaultValue = "0") int page, - @RequestParam(value = "size", defaultValue = "30") int size, - Authentication auth) { - return notificationService.listNotifications(auth.getName(), false, page, size).stream() - .map(notificationMapper::toDto) - .collect(Collectors.toList()); - } + @GetMapping + @Operation( + summary = "List notifications", + description = "Retrieve notifications for the current user" + ) + @ApiResponse( + responseCode = "200", + description = "Notifications", + content = @Content( + array = @ArraySchema(schema = @Schema(implementation = NotificationDto.class)) + ) + ) + @SecurityRequirement(name = "JWT") + public List list( + @RequestParam(value = "page", defaultValue = "0") int page, + @RequestParam(value = "size", defaultValue = "30") int size, + Authentication auth + ) { + return notificationService + .listNotifications(auth.getName(), null, page, size) + .stream() + .map(notificationMapper::toDto) + .collect(Collectors.toList()); + } - @GetMapping("/unread-count") - @Operation(summary = "Unread count", description = "Get count of unread notifications") - @ApiResponse(responseCode = "200", description = "Unread count", - content = @Content(schema = @Schema(implementation = NotificationUnreadCountDto.class))) - @SecurityRequirement(name = "JWT") - public NotificationUnreadCountDto unreadCount(Authentication auth) { - long count = notificationService.countUnread(auth.getName()); - NotificationUnreadCountDto uc = new NotificationUnreadCountDto(); - uc.setCount(count); - return uc; - } + @GetMapping("/unread") + @Operation( + summary = "List unread notifications", + description = "Retrieve unread notifications for the current user" + ) + @ApiResponse( + responseCode = "200", + description = "Unread notifications", + content = @Content( + array = @ArraySchema(schema = @Schema(implementation = NotificationDto.class)) + ) + ) + @SecurityRequirement(name = "JWT") + public List listUnread( + @RequestParam(value = "page", defaultValue = "0") int page, + @RequestParam(value = "size", defaultValue = "30") int size, + Authentication auth + ) { + return notificationService + .listNotifications(auth.getName(), false, page, size) + .stream() + .map(notificationMapper::toDto) + .collect(Collectors.toList()); + } - @PostMapping("/read") - @Operation(summary = "Mark notifications read", description = "Mark notifications as read") - @ApiResponse(responseCode = "200", description = "Marked read") - @SecurityRequirement(name = "JWT") - public void markRead(@RequestBody NotificationMarkReadRequest req, Authentication auth) { - notificationService.markRead(auth.getName(), req.getIds()); - } + @GetMapping("/unread-count") + @Operation(summary = "Unread count", description = "Get count of unread notifications") + @ApiResponse( + responseCode = "200", + description = "Unread count", + content = @Content(schema = @Schema(implementation = NotificationUnreadCountDto.class)) + ) + @SecurityRequirement(name = "JWT") + public NotificationUnreadCountDto unreadCount(Authentication auth) { + long count = notificationService.countUnread(auth.getName()); + NotificationUnreadCountDto uc = new NotificationUnreadCountDto(); + uc.setCount(count); + return uc; + } - @GetMapping("/prefs") - @Operation(summary = "List preferences", description = "List notification preferences") - @ApiResponse(responseCode = "200", description = "Preferences", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = NotificationPreferenceDto.class)))) - @SecurityRequirement(name = "JWT") - public List prefs(Authentication auth) { - return notificationService.listPreferences(auth.getName()); - } + @PostMapping("/read") + @Operation(summary = "Mark notifications read", description = "Mark notifications as read") + @ApiResponse(responseCode = "200", description = "Marked read") + @SecurityRequirement(name = "JWT") + public void markRead(@RequestBody NotificationMarkReadRequest req, Authentication auth) { + notificationService.markRead(auth.getName(), req.getIds()); + } - @PostMapping("/prefs") - @Operation(summary = "Update preference", description = "Update notification preference") - @ApiResponse(responseCode = "200", description = "Preference updated") - @SecurityRequirement(name = "JWT") - public void updatePref(@RequestBody NotificationPreferenceUpdateRequest req, Authentication auth) { - notificationService.updatePreference(auth.getName(), req.getType(), req.isEnabled()); - } + @GetMapping("/prefs") + @Operation(summary = "List preferences", description = "List notification preferences") + @ApiResponse( + responseCode = "200", + description = "Preferences", + content = @Content( + array = @ArraySchema(schema = @Schema(implementation = NotificationPreferenceDto.class)) + ) + ) + @SecurityRequirement(name = "JWT") + public List prefs(Authentication auth) { + return notificationService.listPreferences(auth.getName()); + } - @GetMapping("/email-prefs") - @Operation(summary = "List email preferences", description = "List email notification preferences") - @ApiResponse(responseCode = "200", description = "Email preferences", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = NotificationPreferenceDto.class)))) - @SecurityRequirement(name = "JWT") - public List emailPrefs(Authentication auth) { - return notificationService.listEmailPreferences(auth.getName()); - } + @PostMapping("/prefs") + @Operation(summary = "Update preference", description = "Update notification preference") + @ApiResponse(responseCode = "200", description = "Preference updated") + @SecurityRequirement(name = "JWT") + public void updatePref( + @RequestBody NotificationPreferenceUpdateRequest req, + Authentication auth + ) { + notificationService.updatePreference(auth.getName(), req.getType(), req.isEnabled()); + } - @PostMapping("/email-prefs") - @Operation(summary = "Update email preference", description = "Update email notification preference") - @ApiResponse(responseCode = "200", description = "Email preference updated") - @SecurityRequirement(name = "JWT") - public void updateEmailPref(@RequestBody NotificationPreferenceUpdateRequest req, Authentication auth) { - notificationService.updateEmailPreference(auth.getName(), req.getType(), req.isEnabled()); - } + @GetMapping("/email-prefs") + @Operation( + summary = "List email preferences", + description = "List email notification preferences" + ) + @ApiResponse( + responseCode = "200", + description = "Email preferences", + content = @Content( + array = @ArraySchema(schema = @Schema(implementation = NotificationPreferenceDto.class)) + ) + ) + @SecurityRequirement(name = "JWT") + public List emailPrefs(Authentication auth) { + return notificationService.listEmailPreferences(auth.getName()); + } + + @PostMapping("/email-prefs") + @Operation( + summary = "Update email preference", + description = "Update email notification preference" + ) + @ApiResponse(responseCode = "200", description = "Email preference updated") + @SecurityRequirement(name = "JWT") + public void updateEmailPref( + @RequestBody NotificationPreferenceUpdateRequest req, + Authentication auth + ) { + notificationService.updateEmailPreference(auth.getName(), req.getType(), req.isEnabled()); + } } diff --git a/backend/src/main/java/com/openisle/controller/OnlineController.java b/backend/src/main/java/com/openisle/controller/OnlineController.java index 3119287f0..2a0b97a6b 100644 --- a/backend/src/main/java/com/openisle/controller/OnlineController.java +++ b/backend/src/main/java/com/openisle/controller/OnlineController.java @@ -1,16 +1,15 @@ package com.openisle.controller; import com.openisle.config.CachingConfig; -import lombok.RequiredArgsConstructor; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.web.bind.annotation.*; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; - import java.time.Duration; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.web.bind.annotation.*; /** * @author smallclover @@ -22,21 +21,24 @@ import java.time.Duration; @RequiredArgsConstructor public class OnlineController { - private final RedisTemplate redisTemplate; - private static final String ONLINE_KEY = CachingConfig.ONLINE_CACHE_NAME +":"; + private final RedisTemplate redisTemplate; + private static final String ONLINE_KEY = CachingConfig.ONLINE_CACHE_NAME + ":"; - @PostMapping("/heartbeat") - @Operation(summary = "Heartbeat", description = "Record user heartbeat") - @ApiResponse(responseCode = "200", description = "Heartbeat recorded") - public void ping(@RequestParam String userId){ - redisTemplate.opsForValue().set(ONLINE_KEY+userId,"1", Duration.ofSeconds(150)); - } + @PostMapping("/heartbeat") + @Operation(summary = "Heartbeat", description = "Record user heartbeat") + @ApiResponse(responseCode = "200", description = "Heartbeat recorded") + public void ping(@RequestParam String userId) { + redisTemplate.opsForValue().set(ONLINE_KEY + userId, "1", Duration.ofSeconds(150)); + } - @GetMapping("/count") - @Operation(summary = "Online count", description = "Get current online user count") - @ApiResponse(responseCode = "200", description = "Online count", - content = @Content(schema = @Schema(implementation = Long.class))) - public long count(){ - return redisTemplate.keys(ONLINE_KEY+"*").size(); - } + @GetMapping("/count") + @Operation(summary = "Online count", description = "Get current online user count") + @ApiResponse( + responseCode = "200", + description = "Online count", + content = @Content(schema = @Schema(implementation = Long.class)) + ) + public long count() { + return redisTemplate.keys(ONLINE_KEY + "*").size(); + } } diff --git a/backend/src/main/java/com/openisle/controller/PointHistoryController.java b/backend/src/main/java/com/openisle/controller/PointHistoryController.java index 0d8dc60bb..60db174ff 100644 --- a/backend/src/main/java/com/openisle/controller/PointHistoryController.java +++ b/backend/src/main/java/com/openisle/controller/PointHistoryController.java @@ -3,48 +3,60 @@ package com.openisle.controller; import com.openisle.dto.PointHistoryDto; import com.openisle.mapper.PointHistoryMapper; import com.openisle.service.PointService; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; - import java.util.List; import java.util.Map; import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/point-histories") @RequiredArgsConstructor public class PointHistoryController { - private final PointService pointService; - private final PointHistoryMapper pointHistoryMapper; - @GetMapping - @Operation(summary = "Point history", description = "List point history for current user") - @ApiResponse(responseCode = "200", description = "List of point histories", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = PointHistoryDto.class)))) - @SecurityRequirement(name = "JWT") - public List list(Authentication auth) { - return pointService.listHistory(auth.getName()).stream() - .map(pointHistoryMapper::toDto) - .collect(Collectors.toList()); - } + private final PointService pointService; + private final PointHistoryMapper pointHistoryMapper; - @GetMapping("/trend") - @Operation(summary = "Point trend", description = "Get point trend data for current user") - @ApiResponse(responseCode = "200", description = "Trend data", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = java.util.Map.class)))) - @SecurityRequirement(name = "JWT") - public List> trend(Authentication auth, - @RequestParam(value = "days", defaultValue = "30") int days) { - return pointService.trend(auth.getName(), days); - } + @GetMapping + @Operation(summary = "Point history", description = "List point history for current user") + @ApiResponse( + responseCode = "200", + description = "List of point histories", + content = @Content( + array = @ArraySchema(schema = @Schema(implementation = PointHistoryDto.class)) + ) + ) + @SecurityRequirement(name = "JWT") + public List list(Authentication auth) { + return pointService + .listHistory(auth.getName()) + .stream() + .map(pointHistoryMapper::toDto) + .collect(Collectors.toList()); + } + + @GetMapping("/trend") + @Operation(summary = "Point trend", description = "Get point trend data for current user") + @ApiResponse( + responseCode = "200", + description = "Trend data", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = java.util.Map.class))) + ) + @SecurityRequirement(name = "JWT") + public List> trend( + Authentication auth, + @RequestParam(value = "days", defaultValue = "30") int days + ) { + return pointService.trend(auth.getName(), days); + } } diff --git a/backend/src/main/java/com/openisle/controller/PointMallController.java b/backend/src/main/java/com/openisle/controller/PointMallController.java index b9cf9f916..9ed9d91fe 100644 --- a/backend/src/main/java/com/openisle/controller/PointMallController.java +++ b/backend/src/main/java/com/openisle/controller/PointMallController.java @@ -6,47 +6,55 @@ import com.openisle.mapper.PointGoodMapper; import com.openisle.model.User; import com.openisle.service.PointMallService; import com.openisle.service.UserService; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.*; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; - import java.util.List; import java.util.Map; import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; /** REST controller for point mall. */ @RestController @RequestMapping("/api/point-goods") @RequiredArgsConstructor public class PointMallController { - private final PointMallService pointMallService; - private final UserService userService; - private final PointGoodMapper pointGoodMapper; - @GetMapping - @Operation(summary = "List goods", description = "List all point goods") - @ApiResponse(responseCode = "200", description = "List of goods", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = PointGoodDto.class)))) - public List list() { - return pointMallService.listGoods().stream() - .map(pointGoodMapper::toDto) - .collect(Collectors.toList()); - } + private final PointMallService pointMallService; + private final UserService userService; + private final PointGoodMapper pointGoodMapper; - @PostMapping("/redeem") - @Operation(summary = "Redeem good", description = "Redeem a point good") - @ApiResponse(responseCode = "200", description = "Remaining points", - content = @Content(schema = @Schema(implementation = java.util.Map.class))) - @SecurityRequirement(name = "JWT") - public Map redeem(@RequestBody PointRedeemRequest req, Authentication auth) { - User user = userService.findByIdentifier(auth.getName()).orElseThrow(); - int point = pointMallService.redeem(user, req.getGoodId(), req.getContact()); - return Map.of("point", point); - } + @GetMapping + @Operation(summary = "List goods", description = "List all point goods") + @ApiResponse( + responseCode = "200", + description = "List of goods", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = PointGoodDto.class))) + ) + public List list() { + return pointMallService + .listGoods() + .stream() + .map(pointGoodMapper::toDto) + .collect(Collectors.toList()); + } + + @PostMapping("/redeem") + @Operation(summary = "Redeem good", description = "Redeem a point good") + @ApiResponse( + responseCode = "200", + description = "Remaining points", + content = @Content(schema = @Schema(implementation = java.util.Map.class)) + ) + @SecurityRequirement(name = "JWT") + public Map redeem(@RequestBody PointRedeemRequest req, Authentication auth) { + User user = userService.findByIdentifier(auth.getName()).orElseThrow(); + int point = pointMallService.redeem(user, req.getGoodId(), req.getContact()); + return Map.of("point", point); + } } diff --git a/backend/src/main/java/com/openisle/controller/PostChangeLogController.java b/backend/src/main/java/com/openisle/controller/PostChangeLogController.java index f7f555d2d..9f1e5aecd 100644 --- a/backend/src/main/java/com/openisle/controller/PostChangeLogController.java +++ b/backend/src/main/java/com/openisle/controller/PostChangeLogController.java @@ -3,31 +3,34 @@ package com.openisle.controller; import com.openisle.dto.PostChangeLogDto; import com.openisle.mapper.PostChangeLogMapper; import com.openisle.service.PostChangeLogService; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; - import java.util.List; import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/posts") @RequiredArgsConstructor public class PostChangeLogController { - private final PostChangeLogService changeLogService; - private final PostChangeLogMapper mapper; - @GetMapping("/{id}/change-logs") - @Operation(summary = "Post change logs", description = "List change logs for a post") - @ApiResponse(responseCode = "200", description = "Change logs", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostChangeLogDto.class)))) - public List listLogs(@PathVariable Long id) { - return changeLogService.listLogs(id).stream() - .map(mapper::toDto) - .collect(Collectors.toList()); - } + private final PostChangeLogService changeLogService; + private final PostChangeLogMapper mapper; + + @GetMapping("/{id}/change-logs") + @Operation(summary = "Post change logs", description = "List change logs for a post") + @ApiResponse( + responseCode = "200", + description = "Change logs", + content = @Content( + array = @ArraySchema(schema = @Schema(implementation = PostChangeLogDto.class)) + ) + ) + public List listLogs(@PathVariable Long id) { + return changeLogService.listLogs(id).stream().map(mapper::toDto).collect(Collectors.toList()); + } } diff --git a/backend/src/main/java/com/openisle/controller/PostController.java b/backend/src/main/java/com/openisle/controller/PostController.java index 23386f201..0a039975e 100644 --- a/backend/src/main/java/com/openisle/controller/PostController.java +++ b/backend/src/main/java/com/openisle/controller/PostController.java @@ -1,10 +1,10 @@ package com.openisle.controller; import com.openisle.config.CachingConfig; +import com.openisle.dto.PollDto; import com.openisle.dto.PostDetailDto; import com.openisle.dto.PostRequest; import com.openisle.dto.PostSummaryDto; -import com.openisle.dto.PollDto; import com.openisle.mapper.PostMapper; import com.openisle.model.Post; import com.openisle.service.*; @@ -14,6 +14,8 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import java.util.List; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.cache.annotation.Cacheable; @@ -21,221 +23,296 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; -import java.util.List; -import java.util.stream.Collectors; - @RestController @RequestMapping("/api/posts") @RequiredArgsConstructor public class PostController { - private final PostService postService; - private final CategoryService categoryService; - private final TagService tagService; - private final LevelService levelService; - private final CaptchaService captchaService; - private final DraftService draftService; - private final UserVisitService userVisitService; - private final PostMapper postMapper; - private final PointService pointService; - @Value("${app.captcha.enabled:false}") - private boolean captchaEnabled; + private final PostService postService; + private final CategoryService categoryService; + private final TagService tagService; + private final LevelService levelService; + private final CaptchaService captchaService; + private final DraftService draftService; + private final UserVisitService userVisitService; + private final PostMapper postMapper; + private final PointService pointService; - @Value("${app.captcha.post-enabled:false}") - private boolean postCaptchaEnabled; + @Value("${app.captcha.enabled:false}") + private boolean captchaEnabled; - @PostMapping - @SecurityRequirement(name = "JWT") - @Operation(summary = "Create post", description = "Create a new post") - @ApiResponse(responseCode = "200", description = "Created post", - content = @Content(schema = @Schema(implementation = PostDetailDto.class))) - public ResponseEntity createPost(@RequestBody PostRequest req, Authentication auth) { - if (captchaEnabled && postCaptchaEnabled && !captchaService.verify(req.getCaptcha())) { - return ResponseEntity.badRequest().build(); - } - Post post = postService.createPost(auth.getName(), req.getCategoryId(), - req.getTitle(), req.getContent(), req.getTagIds(), - req.getType(), req.getPrizeDescription(), req.getPrizeIcon(), - req.getPrizeCount(), req.getPointCost(), - req.getStartTime(), req.getEndTime(), - req.getOptions(), req.getMultiple()); - draftService.deleteDraft(auth.getName()); - PostDetailDto dto = postMapper.toDetailDto(post, auth.getName()); - dto.setReward(levelService.awardForPost(auth.getName())); - dto.setPointReward(pointService.awardForPost(auth.getName(), post.getId())); - return ResponseEntity.ok(dto); + @Value("${app.captcha.post-enabled:false}") + private boolean postCaptchaEnabled; + + @PostMapping + @SecurityRequirement(name = "JWT") + @Operation(summary = "Create post", description = "Create a new post") + @ApiResponse( + responseCode = "200", + description = "Created post", + content = @Content(schema = @Schema(implementation = PostDetailDto.class)) + ) + public ResponseEntity createPost( + @RequestBody PostRequest req, + Authentication auth + ) { + if (captchaEnabled && postCaptchaEnabled && !captchaService.verify(req.getCaptcha())) { + return ResponseEntity.badRequest().build(); } + Post post = postService.createPost( + auth.getName(), + req.getCategoryId(), + req.getTitle(), + req.getContent(), + req.getTagIds(), + req.getType(), + req.getPrizeDescription(), + req.getPrizeIcon(), + req.getPrizeCount(), + req.getPointCost(), + req.getStartTime(), + req.getEndTime(), + req.getOptions(), + req.getMultiple() + ); + draftService.deleteDraft(auth.getName()); + PostDetailDto dto = postMapper.toDetailDto(post, auth.getName()); + dto.setReward(levelService.awardForPost(auth.getName())); + dto.setPointReward(pointService.awardForPost(auth.getName(), post.getId())); + return ResponseEntity.ok(dto); + } - @PutMapping("/{id}") - @SecurityRequirement(name = "JWT") - @Operation(summary = "Update post", description = "Update an existing post") - @ApiResponse(responseCode = "200", description = "Updated post", - content = @Content(schema = @Schema(implementation = PostDetailDto.class))) - public ResponseEntity updatePost(@PathVariable Long id, @RequestBody PostRequest req, - Authentication auth) { - Post post = postService.updatePost(id, auth.getName(), req.getCategoryId(), - req.getTitle(), req.getContent(), req.getTagIds()); - return ResponseEntity.ok(postMapper.toDetailDto(post, auth.getName())); - } + @PutMapping("/{id}") + @SecurityRequirement(name = "JWT") + @Operation(summary = "Update post", description = "Update an existing post") + @ApiResponse( + responseCode = "200", + description = "Updated post", + content = @Content(schema = @Schema(implementation = PostDetailDto.class)) + ) + public ResponseEntity updatePost( + @PathVariable Long id, + @RequestBody PostRequest req, + Authentication auth + ) { + Post post = postService.updatePost( + id, + auth.getName(), + req.getCategoryId(), + req.getTitle(), + req.getContent(), + req.getTagIds() + ); + return ResponseEntity.ok(postMapper.toDetailDto(post, auth.getName())); + } - @DeleteMapping("/{id}") - @SecurityRequirement(name = "JWT") - @Operation(summary = "Delete post", description = "Delete a post") - @ApiResponse(responseCode = "200", description = "Post deleted") - public void deletePost(@PathVariable Long id, Authentication auth) { - postService.deletePost(id, auth.getName()); - } + @DeleteMapping("/{id}") + @SecurityRequirement(name = "JWT") + @Operation(summary = "Delete post", description = "Delete a post") + @ApiResponse(responseCode = "200", description = "Post deleted") + public void deletePost(@PathVariable Long id, Authentication auth) { + postService.deletePost(id, auth.getName()); + } - @PostMapping("/{id}/close") - @SecurityRequirement(name = "JWT") - @Operation(summary = "Close post", description = "Close a post to prevent further replies") - @ApiResponse(responseCode = "200", description = "Closed post", - content = @Content(schema = @Schema(implementation = PostSummaryDto.class))) - public PostSummaryDto close(@PathVariable Long id, Authentication auth) { - return postMapper.toSummaryDto(postService.closePost(id, auth.getName())); - } + @PostMapping("/{id}/close") + @SecurityRequirement(name = "JWT") + @Operation(summary = "Close post", description = "Close a post to prevent further replies") + @ApiResponse( + responseCode = "200", + description = "Closed post", + content = @Content(schema = @Schema(implementation = PostSummaryDto.class)) + ) + public PostSummaryDto close(@PathVariable Long id, Authentication auth) { + return postMapper.toSummaryDto(postService.closePost(id, auth.getName())); + } - @PostMapping("/{id}/reopen") - @SecurityRequirement(name = "JWT") - @Operation(summary = "Reopen post", description = "Reopen a closed post") - @ApiResponse(responseCode = "200", description = "Reopened post", - content = @Content(schema = @Schema(implementation = PostSummaryDto.class))) - public PostSummaryDto reopen(@PathVariable Long id, Authentication auth) { - return postMapper.toSummaryDto(postService.reopenPost(id, auth.getName())); - } + @PostMapping("/{id}/reopen") + @SecurityRequirement(name = "JWT") + @Operation(summary = "Reopen post", description = "Reopen a closed post") + @ApiResponse( + responseCode = "200", + description = "Reopened post", + content = @Content(schema = @Schema(implementation = PostSummaryDto.class)) + ) + public PostSummaryDto reopen(@PathVariable Long id, Authentication auth) { + return postMapper.toSummaryDto(postService.reopenPost(id, auth.getName())); + } - @GetMapping("/{id}") - @Operation(summary = "Get post", description = "Get post details by id") - @ApiResponse(responseCode = "200", description = "Post detail", - content = @Content(schema = @Schema(implementation = PostDetailDto.class))) - public ResponseEntity getPost(@PathVariable Long id, Authentication auth) { - String viewer = auth != null ? auth.getName() : null; - Post post = postService.viewPost(id, viewer); - return ResponseEntity.ok(postMapper.toDetailDto(post, viewer)); - } + @GetMapping("/{id}") + @Operation(summary = "Get post", description = "Get post details by id") + @ApiResponse( + responseCode = "200", + description = "Post detail", + content = @Content(schema = @Schema(implementation = PostDetailDto.class)) + ) + public ResponseEntity getPost(@PathVariable Long id, Authentication auth) { + String viewer = auth != null ? auth.getName() : null; + Post post = postService.viewPost(id, viewer); + return ResponseEntity.ok(postMapper.toDetailDto(post, viewer)); + } - @PostMapping("/{id}/lottery/join") - @SecurityRequirement(name = "JWT") - @Operation(summary = "Join lottery", description = "Join a lottery for the post") - @ApiResponse(responseCode = "200", description = "Joined lottery") - public ResponseEntity joinLottery(@PathVariable Long id, Authentication auth) { - postService.joinLottery(id, auth.getName()); - return ResponseEntity.ok().build(); - } + @PostMapping("/{id}/lottery/join") + @SecurityRequirement(name = "JWT") + @Operation(summary = "Join lottery", description = "Join a lottery for the post") + @ApiResponse(responseCode = "200", description = "Joined lottery") + public ResponseEntity joinLottery(@PathVariable Long id, Authentication auth) { + postService.joinLottery(id, auth.getName()); + return ResponseEntity.ok().build(); + } - @GetMapping("/{id}/poll/progress") - @Operation(summary = "Poll progress", description = "Get poll progress for a post") - @ApiResponse(responseCode = "200", description = "Poll progress", - content = @Content(schema = @Schema(implementation = PollDto.class))) - public ResponseEntity pollProgress(@PathVariable Long id) { - return ResponseEntity.ok(postMapper.toSummaryDto(postService.getPoll(id)).getPoll()); - } + @GetMapping("/{id}/poll/progress") + @Operation(summary = "Poll progress", description = "Get poll progress for a post") + @ApiResponse( + responseCode = "200", + description = "Poll progress", + content = @Content(schema = @Schema(implementation = PollDto.class)) + ) + public ResponseEntity pollProgress(@PathVariable Long id) { + return ResponseEntity.ok(postMapper.toSummaryDto(postService.getPoll(id)).getPoll()); + } - @PostMapping("/{id}/poll/vote") - @SecurityRequirement(name = "JWT") - @Operation(summary = "Vote poll", description = "Vote on a poll option") - @ApiResponse(responseCode = "200", description = "Vote recorded") - public ResponseEntity vote(@PathVariable Long id, @RequestParam("option") List option, Authentication auth) { - postService.votePoll(id, auth.getName(), option); - return ResponseEntity.ok().build(); - } + @PostMapping("/{id}/poll/vote") + @SecurityRequirement(name = "JWT") + @Operation(summary = "Vote poll", description = "Vote on a poll option") + @ApiResponse(responseCode = "200", description = "Vote recorded") + public ResponseEntity vote( + @PathVariable Long id, + @RequestParam("option") List option, + Authentication auth + ) { + postService.votePoll(id, auth.getName(), option); + return ResponseEntity.ok().build(); + } - @GetMapping - @Operation(summary = "List posts", description = "List posts by various filters") - @ApiResponse(responseCode = "200", description = "List of posts", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class)))) - @Cacheable( - value = CachingConfig.POST_CACHE_NAME, - key = "new org.springframework.cache.interceptor.SimpleKey('default', #categoryId, #categoryIds, #tagId, #tagIds, #page, #pageSize)" + @GetMapping + @Operation(summary = "List posts", description = "List posts by various filters") + @ApiResponse( + responseCode = "200", + description = "List of posts", + content = @Content( + array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class)) ) - public List listPosts(@RequestParam(value = "categoryId", required = false) Long categoryId, - @RequestParam(value = "categoryIds", required = false) List categoryIds, - @RequestParam(value = "tagId", required = false) Long tagId, - @RequestParam(value = "tagIds", required = false) List tagIds, - @RequestParam(value = "page", required = false) Integer page, - @RequestParam(value = "pageSize", required = false) Integer pageSize, - Authentication auth) { + ) + @Cacheable( + value = CachingConfig.POST_CACHE_NAME, + key = "new org.springframework.cache.interceptor.SimpleKey('default', #categoryId, #categoryIds, #tagId, #tagIds, #page, #pageSize)" + ) + public List listPosts( + @RequestParam(value = "categoryId", required = false) Long categoryId, + @RequestParam(value = "categoryIds", required = false) List categoryIds, + @RequestParam(value = "tagId", required = false) Long tagId, + @RequestParam(value = "tagIds", required = false) List tagIds, + @RequestParam(value = "page", required = false) Integer page, + @RequestParam(value = "pageSize", required = false) Integer pageSize, + Authentication auth + ) { + List ids = categoryService.getSearchCategoryIds(categoryIds, categoryId); + List tids = tagService.getSearchTagIds(tagIds, tagId); + // 只需要在请求的一开始统计一次 + // if (auth != null) { + // userVisitService.recordVisit(auth.getName()); + // } - List ids = categoryService.getSearchCategoryIds(categoryIds, categoryId); - List tids = tagService.getSearchTagIds(tagIds, tagId); -// 只需要在请求的一开始统计一次 -// if (auth != null) { -// userVisitService.recordVisit(auth.getName()); -// } + return postService + .defaultListPosts(ids, tids, page, pageSize) + .stream() + .map(postMapper::toSummaryDto) + .collect(Collectors.toList()); + } - return postService.defaultListPosts(ids,tids,page, pageSize).stream() - .map(postMapper::toSummaryDto).collect(Collectors.toList()); - } - - @GetMapping("/ranking") - @Operation(summary = "Ranking posts", description = "List posts by view rankings") - @ApiResponse(responseCode = "200", description = "Ranked posts", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class)))) - public List rankingPosts(@RequestParam(value = "categoryId", required = false) Long categoryId, - @RequestParam(value = "categoryIds", required = false) List categoryIds, - @RequestParam(value = "tagId", required = false) Long tagId, - @RequestParam(value = "tagIds", required = false) List tagIds, - @RequestParam(value = "page", required = false) Integer page, - @RequestParam(value = "pageSize", required = false) Integer pageSize, - Authentication auth) { - - List ids = categoryService.getSearchCategoryIds(categoryIds, categoryId); - List tids = tagService.getSearchTagIds(tagIds, tagId); -// 只需要在请求的一开始统计一次 -// if (auth != null) { -// userVisitService.recordVisit(auth.getName()); -// } - - return postService.listPostsByViews(ids, tids, page, pageSize) - .stream().map(postMapper::toSummaryDto).collect(Collectors.toList()); - } - - @GetMapping("/latest-reply") - @Operation(summary = "Latest reply posts", description = "List posts by latest replies") - @ApiResponse(responseCode = "200", description = "Posts sorted by latest reply", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class)))) - @Cacheable( - value = CachingConfig.POST_CACHE_NAME, - key = "new org.springframework.cache.interceptor.SimpleKey('latest_reply', #categoryId, #categoryIds, #tagIds, #page, #pageSize)" + @GetMapping("/ranking") + @Operation(summary = "Ranking posts", description = "List posts by view rankings") + @ApiResponse( + responseCode = "200", + description = "Ranked posts", + content = @Content( + array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class)) ) - public List latestReplyPosts(@RequestParam(value = "categoryId", required = false) Long categoryId, - @RequestParam(value = "categoryIds", required = false) List categoryIds, - @RequestParam(value = "tagId", required = false) Long tagId, - @RequestParam(value = "tagIds", required = false) List tagIds, - @RequestParam(value = "page", required = false) Integer page, - @RequestParam(value = "pageSize", required = false) Integer pageSize, - Authentication auth) { + ) + public List rankingPosts( + @RequestParam(value = "categoryId", required = false) Long categoryId, + @RequestParam(value = "categoryIds", required = false) List categoryIds, + @RequestParam(value = "tagId", required = false) Long tagId, + @RequestParam(value = "tagIds", required = false) List tagIds, + @RequestParam(value = "page", required = false) Integer page, + @RequestParam(value = "pageSize", required = false) Integer pageSize, + Authentication auth + ) { + List ids = categoryService.getSearchCategoryIds(categoryIds, categoryId); + List tids = tagService.getSearchTagIds(tagIds, tagId); + // 只需要在请求的一开始统计一次 + // if (auth != null) { + // userVisitService.recordVisit(auth.getName()); + // } - List ids = categoryService.getSearchCategoryIds(categoryIds, categoryId); - List tids = tagService.getSearchTagIds(tagIds, tagId); -// 只需要在请求的一开始统计一次 -// if (auth != null) { -// userVisitService.recordVisit(auth.getName()); -// } + return postService + .listPostsByViews(ids, tids, page, pageSize) + .stream() + .map(postMapper::toSummaryDto) + .collect(Collectors.toList()); + } - List posts = postService.listPostsByLatestReply(ids, tids, page, pageSize); - return posts.stream().map(postMapper::toSummaryDto).collect(Collectors.toList()); - } + @GetMapping("/latest-reply") + @Operation(summary = "Latest reply posts", description = "List posts by latest replies") + @ApiResponse( + responseCode = "200", + description = "Posts sorted by latest reply", + content = @Content( + array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class)) + ) + ) + @Cacheable( + value = CachingConfig.POST_CACHE_NAME, + key = "new org.springframework.cache.interceptor.SimpleKey('latest_reply', #categoryId, #categoryIds, #tagIds, #page, #pageSize)" + ) + public List latestReplyPosts( + @RequestParam(value = "categoryId", required = false) Long categoryId, + @RequestParam(value = "categoryIds", required = false) List categoryIds, + @RequestParam(value = "tagId", required = false) Long tagId, + @RequestParam(value = "tagIds", required = false) List tagIds, + @RequestParam(value = "page", required = false) Integer page, + @RequestParam(value = "pageSize", required = false) Integer pageSize, + Authentication auth + ) { + List ids = categoryService.getSearchCategoryIds(categoryIds, categoryId); + List tids = tagService.getSearchTagIds(tagIds, tagId); + // 只需要在请求的一开始统计一次 + // if (auth != null) { + // userVisitService.recordVisit(auth.getName()); + // } - @GetMapping("/featured") - @Operation(summary = "Featured posts", description = "List featured posts") - @ApiResponse(responseCode = "200", description = "Featured posts", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class)))) - public List featuredPosts(@RequestParam(value = "categoryId", required = false) Long categoryId, - @RequestParam(value = "categoryIds", required = false) List categoryIds, - @RequestParam(value = "tagId", required = false) Long tagId, - @RequestParam(value = "tagIds", required = false) List tagIds, - @RequestParam(value = "page", required = false) Integer page, - @RequestParam(value = "pageSize", required = false) Integer pageSize, - Authentication auth) { + List posts = postService.listPostsByLatestReply(ids, tids, page, pageSize); + return posts.stream().map(postMapper::toSummaryDto).collect(Collectors.toList()); + } - List ids = categoryService.getSearchCategoryIds(categoryIds, categoryId); - List tids = tagService.getSearchTagIds(tagIds, tagId); -// 只需要在请求的一开始统计一次 -// if (auth != null) { -// userVisitService.recordVisit(auth.getName()); -// } - return postService.listFeaturedPosts(ids, tids, page, pageSize) - .stream().map(postMapper::toSummaryDto).collect(Collectors.toList()); - } + @GetMapping("/featured") + @Operation(summary = "Featured posts", description = "List featured posts") + @ApiResponse( + responseCode = "200", + description = "Featured posts", + content = @Content( + array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class)) + ) + ) + public List featuredPosts( + @RequestParam(value = "categoryId", required = false) Long categoryId, + @RequestParam(value = "categoryIds", required = false) List categoryIds, + @RequestParam(value = "tagId", required = false) Long tagId, + @RequestParam(value = "tagIds", required = false) List tagIds, + @RequestParam(value = "page", required = false) Integer page, + @RequestParam(value = "pageSize", required = false) Integer pageSize, + Authentication auth + ) { + List ids = categoryService.getSearchCategoryIds(categoryIds, categoryId); + List tids = tagService.getSearchTagIds(tagIds, tagId); + // 只需要在请求的一开始统计一次 + // if (auth != null) { + // userVisitService.recordVisit(auth.getName()); + // } + return postService + .listFeaturedPosts(ids, tids, page, pageSize) + .stream() + .map(postMapper::toSummaryDto) + .collect(Collectors.toList()); + } } diff --git a/backend/src/main/java/com/openisle/controller/PushSubscriptionController.java b/backend/src/main/java/com/openisle/controller/PushSubscriptionController.java index 3c9546b99..00023f8aa 100644 --- a/backend/src/main/java/com/openisle/controller/PushSubscriptionController.java +++ b/backend/src/main/java/com/openisle/controller/PushSubscriptionController.java @@ -3,39 +3,49 @@ package com.openisle.controller; import com.openisle.dto.PushPublicKeyDto; import com.openisle.dto.PushSubscriptionRequest; import com.openisle.service.PushSubscriptionService; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.*; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/push") @RequiredArgsConstructor public class PushSubscriptionController { - private final PushSubscriptionService pushSubscriptionService; - @Value("${app.webpush.public-key}") - private String publicKey; - @GetMapping("/public-key") - @Operation(summary = "Get public key", description = "Retrieve web push public key") - @ApiResponse(responseCode = "200", description = "Public key", - content = @Content(schema = @Schema(implementation = PushPublicKeyDto.class))) - public PushPublicKeyDto getPublicKey() { - PushPublicKeyDto r = new PushPublicKeyDto(); - r.setKey(publicKey); - return r; - } + private final PushSubscriptionService pushSubscriptionService; - @PostMapping("/subscribe") - @Operation(summary = "Subscribe", description = "Subscribe to push notifications") - @ApiResponse(responseCode = "200", description = "Subscribed") - @SecurityRequirement(name = "JWT") - public void subscribe(@RequestBody PushSubscriptionRequest req, Authentication auth) { - pushSubscriptionService.saveSubscription(auth.getName(), req.getEndpoint(), req.getP256dh(), req.getAuth()); - } + @Value("${app.webpush.public-key}") + private String publicKey; + + @GetMapping("/public-key") + @Operation(summary = "Get public key", description = "Retrieve web push public key") + @ApiResponse( + responseCode = "200", + description = "Public key", + content = @Content(schema = @Schema(implementation = PushPublicKeyDto.class)) + ) + public PushPublicKeyDto getPublicKey() { + PushPublicKeyDto r = new PushPublicKeyDto(); + r.setKey(publicKey); + return r; + } + + @PostMapping("/subscribe") + @Operation(summary = "Subscribe", description = "Subscribe to push notifications") + @ApiResponse(responseCode = "200", description = "Subscribed") + @SecurityRequirement(name = "JWT") + public void subscribe(@RequestBody PushSubscriptionRequest req, Authentication auth) { + pushSubscriptionService.saveSubscription( + auth.getName(), + req.getEndpoint(), + req.getP256dh(), + req.getAuth() + ); + } } diff --git a/backend/src/main/java/com/openisle/controller/ReactionController.java b/backend/src/main/java/com/openisle/controller/ReactionController.java index 93064cb27..b44505d36 100644 --- a/backend/src/main/java/com/openisle/controller/ReactionController.java +++ b/backend/src/main/java/com/openisle/controller/ReactionController.java @@ -8,88 +8,107 @@ import com.openisle.model.ReactionType; import com.openisle.service.LevelService; import com.openisle.service.PointService; import com.openisle.service.ReactionService; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.*; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api") @RequiredArgsConstructor public class ReactionController { - private final ReactionService reactionService; - private final LevelService levelService; - private final ReactionMapper reactionMapper; - private final PointService pointService; - /** - * Get all available reaction types. - */ - @GetMapping("/reaction-types") - @Operation(summary = "List reaction types", description = "Get all available reaction types") - @ApiResponse(responseCode = "200", description = "Reaction types", - content = @Content(schema = @Schema(implementation = ReactionType[].class))) - public ReactionType[] listReactionTypes() { - return ReactionType.values(); - } + private final ReactionService reactionService; + private final LevelService levelService; + private final ReactionMapper reactionMapper; + private final PointService pointService; - @PostMapping("/posts/{postId}/reactions") - @Operation(summary = "React to post", description = "React to a post") - @ApiResponse(responseCode = "200", description = "Reaction result", - content = @Content(schema = @Schema(implementation = ReactionDto.class))) - @SecurityRequirement(name = "JWT") - public ResponseEntity reactToPost(@PathVariable Long postId, - @RequestBody ReactionRequest req, - Authentication auth) { - Reaction reaction = reactionService.reactToPost(auth.getName(), postId, req.getType()); - if (reaction == null) { - pointService.deductForReactionOfPost(auth.getName(), postId); - return ResponseEntity.noContent().build(); - } - ReactionDto dto = reactionMapper.toDto(reaction); - dto.setReward(levelService.awardForReaction(auth.getName())); - pointService.awardForReactionOfPost(auth.getName(), postId); - return ResponseEntity.ok(dto); - } + /** + * Get all available reaction types. + */ + @GetMapping("/reaction-types") + @Operation(summary = "List reaction types", description = "Get all available reaction types") + @ApiResponse( + responseCode = "200", + description = "Reaction types", + content = @Content(schema = @Schema(implementation = ReactionType[].class)) + ) + public ReactionType[] listReactionTypes() { + return ReactionType.values(); + } - @PostMapping("/comments/{commentId}/reactions") - @Operation(summary = "React to comment", description = "React to a comment") - @ApiResponse(responseCode = "200", description = "Reaction result", - content = @Content(schema = @Schema(implementation = ReactionDto.class))) - @SecurityRequirement(name = "JWT") - public ResponseEntity reactToComment(@PathVariable Long commentId, - @RequestBody ReactionRequest req, - Authentication auth) { - Reaction reaction = reactionService.reactToComment(auth.getName(), commentId, req.getType()); - if (reaction == null) { - pointService.deductForReactionOfComment(auth.getName(), commentId); - return ResponseEntity.noContent().build(); - } - ReactionDto dto = reactionMapper.toDto(reaction); - dto.setReward(levelService.awardForReaction(auth.getName())); - pointService.awardForReactionOfComment(auth.getName(), commentId); - return ResponseEntity.ok(dto); + @PostMapping("/posts/{postId}/reactions") + @Operation(summary = "React to post", description = "React to a post") + @ApiResponse( + responseCode = "200", + description = "Reaction result", + content = @Content(schema = @Schema(implementation = ReactionDto.class)) + ) + @SecurityRequirement(name = "JWT") + public ResponseEntity reactToPost( + @PathVariable Long postId, + @RequestBody ReactionRequest req, + Authentication auth + ) { + Reaction reaction = reactionService.reactToPost(auth.getName(), postId, req.getType()); + if (reaction == null) { + pointService.deductForReactionOfPost(auth.getName(), postId); + return ResponseEntity.noContent().build(); } + ReactionDto dto = reactionMapper.toDto(reaction); + dto.setReward(levelService.awardForReaction(auth.getName())); + pointService.awardForReactionOfPost(auth.getName(), postId); + return ResponseEntity.ok(dto); + } - @PostMapping("/messages/{messageId}/reactions") - @Operation(summary = "React to message", description = "React to a message") - @ApiResponse(responseCode = "200", description = "Reaction result", - content = @Content(schema = @Schema(implementation = ReactionDto.class))) - @SecurityRequirement(name = "JWT") - public ResponseEntity reactToMessage(@PathVariable Long messageId, - @RequestBody ReactionRequest req, - Authentication auth) { - Reaction reaction = reactionService.reactToMessage(auth.getName(), messageId, req.getType()); - if (reaction == null) { - return ResponseEntity.noContent().build(); - } - ReactionDto dto = reactionMapper.toDto(reaction); - dto.setReward(levelService.awardForReaction(auth.getName())); - return ResponseEntity.ok(dto); + @PostMapping("/comments/{commentId}/reactions") + @Operation(summary = "React to comment", description = "React to a comment") + @ApiResponse( + responseCode = "200", + description = "Reaction result", + content = @Content(schema = @Schema(implementation = ReactionDto.class)) + ) + @SecurityRequirement(name = "JWT") + public ResponseEntity reactToComment( + @PathVariable Long commentId, + @RequestBody ReactionRequest req, + Authentication auth + ) { + Reaction reaction = reactionService.reactToComment(auth.getName(), commentId, req.getType()); + if (reaction == null) { + pointService.deductForReactionOfComment(auth.getName(), commentId); + return ResponseEntity.noContent().build(); } + ReactionDto dto = reactionMapper.toDto(reaction); + dto.setReward(levelService.awardForReaction(auth.getName())); + pointService.awardForReactionOfComment(auth.getName(), commentId); + return ResponseEntity.ok(dto); + } + + @PostMapping("/messages/{messageId}/reactions") + @Operation(summary = "React to message", description = "React to a message") + @ApiResponse( + responseCode = "200", + description = "Reaction result", + content = @Content(schema = @Schema(implementation = ReactionDto.class)) + ) + @SecurityRequirement(name = "JWT") + public ResponseEntity reactToMessage( + @PathVariable Long messageId, + @RequestBody ReactionRequest req, + Authentication auth + ) { + Reaction reaction = reactionService.reactToMessage(auth.getName(), messageId, req.getType()); + if (reaction == null) { + return ResponseEntity.noContent().build(); + } + ReactionDto dto = reactionMapper.toDto(reaction); + dto.setReward(levelService.awardForReaction(auth.getName())); + return ResponseEntity.ok(dto); + } } diff --git a/backend/src/main/java/com/openisle/controller/RssController.java b/backend/src/main/java/com/openisle/controller/RssController.java index ba402afcf..90788c2f6 100644 --- a/backend/src/main/java/com/openisle/controller/RssController.java +++ b/backend/src/main/java/com/openisle/controller/RssController.java @@ -1,10 +1,28 @@ package com.openisle.controller; -import com.openisle.model.Post; import com.openisle.model.Comment; import com.openisle.model.CommentSort; -import com.openisle.service.PostService; +import com.openisle.model.Post; import com.openisle.service.CommentService; +import com.openisle.service.PostService; +import com.vladsch.flexmark.ext.autolink.AutolinkExtension; +import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension; +import com.vladsch.flexmark.ext.gfm.tasklist.TaskListExtension; +import com.vladsch.flexmark.ext.tables.TablesExtension; +import com.vladsch.flexmark.html.HtmlRenderer; +import com.vladsch.flexmark.parser.Parser; +import com.vladsch.flexmark.util.data.MutableDataSet; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import java.net.URI; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import lombok.RequiredArgsConstructor; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; @@ -13,346 +31,376 @@ import org.jsoup.safety.Safelist; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; - -import com.vladsch.flexmark.ext.autolink.AutolinkExtension; -import com.vladsch.flexmark.ext.tables.TablesExtension; -import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension; -import com.vladsch.flexmark.ext.gfm.tasklist.TaskListExtension; -import com.vladsch.flexmark.html.HtmlRenderer; -import com.vladsch.flexmark.parser.Parser; -import com.vladsch.flexmark.util.data.MutableDataSet; - -import java.net.URI; -import java.time.ZoneId; -import java.time.ZonedDateTime; -import java.time.format.DateTimeFormatter; -import java.util.*; -import java.util.regex.Matcher; -import java.util.regex.Pattern; @RestController @RequiredArgsConstructor public class RssController { - private final PostService postService; - private final CommentService commentService; - @Value("${app.website-url:https://www.open-isle.com}") - private String websiteUrl; + private final PostService postService; + private final CommentService commentService; - // 兼容 Markdown/HTML 两类图片写法(用于 enclosure) - private static final Pattern MD_IMAGE = Pattern.compile("!\\[[^\\]]*\\]\\(([^)]+)\\)"); - private static final Pattern HTML_IMAGE = Pattern.compile("]+src=[\"']?([^\"'>]+)[\"']?[^>]*>"); + @Value("${app.website-url:https://www.open-isle.com}") + private String websiteUrl; - private static final DateTimeFormatter RFC1123 = DateTimeFormatter.RFC_1123_DATE_TIME; + // 兼容 Markdown/HTML 两类图片写法(用于 enclosure) + private static final Pattern MD_IMAGE = Pattern.compile("!\\[[^\\]]*\\]\\(([^)]+)\\)"); + private static final Pattern HTML_IMAGE = Pattern.compile( + "]+src=[\"']?([^\"'>]+)[\"']?[^>]*>" + ); - // flexmark:Markdown -> HTML - private static final Parser MD_PARSER; - private static final HtmlRenderer MD_RENDERER; - static { - MutableDataSet opts = new MutableDataSet(); - opts.set(Parser.EXTENSIONS, Arrays.asList( - TablesExtension.create(), - AutolinkExtension.create(), - StrikethroughExtension.create(), - TaskListExtension.create() - )); - // 允许内联 HTML(下游再做 sanitize) - opts.set(Parser.HTML_BLOCK_PARSER, true); - MD_PARSER = Parser.builder(opts).build(); - MD_RENDERER = HtmlRenderer.builder(opts).escapeHtml(false).build(); + private static final DateTimeFormatter RFC1123 = DateTimeFormatter.RFC_1123_DATE_TIME; + + // flexmark:Markdown -> HTML + private static final Parser MD_PARSER; + private static final HtmlRenderer MD_RENDERER; + + static { + MutableDataSet opts = new MutableDataSet(); + opts.set( + Parser.EXTENSIONS, + Arrays.asList( + TablesExtension.create(), + AutolinkExtension.create(), + StrikethroughExtension.create(), + TaskListExtension.create() + ) + ); + // 允许内联 HTML(下游再做 sanitize) + opts.set(Parser.HTML_BLOCK_PARSER, true); + MD_PARSER = Parser.builder(opts).build(); + MD_RENDERER = HtmlRenderer.builder(opts).escapeHtml(false).build(); + } + + @GetMapping(value = "/api/rss", produces = "application/rss+xml;charset=UTF-8") + @Operation(summary = "RSS feed", description = "Generate RSS feed for latest posts") + @ApiResponse( + responseCode = "200", + description = "RSS XML", + content = @Content(schema = @Schema(implementation = String.class)) + ) + public String feed() { + // 建议 20;你现在是 10,这里保留你的 10 + List posts = postService.listLatestRssPosts(10); + String base = trimTrailingSlash(websiteUrl); + + StringBuilder sb = new StringBuilder(4096); + sb.append(""); + sb.append(""); + sb.append(""); + elem(sb, "title", cdata("OpenIsle RSS")); + elem(sb, "link", base + "/"); + elem(sb, "description", cdata("Latest posts")); + ZonedDateTime updated = posts + .stream() + .map(p -> p.getCreatedAt().atZone(ZoneId.systemDefault())) + .max(Comparator.naturalOrder()) + .orElse(ZonedDateTime.now()); + // channel lastBuildDate(GMT) + elem(sb, "lastBuildDate", toRfc1123Gmt(updated)); + + for (Post p : posts) { + String link = base + "/posts/" + p.getId(); + + // 1) Markdown -> HTML + String html = renderMarkdown(p.getContent()); + + // 2) Sanitize(白名单增强) + String safeHtml = sanitizeHtml(html); + + // 3) 绝对化 href/src + 强制 rel/target + String absHtml = absolutifyHtml(safeHtml, base); + + // 4) 纯文本摘要(用于 ) + String plain = textSummary(absHtml, 180); + + // 5) enclosure(首图,已绝对化) + String enclosure = firstImage(p.getContent()); + if (enclosure == null) { + // 如果 Markdown 没有图,尝试从渲染后的 HTML 再抓一次 + enclosure = firstImage(absHtml); + } + if (enclosure != null) { + enclosure = absolutifyUrl(enclosure, base); + } + + // 6) 构造优雅的附加区块(原文链接 + 精选评论),编入 + List topComments = commentService.getCommentsForPost( + p.getId(), + CommentSort.MOST_INTERACTIONS + ); + topComments = topComments.subList(0, Math.min(10, topComments.size())); + String footerHtml = buildFooterHtml(base, link, topComments); + + sb.append(""); + elem(sb, "title", cdata(nullSafe(p.getTitle()))); + elem(sb, "link", link); + sb.append("").append(link).append(""); + elem(sb, "pubDate", toRfc1123Gmt(p.getCreatedAt().atZone(ZoneId.systemDefault()))); + // 摘要 + elem(sb, "description", cdata(plain)); + // 全文(HTML):正文 + 优雅的 Markdown 区块(已转 HTML) + sb + .append(""); + // 首图 enclosure(图片类型) + if (enclosure != null) { + sb + .append(""); + } + sb.append(""); } - @GetMapping(value = "/api/rss", produces = "application/rss+xml;charset=UTF-8") - @Operation(summary = "RSS feed", description = "Generate RSS feed for latest posts") - @ApiResponse(responseCode = "200", description = "RSS XML", content = @Content(schema = @Schema(implementation = String.class))) - public String feed() { - // 建议 20;你现在是 10,这里保留你的 10 - List posts = postService.listLatestRssPosts(10); - String base = trimTrailingSlash(websiteUrl); + sb.append(""); + return sb.toString(); + } - StringBuilder sb = new StringBuilder(4096); - sb.append(""); - sb.append(""); - sb.append(""); - elem(sb, "title", cdata("OpenIsle RSS")); - elem(sb, "link", base + "/"); - elem(sb, "description", cdata("Latest posts")); - ZonedDateTime updated = posts.stream() - .map(p -> p.getCreatedAt().atZone(ZoneId.systemDefault())) - .max(Comparator.naturalOrder()) - .orElse(ZonedDateTime.now()); - // channel lastBuildDate(GMT) - elem(sb, "lastBuildDate", toRfc1123Gmt(updated)); + /* ===================== Markdown → HTML ===================== */ - for (Post p : posts) { - String link = base + "/posts/" + p.getId(); + private static String renderMarkdown(String md) { + if (md == null || md.isEmpty()) return ""; + return MD_RENDERER.render(MD_PARSER.parse(md)); + } - // 1) Markdown -> HTML - String html = renderMarkdown(p.getContent()); + /* ===================== Sanitize & 绝对化 ===================== */ - // 2) Sanitize(白名单增强) - String safeHtml = sanitizeHtml(html); + private static String sanitizeHtml(String html) { + if (html == null) return ""; + Safelist wl = Safelist.relaxed() + .addTags( + "pre", + "code", + "figure", + "figcaption", + "picture", + "source", + "table", + "thead", + "tbody", + "tr", + "th", + "td", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "hr", + "blockquote" + ) + .addAttributes("a", "href", "title", "target", "rel") + .addAttributes("img", "src", "alt", "title", "width", "height") + .addAttributes("source", "srcset", "type", "media") + .addAttributes("code", "class") + .addAttributes("pre", "class") + .addProtocols("a", "href", "http", "https", "mailto") + .addProtocols("img", "src", "http", "https", "data") + .addProtocols("source", "srcset", "http", "https"); + // 清除所有 on* 事件、style(避免阅读器环境差异) + return Jsoup.clean(html, wl); + } - // 3) 绝对化 href/src + 强制 rel/target - String absHtml = absolutifyHtml(safeHtml, base); + private static String absolutifyHtml(String html, String baseUrl) { + if (html == null || html.isEmpty()) return ""; + Document doc = Jsoup.parseBodyFragment(html, baseUrl); + // a[href] + for (Element a : doc.select("a[href]")) { + String href = a.attr("href"); + String abs = absolutifyUrl(href, baseUrl); + a.attr("href", abs); + // 强制外链安全属性 + a.attr("rel", "noopener noreferrer nofollow"); + a.attr("target", "_blank"); + } + // img[src] + for (Element img : doc.select("img[src]")) { + String src = img.attr("src"); + String abs = absolutifyUrl(src, baseUrl); + img.attr("src", abs); + } + // source[srcset] (picture/webp) + for (Element s : doc.select("source[srcset]")) { + String srcset = s.attr("srcset"); + s.attr("srcset", absolutifySrcset(srcset, baseUrl)); + } + return doc.body().html(); + } - // 4) 纯文本摘要(用于 ) - String plain = textSummary(absHtml, 180); + private static String absolutifyUrl(String url, String baseUrl) { + if (url == null || url.isEmpty()) return url; + String u = url.trim(); + if (u.startsWith("//")) { + return "https:" + u; + } + if (u.startsWith("#")) { + // 保留页面内锚点:拼接到首页(也可拼接到当前帖子的 link,但此处无上下文) + return baseUrl + "/" + u; + } + try { + URI base = URI.create(ensureTrailingSlash(baseUrl)); + URI abs = base.resolve(u); + return abs.toString(); + } catch (Exception e) { + return url; + } + } - // 5) enclosure(首图,已绝对化) - String enclosure = firstImage(p.getContent()); - if (enclosure == null) { - // 如果 Markdown 没有图,尝试从渲染后的 HTML 再抓一次 - enclosure = firstImage(absHtml); - } - if (enclosure != null) { - enclosure = absolutifyUrl(enclosure, base); - } + private static String absolutifySrcset(String srcset, String baseUrl) { + if (srcset == null || srcset.isEmpty()) return srcset; + String[] parts = srcset.split(","); + List out = new ArrayList<>(parts.length); + for (String part : parts) { + String p = part.trim(); + if (p.isEmpty()) continue; + String[] seg = p.split("\\s+"); + String url = seg[0]; + String size = seg.length > 1 ? seg[1] : ""; + out.add(absolutifyUrl(url, baseUrl) + (size.isEmpty() ? "" : " " + size)); + } + return String.join(", ", out); + } - // 6) 构造优雅的附加区块(原文链接 + 精选评论),编入 - List topComments = commentService - .getCommentsForPost(p.getId(), CommentSort.MOST_INTERACTIONS); - topComments = topComments.subList(0, Math.min(10, topComments.size())); - String footerHtml = buildFooterHtml(base, link, topComments); + /* ===================== 摘要 & enclosure ===================== */ - sb.append(""); - elem(sb, "title", cdata(nullSafe(p.getTitle()))); - elem(sb, "link", link); - sb.append("").append(link).append(""); - elem(sb, "pubDate", toRfc1123Gmt(p.getCreatedAt().atZone(ZoneId.systemDefault()))); - // 摘要 - elem(sb, "description", cdata(plain)); - // 全文(HTML):正文 + 优雅的 Markdown 区块(已转 HTML) - sb.append(""); - // 首图 enclosure(图片类型) - if (enclosure != null) { - sb.append(""); - } - sb.append(""); - } + private static String textSummary(String html, int maxLen) { + if (html == null) return ""; + String text = Jsoup.parse(html).text().replaceAll("\\s+", " ").trim(); + if (text.length() <= maxLen) return text; + return text.substring(0, maxLen) + "…"; + } - sb.append(""); - return sb.toString(); + private String firstImage(String content) { + if (content == null) return null; + Matcher m = MD_IMAGE.matcher(content); + if (m.find()) return m.group(1); + m = HTML_IMAGE.matcher(content); + if (m.find()) return m.group(1); + // 再从纯 HTML 里解析一次(如果传入的是渲染后的) + try { + Document doc = Jsoup.parse(content); + Element img = doc.selectFirst("img[src]"); + if (img != null) return img.attr("src"); + } catch (Exception ignored) {} + return null; + } + + private static String getMimeType(String url) { + String lower = url == null ? "" : url.toLowerCase(Locale.ROOT); + if (lower.endsWith(".png")) return "image/png"; + if (lower.endsWith(".gif")) return "image/gif"; + if (lower.endsWith(".webp")) return "image/webp"; + if (lower.endsWith(".svg")) return "image/svg+xml"; + if (lower.endsWith(".avif")) return "image/avif"; + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; + // 默认兜底 + return "image/jpeg"; + } + + /* ===================== 附加区块(原文链接 + 精选评论) ===================== */ + + /** + * 将“原文链接 + 精选评论(最多 10 条)”以优雅的 Markdown 形式渲染为 HTML, + * 并做 sanitize + 绝对化,然后拼入 content:encoded 尾部。 + */ + private static String buildFooterHtml( + String baseUrl, + String originalLink, + List topComments + ) { + StringBuilder md = new StringBuilder(256); + + // 分割线 + md.append("\n\n---\n\n"); + + // 原文链接(强调 + 可点击) + md + .append("**原文链接:** ") + .append("[") + .append(originalLink) + .append("](") + .append(originalLink) + .append(")") + .append("\n\n"); + + // 精选评论(仅当有评论时展示) + if (topComments != null && !topComments.isEmpty()) { + md.append("### 精选评论(Top ").append(Math.min(10, topComments.size())).append(")\n\n"); + for (Comment c : topComments) { + String author = usernameOf(c); + String content = nullSafe(c.getContent()).replace("\r", ""); + // 使用引用样式展示,提升可读性 + md.append("> @").append(author).append(": ").append(content).append("\n\n"); + } } - /* ===================== Markdown → HTML ===================== */ + // 渲染为 HTML,并保持和正文一致的处理流程 + String html = renderMarkdown(md.toString()); + String safe = sanitizeHtml(html); + return absolutifyHtml(safe, baseUrl); + } - private static String renderMarkdown(String md) { - if (md == null || md.isEmpty()) return ""; - return MD_RENDERER.render(MD_PARSER.parse(md)); + private static String usernameOf(Comment c) { + if (c == null) return "匿名"; + try { + Object authorObj = c.getAuthor(); + if (authorObj == null) return "匿名"; + // 反射避免直接依赖实体字段名变化(也可直接强转到具体类型) + String username; + try { + username = (String) authorObj.getClass().getMethod("getUsername").invoke(authorObj); + } catch (Exception e) { + username = null; + } + if (username == null || username.isEmpty()) return "匿名"; + return username; + } catch (Exception ignored) { + return "匿名"; } + } - /* ===================== Sanitize & 绝对化 ===================== */ + /* ===================== 时间/字符串/XML ===================== */ - private static String sanitizeHtml(String html) { - if (html == null) return ""; - Safelist wl = Safelist.relaxed() - .addTags( - "pre","code","figure","figcaption","picture","source", - "table","thead","tbody","tr","th","td", - "h1","h2","h3","h4","h5","h6", - "hr","blockquote" - ) - .addAttributes("a", "href", "title", "target", "rel") - .addAttributes("img", "src", "alt", "title", "width", "height") - .addAttributes("source", "srcset", "type", "media") - .addAttributes("code", "class") - .addAttributes("pre", "class") - .addProtocols("a", "href", "http", "https", "mailto") - .addProtocols("img", "src", "http", "https", "data") - .addProtocols("source", "srcset", "http", "https"); - // 清除所有 on* 事件、style(避免阅读器环境差异) - return Jsoup.clean(html, wl); - } + private static String toRfc1123Gmt(ZonedDateTime zdt) { + return zdt.withZoneSameInstant(ZoneId.of("GMT")).format(RFC1123); + } - private static String absolutifyHtml(String html, String baseUrl) { - if (html == null || html.isEmpty()) return ""; - Document doc = Jsoup.parseBodyFragment(html, baseUrl); - // a[href] - for (Element a : doc.select("a[href]")) { - String href = a.attr("href"); - String abs = absolutifyUrl(href, baseUrl); - a.attr("href", abs); - // 强制外链安全属性 - a.attr("rel", "noopener noreferrer nofollow"); - a.attr("target", "_blank"); - } - // img[src] - for (Element img : doc.select("img[src]")) { - String src = img.attr("src"); - String abs = absolutifyUrl(src, baseUrl); - img.attr("src", abs); - } - // source[srcset] (picture/webp) - for (Element s : doc.select("source[srcset]")) { - String srcset = s.attr("srcset"); - s.attr("srcset", absolutifySrcset(srcset, baseUrl)); - } - return doc.body().html(); - } + private static String cdata(String s) { + if (s == null) return ""; + // 防止出现 "]]>" 终止标记破坏 CDATA + return "", "]]]]>") + "]]>"; + } - private static String absolutifyUrl(String url, String baseUrl) { - if (url == null || url.isEmpty()) return url; - String u = url.trim(); - if (u.startsWith("//")) { - return "https:" + u; - } - if (u.startsWith("#")) { - // 保留页面内锚点:拼接到首页(也可拼接到当前帖子的 link,但此处无上下文) - return baseUrl + "/" + u; - } - try { - URI base = URI.create(ensureTrailingSlash(baseUrl)); - URI abs = base.resolve(u); - return abs.toString(); - } catch (Exception e) { - return url; - } - } + private static void elem(StringBuilder sb, String name, String value) { + sb.append('<').append(name).append('>').append(value).append("'); + } - private static String absolutifySrcset(String srcset, String baseUrl) { - if (srcset == null || srcset.isEmpty()) return srcset; - String[] parts = srcset.split(","); - List out = new ArrayList<>(parts.length); - for (String part : parts) { - String p = part.trim(); - if (p.isEmpty()) continue; - String[] seg = p.split("\\s+"); - String url = seg[0]; - String size = seg.length > 1 ? seg[1] : ""; - out.add(absolutifyUrl(url, baseUrl) + (size.isEmpty() ? "" : " " + size)); - } - return String.join(", ", out); - } + private static String escapeXml(String s) { + if (s == null) return ""; + return s + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } - /* ===================== 摘要 & enclosure ===================== */ + private static String trimTrailingSlash(String s) { + if (s == null) return ""; + return s.endsWith("/") ? s.substring(0, s.length() - 1) : s; + } - private static String textSummary(String html, int maxLen) { - if (html == null) return ""; - String text = Jsoup.parse(html).text().replaceAll("\\s+", " ").trim(); - if (text.length() <= maxLen) return text; - return text.substring(0, maxLen) + "…"; - } + private static String ensureTrailingSlash(String s) { + if (s == null || s.isEmpty()) return "/"; + return s.endsWith("/") ? s : s + "/"; + } - private String firstImage(String content) { - if (content == null) return null; - Matcher m = MD_IMAGE.matcher(content); - if (m.find()) return m.group(1); - m = HTML_IMAGE.matcher(content); - if (m.find()) return m.group(1); - // 再从纯 HTML 里解析一次(如果传入的是渲染后的) - try { - Document doc = Jsoup.parse(content); - Element img = doc.selectFirst("img[src]"); - if (img != null) return img.attr("src"); - } catch (Exception ignored) {} - return null; - } - - private static String getMimeType(String url) { - String lower = url == null ? "" : url.toLowerCase(Locale.ROOT); - if (lower.endsWith(".png")) return "image/png"; - if (lower.endsWith(".gif")) return "image/gif"; - if (lower.endsWith(".webp")) return "image/webp"; - if (lower.endsWith(".svg")) return "image/svg+xml"; - if (lower.endsWith(".avif")) return "image/avif"; - if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; - // 默认兜底 - return "image/jpeg"; - } - - /* ===================== 附加区块(原文链接 + 精选评论) ===================== */ - - /** - * 将“原文链接 + 精选评论(最多 10 条)”以优雅的 Markdown 形式渲染为 HTML, - * 并做 sanitize + 绝对化,然后拼入 content:encoded 尾部。 - */ - private static String buildFooterHtml(String baseUrl, String originalLink, List topComments) { - StringBuilder md = new StringBuilder(256); - - // 分割线 - md.append("\n\n---\n\n"); - - // 原文链接(强调 + 可点击) - md.append("**原文链接:** ") - .append("[").append(originalLink).append("](").append(originalLink).append(")") - .append("\n\n"); - - // 精选评论(仅当有评论时展示) - if (topComments != null && !topComments.isEmpty()) { - md.append("### 精选评论(Top ").append(Math.min(10, topComments.size())).append(")\n\n"); - for (Comment c : topComments) { - String author = usernameOf(c); - String content = nullSafe(c.getContent()).replace("\r", ""); - // 使用引用样式展示,提升可读性 - md.append("> @").append(author).append(": ").append(content).append("\n\n"); - } - } - - // 渲染为 HTML,并保持和正文一致的处理流程 - String html = renderMarkdown(md.toString()); - String safe = sanitizeHtml(html); - return absolutifyHtml(safe, baseUrl); - } - - private static String usernameOf(Comment c) { - if (c == null) return "匿名"; - try { - Object authorObj = c.getAuthor(); - if (authorObj == null) return "匿名"; - // 反射避免直接依赖实体字段名变化(也可直接强转到具体类型) - String username; - try { - username = (String) authorObj.getClass().getMethod("getUsername").invoke(authorObj); - } catch (Exception e) { - username = null; - } - if (username == null || username.isEmpty()) return "匿名"; - return username; - } catch (Exception ignored) { - return "匿名"; - } - } - - /* ===================== 时间/字符串/XML ===================== */ - - private static String toRfc1123Gmt(ZonedDateTime zdt) { - return zdt.withZoneSameInstant(ZoneId.of("GMT")).format(RFC1123); - } - - private static String cdata(String s) { - if (s == null) return ""; - // 防止出现 "]]>" 终止标记破坏 CDATA - return "", "]]]]>") + "]]>"; - } - - private static void elem(StringBuilder sb, String name, String value) { - sb.append('<').append(name).append('>').append(value).append("'); - } - - private static String escapeXml(String s) { - if (s == null) return ""; - return s.replace("&", "&").replace("<", "<").replace(">", ">") - .replace("\"", """).replace("'", "'"); - } - - private static String trimTrailingSlash(String s) { - if (s == null) return ""; - return s.endsWith("/") ? s.substring(0, s.length() - 1) : s; - } - - private static String ensureTrailingSlash(String s) { - if (s == null || s.isEmpty()) return "/"; - return s.endsWith("/") ? s : s + "/"; - } - - private static String nullSafe(String s) { return s == null ? "" : s; } + private static String nullSafe(String s) { + return s == null ? "" : s; + } } diff --git a/backend/src/main/java/com/openisle/controller/SearchController.java b/backend/src/main/java/com/openisle/controller/SearchController.java index 034d293e8..1757e0fe7 100644 --- a/backend/src/main/java/com/openisle/controller/SearchController.java +++ b/backend/src/main/java/com/openisle/controller/SearchController.java @@ -6,84 +6,117 @@ import com.openisle.dto.UserDto; import com.openisle.mapper.PostMapper; import com.openisle.mapper.UserMapper; import com.openisle.service.SearchService; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; - import java.util.List; import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/search") @RequiredArgsConstructor public class SearchController { - private final SearchService searchService; - private final UserMapper userMapper; - private final PostMapper postMapper; - @GetMapping("/users") - @Operation(summary = "Search users", description = "Search users by keyword") - @ApiResponse(responseCode = "200", description = "List of users", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserDto.class)))) - public List searchUsers(@RequestParam String keyword) { - return searchService.searchUsers(keyword).stream() - .map(userMapper::toDto) - .collect(Collectors.toList()); - } + private final SearchService searchService; + private final UserMapper userMapper; + private final PostMapper postMapper; - @GetMapping("/posts") - @Operation(summary = "Search posts", description = "Search posts by keyword") - @ApiResponse(responseCode = "200", description = "List of posts", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class)))) - public List searchPosts(@RequestParam String keyword) { - return searchService.searchPosts(keyword).stream() - .map(postMapper::toSummaryDto) - .collect(Collectors.toList()); - } + @GetMapping("/users") + @Operation(summary = "Search users", description = "Search users by keyword") + @ApiResponse( + responseCode = "200", + description = "List of users", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserDto.class))) + ) + public List searchUsers(@RequestParam String keyword) { + return searchService + .searchUsers(keyword) + .stream() + .map(userMapper::toDto) + .collect(Collectors.toList()); + } - @GetMapping("/posts/content") - @Operation(summary = "Search posts by content", description = "Search posts by content keyword") - @ApiResponse(responseCode = "200", description = "List of posts", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class)))) - public List searchPostsByContent(@RequestParam String keyword) { - return searchService.searchPostsByContent(keyword).stream() - .map(postMapper::toSummaryDto) - .collect(Collectors.toList()); - } + @GetMapping("/posts") + @Operation(summary = "Search posts", description = "Search posts by keyword") + @ApiResponse( + responseCode = "200", + description = "List of posts", + content = @Content( + array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class)) + ) + ) + public List searchPosts(@RequestParam String keyword) { + return searchService + .searchPosts(keyword) + .stream() + .map(postMapper::toSummaryDto) + .collect(Collectors.toList()); + } - @GetMapping("/posts/title") - @Operation(summary = "Search posts by title", description = "Search posts by title keyword") - @ApiResponse(responseCode = "200", description = "List of posts", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class)))) - public List searchPostsByTitle(@RequestParam String keyword) { - return searchService.searchPostsByTitle(keyword).stream() - .map(postMapper::toSummaryDto) - .collect(Collectors.toList()); - } + @GetMapping("/posts/content") + @Operation(summary = "Search posts by content", description = "Search posts by content keyword") + @ApiResponse( + responseCode = "200", + description = "List of posts", + content = @Content( + array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class)) + ) + ) + public List searchPostsByContent(@RequestParam String keyword) { + return searchService + .searchPostsByContent(keyword) + .stream() + .map(postMapper::toSummaryDto) + .collect(Collectors.toList()); + } - @GetMapping("/global") - @Operation(summary = "Global search", description = "Search users and posts globally") - @ApiResponse(responseCode = "200", description = "Search results", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = SearchResultDto.class)))) - public List global(@RequestParam String keyword) { - return searchService.globalSearch(keyword).stream() - .map(r -> { - SearchResultDto dto = new SearchResultDto(); - dto.setType(r.type()); - dto.setId(r.id()); - dto.setText(r.text()); - dto.setSubText(r.subText()); - dto.setExtra(r.extra()); - dto.setPostId(r.postId()); - return dto; - }) - .collect(Collectors.toList()); - } + @GetMapping("/posts/title") + @Operation(summary = "Search posts by title", description = "Search posts by title keyword") + @ApiResponse( + responseCode = "200", + description = "List of posts", + content = @Content( + array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class)) + ) + ) + public List searchPostsByTitle(@RequestParam String keyword) { + return searchService + .searchPostsByTitle(keyword) + .stream() + .map(postMapper::toSummaryDto) + .collect(Collectors.toList()); + } + + @GetMapping("/global") + @Operation(summary = "Global search", description = "Search users and posts globally") + @ApiResponse( + responseCode = "200", + description = "Search results", + content = @Content( + array = @ArraySchema(schema = @Schema(implementation = SearchResultDto.class)) + ) + ) + public List global(@RequestParam String keyword) { + return searchService + .globalSearch(keyword) + .stream() + .map(r -> { + SearchResultDto dto = new SearchResultDto(); + dto.setType(r.type()); + dto.setId(r.id()); + dto.setText(r.text()); + dto.setSubText(r.subText()); + dto.setExtra(r.extra()); + dto.setPostId(r.postId()); + return dto; + }) + .collect(Collectors.toList()); + } } diff --git a/backend/src/main/java/com/openisle/controller/SitemapController.java b/backend/src/main/java/com/openisle/controller/SitemapController.java index a7c56ab8a..10e820e0d 100644 --- a/backend/src/main/java/com/openisle/controller/SitemapController.java +++ b/backend/src/main/java/com/openisle/controller/SitemapController.java @@ -3,6 +3,11 @@ package com.openisle.controller; import com.openisle.model.Post; import com.openisle.model.PostStatus; import com.openisle.repository.PostRepository; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; @@ -10,12 +15,6 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; - -import java.util.List; /** * Controller for dynamic sitemap generation. @@ -24,53 +23,47 @@ import java.util.List; @RequiredArgsConstructor @RequestMapping("/api") public class SitemapController { - private final PostRepository postRepository; - @Value("${app.website-url}") - private String websiteUrl; + private final PostRepository postRepository; - @GetMapping(value = "/sitemap.xml", produces = MediaType.APPLICATION_XML_VALUE) - @Operation(summary = "Sitemap", description = "Generate sitemap xml") - @ApiResponse(responseCode = "200", description = "Sitemap xml", - content = @Content(schema = @Schema(implementation = String.class))) - public ResponseEntity sitemap() { - List posts = postRepository.findByStatus(PostStatus.PUBLISHED); + @Value("${app.website-url}") + private String websiteUrl; - StringBuilder body = new StringBuilder(); - body.append("\n"); - body.append("\n"); + @GetMapping(value = "/sitemap.xml", produces = MediaType.APPLICATION_XML_VALUE) + @Operation(summary = "Sitemap", description = "Generate sitemap xml") + @ApiResponse( + responseCode = "200", + description = "Sitemap xml", + content = @Content(schema = @Schema(implementation = String.class)) + ) + public ResponseEntity sitemap() { + List posts = postRepository.findByStatus(PostStatus.PUBLISHED); - List staticRoutes = List.of( - "/", - "/about", - "/activities", - "/login", - "/signup" - ); + StringBuilder body = new StringBuilder(); + body.append("\n"); + body.append("\n"); - for (String path : staticRoutes) { - body.append(" ") - .append(websiteUrl) - .append(path) - .append("\n"); - } + List staticRoutes = List.of("/", "/about", "/activities", "/login", "/signup"); - for (Post p : posts) { - body.append(" \n") - .append(" ") - .append(websiteUrl) - .append("/posts/") - .append(p.getId()) - .append("\n") - .append(" ") - .append(p.getCreatedAt().toLocalDate()) - .append("\n") - .append(" \n"); - } - - body.append(""); - return ResponseEntity.ok() - .contentType(MediaType.APPLICATION_XML) - .body(body.toString()); + for (String path : staticRoutes) { + body.append(" ").append(websiteUrl).append(path).append("\n"); } + + for (Post p : posts) { + body + .append(" \n") + .append(" ") + .append(websiteUrl) + .append("/posts/") + .append(p.getId()) + .append("\n") + .append(" ") + .append(p.getCreatedAt().toLocalDate()) + .append("\n") + .append(" \n"); + } + + body.append(""); + return ResponseEntity.ok().contentType(MediaType.APPLICATION_XML).body(body.toString()); + } } diff --git a/backend/src/main/java/com/openisle/controller/StatController.java b/backend/src/main/java/com/openisle/controller/StatController.java index 2bcf9a1a8..a4af4fa39 100644 --- a/backend/src/main/java/com/openisle/controller/StatController.java +++ b/backend/src/main/java/com/openisle/controller/StatController.java @@ -1,105 +1,127 @@ package com.openisle.controller; -import com.openisle.service.UserVisitService; import com.openisle.service.StatService; +import com.openisle.service.UserVisitService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.ArraySchema; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; - -import java.time.LocalDate; -import java.util.List; -import java.util.Map; @RestController @RequestMapping("/api/stats") @RequiredArgsConstructor public class StatController { - private final UserVisitService userVisitService; - private final StatService statService; - @GetMapping("/dau") - @Operation(summary = "Daily active users", description = "Get daily active user count") - @ApiResponse(responseCode = "200", description = "DAU count", - content = @Content(schema = @Schema(implementation = java.util.Map.class))) - public Map dau(@RequestParam(value = "date", required = false) - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) { - long count = userVisitService.countDau(date); - return Map.of("dau", count); - } + private final UserVisitService userVisitService; + private final StatService statService; - @GetMapping("/dau-range") - @Operation(summary = "DAU range", description = "Get daily active users over range of days") - @ApiResponse(responseCode = "200", description = "DAU data", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = java.util.Map.class)))) - public List> dauRange(@RequestParam(value = "days", defaultValue = "30") int days) { - if (days < 1) days = 1; - LocalDate end = LocalDate.now(); - LocalDate start = end.minusDays(days - 1L); - var data = userVisitService.countDauRange(start, end); - return data.entrySet().stream() - .map(e -> Map.of( - "date", e.getKey().toString(), - "value", e.getValue() - )) - .toList(); - } + @GetMapping("/dau") + @Operation(summary = "Daily active users", description = "Get daily active user count") + @ApiResponse( + responseCode = "200", + description = "DAU count", + content = @Content(schema = @Schema(implementation = java.util.Map.class)) + ) + public Map dau( + @RequestParam(value = "date", required = false) @DateTimeFormat( + iso = DateTimeFormat.ISO.DATE + ) LocalDate date + ) { + long count = userVisitService.countDau(date); + return Map.of("dau", count); + } - @GetMapping("/new-users-range") - @Operation(summary = "New users range", description = "Get new users over range of days") - @ApiResponse(responseCode = "200", description = "New user data", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = java.util.Map.class)))) - public List> newUsersRange(@RequestParam(value = "days", defaultValue = "30") int days) { - if (days < 1) days = 1; - LocalDate end = LocalDate.now(); - LocalDate start = end.minusDays(days - 1L); - var data = statService.countNewUsersRange(start, end); - return data.entrySet().stream() - .map(e -> Map.of( - "date", e.getKey().toString(), - "value", e.getValue() - )) - .toList(); - } + @GetMapping("/dau-range") + @Operation(summary = "DAU range", description = "Get daily active users over range of days") + @ApiResponse( + responseCode = "200", + description = "DAU data", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = java.util.Map.class))) + ) + public List> dauRange( + @RequestParam(value = "days", defaultValue = "30") int days + ) { + if (days < 1) days = 1; + LocalDate end = LocalDate.now(); + LocalDate start = end.minusDays(days - 1L); + var data = userVisitService.countDauRange(start, end); + return data + .entrySet() + .stream() + .map(e -> Map.of("date", e.getKey().toString(), "value", e.getValue())) + .toList(); + } - @GetMapping("/posts-range") - @Operation(summary = "Posts range", description = "Get posts count over range of days") - @ApiResponse(responseCode = "200", description = "Post data", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = java.util.Map.class)))) - public List> postsRange(@RequestParam(value = "days", defaultValue = "30") int days) { - if (days < 1) days = 1; - LocalDate end = LocalDate.now(); - LocalDate start = end.minusDays(days - 1L); - var data = statService.countPostsRange(start, end); - return data.entrySet().stream() - .map(e -> Map.of( - "date", e.getKey().toString(), - "value", e.getValue() - )) - .toList(); - } + @GetMapping("/new-users-range") + @Operation(summary = "New users range", description = "Get new users over range of days") + @ApiResponse( + responseCode = "200", + description = "New user data", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = java.util.Map.class))) + ) + public List> newUsersRange( + @RequestParam(value = "days", defaultValue = "30") int days + ) { + if (days < 1) days = 1; + LocalDate end = LocalDate.now(); + LocalDate start = end.minusDays(days - 1L); + var data = statService.countNewUsersRange(start, end); + return data + .entrySet() + .stream() + .map(e -> Map.of("date", e.getKey().toString(), "value", e.getValue())) + .toList(); + } - @GetMapping("/comments-range") - @Operation(summary = "Comments range", description = "Get comments count over range of days") - @ApiResponse(responseCode = "200", description = "Comment data", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = java.util.Map.class)))) - public List> commentsRange(@RequestParam(value = "days", defaultValue = "30") int days) { - if (days < 1) days = 1; - LocalDate end = LocalDate.now(); - LocalDate start = end.minusDays(days - 1L); - var data = statService.countCommentsRange(start, end); - return data.entrySet().stream() - .map(e -> Map.of( - "date", e.getKey().toString(), - "value", e.getValue() - )) - .toList(); - } + @GetMapping("/posts-range") + @Operation(summary = "Posts range", description = "Get posts count over range of days") + @ApiResponse( + responseCode = "200", + description = "Post data", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = java.util.Map.class))) + ) + public List> postsRange( + @RequestParam(value = "days", defaultValue = "30") int days + ) { + if (days < 1) days = 1; + LocalDate end = LocalDate.now(); + LocalDate start = end.minusDays(days - 1L); + var data = statService.countPostsRange(start, end); + return data + .entrySet() + .stream() + .map(e -> Map.of("date", e.getKey().toString(), "value", e.getValue())) + .toList(); + } + + @GetMapping("/comments-range") + @Operation(summary = "Comments range", description = "Get comments count over range of days") + @ApiResponse( + responseCode = "200", + description = "Comment data", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = java.util.Map.class))) + ) + public List> commentsRange( + @RequestParam(value = "days", defaultValue = "30") int days + ) { + if (days < 1) days = 1; + LocalDate end = LocalDate.now(); + LocalDate start = end.minusDays(days - 1L); + var data = statService.countCommentsRange(start, end); + return data + .entrySet() + .stream() + .map(e -> Map.of("date", e.getKey().toString(), "value", e.getValue())) + .toList(); + } } diff --git a/backend/src/main/java/com/openisle/controller/SubscriptionController.java b/backend/src/main/java/com/openisle/controller/SubscriptionController.java index 3c5fe351d..823ab8260 100644 --- a/backend/src/main/java/com/openisle/controller/SubscriptionController.java +++ b/backend/src/main/java/com/openisle/controller/SubscriptionController.java @@ -1,65 +1,66 @@ package com.openisle.controller; import com.openisle.service.SubscriptionService; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.*; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; /** Endpoints for subscribing to posts, comments and users. */ @RestController @RequestMapping("/api/subscriptions") @RequiredArgsConstructor public class SubscriptionController { - private final SubscriptionService subscriptionService; - @PostMapping("/posts/{postId}") - @Operation(summary = "Subscribe post", description = "Subscribe to a post") - @ApiResponse(responseCode = "200", description = "Subscribed") - @SecurityRequirement(name = "JWT") - public void subscribePost(@PathVariable Long postId, Authentication auth) { - subscriptionService.subscribePost(auth.getName(), postId); - } + private final SubscriptionService subscriptionService; - @DeleteMapping("/posts/{postId}") - @Operation(summary = "Unsubscribe post", description = "Unsubscribe from a post") - @ApiResponse(responseCode = "200", description = "Unsubscribed") - @SecurityRequirement(name = "JWT") - public void unsubscribePost(@PathVariable Long postId, Authentication auth) { - subscriptionService.unsubscribePost(auth.getName(), postId); - } + @PostMapping("/posts/{postId}") + @Operation(summary = "Subscribe post", description = "Subscribe to a post") + @ApiResponse(responseCode = "200", description = "Subscribed") + @SecurityRequirement(name = "JWT") + public void subscribePost(@PathVariable Long postId, Authentication auth) { + subscriptionService.subscribePost(auth.getName(), postId); + } - @PostMapping("/comments/{commentId}") - @Operation(summary = "Subscribe comment", description = "Subscribe to a comment") - @ApiResponse(responseCode = "200", description = "Subscribed") - @SecurityRequirement(name = "JWT") - public void subscribeComment(@PathVariable Long commentId, Authentication auth) { - subscriptionService.subscribeComment(auth.getName(), commentId); - } + @DeleteMapping("/posts/{postId}") + @Operation(summary = "Unsubscribe post", description = "Unsubscribe from a post") + @ApiResponse(responseCode = "200", description = "Unsubscribed") + @SecurityRequirement(name = "JWT") + public void unsubscribePost(@PathVariable Long postId, Authentication auth) { + subscriptionService.unsubscribePost(auth.getName(), postId); + } - @DeleteMapping("/comments/{commentId}") - @Operation(summary = "Unsubscribe comment", description = "Unsubscribe from a comment") - @ApiResponse(responseCode = "200", description = "Unsubscribed") - @SecurityRequirement(name = "JWT") - public void unsubscribeComment(@PathVariable Long commentId, Authentication auth) { - subscriptionService.unsubscribeComment(auth.getName(), commentId); - } + @PostMapping("/comments/{commentId}") + @Operation(summary = "Subscribe comment", description = "Subscribe to a comment") + @ApiResponse(responseCode = "200", description = "Subscribed") + @SecurityRequirement(name = "JWT") + public void subscribeComment(@PathVariable Long commentId, Authentication auth) { + subscriptionService.subscribeComment(auth.getName(), commentId); + } - @PostMapping("/users/{username}") - @Operation(summary = "Subscribe user", description = "Subscribe to a user") - @ApiResponse(responseCode = "200", description = "Subscribed") - @SecurityRequirement(name = "JWT") - public void subscribeUser(@PathVariable String username, Authentication auth) { - subscriptionService.subscribeUser(auth.getName(), username); - } + @DeleteMapping("/comments/{commentId}") + @Operation(summary = "Unsubscribe comment", description = "Unsubscribe from a comment") + @ApiResponse(responseCode = "200", description = "Unsubscribed") + @SecurityRequirement(name = "JWT") + public void unsubscribeComment(@PathVariable Long commentId, Authentication auth) { + subscriptionService.unsubscribeComment(auth.getName(), commentId); + } - @DeleteMapping("/users/{username}") - @Operation(summary = "Unsubscribe user", description = "Unsubscribe from a user") - @ApiResponse(responseCode = "200", description = "Unsubscribed") - @SecurityRequirement(name = "JWT") - public void unsubscribeUser(@PathVariable String username, Authentication auth) { - subscriptionService.unsubscribeUser(auth.getName(), username); - } + @PostMapping("/users/{username}") + @Operation(summary = "Subscribe user", description = "Subscribe to a user") + @ApiResponse(responseCode = "200", description = "Subscribed") + @SecurityRequirement(name = "JWT") + public void subscribeUser(@PathVariable String username, Authentication auth) { + subscriptionService.subscribeUser(auth.getName(), username); + } + + @DeleteMapping("/users/{username}") + @Operation(summary = "Unsubscribe user", description = "Unsubscribe from a user") + @ApiResponse(responseCode = "200", description = "Unsubscribed") + @SecurityRequirement(name = "JWT") + public void unsubscribeUser(@PathVariable String username, Authentication auth) { + subscriptionService.unsubscribeUser(auth.getName(), username); + } } diff --git a/backend/src/main/java/com/openisle/controller/TagController.java b/backend/src/main/java/com/openisle/controller/TagController.java index b5a388849..51d9c54f1 100644 --- a/backend/src/main/java/com/openisle/controller/TagController.java +++ b/backend/src/main/java/com/openisle/controller/TagController.java @@ -11,109 +11,142 @@ import com.openisle.model.Tag; import com.openisle.repository.UserRepository; import com.openisle.service.PostService; import com.openisle.service.TagService; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; - import java.util.List; import java.util.Map; import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/tags") @RequiredArgsConstructor public class TagController { - private final TagService tagService; - private final PostService postService; - private final UserRepository userRepository; - private final PostMapper postMapper; - private final TagMapper tagMapper; - @PostMapping - @Operation(summary = "Create tag", description = "Create a new tag") - @ApiResponse(responseCode = "200", description = "Created tag", - content = @Content(schema = @Schema(implementation = TagDto.class))) - @SecurityRequirement(name = "JWT") - public TagDto create(@RequestBody TagRequest req, org.springframework.security.core.Authentication auth) { - boolean approved = true; - if (postService.getPublishMode() == PublishMode.REVIEW && auth != null) { - com.openisle.model.User user = userRepository.findByUsername(auth.getName()).orElseThrow(); - if (user.getRole() != Role.ADMIN) { - approved = false; - } - } - Tag tag = tagService.createTag( - req.getName(), - req.getDescription(), - req.getIcon(), - req.getSmallIcon(), - approved, - auth != null ? auth.getName() : null); - long count = postService.countPostsByTag(tag.getId()); - return tagMapper.toDto(tag, count); - } + private final TagService tagService; + private final PostService postService; + private final UserRepository userRepository; + private final PostMapper postMapper; + private final TagMapper tagMapper; - @PutMapping("/{id}") - @Operation(summary = "Update tag", description = "Update an existing tag") - @ApiResponse(responseCode = "200", description = "Updated tag", - content = @Content(schema = @Schema(implementation = TagDto.class))) - public TagDto update(@PathVariable Long id, @RequestBody TagRequest req) { - Tag tag = tagService.updateTag(id, req.getName(), req.getDescription(), req.getIcon(), req.getSmallIcon()); - long count = postService.countPostsByTag(tag.getId()); - return tagMapper.toDto(tag, count); + @PostMapping + @Operation(summary = "Create tag", description = "Create a new tag") + @ApiResponse( + responseCode = "200", + description = "Created tag", + content = @Content(schema = @Schema(implementation = TagDto.class)) + ) + @SecurityRequirement(name = "JWT") + public TagDto create( + @RequestBody TagRequest req, + org.springframework.security.core.Authentication auth + ) { + boolean approved = true; + if (postService.getPublishMode() == PublishMode.REVIEW && auth != null) { + com.openisle.model.User user = userRepository.findByUsername(auth.getName()).orElseThrow(); + if (user.getRole() != Role.ADMIN) { + approved = false; + } } + Tag tag = tagService.createTag( + req.getName(), + req.getDescription(), + req.getIcon(), + req.getSmallIcon(), + approved, + auth != null ? auth.getName() : null + ); + long count = postService.countPostsByTag(tag.getId()); + return tagMapper.toDto(tag, count); + } - @DeleteMapping("/{id}") - @Operation(summary = "Delete tag", description = "Delete a tag by id") - @ApiResponse(responseCode = "200", description = "Tag deleted") - public void delete(@PathVariable Long id) { - tagService.deleteTag(id); - } + @PutMapping("/{id}") + @Operation(summary = "Update tag", description = "Update an existing tag") + @ApiResponse( + responseCode = "200", + description = "Updated tag", + content = @Content(schema = @Schema(implementation = TagDto.class)) + ) + public TagDto update(@PathVariable Long id, @RequestBody TagRequest req) { + Tag tag = tagService.updateTag( + id, + req.getName(), + req.getDescription(), + req.getIcon(), + req.getSmallIcon() + ); + long count = postService.countPostsByTag(tag.getId()); + return tagMapper.toDto(tag, count); + } - @GetMapping - @Operation(summary = "List tags", description = "List tags with optional keyword") - @ApiResponse(responseCode = "200", description = "List of tags", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = TagDto.class)))) - public List list(@RequestParam(value = "keyword", required = false) String keyword, - @RequestParam(value = "limit", required = false) Integer limit) { - List tags = tagService.searchTags(keyword); - List tagIds = tags.stream().map(Tag::getId).toList(); - Map postCntByTagIds = postService.countPostsByTagIds(tagIds); - List dtos = tags.stream() - .map(t -> tagMapper.toDto(t, postCntByTagIds.getOrDefault(t.getId(), 0L))) - .sorted((a, b) -> Long.compare(b.getCount(), a.getCount())) - .collect(Collectors.toList()); - if (limit != null && limit > 0 && dtos.size() > limit) { - return dtos.subList(0, limit); - } - return dtos; - } + @DeleteMapping("/{id}") + @Operation(summary = "Delete tag", description = "Delete a tag by id") + @ApiResponse(responseCode = "200", description = "Tag deleted") + public void delete(@PathVariable Long id) { + tagService.deleteTag(id); + } - @GetMapping("/{id}") - @Operation(summary = "Get tag", description = "Get tag by id") - @ApiResponse(responseCode = "200", description = "Tag detail", - content = @Content(schema = @Schema(implementation = TagDto.class))) - public TagDto get(@PathVariable Long id) { - Tag tag = tagService.getTag(id); - long count = postService.countPostsByTag(tag.getId()); - return tagMapper.toDto(tag, count); + @GetMapping + @Operation(summary = "List tags", description = "List tags with optional keyword") + @ApiResponse( + responseCode = "200", + description = "List of tags", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = TagDto.class))) + ) + public List list( + @RequestParam(value = "keyword", required = false) String keyword, + @RequestParam(value = "limit", required = false) Integer limit + ) { + List tags = tagService.searchTags(keyword); + List tagIds = tags.stream().map(Tag::getId).toList(); + Map postCntByTagIds = postService.countPostsByTagIds(tagIds); + List dtos = tags + .stream() + .map(t -> tagMapper.toDto(t, postCntByTagIds.getOrDefault(t.getId(), 0L))) + .sorted((a, b) -> Long.compare(b.getCount(), a.getCount())) + .collect(Collectors.toList()); + if (limit != null && limit > 0 && dtos.size() > limit) { + return dtos.subList(0, limit); } + return dtos; + } - @GetMapping("/{id}/posts") - @Operation(summary = "List posts by tag", description = "Get posts with specific tag") - @ApiResponse(responseCode = "200", description = "List of posts", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class)))) - public List listPostsByTag(@PathVariable Long id, - @RequestParam(value = "page", required = false) Integer page, - @RequestParam(value = "pageSize", required = false) Integer pageSize) { - return postService.listPostsByTags(java.util.List.of(id), page, pageSize) - .stream() - .map(postMapper::toSummaryDto) - .collect(Collectors.toList()); - } + @GetMapping("/{id}") + @Operation(summary = "Get tag", description = "Get tag by id") + @ApiResponse( + responseCode = "200", + description = "Tag detail", + content = @Content(schema = @Schema(implementation = TagDto.class)) + ) + public TagDto get(@PathVariable Long id) { + Tag tag = tagService.getTag(id); + long count = postService.countPostsByTag(tag.getId()); + return tagMapper.toDto(tag, count); + } + + @GetMapping("/{id}/posts") + @Operation(summary = "List posts by tag", description = "Get posts with specific tag") + @ApiResponse( + responseCode = "200", + description = "List of posts", + content = @Content( + array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class)) + ) + ) + public List listPostsByTag( + @PathVariable Long id, + @RequestParam(value = "page", required = false) Integer page, + @RequestParam(value = "pageSize", required = false) Integer pageSize + ) { + return postService + .listPostsByTags(java.util.List.of(id), page, pageSize) + .stream() + .map(postMapper::toSummaryDto) + .collect(Collectors.toList()); + } } diff --git a/backend/src/main/java/com/openisle/controller/UploadController.java b/backend/src/main/java/com/openisle/controller/UploadController.java index fb2186596..b7e15a175 100644 --- a/backend/src/main/java/com/openisle/controller/UploadController.java +++ b/backend/src/main/java/com/openisle/controller/UploadController.java @@ -1,95 +1,99 @@ package com.openisle.controller; import com.openisle.service.ImageUploader; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.multipart.MultipartFile; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; - import java.io.ByteArrayInputStream; import java.io.IOException; import java.net.URI; import java.net.URL; import java.net.URLConnection; import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; @RestController @RequestMapping("/api/upload") @RequiredArgsConstructor public class UploadController { - private final ImageUploader imageUploader; - @Value("${app.upload.check-type:true}") - private boolean checkImageType; + private final ImageUploader imageUploader; - @Value("${app.upload.max-size:5242880}") - private long maxUploadSize; + @Value("${app.upload.check-type:true}") + private boolean checkImageType; - @PostMapping - @Operation(summary = "Upload file", description = "Upload image file") - @ApiResponse(responseCode = "200", description = "Upload result", - content = @Content(schema = @Schema(implementation = java.util.Map.class))) - public ResponseEntity upload(@RequestParam("file") MultipartFile file) { - if (checkImageType && (file.getContentType() == null || !file.getContentType().startsWith("image/"))) { - return ResponseEntity.badRequest().body(Map.of("code", 1, "msg", "File is not an image")); - } - if (file.getSize() > maxUploadSize) { - return ResponseEntity.badRequest().body(Map.of("code", 2, "msg", "File too large")); - } - String url; - try { - url = imageUploader.upload(file.getBytes(), file.getOriginalFilename()).join(); - } catch (IOException e) { - return ResponseEntity.internalServerError().body(Map.of("code", 3, "msg", "Upload failed")); - } - return ResponseEntity.ok(Map.of( - "code", 0, - "msg", "ok", - "data", Map.of("url", url) - )); + @Value("${app.upload.max-size:5242880}") + private long maxUploadSize; + + @PostMapping + @Operation(summary = "Upload file", description = "Upload image file") + @ApiResponse( + responseCode = "200", + description = "Upload result", + content = @Content(schema = @Schema(implementation = java.util.Map.class)) + ) + public ResponseEntity upload(@RequestParam("file") MultipartFile file) { + if ( + checkImageType && + (file.getContentType() == null || !file.getContentType().startsWith("image/")) + ) { + return ResponseEntity.badRequest().body(Map.of("code", 1, "msg", "File is not an image")); } - - @PostMapping("/url") - @Operation(summary = "Upload from URL", description = "Upload image from remote URL") - @ApiResponse(responseCode = "200", description = "Upload result", - content = @Content(schema = @Schema(implementation = java.util.Map.class))) - public ResponseEntity uploadUrl(@RequestBody Map body) { - String link = body.get("url"); - if (link == null || link.isBlank()) { - return ResponseEntity.badRequest().body(Map.of("code", 1, "msg", "Missing url")); - } - try { - URL u = URI.create(link).toURL(); - byte[] data = u.openStream().readAllBytes(); - if (data.length > maxUploadSize) { - return ResponseEntity.badRequest().body(Map.of("code", 2, "msg", "File too large")); - } - String filename = link.substring(link.lastIndexOf('/') + 1); - String contentType = URLConnection.guessContentTypeFromStream(new ByteArrayInputStream(data)); - if (checkImageType && (contentType == null || !contentType.startsWith("image/"))) { - return ResponseEntity.badRequest().body(Map.of("code", 1, "msg", "File is not an image")); - } - String url = imageUploader.upload(data, filename).join(); - return ResponseEntity.ok(Map.of( - "code", 0, - "msg", "ok", - "data", Map.of("url", url) - )); - } catch (Exception e) { - return ResponseEntity.internalServerError().body(Map.of("code", 3, "msg", "Upload failed")); - } + if (file.getSize() > maxUploadSize) { + return ResponseEntity.badRequest().body(Map.of("code", 2, "msg", "File too large")); } - - @GetMapping("/presign") - @Operation(summary = "Presign upload", description = "Get presigned upload URL") - @ApiResponse(responseCode = "200", description = "Presigned URL", - content = @Content(schema = @Schema(implementation = java.util.Map.class))) - public java.util.Map presign(@RequestParam("filename") String filename) { - return imageUploader.presignUpload(filename); + String url; + try { + url = imageUploader.upload(file.getBytes(), file.getOriginalFilename()).join(); + } catch (IOException e) { + return ResponseEntity.internalServerError().body(Map.of("code", 3, "msg", "Upload failed")); } + return ResponseEntity.ok(Map.of("code", 0, "msg", "ok", "data", Map.of("url", url))); + } + + @PostMapping("/url") + @Operation(summary = "Upload from URL", description = "Upload image from remote URL") + @ApiResponse( + responseCode = "200", + description = "Upload result", + content = @Content(schema = @Schema(implementation = java.util.Map.class)) + ) + public ResponseEntity uploadUrl(@RequestBody Map body) { + String link = body.get("url"); + if (link == null || link.isBlank()) { + return ResponseEntity.badRequest().body(Map.of("code", 1, "msg", "Missing url")); + } + try { + URL u = URI.create(link).toURL(); + byte[] data = u.openStream().readAllBytes(); + if (data.length > maxUploadSize) { + return ResponseEntity.badRequest().body(Map.of("code", 2, "msg", "File too large")); + } + String filename = link.substring(link.lastIndexOf('/') + 1); + String contentType = URLConnection.guessContentTypeFromStream(new ByteArrayInputStream(data)); + if (checkImageType && (contentType == null || !contentType.startsWith("image/"))) { + return ResponseEntity.badRequest().body(Map.of("code", 1, "msg", "File is not an image")); + } + String url = imageUploader.upload(data, filename).join(); + return ResponseEntity.ok(Map.of("code", 0, "msg", "ok", "data", Map.of("url", url))); + } catch (Exception e) { + return ResponseEntity.internalServerError().body(Map.of("code", 3, "msg", "Upload failed")); + } + } + + @GetMapping("/presign") + @Operation(summary = "Presign upload", description = "Get presigned upload URL") + @ApiResponse( + responseCode = "200", + description = "Presigned URL", + content = @Content(schema = @Schema(implementation = java.util.Map.class)) + ) + public java.util.Map presign(@RequestParam("filename") String filename) { + return imageUploader.presignUpload(filename); + } } diff --git a/backend/src/main/java/com/openisle/controller/UserController.java b/backend/src/main/java/com/openisle/controller/UserController.java index f43e39832..cc4e239e8 100644 --- a/backend/src/main/java/com/openisle/controller/UserController.java +++ b/backend/src/main/java/com/openisle/controller/UserController.java @@ -12,6 +12,8 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import java.io.IOException; +import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; @@ -19,257 +21,359 @@ import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; -import java.io.IOException; -import java.util.Map; - @RestController @RequestMapping("/api/users") @RequiredArgsConstructor public class UserController { - private final UserService userService; - private final ImageUploader imageUploader; - private final PostService postService; - private final CommentService commentService; - private final ReactionService reactionService; - private final TagService tagService; - private final SubscriptionService subscriptionService; - private final LevelService levelService; - private final JwtService jwtService; - private final UserMapper userMapper; - private final TagMapper tagMapper; - @Value("${app.upload.check-type:true}") - private boolean checkImageType; + private final UserService userService; + private final ImageUploader imageUploader; + private final PostService postService; + private final CommentService commentService; + private final ReactionService reactionService; + private final TagService tagService; + private final SubscriptionService subscriptionService; + private final LevelService levelService; + private final JwtService jwtService; + private final UserMapper userMapper; + private final TagMapper tagMapper; - @Value("${app.upload.max-size:5242880}") - private long maxUploadSize; + @Value("${app.upload.check-type:true}") + private boolean checkImageType; - @Value("${app.user.posts-limit:10}") - private int defaultPostsLimit; + @Value("${app.upload.max-size:5242880}") + private long maxUploadSize; - @Value("${app.user.replies-limit:50}") - private int defaultRepliesLimit; + @Value("${app.user.posts-limit:10}") + private int defaultPostsLimit; - @Value("${app.user.tags-limit:50}") - private int defaultTagsLimit; + @Value("${app.user.replies-limit:50}") + private int defaultRepliesLimit; - @GetMapping("/me") - @SecurityRequirement(name = "JWT") - @Operation(summary = "Current user", description = "Get current authenticated user information") - @ApiResponse(responseCode = "200", description = "User detail", - content = @Content(schema = @Schema(implementation = UserDto.class))) - public ResponseEntity me(Authentication auth) { - User user = userService.findByUsername(auth.getName()).orElseThrow(); - return ResponseEntity.ok(userMapper.toDto(user, auth)); + @Value("${app.user.tags-limit:50}") + private int defaultTagsLimit; + + @GetMapping("/me") + @SecurityRequirement(name = "JWT") + @Operation(summary = "Current user", description = "Get current authenticated user information") + @ApiResponse( + responseCode = "200", + description = "User detail", + content = @Content(schema = @Schema(implementation = UserDto.class)) + ) + public ResponseEntity me(Authentication auth) { + User user = userService.findByUsername(auth.getName()).orElseThrow(); + return ResponseEntity.ok(userMapper.toDto(user, auth)); + } + + @PostMapping("/me/avatar") + @SecurityRequirement(name = "JWT") + @Operation(summary = "Upload avatar", description = "Upload avatar for current user") + @ApiResponse( + responseCode = "200", + description = "Upload result", + content = @Content(schema = @Schema(implementation = Map.class)) + ) + public ResponseEntity uploadAvatar( + @RequestParam("file") MultipartFile file, + Authentication auth + ) { + if ( + checkImageType && + (file.getContentType() == null || !file.getContentType().startsWith("image/")) + ) { + return ResponseEntity.badRequest().body(Map.of("error", "File is not an image")); } - - @PostMapping("/me/avatar") - @SecurityRequirement(name = "JWT") - @Operation(summary = "Upload avatar", description = "Upload avatar for current user") - @ApiResponse(responseCode = "200", description = "Upload result", - content = @Content(schema = @Schema(implementation = Map.class))) - public ResponseEntity uploadAvatar(@RequestParam("file") MultipartFile file, - Authentication auth) { - if (checkImageType && (file.getContentType() == null || !file.getContentType().startsWith("image/"))) { - return ResponseEntity.badRequest().body(Map.of("error", "File is not an image")); - } - if (file.getSize() > maxUploadSize) { - return ResponseEntity.badRequest().body(Map.of("error", "File too large")); - } - String url = null; - try { - url = imageUploader.upload(file.getBytes(), file.getOriginalFilename()).join(); - } catch (IOException e) { - return ResponseEntity.internalServerError().body(Map.of("url", url)); - } - userService.updateAvatar(auth.getName(), url); - return ResponseEntity.ok(Map.of("url", url)); + if (file.getSize() > maxUploadSize) { + return ResponseEntity.badRequest().body(Map.of("error", "File too large")); } - - @PutMapping("/me") - @SecurityRequirement(name = "JWT") - @Operation(summary = "Update profile", description = "Update current user's profile") - @ApiResponse(responseCode = "200", description = "Updated profile", - content = @Content(schema = @Schema(implementation = Map.class))) - public ResponseEntity updateProfile(@RequestBody UpdateProfileDto dto, - Authentication auth) { - User user = userService.updateProfile(auth.getName(), dto.getUsername(), dto.getIntroduction()); - return ResponseEntity.ok(Map.of( - "token", jwtService.generateToken(user.getUsername()), - "user", userMapper.toDto(user, auth) - )); + String url = null; + try { + url = imageUploader.upload(file.getBytes(), file.getOriginalFilename()).join(); + } catch (IOException e) { + return ResponseEntity.internalServerError().body(Map.of("url", url)); } + userService.updateAvatar(auth.getName(), url); + return ResponseEntity.ok(Map.of("url", url)); + } - // 这个方法似乎没有使用? - @PostMapping("/me/signin") - @SecurityRequirement(name = "JWT") - @Operation(summary = "Daily sign in", description = "Sign in to receive rewards") - @ApiResponse(responseCode = "200", description = "Sign in reward", - content = @Content(schema = @Schema(implementation = Map.class))) - public Map signIn(Authentication auth) { - int reward = levelService.awardForSignin(auth.getName()); - return Map.of("reward", reward); - } + @PutMapping("/me") + @SecurityRequirement(name = "JWT") + @Operation(summary = "Update profile", description = "Update current user's profile") + @ApiResponse( + responseCode = "200", + description = "Updated profile", + content = @Content(schema = @Schema(implementation = Map.class)) + ) + public ResponseEntity updateProfile(@RequestBody UpdateProfileDto dto, Authentication auth) { + User user = userService.updateProfile(auth.getName(), dto.getUsername(), dto.getIntroduction()); + return ResponseEntity.ok( + Map.of( + "token", + jwtService.generateToken(user.getUsername()), + "user", + userMapper.toDto(user, auth) + ) + ); + } - @GetMapping("/{identifier}") - @Operation(summary = "Get user", description = "Get user by identifier") - @ApiResponse(responseCode = "200", description = "User detail", - content = @Content(schema = @Schema(implementation = UserDto.class))) - public ResponseEntity getUser(@PathVariable("identifier") String identifier, - Authentication auth) { - User user = userService.findByIdentifier(identifier).orElseThrow(() -> new NotFoundException("User not found")); - return ResponseEntity.ok(userMapper.toDto(user, auth)); - } + // 这个方法似乎没有使用? + @PostMapping("/me/signin") + @SecurityRequirement(name = "JWT") + @Operation(summary = "Daily sign in", description = "Sign in to receive rewards") + @ApiResponse( + responseCode = "200", + description = "Sign in reward", + content = @Content(schema = @Schema(implementation = Map.class)) + ) + public Map signIn(Authentication auth) { + int reward = levelService.awardForSignin(auth.getName()); + return Map.of("reward", reward); + } - @GetMapping("/{identifier}/posts") - @Operation(summary = "User posts", description = "Get recent posts by user") - @ApiResponse(responseCode = "200", description = "User posts", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostMetaDto.class)))) - public java.util.List userPosts(@PathVariable("identifier") String identifier, - @RequestParam(value = "limit", required = false) Integer limit) { - int l = limit != null ? limit : defaultPostsLimit; - User user = userService.findByIdentifier(identifier).orElseThrow(); - return postService.getRecentPostsByUser(user.getUsername(), l).stream() - .map(userMapper::toMetaDto) - .collect(java.util.stream.Collectors.toList()); - } + @GetMapping("/{identifier}") + @Operation(summary = "Get user", description = "Get user by identifier") + @ApiResponse( + responseCode = "200", + description = "User detail", + content = @Content(schema = @Schema(implementation = UserDto.class)) + ) + public ResponseEntity getUser( + @PathVariable("identifier") String identifier, + Authentication auth + ) { + User user = userService + .findByIdentifier(identifier) + .orElseThrow(() -> new NotFoundException("User not found")); + return ResponseEntity.ok(userMapper.toDto(user, auth)); + } - @GetMapping("/{identifier}/subscribed-posts") - @Operation(summary = "Subscribed posts", description = "Get posts the user subscribed to") - @ApiResponse(responseCode = "200", description = "Subscribed posts", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostMetaDto.class)))) - public java.util.List subscribedPosts(@PathVariable("identifier") String identifier, - @RequestParam(value = "limit", required = false) Integer limit) { - int l = limit != null ? limit : defaultPostsLimit; - User user = userService.findByIdentifier(identifier).orElseThrow(); - return subscriptionService.getSubscribedPosts(user.getUsername()).stream() - .limit(l) - .map(userMapper::toMetaDto) - .collect(java.util.stream.Collectors.toList()); - } + @GetMapping("/{identifier}/posts") + @Operation(summary = "User posts", description = "Get recent posts by user") + @ApiResponse( + responseCode = "200", + description = "User posts", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostMetaDto.class))) + ) + public java.util.List userPosts( + @PathVariable("identifier") String identifier, + @RequestParam(value = "limit", required = false) Integer limit + ) { + int l = limit != null ? limit : defaultPostsLimit; + User user = userService.findByIdentifier(identifier).orElseThrow(); + return postService + .getRecentPostsByUser(user.getUsername(), l) + .stream() + .map(userMapper::toMetaDto) + .collect(java.util.stream.Collectors.toList()); + } - @GetMapping("/{identifier}/replies") - @Operation(summary = "User replies", description = "Get recent replies by user") - @ApiResponse(responseCode = "200", description = "User replies", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = CommentInfoDto.class)))) - public java.util.List userReplies(@PathVariable("identifier") String identifier, - @RequestParam(value = "limit", required = false) Integer limit) { - int l = limit != null ? limit : defaultRepliesLimit; - User user = userService.findByIdentifier(identifier).orElseThrow(); - return commentService.getRecentCommentsByUser(user.getUsername(), l).stream() - .map(userMapper::toCommentInfoDto) - .collect(java.util.stream.Collectors.toList()); - } + @GetMapping("/{identifier}/subscribed-posts") + @Operation(summary = "Subscribed posts", description = "Get posts the user subscribed to") + @ApiResponse( + responseCode = "200", + description = "Subscribed posts", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostMetaDto.class))) + ) + public java.util.List subscribedPosts( + @PathVariable("identifier") String identifier, + @RequestParam(value = "limit", required = false) Integer limit + ) { + int l = limit != null ? limit : defaultPostsLimit; + User user = userService.findByIdentifier(identifier).orElseThrow(); + return subscriptionService + .getSubscribedPosts(user.getUsername()) + .stream() + .limit(l) + .map(userMapper::toMetaDto) + .collect(java.util.stream.Collectors.toList()); + } - @GetMapping("/{identifier}/hot-posts") - @Operation(summary = "User hot posts", description = "Get most reacted posts by user") - @ApiResponse(responseCode = "200", description = "Hot posts", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostMetaDto.class)))) - public java.util.List hotPosts(@PathVariable("identifier") String identifier, - @RequestParam(value = "limit", required = false) Integer limit) { - int l = limit != null ? limit : 10; - User user = userService.findByIdentifier(identifier).orElseThrow(); - java.util.List ids = reactionService.topPostIds(user.getUsername(), l); - return postService.getPostsByIds(ids).stream() - .map(userMapper::toMetaDto) - .collect(java.util.stream.Collectors.toList()); - } + @GetMapping("/{identifier}/replies") + @Operation(summary = "User replies", description = "Get recent replies by user") + @ApiResponse( + responseCode = "200", + description = "User replies", + content = @Content( + array = @ArraySchema(schema = @Schema(implementation = CommentInfoDto.class)) + ) + ) + public java.util.List userReplies( + @PathVariable("identifier") String identifier, + @RequestParam(value = "limit", required = false) Integer limit + ) { + int l = limit != null ? limit : defaultRepliesLimit; + User user = userService.findByIdentifier(identifier).orElseThrow(); + return commentService + .getRecentCommentsByUser(user.getUsername(), l) + .stream() + .map(userMapper::toCommentInfoDto) + .collect(java.util.stream.Collectors.toList()); + } - @GetMapping("/{identifier}/hot-replies") - @Operation(summary = "User hot replies", description = "Get most reacted replies by user") - @ApiResponse(responseCode = "200", description = "Hot replies", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = CommentInfoDto.class)))) - public java.util.List hotReplies(@PathVariable("identifier") String identifier, - @RequestParam(value = "limit", required = false) Integer limit) { - int l = limit != null ? limit : 10; - User user = userService.findByIdentifier(identifier).orElseThrow(); - java.util.List ids = reactionService.topCommentIds(user.getUsername(), l); - return commentService.getCommentsByIds(ids).stream() - .map(userMapper::toCommentInfoDto) - .collect(java.util.stream.Collectors.toList()); - } + @GetMapping("/{identifier}/hot-posts") + @Operation(summary = "User hot posts", description = "Get most reacted posts by user") + @ApiResponse( + responseCode = "200", + description = "Hot posts", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostMetaDto.class))) + ) + public java.util.List hotPosts( + @PathVariable("identifier") String identifier, + @RequestParam(value = "limit", required = false) Integer limit + ) { + int l = limit != null ? limit : 10; + User user = userService.findByIdentifier(identifier).orElseThrow(); + java.util.List ids = reactionService.topPostIds(user.getUsername(), l); + return postService + .getPostsByIds(ids) + .stream() + .map(userMapper::toMetaDto) + .collect(java.util.stream.Collectors.toList()); + } - @GetMapping("/{identifier}/hot-tags") - @Operation(summary = "User hot tags", description = "Get tags frequently used by user") - @ApiResponse(responseCode = "200", description = "Hot tags", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = TagDto.class)))) - public java.util.List hotTags(@PathVariable("identifier") String identifier, - @RequestParam(value = "limit", required = false) Integer limit) { - int l = limit != null ? limit : 10; - User user = userService.findByIdentifier(identifier).orElseThrow(); - return tagService.getTagsByUser(user.getUsername()).stream() - .map(t -> tagMapper.toDto(t, postService.countPostsByTag(t.getId()))) - .sorted((a, b) -> Long.compare(b.getCount(), a.getCount())) - .limit(l) - .collect(java.util.stream.Collectors.toList()); - } + @GetMapping("/{identifier}/hot-replies") + @Operation(summary = "User hot replies", description = "Get most reacted replies by user") + @ApiResponse( + responseCode = "200", + description = "Hot replies", + content = @Content( + array = @ArraySchema(schema = @Schema(implementation = CommentInfoDto.class)) + ) + ) + public java.util.List hotReplies( + @PathVariable("identifier") String identifier, + @RequestParam(value = "limit", required = false) Integer limit + ) { + int l = limit != null ? limit : 10; + User user = userService.findByIdentifier(identifier).orElseThrow(); + java.util.List ids = reactionService.topCommentIds(user.getUsername(), l); + return commentService + .getCommentsByIds(ids) + .stream() + .map(userMapper::toCommentInfoDto) + .collect(java.util.stream.Collectors.toList()); + } - @GetMapping("/{identifier}/tags") - @Operation(summary = "User tags", description = "Get recent tags used by user") - @ApiResponse(responseCode = "200", description = "User tags", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = TagDto.class)))) - public java.util.List userTags(@PathVariable("identifier") String identifier, - @RequestParam(value = "limit", required = false) Integer limit) { - int l = limit != null ? limit : defaultTagsLimit; - User user = userService.findByIdentifier(identifier).orElseThrow(); - return tagService.getRecentTagsByUser(user.getUsername(), l).stream() - .map(t -> tagMapper.toDto(t, postService.countPostsByTag(t.getId()))) - .collect(java.util.stream.Collectors.toList()); - } + @GetMapping("/{identifier}/hot-tags") + @Operation(summary = "User hot tags", description = "Get tags frequently used by user") + @ApiResponse( + responseCode = "200", + description = "Hot tags", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = TagDto.class))) + ) + public java.util.List hotTags( + @PathVariable("identifier") String identifier, + @RequestParam(value = "limit", required = false) Integer limit + ) { + int l = limit != null ? limit : 10; + User user = userService.findByIdentifier(identifier).orElseThrow(); + return tagService + .getTagsByUser(user.getUsername()) + .stream() + .map(t -> tagMapper.toDto(t, postService.countPostsByTag(t.getId()))) + .sorted((a, b) -> Long.compare(b.getCount(), a.getCount())) + .limit(l) + .collect(java.util.stream.Collectors.toList()); + } - @GetMapping("/{identifier}/following") - @Operation(summary = "Following users", description = "Get users that this user is following") - @ApiResponse(responseCode = "200", description = "Following list", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserDto.class)))) - public java.util.List following(@PathVariable("identifier") String identifier) { - User user = userService.findByIdentifier(identifier).orElseThrow(); - return subscriptionService.getSubscribedUsers(user.getUsername()).stream() - .map(userMapper::toDto) - .collect(java.util.stream.Collectors.toList()); - } + @GetMapping("/{identifier}/tags") + @Operation(summary = "User tags", description = "Get recent tags used by user") + @ApiResponse( + responseCode = "200", + description = "User tags", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = TagDto.class))) + ) + public java.util.List userTags( + @PathVariable("identifier") String identifier, + @RequestParam(value = "limit", required = false) Integer limit + ) { + int l = limit != null ? limit : defaultTagsLimit; + User user = userService.findByIdentifier(identifier).orElseThrow(); + return tagService + .getRecentTagsByUser(user.getUsername(), l) + .stream() + .map(t -> tagMapper.toDto(t, postService.countPostsByTag(t.getId()))) + .collect(java.util.stream.Collectors.toList()); + } - @GetMapping("/{identifier}/followers") - @Operation(summary = "Followers", description = "Get followers of this user") - @ApiResponse(responseCode = "200", description = "Followers list", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserDto.class)))) - public java.util.List followers(@PathVariable("identifier") String identifier) { - User user = userService.findByIdentifier(identifier).orElseThrow(); - return subscriptionService.getSubscribers(user.getUsername()).stream() - .map(userMapper::toDto) - .collect(java.util.stream.Collectors.toList()); - } + @GetMapping("/{identifier}/following") + @Operation(summary = "Following users", description = "Get users that this user is following") + @ApiResponse( + responseCode = "200", + description = "Following list", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserDto.class))) + ) + public java.util.List following(@PathVariable("identifier") String identifier) { + User user = userService.findByIdentifier(identifier).orElseThrow(); + return subscriptionService + .getSubscribedUsers(user.getUsername()) + .stream() + .map(userMapper::toDto) + .collect(java.util.stream.Collectors.toList()); + } - @GetMapping("/admins") - @Operation(summary = "Admin users", description = "List administrator users") - @ApiResponse(responseCode = "200", description = "Admin users", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserDto.class)))) - public java.util.List admins() { - return userService.getAdmins().stream() - .map(userMapper::toDto) - .collect(java.util.stream.Collectors.toList()); - } + @GetMapping("/{identifier}/followers") + @Operation(summary = "Followers", description = "Get followers of this user") + @ApiResponse( + responseCode = "200", + description = "Followers list", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserDto.class))) + ) + public java.util.List followers(@PathVariable("identifier") String identifier) { + User user = userService.findByIdentifier(identifier).orElseThrow(); + return subscriptionService + .getSubscribers(user.getUsername()) + .stream() + .map(userMapper::toDto) + .collect(java.util.stream.Collectors.toList()); + } - @GetMapping("/{identifier}/all") - @Operation(summary = "User aggregate", description = "Get aggregate information for user") - @ApiResponse(responseCode = "200", description = "User aggregate", - content = @Content(schema = @Schema(implementation = UserAggregateDto.class))) - public ResponseEntity userAggregate(@PathVariable("identifier") String identifier, - @RequestParam(value = "postsLimit", required = false) Integer postsLimit, - @RequestParam(value = "repliesLimit", required = false) Integer repliesLimit, - Authentication auth) { - User user = userService.findByIdentifier(identifier).orElseThrow(); - int pLimit = postsLimit != null ? postsLimit : defaultPostsLimit; - int rLimit = repliesLimit != null ? repliesLimit : defaultRepliesLimit; - java.util.List posts = postService.getRecentPostsByUser(user.getUsername(), pLimit).stream() - .map(userMapper::toMetaDto) - .collect(java.util.stream.Collectors.toList()); - java.util.List replies = commentService.getRecentCommentsByUser(user.getUsername(), rLimit).stream() - .map(userMapper::toCommentInfoDto) - .collect(java.util.stream.Collectors.toList()); - UserAggregateDto dto = new UserAggregateDto(); - dto.setUser(userMapper.toDto(user, auth)); - dto.setPosts(posts); - dto.setReplies(replies); - return ResponseEntity.ok(dto); - } + @GetMapping("/admins") + @Operation(summary = "Admin users", description = "List administrator users") + @ApiResponse( + responseCode = "200", + description = "Admin users", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserDto.class))) + ) + public java.util.List admins() { + return userService + .getAdmins() + .stream() + .map(userMapper::toDto) + .collect(java.util.stream.Collectors.toList()); + } + + @GetMapping("/{identifier}/all") + @Operation(summary = "User aggregate", description = "Get aggregate information for user") + @ApiResponse( + responseCode = "200", + description = "User aggregate", + content = @Content(schema = @Schema(implementation = UserAggregateDto.class)) + ) + public ResponseEntity userAggregate( + @PathVariable("identifier") String identifier, + @RequestParam(value = "postsLimit", required = false) Integer postsLimit, + @RequestParam(value = "repliesLimit", required = false) Integer repliesLimit, + Authentication auth + ) { + User user = userService.findByIdentifier(identifier).orElseThrow(); + int pLimit = postsLimit != null ? postsLimit : defaultPostsLimit; + int rLimit = repliesLimit != null ? repliesLimit : defaultRepliesLimit; + java.util.List posts = postService + .getRecentPostsByUser(user.getUsername(), pLimit) + .stream() + .map(userMapper::toMetaDto) + .collect(java.util.stream.Collectors.toList()); + java.util.List replies = commentService + .getRecentCommentsByUser(user.getUsername(), rLimit) + .stream() + .map(userMapper::toCommentInfoDto) + .collect(java.util.stream.Collectors.toList()); + UserAggregateDto dto = new UserAggregateDto(); + dto.setUser(userMapper.toDto(user, auth)); + dto.setPosts(posts); + dto.setReplies(replies); + return ResponseEntity.ok(dto); + } } diff --git a/backend/src/main/java/com/openisle/dto/ActivityDto.java b/backend/src/main/java/com/openisle/dto/ActivityDto.java index 75e71ee17..d95216a9a 100644 --- a/backend/src/main/java/com/openisle/dto/ActivityDto.java +++ b/backend/src/main/java/com/openisle/dto/ActivityDto.java @@ -1,21 +1,21 @@ package com.openisle.dto; import com.openisle.model.ActivityType; -import lombok.Data; - import java.time.LocalDateTime; +import lombok.Data; /** * DTO representing an activity without participant details. */ @Data public class ActivityDto { - private Long id; - private String title; - private String icon; - private String content; - private LocalDateTime startTime; - private LocalDateTime endTime; - private ActivityType type; - private boolean ended; + + private Long id; + private String title; + private String icon; + private String content; + private LocalDateTime startTime; + private LocalDateTime endTime; + private ActivityType type; + private boolean ended; } diff --git a/backend/src/main/java/com/openisle/dto/AuthorDto.java b/backend/src/main/java/com/openisle/dto/AuthorDto.java index 4f0bc4887..6a2512fff 100644 --- a/backend/src/main/java/com/openisle/dto/AuthorDto.java +++ b/backend/src/main/java/com/openisle/dto/AuthorDto.java @@ -1,16 +1,16 @@ package com.openisle.dto; -import lombok.Data; import com.openisle.model.MedalType; +import lombok.Data; /** * DTO representing a post or comment author. */ @Data public class AuthorDto { - private Long id; - private String username; - private String avatar; - private MedalType displayMedal; -} + private Long id; + private String username; + private String avatar; + private MedalType displayMedal; +} diff --git a/backend/src/main/java/com/openisle/dto/CategoryDto.java b/backend/src/main/java/com/openisle/dto/CategoryDto.java index 708fefa24..02c4923db 100644 --- a/backend/src/main/java/com/openisle/dto/CategoryDto.java +++ b/backend/src/main/java/com/openisle/dto/CategoryDto.java @@ -7,11 +7,11 @@ import lombok.Data; */ @Data public class CategoryDto { - private Long id; - private String name; - private String description; - private String icon; - private String smallIcon; - private Long count; -} + private Long id; + private String name; + private String description; + private String icon; + private String smallIcon; + private Long count; +} diff --git a/backend/src/main/java/com/openisle/dto/CategoryRequest.java b/backend/src/main/java/com/openisle/dto/CategoryRequest.java index 44deae3a7..6fdcebff1 100644 --- a/backend/src/main/java/com/openisle/dto/CategoryRequest.java +++ b/backend/src/main/java/com/openisle/dto/CategoryRequest.java @@ -5,8 +5,9 @@ import lombok.Data; /** Request body for creating or updating a category. */ @Data public class CategoryRequest { - private String name; - private String description; - private String icon; - private String smallIcon; + + private String name; + private String description; + private String icon; + private String smallIcon; } diff --git a/backend/src/main/java/com/openisle/dto/ChannelDto.java b/backend/src/main/java/com/openisle/dto/ChannelDto.java index 5c3d20d7f..aaefd9708 100644 --- a/backend/src/main/java/com/openisle/dto/ChannelDto.java +++ b/backend/src/main/java/com/openisle/dto/ChannelDto.java @@ -6,12 +6,13 @@ import lombok.Setter; @Getter @Setter public class ChannelDto { - private Long id; - private String name; - private String description; - private String avatar; - private MessageDto lastMessage; - private long memberCount; - private boolean joined; - private long unreadCount; + + private Long id; + private String name; + private String description; + private String avatar; + private MessageDto lastMessage; + private long memberCount; + private boolean joined; + private long unreadCount; } diff --git a/backend/src/main/java/com/openisle/dto/CommentDto.java b/backend/src/main/java/com/openisle/dto/CommentDto.java index 4442d35ca..6fe0a2052 100644 --- a/backend/src/main/java/com/openisle/dto/CommentDto.java +++ b/backend/src/main/java/com/openisle/dto/CommentDto.java @@ -1,23 +1,22 @@ package com.openisle.dto; -import lombok.Data; - import java.time.LocalDateTime; import java.util.List; +import lombok.Data; /** * DTO representing a comment and its nested replies. */ @Data public class CommentDto { - private Long id; - private String content; - private LocalDateTime createdAt; - private LocalDateTime pinnedAt; - private AuthorDto author; - private List replies; - private List reactions; - private int reward; - private int pointReward; -} + private Long id; + private String content; + private LocalDateTime createdAt; + private LocalDateTime pinnedAt; + private AuthorDto author; + private List replies; + private List reactions; + private int reward; + private int pointReward; +} diff --git a/backend/src/main/java/com/openisle/dto/CommentInfoDto.java b/backend/src/main/java/com/openisle/dto/CommentInfoDto.java index ccf31d04d..8c618ddcd 100644 --- a/backend/src/main/java/com/openisle/dto/CommentInfoDto.java +++ b/backend/src/main/java/com/openisle/dto/CommentInfoDto.java @@ -1,15 +1,15 @@ package com.openisle.dto; -import lombok.Data; - import java.time.LocalDateTime; +import lombok.Data; /** DTO for comment information in user profiles. */ @Data public class CommentInfoDto { - private Long id; - private String content; - private LocalDateTime createdAt; - private PostMetaDto post; - private ParentCommentDto parentComment; + + private Long id; + private String content; + private LocalDateTime createdAt; + private PostMetaDto post; + private ParentCommentDto parentComment; } diff --git a/backend/src/main/java/com/openisle/dto/CommentMedalDto.java b/backend/src/main/java/com/openisle/dto/CommentMedalDto.java index 045194f9b..b50a4eac3 100644 --- a/backend/src/main/java/com/openisle/dto/CommentMedalDto.java +++ b/backend/src/main/java/com/openisle/dto/CommentMedalDto.java @@ -6,6 +6,7 @@ import lombok.EqualsAndHashCode; @Data @EqualsAndHashCode(callSuper = true) public class CommentMedalDto extends MedalDto { - private long currentCommentCount; - private long targetCommentCount; + + private long currentCommentCount; + private long targetCommentCount; } diff --git a/backend/src/main/java/com/openisle/dto/CommentRequest.java b/backend/src/main/java/com/openisle/dto/CommentRequest.java index ca59b6f01..4cea1744e 100644 --- a/backend/src/main/java/com/openisle/dto/CommentRequest.java +++ b/backend/src/main/java/com/openisle/dto/CommentRequest.java @@ -5,6 +5,7 @@ import lombok.Data; /** Request body for creating or replying to a comment. */ @Data public class CommentRequest { - private String content; - private String captcha; + + private String content; + private String captcha; } diff --git a/backend/src/main/java/com/openisle/dto/ConfigDto.java b/backend/src/main/java/com/openisle/dto/ConfigDto.java index 9745ee42f..3dce93530 100644 --- a/backend/src/main/java/com/openisle/dto/ConfigDto.java +++ b/backend/src/main/java/com/openisle/dto/ConfigDto.java @@ -8,8 +8,9 @@ import lombok.Data; /** DTO for site configuration. */ @Data public class ConfigDto { - private PublishMode publishMode; - private PasswordStrength passwordStrength; - private Integer aiFormatLimit; - private RegisterMode registerMode; + + private PublishMode publishMode; + private PasswordStrength passwordStrength; + private Integer aiFormatLimit; + private RegisterMode registerMode; } diff --git a/backend/src/main/java/com/openisle/dto/ContributorMedalDto.java b/backend/src/main/java/com/openisle/dto/ContributorMedalDto.java index da130ccca..67cf0f917 100644 --- a/backend/src/main/java/com/openisle/dto/ContributorMedalDto.java +++ b/backend/src/main/java/com/openisle/dto/ContributorMedalDto.java @@ -6,7 +6,7 @@ import lombok.EqualsAndHashCode; @Data @EqualsAndHashCode(callSuper = true) public class ContributorMedalDto extends MedalDto { - private long currentContributionLines; - private long targetContributionLines; -} + private long currentContributionLines; + private long targetContributionLines; +} diff --git a/backend/src/main/java/com/openisle/dto/ConversationDetailDto.java b/backend/src/main/java/com/openisle/dto/ConversationDetailDto.java index 6b0c9e97c..e7d46d7c4 100644 --- a/backend/src/main/java/com/openisle/dto/ConversationDetailDto.java +++ b/backend/src/main/java/com/openisle/dto/ConversationDetailDto.java @@ -1,16 +1,16 @@ package com.openisle.dto; +import java.util.List; import lombok.Data; import org.springframework.data.domain.Page; -import java.util.List; - @Data public class ConversationDetailDto { - private Long id; - private String name; - private boolean channel; - private String avatar; - private List participants; - private Page messages; -} \ No newline at end of file + + private Long id; + private String name; + private boolean channel; + private String avatar; + private List participants; + private Page messages; +} diff --git a/backend/src/main/java/com/openisle/dto/ConversationDto.java b/backend/src/main/java/com/openisle/dto/ConversationDto.java index fdc83e639..cf18945ec 100644 --- a/backend/src/main/java/com/openisle/dto/ConversationDto.java +++ b/backend/src/main/java/com/openisle/dto/ConversationDto.java @@ -1,20 +1,20 @@ package com.openisle.dto; -import lombok.Getter; -import lombok.Setter; - import java.time.LocalDateTime; import java.util.List; +import lombok.Getter; +import lombok.Setter; @Getter @Setter public class ConversationDto { - private Long id; - private String name; - private boolean channel; - private String avatar; - private MessageDto lastMessage; - private List participants; - private LocalDateTime createdAt; - private long unreadCount; -} \ No newline at end of file + + private Long id; + private String name; + private boolean channel; + private String avatar; + private MessageDto lastMessage; + private List participants; + private LocalDateTime createdAt; + private long unreadCount; +} diff --git a/backend/src/main/java/com/openisle/dto/CreateConversationRequest.java b/backend/src/main/java/com/openisle/dto/CreateConversationRequest.java index 611557360..88514b399 100644 --- a/backend/src/main/java/com/openisle/dto/CreateConversationRequest.java +++ b/backend/src/main/java/com/openisle/dto/CreateConversationRequest.java @@ -4,5 +4,6 @@ import lombok.Data; @Data public class CreateConversationRequest { - private Long recipientId; -} \ No newline at end of file + + private Long recipientId; +} diff --git a/backend/src/main/java/com/openisle/dto/CreateConversationResponse.java b/backend/src/main/java/com/openisle/dto/CreateConversationResponse.java index 349f120f9..1887b5fce 100644 --- a/backend/src/main/java/com/openisle/dto/CreateConversationResponse.java +++ b/backend/src/main/java/com/openisle/dto/CreateConversationResponse.java @@ -8,5 +8,6 @@ import lombok.NoArgsConstructor; @AllArgsConstructor @NoArgsConstructor public class CreateConversationResponse { - private Long conversationId; -} \ No newline at end of file + + private Long conversationId; +} diff --git a/backend/src/main/java/com/openisle/dto/DiscordLoginRequest.java b/backend/src/main/java/com/openisle/dto/DiscordLoginRequest.java index 90e41163c..1623f04aa 100644 --- a/backend/src/main/java/com/openisle/dto/DiscordLoginRequest.java +++ b/backend/src/main/java/com/openisle/dto/DiscordLoginRequest.java @@ -5,7 +5,8 @@ import lombok.Data; /** Request for Discord OAuth login. */ @Data public class DiscordLoginRequest { - private String code; - private String redirectUri; - private String inviteToken; + + private String code; + private String redirectUri; + private String inviteToken; } diff --git a/backend/src/main/java/com/openisle/dto/DraftDto.java b/backend/src/main/java/com/openisle/dto/DraftDto.java index 467fc1979..40b004c12 100644 --- a/backend/src/main/java/com/openisle/dto/DraftDto.java +++ b/backend/src/main/java/com/openisle/dto/DraftDto.java @@ -1,15 +1,15 @@ package com.openisle.dto; -import lombok.Data; - import java.util.List; +import lombok.Data; /** DTO representing a saved draft. */ @Data public class DraftDto { - private Long id; - private String title; - private String content; - private Long categoryId; - private List tagIds; + + private Long id; + private String title; + private String content; + private Long categoryId; + private List tagIds; } diff --git a/backend/src/main/java/com/openisle/dto/DraftRequest.java b/backend/src/main/java/com/openisle/dto/DraftRequest.java index ba5b0dd43..6cff7210a 100644 --- a/backend/src/main/java/com/openisle/dto/DraftRequest.java +++ b/backend/src/main/java/com/openisle/dto/DraftRequest.java @@ -1,14 +1,14 @@ package com.openisle.dto; -import lombok.Data; - import java.util.List; +import lombok.Data; /** Request body for saving a draft. */ @Data public class DraftRequest { - private String title; - private String content; - private Long categoryId; - private List tagIds; + + private String title; + private String content; + private Long categoryId; + private List tagIds; } diff --git a/backend/src/main/java/com/openisle/dto/FeaturedMedalDto.java b/backend/src/main/java/com/openisle/dto/FeaturedMedalDto.java index 2e5cbaf9e..0c50ff52a 100644 --- a/backend/src/main/java/com/openisle/dto/FeaturedMedalDto.java +++ b/backend/src/main/java/com/openisle/dto/FeaturedMedalDto.java @@ -6,7 +6,7 @@ import lombok.EqualsAndHashCode; @Data @EqualsAndHashCode(callSuper = true) public class FeaturedMedalDto extends MedalDto { - private long currentFeaturedCount; - private long targetFeaturedCount; -} + private long currentFeaturedCount; + private long targetFeaturedCount; +} diff --git a/backend/src/main/java/com/openisle/dto/ForgotPasswordRequest.java b/backend/src/main/java/com/openisle/dto/ForgotPasswordRequest.java index 242e0285a..d1924da94 100644 --- a/backend/src/main/java/com/openisle/dto/ForgotPasswordRequest.java +++ b/backend/src/main/java/com/openisle/dto/ForgotPasswordRequest.java @@ -5,5 +5,6 @@ import lombok.Data; /** Request to trigger a forgot password email. */ @Data public class ForgotPasswordRequest { - private String email; + + private String email; } diff --git a/backend/src/main/java/com/openisle/dto/GithubLoginRequest.java b/backend/src/main/java/com/openisle/dto/GithubLoginRequest.java index dc9edf8c5..ca65968ba 100644 --- a/backend/src/main/java/com/openisle/dto/GithubLoginRequest.java +++ b/backend/src/main/java/com/openisle/dto/GithubLoginRequest.java @@ -5,7 +5,8 @@ import lombok.Data; /** Request for GitHub OAuth login. */ @Data public class GithubLoginRequest { - private String code; - private String redirectUri; - private String inviteToken; + + private String code; + private String redirectUri; + private String inviteToken; } diff --git a/backend/src/main/java/com/openisle/dto/GoogleLoginRequest.java b/backend/src/main/java/com/openisle/dto/GoogleLoginRequest.java index 3159e10dc..a9cabd65a 100644 --- a/backend/src/main/java/com/openisle/dto/GoogleLoginRequest.java +++ b/backend/src/main/java/com/openisle/dto/GoogleLoginRequest.java @@ -5,6 +5,7 @@ import lombok.Data; /** Request for Google OAuth login. */ @Data public class GoogleLoginRequest { - private String idToken; - private String inviteToken; + + private String idToken; + private String inviteToken; } diff --git a/backend/src/main/java/com/openisle/dto/LoginRequest.java b/backend/src/main/java/com/openisle/dto/LoginRequest.java index 2ac1f4df7..20bc9e5a7 100644 --- a/backend/src/main/java/com/openisle/dto/LoginRequest.java +++ b/backend/src/main/java/com/openisle/dto/LoginRequest.java @@ -5,7 +5,8 @@ import lombok.Data; /** Request to login. */ @Data public class LoginRequest { - private String username; - private String password; - private String captcha; + + private String username; + private String password; + private String captcha; } diff --git a/backend/src/main/java/com/openisle/dto/LotteryDto.java b/backend/src/main/java/com/openisle/dto/LotteryDto.java index 73b5ca7d7..3839179c6 100644 --- a/backend/src/main/java/com/openisle/dto/LotteryDto.java +++ b/backend/src/main/java/com/openisle/dto/LotteryDto.java @@ -1,18 +1,19 @@ package com.openisle.dto; -import lombok.Data; import java.time.LocalDateTime; import java.util.List; +import lombok.Data; /** Metadata for lottery posts. */ @Data public class LotteryDto { - private String prizeDescription; - private String prizeIcon; - private int prizeCount; - private int pointCost; - private LocalDateTime startTime; - private LocalDateTime endTime; - private List participants; - private List winners; + + private String prizeDescription; + private String prizeIcon; + private int prizeCount; + private int pointCost; + private LocalDateTime startTime; + private LocalDateTime endTime; + private List participants; + private List winners; } diff --git a/backend/src/main/java/com/openisle/dto/MakeReasonRequest.java b/backend/src/main/java/com/openisle/dto/MakeReasonRequest.java index 8c7c4e699..b27a18ff1 100644 --- a/backend/src/main/java/com/openisle/dto/MakeReasonRequest.java +++ b/backend/src/main/java/com/openisle/dto/MakeReasonRequest.java @@ -5,6 +5,7 @@ import lombok.Data; /** Request to submit a reason (e.g., for moderation). */ @Data public class MakeReasonRequest { - private String token; - private String reason; + + private String token; + private String reason; } diff --git a/backend/src/main/java/com/openisle/dto/MedalDto.java b/backend/src/main/java/com/openisle/dto/MedalDto.java index 5d6bc8f9a..914b30436 100644 --- a/backend/src/main/java/com/openisle/dto/MedalDto.java +++ b/backend/src/main/java/com/openisle/dto/MedalDto.java @@ -5,10 +5,11 @@ import lombok.Data; @Data public class MedalDto { - private String icon; - private String title; - private String description; - private MedalType type; - private boolean completed; - private boolean selected; + + private String icon; + private String title; + private String description; + private MedalType type; + private boolean completed; + private boolean selected; } diff --git a/backend/src/main/java/com/openisle/dto/MedalSelectRequest.java b/backend/src/main/java/com/openisle/dto/MedalSelectRequest.java index 0d1f94d1d..17c185590 100644 --- a/backend/src/main/java/com/openisle/dto/MedalSelectRequest.java +++ b/backend/src/main/java/com/openisle/dto/MedalSelectRequest.java @@ -5,5 +5,6 @@ import lombok.Data; @Data public class MedalSelectRequest { - private MedalType type; + + private MedalType type; } diff --git a/backend/src/main/java/com/openisle/dto/MessageDto.java b/backend/src/main/java/com/openisle/dto/MessageDto.java index 956a8a7c4..86ebcac40 100644 --- a/backend/src/main/java/com/openisle/dto/MessageDto.java +++ b/backend/src/main/java/com/openisle/dto/MessageDto.java @@ -1,16 +1,17 @@ package com.openisle.dto; -import lombok.Data; import java.time.LocalDateTime; import java.util.List; +import lombok.Data; @Data public class MessageDto { - private Long id; - private String content; - private UserSummaryDto sender; - private Long conversationId; - private LocalDateTime createdAt; - private MessageDto replyTo; - private List reactions; -} \ No newline at end of file + + private Long id; + private String content; + private UserSummaryDto sender; + private Long conversationId; + private LocalDateTime createdAt; + private MessageDto replyTo; + private List reactions; +} diff --git a/backend/src/main/java/com/openisle/dto/MessageNotificationPayload.java b/backend/src/main/java/com/openisle/dto/MessageNotificationPayload.java index 72a4a143b..50f588006 100644 --- a/backend/src/main/java/com/openisle/dto/MessageNotificationPayload.java +++ b/backend/src/main/java/com/openisle/dto/MessageNotificationPayload.java @@ -1,15 +1,15 @@ package com.openisle.dto; +import java.io.Serializable; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; -import java.io.Serializable; - @Data @NoArgsConstructor @AllArgsConstructor public class MessageNotificationPayload implements Serializable { - private String targetUsername; - private Object payload; -} \ No newline at end of file + + private String targetUsername; + private Object payload; +} diff --git a/backend/src/main/java/com/openisle/dto/MilkTeaInfoDto.java b/backend/src/main/java/com/openisle/dto/MilkTeaInfoDto.java index a7c7fb4f8..0e524cd58 100644 --- a/backend/src/main/java/com/openisle/dto/MilkTeaInfoDto.java +++ b/backend/src/main/java/com/openisle/dto/MilkTeaInfoDto.java @@ -5,6 +5,7 @@ import lombok.Data; /** Info about the milk tea activity. */ @Data public class MilkTeaInfoDto { - private long redeemCount; - private boolean ended; + + private long redeemCount; + private boolean ended; } diff --git a/backend/src/main/java/com/openisle/dto/MilkTeaRedeemRequest.java b/backend/src/main/java/com/openisle/dto/MilkTeaRedeemRequest.java index 63cb17422..aac5f7f26 100644 --- a/backend/src/main/java/com/openisle/dto/MilkTeaRedeemRequest.java +++ b/backend/src/main/java/com/openisle/dto/MilkTeaRedeemRequest.java @@ -5,5 +5,6 @@ import lombok.Data; /** Request to redeem the milk tea activity. */ @Data public class MilkTeaRedeemRequest { - private String contact; + + private String contact; } diff --git a/backend/src/main/java/com/openisle/dto/NotificationDto.java b/backend/src/main/java/com/openisle/dto/NotificationDto.java index 4b741c537..a92e76605 100644 --- a/backend/src/main/java/com/openisle/dto/NotificationDto.java +++ b/backend/src/main/java/com/openisle/dto/NotificationDto.java @@ -2,22 +2,22 @@ package com.openisle.dto; import com.openisle.model.NotificationType; import com.openisle.model.ReactionType; -import lombok.Data; - import java.time.LocalDateTime; +import lombok.Data; /** DTO representing a user notification. */ @Data public class NotificationDto { - private Long id; - private NotificationType type; - private PostSummaryDto post; - private CommentDto comment; - private CommentDto parentComment; - private AuthorDto fromUser; - private ReactionType reactionType; - private String content; - private Boolean approved; - private boolean read; - private LocalDateTime createdAt; + + private Long id; + private NotificationType type; + private PostSummaryDto post; + private CommentDto comment; + private CommentDto parentComment; + private AuthorDto fromUser; + private ReactionType reactionType; + private String content; + private Boolean approved; + private boolean read; + private LocalDateTime createdAt; } diff --git a/backend/src/main/java/com/openisle/dto/NotificationMarkReadRequest.java b/backend/src/main/java/com/openisle/dto/NotificationMarkReadRequest.java index 39b7d75e9..ebfbd9530 100644 --- a/backend/src/main/java/com/openisle/dto/NotificationMarkReadRequest.java +++ b/backend/src/main/java/com/openisle/dto/NotificationMarkReadRequest.java @@ -1,11 +1,11 @@ package com.openisle.dto; -import lombok.Data; - import java.util.List; +import lombok.Data; /** Request to mark notifications as read. */ @Data public class NotificationMarkReadRequest { - private List ids; + + private List ids; } diff --git a/backend/src/main/java/com/openisle/dto/NotificationPreferenceDto.java b/backend/src/main/java/com/openisle/dto/NotificationPreferenceDto.java index 4d4e6a4d7..7eaed7f28 100644 --- a/backend/src/main/java/com/openisle/dto/NotificationPreferenceDto.java +++ b/backend/src/main/java/com/openisle/dto/NotificationPreferenceDto.java @@ -6,6 +6,7 @@ import lombok.Data; /** User notification preference DTO. */ @Data public class NotificationPreferenceDto { - private NotificationType type; - private boolean enabled; + + private NotificationType type; + private boolean enabled; } diff --git a/backend/src/main/java/com/openisle/dto/NotificationPreferenceUpdateRequest.java b/backend/src/main/java/com/openisle/dto/NotificationPreferenceUpdateRequest.java index ad68bf45f..c309be64e 100644 --- a/backend/src/main/java/com/openisle/dto/NotificationPreferenceUpdateRequest.java +++ b/backend/src/main/java/com/openisle/dto/NotificationPreferenceUpdateRequest.java @@ -6,6 +6,7 @@ import lombok.Data; /** Request to update a single notification preference. */ @Data public class NotificationPreferenceUpdateRequest { - private NotificationType type; - private boolean enabled; + + private NotificationType type; + private boolean enabled; } diff --git a/backend/src/main/java/com/openisle/dto/NotificationUnreadCountDto.java b/backend/src/main/java/com/openisle/dto/NotificationUnreadCountDto.java index 37f16d005..2f300aabf 100644 --- a/backend/src/main/java/com/openisle/dto/NotificationUnreadCountDto.java +++ b/backend/src/main/java/com/openisle/dto/NotificationUnreadCountDto.java @@ -5,5 +5,6 @@ import lombok.Data; /** DTO representing unread notification count. */ @Data public class NotificationUnreadCountDto { - private long count; + + private long count; } diff --git a/backend/src/main/java/com/openisle/dto/ParentCommentDto.java b/backend/src/main/java/com/openisle/dto/ParentCommentDto.java index f24029c60..7f7c19dbf 100644 --- a/backend/src/main/java/com/openisle/dto/ParentCommentDto.java +++ b/backend/src/main/java/com/openisle/dto/ParentCommentDto.java @@ -5,7 +5,8 @@ import lombok.Data; /** DTO representing a parent comment. */ @Data public class ParentCommentDto { - private Long id; - private String author; - private String content; + + private Long id; + private String author; + private String content; } diff --git a/backend/src/main/java/com/openisle/dto/PioneerMedalDto.java b/backend/src/main/java/com/openisle/dto/PioneerMedalDto.java index bd04f93d7..0483e170d 100644 --- a/backend/src/main/java/com/openisle/dto/PioneerMedalDto.java +++ b/backend/src/main/java/com/openisle/dto/PioneerMedalDto.java @@ -6,5 +6,6 @@ import lombok.EqualsAndHashCode; @Data @EqualsAndHashCode(callSuper = true) public class PioneerMedalDto extends MedalDto { - private long rank; + + private long rank; } diff --git a/backend/src/main/java/com/openisle/dto/PointGoodDto.java b/backend/src/main/java/com/openisle/dto/PointGoodDto.java index cf7384283..18c9130e5 100644 --- a/backend/src/main/java/com/openisle/dto/PointGoodDto.java +++ b/backend/src/main/java/com/openisle/dto/PointGoodDto.java @@ -5,8 +5,9 @@ import lombok.Data; /** Point mall good info. */ @Data public class PointGoodDto { - private Long id; - private String name; - private int cost; - private String image; + + private Long id; + private String name; + private int cost; + private String image; } diff --git a/backend/src/main/java/com/openisle/dto/PointHistoryDto.java b/backend/src/main/java/com/openisle/dto/PointHistoryDto.java index cae0b6f6b..f231a3b91 100644 --- a/backend/src/main/java/com/openisle/dto/PointHistoryDto.java +++ b/backend/src/main/java/com/openisle/dto/PointHistoryDto.java @@ -1,23 +1,23 @@ package com.openisle.dto; import com.openisle.model.PointHistoryType; +import java.time.LocalDateTime; import lombok.Getter; import lombok.Setter; -import java.time.LocalDateTime; - @Getter @Setter public class PointHistoryDto { - private Long id; - private PointHistoryType type; - private int amount; - private int balance; - private Long postId; - private String postTitle; - private Long commentId; - private String commentContent; - private Long fromUserId; - private String fromUserName; - private LocalDateTime createdAt; + + private Long id; + private PointHistoryType type; + private int amount; + private int balance; + private Long postId; + private String postTitle; + private Long commentId; + private String commentContent; + private Long fromUserId; + private String fromUserName; + private LocalDateTime createdAt; } diff --git a/backend/src/main/java/com/openisle/dto/PointRedeemRequest.java b/backend/src/main/java/com/openisle/dto/PointRedeemRequest.java index 6bbefdba6..1f7b070b3 100644 --- a/backend/src/main/java/com/openisle/dto/PointRedeemRequest.java +++ b/backend/src/main/java/com/openisle/dto/PointRedeemRequest.java @@ -5,6 +5,7 @@ import lombok.Data; /** Request to redeem a point mall good. */ @Data public class PointRedeemRequest { - private Long goodId; - private String contact; + + private Long goodId; + private String contact; } diff --git a/backend/src/main/java/com/openisle/dto/PollDto.java b/backend/src/main/java/com/openisle/dto/PollDto.java index af8da1de0..ec2ee5406 100644 --- a/backend/src/main/java/com/openisle/dto/PollDto.java +++ b/backend/src/main/java/com/openisle/dto/PollDto.java @@ -1,17 +1,17 @@ package com.openisle.dto; -import lombok.Data; - import java.time.LocalDateTime; import java.util.List; import java.util.Map; +import lombok.Data; @Data public class PollDto { - private List options; - private Map votes; - private LocalDateTime endTime; - private List participants; - private Map> optionParticipants; - private boolean multiple; + + private List options; + private Map votes; + private LocalDateTime endTime; + private List participants; + private Map> optionParticipants; + private boolean multiple; } diff --git a/backend/src/main/java/com/openisle/dto/PostChangeLogDto.java b/backend/src/main/java/com/openisle/dto/PostChangeLogDto.java index f07373c46..ce5d55201 100644 --- a/backend/src/main/java/com/openisle/dto/PostChangeLogDto.java +++ b/backend/src/main/java/com/openisle/dto/PostChangeLogDto.java @@ -1,32 +1,32 @@ package com.openisle.dto; import com.openisle.model.PostChangeType; -import lombok.Getter; -import lombok.Setter; - import java.time.LocalDateTime; import java.util.List; +import lombok.Getter; +import lombok.Setter; @Getter @Setter public class PostChangeLogDto { - private Long id; - private String username; - private String userAvatar; - private PostChangeType type; - private LocalDateTime time; - private String oldTitle; - private String newTitle; - private String oldContent; - private String newContent; - private CategoryDto oldCategory; - private CategoryDto newCategory; - private List oldTags; - private List newTags; - private Boolean oldClosed; - private Boolean newClosed; - private LocalDateTime oldPinnedAt; - private LocalDateTime newPinnedAt; - private Boolean oldFeatured; - private Boolean newFeatured; + + private Long id; + private String username; + private String userAvatar; + private PostChangeType type; + private LocalDateTime time; + private String oldTitle; + private String newTitle; + private String oldContent; + private String newContent; + private CategoryDto oldCategory; + private CategoryDto newCategory; + private List oldTags; + private List newTags; + private Boolean oldClosed; + private Boolean newClosed; + private LocalDateTime oldPinnedAt; + private LocalDateTime newPinnedAt; + private Boolean oldFeatured; + private Boolean newFeatured; } diff --git a/backend/src/main/java/com/openisle/dto/PostDetailDto.java b/backend/src/main/java/com/openisle/dto/PostDetailDto.java index 8cd8e402b..307a7ba54 100644 --- a/backend/src/main/java/com/openisle/dto/PostDetailDto.java +++ b/backend/src/main/java/com/openisle/dto/PostDetailDto.java @@ -1,16 +1,15 @@ package com.openisle.dto; +import java.util.List; import lombok.Data; import lombok.EqualsAndHashCode; -import java.util.List; - /** * Detailed DTO for a post, including comments. */ @Data @EqualsAndHashCode(callSuper = true) public class PostDetailDto extends PostSummaryDto { - private List comments; -} + private List comments; +} diff --git a/backend/src/main/java/com/openisle/dto/PostMedalDto.java b/backend/src/main/java/com/openisle/dto/PostMedalDto.java index 9b538b207..6e2ca09ad 100644 --- a/backend/src/main/java/com/openisle/dto/PostMedalDto.java +++ b/backend/src/main/java/com/openisle/dto/PostMedalDto.java @@ -6,6 +6,7 @@ import lombok.EqualsAndHashCode; @Data @EqualsAndHashCode(callSuper = true) public class PostMedalDto extends MedalDto { - private long currentPostCount; - private long targetPostCount; + + private long currentPostCount; + private long targetPostCount; } diff --git a/backend/src/main/java/com/openisle/dto/PostMetaDto.java b/backend/src/main/java/com/openisle/dto/PostMetaDto.java index a48aade20..1667b047f 100644 --- a/backend/src/main/java/com/openisle/dto/PostMetaDto.java +++ b/backend/src/main/java/com/openisle/dto/PostMetaDto.java @@ -1,16 +1,16 @@ package com.openisle.dto; -import lombok.Data; - import java.time.LocalDateTime; +import lombok.Data; /** Lightweight post metadata used in user profile lists. */ @Data public class PostMetaDto { - private Long id; - private String title; - private String snippet; - private LocalDateTime createdAt; - private String category; - private long views; + + private Long id; + private String title; + private String snippet; + private LocalDateTime createdAt; + private String category; + private long views; } diff --git a/backend/src/main/java/com/openisle/dto/PostRequest.java b/backend/src/main/java/com/openisle/dto/PostRequest.java index bdebadb6c..0419804ea 100644 --- a/backend/src/main/java/com/openisle/dto/PostRequest.java +++ b/backend/src/main/java/com/openisle/dto/PostRequest.java @@ -1,33 +1,31 @@ package com.openisle.dto; -import lombok.Data; - +import com.openisle.model.PostType; import java.time.LocalDateTime; import java.util.List; - -import com.openisle.model.PostType; +import lombok.Data; /** * Request body for creating or updating a post. */ @Data public class PostRequest { - private Long categoryId; - private String title; - private String content; - private List tagIds; - private String captcha; - // optional for lottery posts - private PostType type; - private String prizeDescription; - private String prizeIcon; - private Integer prizeCount; - private Integer pointCost; - private LocalDateTime startTime; - private LocalDateTime endTime; - // fields for poll posts - private List options; - private Boolean multiple; + private Long categoryId; + private String title; + private String content; + private List tagIds; + private String captcha; + + // optional for lottery posts + private PostType type; + private String prizeDescription; + private String prizeIcon; + private Integer prizeCount; + private Integer pointCost; + private LocalDateTime startTime; + private LocalDateTime endTime; + // fields for poll posts + private List options; + private Boolean multiple; } - diff --git a/backend/src/main/java/com/openisle/dto/PostSummaryDto.java b/backend/src/main/java/com/openisle/dto/PostSummaryDto.java index 48205bc0e..b2fc5c1bf 100644 --- a/backend/src/main/java/com/openisle/dto/PostSummaryDto.java +++ b/backend/src/main/java/com/openisle/dto/PostSummaryDto.java @@ -2,37 +2,36 @@ package com.openisle.dto; import com.openisle.model.PostStatus; import com.openisle.model.PostType; -import lombok.Data; - import java.time.LocalDateTime; import java.util.List; +import lombok.Data; /** * Lightweight DTO for listing posts without comments. */ @Data public class PostSummaryDto { - private Long id; - private String title; - private String content; - private LocalDateTime createdAt; - private AuthorDto author; - private CategoryDto category; - private List tags; - private long views; - private long commentCount; - private PostStatus status; - private LocalDateTime pinnedAt; - private LocalDateTime lastReplyAt; - private List reactions; - private List participants; - private boolean subscribed; - private int reward; - private int pointReward; - private PostType type; - private LotteryDto lottery; - private PollDto poll; - private boolean rssExcluded; - private boolean closed; -} + private Long id; + private String title; + private String content; + private LocalDateTime createdAt; + private AuthorDto author; + private CategoryDto category; + private List tags; + private long views; + private long commentCount; + private PostStatus status; + private LocalDateTime pinnedAt; + private LocalDateTime lastReplyAt; + private List reactions; + private List participants; + private boolean subscribed; + private int reward; + private int pointReward; + private PostType type; + private LotteryDto lottery; + private PollDto poll; + private boolean rssExcluded; + private boolean closed; +} diff --git a/backend/src/main/java/com/openisle/dto/PushPublicKeyDto.java b/backend/src/main/java/com/openisle/dto/PushPublicKeyDto.java index 9aa6169c5..69549a2d1 100644 --- a/backend/src/main/java/com/openisle/dto/PushPublicKeyDto.java +++ b/backend/src/main/java/com/openisle/dto/PushPublicKeyDto.java @@ -5,5 +5,6 @@ import lombok.Data; /** Public key response for web push. */ @Data public class PushPublicKeyDto { - private String key; + + private String key; } diff --git a/backend/src/main/java/com/openisle/dto/PushSubscriptionRequest.java b/backend/src/main/java/com/openisle/dto/PushSubscriptionRequest.java index b62d204c3..ce93d118d 100644 --- a/backend/src/main/java/com/openisle/dto/PushSubscriptionRequest.java +++ b/backend/src/main/java/com/openisle/dto/PushSubscriptionRequest.java @@ -5,7 +5,8 @@ import lombok.Data; /** Request body for saving a push subscription. */ @Data public class PushSubscriptionRequest { - private String endpoint; - private String p256dh; - private String auth; + + private String endpoint; + private String p256dh; + private String auth; } diff --git a/backend/src/main/java/com/openisle/dto/ReactionDto.java b/backend/src/main/java/com/openisle/dto/ReactionDto.java index 7266bbfc0..18de8024e 100644 --- a/backend/src/main/java/com/openisle/dto/ReactionDto.java +++ b/backend/src/main/java/com/openisle/dto/ReactionDto.java @@ -8,12 +8,12 @@ import lombok.Data; */ @Data public class ReactionDto { - private Long id; - private ReactionType type; - private String user; - private Long postId; - private Long commentId; - private Long messageId; - private int reward; -} + private Long id; + private ReactionType type; + private String user; + private Long postId; + private Long commentId; + private Long messageId; + private int reward; +} diff --git a/backend/src/main/java/com/openisle/dto/ReactionRequest.java b/backend/src/main/java/com/openisle/dto/ReactionRequest.java index a2a69f3b4..59242b17a 100644 --- a/backend/src/main/java/com/openisle/dto/ReactionRequest.java +++ b/backend/src/main/java/com/openisle/dto/ReactionRequest.java @@ -6,5 +6,6 @@ import lombok.Data; /** Request for reacting to a post or comment. */ @Data public class ReactionRequest { - private ReactionType type; + + private ReactionType type; } diff --git a/backend/src/main/java/com/openisle/dto/RegisterRequest.java b/backend/src/main/java/com/openisle/dto/RegisterRequest.java index 66a6e24b2..9c41e1524 100644 --- a/backend/src/main/java/com/openisle/dto/RegisterRequest.java +++ b/backend/src/main/java/com/openisle/dto/RegisterRequest.java @@ -5,9 +5,10 @@ import lombok.Data; /** Request to register a new user. */ @Data public class RegisterRequest { - private String username; - private String email; - private String password; - private String captcha; - private String inviteToken; + + private String username; + private String email; + private String password; + private String captcha; + private String inviteToken; } diff --git a/backend/src/main/java/com/openisle/dto/ResetPasswordRequest.java b/backend/src/main/java/com/openisle/dto/ResetPasswordRequest.java index 0043058a7..59921b2dc 100644 --- a/backend/src/main/java/com/openisle/dto/ResetPasswordRequest.java +++ b/backend/src/main/java/com/openisle/dto/ResetPasswordRequest.java @@ -5,6 +5,7 @@ import lombok.Data; /** Request to reset password. */ @Data public class ResetPasswordRequest { - private String token; - private String password; + + private String token; + private String password; } diff --git a/backend/src/main/java/com/openisle/dto/SearchResultDto.java b/backend/src/main/java/com/openisle/dto/SearchResultDto.java index bae753533..59b7b0c72 100644 --- a/backend/src/main/java/com/openisle/dto/SearchResultDto.java +++ b/backend/src/main/java/com/openisle/dto/SearchResultDto.java @@ -5,10 +5,11 @@ import lombok.Data; /** DTO representing a search result entry. */ @Data public class SearchResultDto { - private String type; - private Long id; - private String text; - private String subText; - private String extra; - private Long postId; + + private String type; + private Long id; + private String text; + private String subText; + private String extra; + private Long postId; } diff --git a/backend/src/main/java/com/openisle/dto/SeedUserMedalDto.java b/backend/src/main/java/com/openisle/dto/SeedUserMedalDto.java index b1a961dbd..c433737d2 100644 --- a/backend/src/main/java/com/openisle/dto/SeedUserMedalDto.java +++ b/backend/src/main/java/com/openisle/dto/SeedUserMedalDto.java @@ -7,5 +7,6 @@ import lombok.EqualsAndHashCode; @Data @EqualsAndHashCode(callSuper = true) public class SeedUserMedalDto extends MedalDto { - private LocalDateTime registerDate; + + private LocalDateTime registerDate; } diff --git a/backend/src/main/java/com/openisle/dto/SiteConfigDto.java b/backend/src/main/java/com/openisle/dto/SiteConfigDto.java index a2e9ced8d..d2ccf156e 100644 --- a/backend/src/main/java/com/openisle/dto/SiteConfigDto.java +++ b/backend/src/main/java/com/openisle/dto/SiteConfigDto.java @@ -6,11 +6,12 @@ import lombok.Data; /** Public site configuration values. */ @Data public class SiteConfigDto { - private boolean captchaEnabled; - private boolean registerCaptchaEnabled; - private boolean loginCaptchaEnabled; - private boolean postCaptchaEnabled; - private boolean commentCaptchaEnabled; - private int aiFormatLimit; - private RegisterMode registerMode; + + private boolean captchaEnabled; + private boolean registerCaptchaEnabled; + private boolean loginCaptchaEnabled; + private boolean postCaptchaEnabled; + private boolean commentCaptchaEnabled; + private int aiFormatLimit; + private RegisterMode registerMode; } diff --git a/backend/src/main/java/com/openisle/dto/TagDto.java b/backend/src/main/java/com/openisle/dto/TagDto.java index 26cb31fb9..ff946b04d 100644 --- a/backend/src/main/java/com/openisle/dto/TagDto.java +++ b/backend/src/main/java/com/openisle/dto/TagDto.java @@ -1,20 +1,19 @@ package com.openisle.dto; -import lombok.Data; - import java.time.LocalDateTime; +import lombok.Data; /** * DTO representing a tag. */ @Data public class TagDto { - private Long id; - private String name; - private String description; - private String icon; - private String smallIcon; - private LocalDateTime createdAt; - private Long count; -} + private Long id; + private String name; + private String description; + private String icon; + private String smallIcon; + private LocalDateTime createdAt; + private Long count; +} diff --git a/backend/src/main/java/com/openisle/dto/TagRequest.java b/backend/src/main/java/com/openisle/dto/TagRequest.java index 4d0f32e0e..68b074022 100644 --- a/backend/src/main/java/com/openisle/dto/TagRequest.java +++ b/backend/src/main/java/com/openisle/dto/TagRequest.java @@ -5,8 +5,9 @@ import lombok.Data; /** Request body for creating or updating a tag. */ @Data public class TagRequest { - private String name; - private String description; - private String icon; - private String smallIcon; + + private String name; + private String description; + private String icon; + private String smallIcon; } diff --git a/backend/src/main/java/com/openisle/dto/TelegramLoginRequest.java b/backend/src/main/java/com/openisle/dto/TelegramLoginRequest.java index d4b40ee4b..c4d40d039 100644 --- a/backend/src/main/java/com/openisle/dto/TelegramLoginRequest.java +++ b/backend/src/main/java/com/openisle/dto/TelegramLoginRequest.java @@ -5,12 +5,13 @@ import lombok.Data; /** Request for Telegram login. */ @Data public class TelegramLoginRequest { - private String id; - private String firstName; - private String lastName; - private String username; - private String photoUrl; - private Long authDate; - private String hash; - private String inviteToken; + + private String id; + private String firstName; + private String lastName; + private String username; + private String photoUrl; + private Long authDate; + private String hash; + private String inviteToken; } diff --git a/backend/src/main/java/com/openisle/dto/TimelineItemDto.java b/backend/src/main/java/com/openisle/dto/TimelineItemDto.java index 7e53f8a9c..d492e3181 100644 --- a/backend/src/main/java/com/openisle/dto/TimelineItemDto.java +++ b/backend/src/main/java/com/openisle/dto/TimelineItemDto.java @@ -1,8 +1,7 @@ package com.openisle.dto; -import lombok.*; - import java.time.LocalDateTime; +import lombok.*; /** * comment and change_log Dto @@ -13,8 +12,8 @@ import java.time.LocalDateTime; @NoArgsConstructor public class TimelineItemDto { - private Long id; - private String kind; // "comment" | "log" - private LocalDateTime createdAt; - private T payload; // 泛型,具体类型由外部决定 + private Long id; + private String kind; // "comment" | "log" + private LocalDateTime createdAt; + private T payload; // 泛型,具体类型由外部决定 } diff --git a/backend/src/main/java/com/openisle/dto/TwitterLoginRequest.java b/backend/src/main/java/com/openisle/dto/TwitterLoginRequest.java index 0bd82a956..c61e0b81c 100644 --- a/backend/src/main/java/com/openisle/dto/TwitterLoginRequest.java +++ b/backend/src/main/java/com/openisle/dto/TwitterLoginRequest.java @@ -5,8 +5,9 @@ import lombok.Data; /** Request for Twitter OAuth login. */ @Data public class TwitterLoginRequest { - private String code; - private String redirectUri; - private String codeVerifier; - private String inviteToken; + + private String code; + private String redirectUri; + private String codeVerifier; + private String inviteToken; } diff --git a/backend/src/main/java/com/openisle/dto/UpdateProfileDto.java b/backend/src/main/java/com/openisle/dto/UpdateProfileDto.java index 0dc34b326..246043f00 100644 --- a/backend/src/main/java/com/openisle/dto/UpdateProfileDto.java +++ b/backend/src/main/java/com/openisle/dto/UpdateProfileDto.java @@ -5,6 +5,7 @@ import lombok.Data; /** Request body for updating user profile. */ @Data public class UpdateProfileDto { - private String username; - private String introduction; + + private String username; + private String introduction; } diff --git a/backend/src/main/java/com/openisle/dto/UserAggregateDto.java b/backend/src/main/java/com/openisle/dto/UserAggregateDto.java index 99fc7470a..d70a04283 100644 --- a/backend/src/main/java/com/openisle/dto/UserAggregateDto.java +++ b/backend/src/main/java/com/openisle/dto/UserAggregateDto.java @@ -1,13 +1,13 @@ package com.openisle.dto; -import lombok.Data; - import java.util.List; +import lombok.Data; /** Aggregated user data including posts and replies. */ @Data public class UserAggregateDto { - private UserDto user; - private List posts; - private List replies; + + private UserDto user; + private List posts; + private List replies; } diff --git a/backend/src/main/java/com/openisle/dto/UserDto.java b/backend/src/main/java/com/openisle/dto/UserDto.java index 158340041..cf502c546 100644 --- a/backend/src/main/java/com/openisle/dto/UserDto.java +++ b/backend/src/main/java/com/openisle/dto/UserDto.java @@ -1,31 +1,31 @@ package com.openisle.dto; -import lombok.Data; - import java.time.LocalDateTime; +import lombok.Data; /** Detailed user information. */ @Data public class UserDto { - private Long id; - private String username; - private String email; - private String avatar; - private String role; - private String introduction; - private long followers; - private long following; - private LocalDateTime createdAt; - private LocalDateTime lastPostTime; - private LocalDateTime lastCommentTime; - private long totalViews; - private long visitedDays; - private long readPosts; - private long likesSent; - private long likesReceived; - private boolean subscribed; - private int experience; - private int point; - private int currentLevel; - private int nextLevelExp; + + private Long id; + private String username; + private String email; + private String avatar; + private String role; + private String introduction; + private long followers; + private long following; + private LocalDateTime createdAt; + private LocalDateTime lastPostTime; + private LocalDateTime lastCommentTime; + private long totalViews; + private long visitedDays; + private long readPosts; + private long likesSent; + private long likesReceived; + private boolean subscribed; + private int experience; + private int point; + private int currentLevel; + private int nextLevelExp; } diff --git a/backend/src/main/java/com/openisle/dto/UserSummaryDto.java b/backend/src/main/java/com/openisle/dto/UserSummaryDto.java index 5df8254a4..8db045f79 100644 --- a/backend/src/main/java/com/openisle/dto/UserSummaryDto.java +++ b/backend/src/main/java/com/openisle/dto/UserSummaryDto.java @@ -4,7 +4,8 @@ import lombok.Data; @Data public class UserSummaryDto { - private Long id; - private String username; - private String avatar; -} \ No newline at end of file + + private Long id; + private String username; + private String avatar; +} diff --git a/backend/src/main/java/com/openisle/dto/VerifyForgotRequest.java b/backend/src/main/java/com/openisle/dto/VerifyForgotRequest.java index b5b97a63c..32411f492 100644 --- a/backend/src/main/java/com/openisle/dto/VerifyForgotRequest.java +++ b/backend/src/main/java/com/openisle/dto/VerifyForgotRequest.java @@ -5,6 +5,7 @@ import lombok.Data; /** Request to verify a forgot password code. */ @Data public class VerifyForgotRequest { - private String email; - private String code; + + private String email; + private String code; } diff --git a/backend/src/main/java/com/openisle/dto/VerifyRequest.java b/backend/src/main/java/com/openisle/dto/VerifyRequest.java index 08deec734..a1e1d7988 100644 --- a/backend/src/main/java/com/openisle/dto/VerifyRequest.java +++ b/backend/src/main/java/com/openisle/dto/VerifyRequest.java @@ -5,6 +5,7 @@ import lombok.Data; /** Request to verify a user registration. */ @Data public class VerifyRequest { - private String username; - private String code; + + private String username; + private String code; } diff --git a/backend/src/main/java/com/openisle/exception/FieldException.java b/backend/src/main/java/com/openisle/exception/FieldException.java index af56b3314..ef1e3552c 100644 --- a/backend/src/main/java/com/openisle/exception/FieldException.java +++ b/backend/src/main/java/com/openisle/exception/FieldException.java @@ -9,11 +9,11 @@ import lombok.Getter; */ @Getter public class FieldException extends RuntimeException { - private final String field; - public FieldException(String field, String message) { - super(message); - this.field = field; - } + private final String field; + + public FieldException(String field, String message) { + super(message); + this.field = field; + } } - diff --git a/backend/src/main/java/com/openisle/exception/NotFoundException.java b/backend/src/main/java/com/openisle/exception/NotFoundException.java index 875c41bfb..2a3422c7f 100644 --- a/backend/src/main/java/com/openisle/exception/NotFoundException.java +++ b/backend/src/main/java/com/openisle/exception/NotFoundException.java @@ -4,7 +4,8 @@ package com.openisle.exception; * Exception representing a missing resource such as a post or user. */ public class NotFoundException extends RuntimeException { - public NotFoundException(String message) { - super(message); - } + + public NotFoundException(String message) { + super(message); + } } diff --git a/backend/src/main/java/com/openisle/exception/RateLimitException.java b/backend/src/main/java/com/openisle/exception/RateLimitException.java index bd321980c..ab3e07a90 100644 --- a/backend/src/main/java/com/openisle/exception/RateLimitException.java +++ b/backend/src/main/java/com/openisle/exception/RateLimitException.java @@ -4,7 +4,8 @@ package com.openisle.exception; * Exception thrown when a user exceeds allowed action rate. */ public class RateLimitException extends RuntimeException { - public RateLimitException(String message) { - super(message); - } + + public RateLimitException(String message) { + super(message); + } } diff --git a/backend/src/main/java/com/openisle/mapper/ActivityMapper.java b/backend/src/main/java/com/openisle/mapper/ActivityMapper.java index 5c273f7f1..a0452eb14 100644 --- a/backend/src/main/java/com/openisle/mapper/ActivityMapper.java +++ b/backend/src/main/java/com/openisle/mapper/ActivityMapper.java @@ -8,16 +8,16 @@ import org.springframework.stereotype.Component; @Component public class ActivityMapper { - public ActivityDto toDto(Activity a) { - ActivityDto dto = new ActivityDto(); - dto.setId(a.getId()); - dto.setTitle(a.getTitle()); - dto.setIcon(a.getIcon()); - dto.setContent(a.getContent()); - dto.setStartTime(a.getStartTime()); - dto.setEndTime(a.getEndTime()); - dto.setType(a.getType()); - dto.setEnded(a.isEnded()); - return dto; - } + public ActivityDto toDto(Activity a) { + ActivityDto dto = new ActivityDto(); + dto.setId(a.getId()); + dto.setTitle(a.getTitle()); + dto.setIcon(a.getIcon()); + dto.setContent(a.getContent()); + dto.setStartTime(a.getStartTime()); + dto.setEndTime(a.getEndTime()); + dto.setType(a.getType()); + dto.setEnded(a.isEnded()); + return dto; + } } diff --git a/backend/src/main/java/com/openisle/mapper/CategoryMapper.java b/backend/src/main/java/com/openisle/mapper/CategoryMapper.java index cd078b677..ec221fb8e 100644 --- a/backend/src/main/java/com/openisle/mapper/CategoryMapper.java +++ b/backend/src/main/java/com/openisle/mapper/CategoryMapper.java @@ -8,18 +8,18 @@ import org.springframework.stereotype.Component; @Component public class CategoryMapper { - public CategoryDto toDto(Category c) { - return toDto(c, null); - } + public CategoryDto toDto(Category c) { + return toDto(c, null); + } - public CategoryDto toDto(Category c, Long count) { - CategoryDto dto = new CategoryDto(); - dto.setId(c.getId()); - dto.setName(c.getName()); - dto.setDescription(c.getDescription()); - dto.setIcon(c.getIcon()); - dto.setSmallIcon(c.getSmallIcon()); - dto.setCount(count); - return dto; - } + public CategoryDto toDto(Category c, Long count) { + CategoryDto dto = new CategoryDto(); + dto.setId(c.getId()); + dto.setName(c.getName()); + dto.setDescription(c.getDescription()); + dto.setIcon(c.getIcon()); + dto.setSmallIcon(c.getSmallIcon()); + dto.setCount(count); + return dto; + } } diff --git a/backend/src/main/java/com/openisle/mapper/CommentMapper.java b/backend/src/main/java/com/openisle/mapper/CommentMapper.java index a83fb44c3..cdfcf2935 100644 --- a/backend/src/main/java/com/openisle/mapper/CommentMapper.java +++ b/backend/src/main/java/com/openisle/mapper/CommentMapper.java @@ -4,40 +4,47 @@ import com.openisle.dto.CommentDto; import com.openisle.model.Comment; import com.openisle.service.CommentService; import com.openisle.service.ReactionService; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import java.util.stream.Collectors; - /** Mapper for comments including replies and reactions. */ @Component @RequiredArgsConstructor public class CommentMapper { - private final CommentService commentService; - private final ReactionService reactionService; - private final ReactionMapper reactionMapper; - private final UserMapper userMapper; + private final CommentService commentService; + private final ReactionService reactionService; + private final ReactionMapper reactionMapper; + private final UserMapper userMapper; - public CommentDto toDto(Comment comment) { - CommentDto dto = new CommentDto(); - dto.setId(comment.getId()); - dto.setContent(comment.getContent()); - dto.setCreatedAt(comment.getCreatedAt()); - dto.setPinnedAt(comment.getPinnedAt()); - dto.setAuthor(userMapper.toAuthorDto(comment.getAuthor())); - dto.setReward(0); - return dto; - } + public CommentDto toDto(Comment comment) { + CommentDto dto = new CommentDto(); + dto.setId(comment.getId()); + dto.setContent(comment.getContent()); + dto.setCreatedAt(comment.getCreatedAt()); + dto.setPinnedAt(comment.getPinnedAt()); + dto.setAuthor(userMapper.toAuthorDto(comment.getAuthor())); + dto.setReward(0); + return dto; + } - public CommentDto toDtoWithReplies(Comment comment) { - CommentDto dto = toDto(comment); - dto.setReplies(commentService.getReplies(comment.getId()).stream() - .map(this::toDtoWithReplies) - .collect(Collectors.toList())); - dto.setReactions(reactionService.getReactionsForComment(comment.getId()).stream() - .map(reactionMapper::toDto) - .collect(Collectors.toList())); - return dto; - } + public CommentDto toDtoWithReplies(Comment comment) { + CommentDto dto = toDto(comment); + dto.setReplies( + commentService + .getReplies(comment.getId()) + .stream() + .map(this::toDtoWithReplies) + .collect(Collectors.toList()) + ); + dto.setReactions( + reactionService + .getReactionsForComment(comment.getId()) + .stream() + .map(reactionMapper::toDto) + .collect(Collectors.toList()) + ); + return dto; + } } diff --git a/backend/src/main/java/com/openisle/mapper/DraftMapper.java b/backend/src/main/java/com/openisle/mapper/DraftMapper.java index 5e93fb05d..23d845c95 100644 --- a/backend/src/main/java/com/openisle/mapper/DraftMapper.java +++ b/backend/src/main/java/com/openisle/mapper/DraftMapper.java @@ -2,23 +2,28 @@ package com.openisle.mapper; import com.openisle.dto.DraftDto; import com.openisle.model.Draft; -import org.springframework.stereotype.Component; - import java.util.stream.Collectors; +import org.springframework.stereotype.Component; /** Mapper for draft entities. */ @Component public class DraftMapper { - public DraftDto toDto(Draft draft) { - DraftDto dto = new DraftDto(); - dto.setId(draft.getId()); - dto.setTitle(draft.getTitle()); - dto.setContent(draft.getContent()); - if (draft.getCategory() != null) { - dto.setCategoryId(draft.getCategory().getId()); - } - dto.setTagIds(draft.getTags().stream().map(tag -> tag.getId()).collect(Collectors.toList())); - return dto; + public DraftDto toDto(Draft draft) { + DraftDto dto = new DraftDto(); + dto.setId(draft.getId()); + dto.setTitle(draft.getTitle()); + dto.setContent(draft.getContent()); + if (draft.getCategory() != null) { + dto.setCategoryId(draft.getCategory().getId()); } + dto.setTagIds( + draft + .getTags() + .stream() + .map(tag -> tag.getId()) + .collect(Collectors.toList()) + ); + return dto; + } } diff --git a/backend/src/main/java/com/openisle/mapper/NotificationMapper.java b/backend/src/main/java/com/openisle/mapper/NotificationMapper.java index 26bc751b3..078224284 100644 --- a/backend/src/main/java/com/openisle/mapper/NotificationMapper.java +++ b/backend/src/main/java/com/openisle/mapper/NotificationMapper.java @@ -12,36 +12,36 @@ import org.springframework.stereotype.Component; @RequiredArgsConstructor public class NotificationMapper { - private final CommentMapper commentMapper; - private final UserMapper userMapper; + private final CommentMapper commentMapper; + private final UserMapper userMapper; - public NotificationDto toDto(Notification n) { - NotificationDto dto = new NotificationDto(); - dto.setId(n.getId()); - dto.setType(n.getType()); - if (n.getPost() != null) { - PostSummaryDto postDto = new PostSummaryDto(); - postDto.setId(n.getPost().getId()); - postDto.setTitle(n.getPost().getTitle()); - dto.setPost(postDto); - } - if (n.getComment() != null) { - dto.setComment(commentMapper.toDto(n.getComment())); - Comment parent = n.getComment().getParent(); - if (parent != null) { - dto.setParentComment(commentMapper.toDto(parent)); - } - } - if (n.getFromUser() != null) { - dto.setFromUser(userMapper.toAuthorDto(n.getFromUser())); - } - if (n.getReactionType() != null) { - dto.setReactionType(n.getReactionType()); - } - dto.setApproved(n.getApproved()); - dto.setContent(n.getContent()); - dto.setRead(n.isRead()); - dto.setCreatedAt(n.getCreatedAt()); - return dto; + public NotificationDto toDto(Notification n) { + NotificationDto dto = new NotificationDto(); + dto.setId(n.getId()); + dto.setType(n.getType()); + if (n.getPost() != null) { + PostSummaryDto postDto = new PostSummaryDto(); + postDto.setId(n.getPost().getId()); + postDto.setTitle(n.getPost().getTitle()); + dto.setPost(postDto); } + if (n.getComment() != null) { + dto.setComment(commentMapper.toDto(n.getComment())); + Comment parent = n.getComment().getParent(); + if (parent != null) { + dto.setParentComment(commentMapper.toDto(parent)); + } + } + if (n.getFromUser() != null) { + dto.setFromUser(userMapper.toAuthorDto(n.getFromUser())); + } + if (n.getReactionType() != null) { + dto.setReactionType(n.getReactionType()); + } + dto.setApproved(n.getApproved()); + dto.setContent(n.getContent()); + dto.setRead(n.isRead()); + dto.setCreatedAt(n.getCreatedAt()); + return dto; + } } diff --git a/backend/src/main/java/com/openisle/mapper/PointGoodMapper.java b/backend/src/main/java/com/openisle/mapper/PointGoodMapper.java index f0f2f2770..f390ed47c 100644 --- a/backend/src/main/java/com/openisle/mapper/PointGoodMapper.java +++ b/backend/src/main/java/com/openisle/mapper/PointGoodMapper.java @@ -7,12 +7,13 @@ import org.springframework.stereotype.Component; /** Mapper for point mall goods. */ @Component public class PointGoodMapper { - public PointGoodDto toDto(PointGood good) { - PointGoodDto dto = new PointGoodDto(); - dto.setId(good.getId()); - dto.setName(good.getName()); - dto.setCost(good.getCost()); - dto.setImage(good.getImage()); - return dto; - } + + public PointGoodDto toDto(PointGood good) { + PointGoodDto dto = new PointGoodDto(); + dto.setId(good.getId()); + dto.setName(good.getName()); + dto.setCost(good.getCost()); + dto.setImage(good.getImage()); + return dto; + } } diff --git a/backend/src/main/java/com/openisle/mapper/PointHistoryMapper.java b/backend/src/main/java/com/openisle/mapper/PointHistoryMapper.java index 9a3881d5a..d6684a3b4 100644 --- a/backend/src/main/java/com/openisle/mapper/PointHistoryMapper.java +++ b/backend/src/main/java/com/openisle/mapper/PointHistoryMapper.java @@ -6,29 +6,30 @@ import org.springframework.stereotype.Component; @Component public class PointHistoryMapper { - public PointHistoryDto toDto(PointHistory history) { - PointHistoryDto dto = new PointHistoryDto(); - dto.setId(history.getId()); - dto.setType(history.getType()); - dto.setAmount(history.getAmount()); - dto.setBalance(history.getBalance()); - dto.setCreatedAt(history.getCreatedAt()); - if (history.getPost() != null) { - dto.setPostId(history.getPost().getId()); - dto.setPostTitle(history.getPost().getTitle()); - } - if (history.getComment() != null) { - dto.setCommentId(history.getComment().getId()); - dto.setCommentContent(history.getComment().getContent()); - if (history.getComment().getPost() != null && dto.getPostId() == null) { - dto.setPostId(history.getComment().getPost().getId()); - dto.setPostTitle(history.getComment().getPost().getTitle()); - } - } - if (history.getFromUser() != null) { - dto.setFromUserId(history.getFromUser().getId()); - dto.setFromUserName(history.getFromUser().getUsername()); - } - return dto; + + public PointHistoryDto toDto(PointHistory history) { + PointHistoryDto dto = new PointHistoryDto(); + dto.setId(history.getId()); + dto.setType(history.getType()); + dto.setAmount(history.getAmount()); + dto.setBalance(history.getBalance()); + dto.setCreatedAt(history.getCreatedAt()); + if (history.getPost() != null) { + dto.setPostId(history.getPost().getId()); + dto.setPostTitle(history.getPost().getTitle()); } + if (history.getComment() != null) { + dto.setCommentId(history.getComment().getId()); + dto.setCommentContent(history.getComment().getContent()); + if (history.getComment().getPost() != null && dto.getPostId() == null) { + dto.setPostId(history.getComment().getPost().getId()); + dto.setPostTitle(history.getComment().getPost().getTitle()); + } + } + if (history.getFromUser() != null) { + dto.setFromUserId(history.getFromUser().getId()); + dto.setFromUserName(history.getFromUser().getUsername()); + } + return dto; + } } diff --git a/backend/src/main/java/com/openisle/mapper/PostChangeLogMapper.java b/backend/src/main/java/com/openisle/mapper/PostChangeLogMapper.java index e7c380256..52611c5d7 100644 --- a/backend/src/main/java/com/openisle/mapper/PostChangeLogMapper.java +++ b/backend/src/main/java/com/openisle/mapper/PostChangeLogMapper.java @@ -6,87 +6,88 @@ import com.openisle.dto.TagDto; import com.openisle.model.*; import com.openisle.repository.CategoryRepository; import com.openisle.repository.TagRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; @Component @RequiredArgsConstructor public class PostChangeLogMapper { - private final CategoryRepository categoryRepository; - private final TagRepository tagRepository; - private final CategoryMapper categoryMapper; - private final TagMapper tagMapper; + private final CategoryRepository categoryRepository; + private final TagRepository tagRepository; + private final CategoryMapper categoryMapper; + private final TagMapper tagMapper; - public PostChangeLogDto toDto(PostChangeLog log) { - PostChangeLogDto dto = new PostChangeLogDto(); - dto.setId(log.getId()); - if (log.getUser() != null) { - dto.setUsername(log.getUser().getUsername()); - dto.setUserAvatar(log.getUser().getAvatar()); - } - dto.setType(log.getType()); - dto.setTime(log.getCreatedAt()); - if (log instanceof PostTitleChangeLog t) { - dto.setOldTitle(t.getOldTitle()); - dto.setNewTitle(t.getNewTitle()); - } else if (log instanceof PostContentChangeLog c) { - dto.setOldContent(c.getOldContent()); - dto.setNewContent(c.getNewContent()); - } else if (log instanceof PostCategoryChangeLog cat) { - dto.setOldCategory(mapCategory(cat.getOldCategory())); - dto.setNewCategory(mapCategory(cat.getNewCategory())); - } else if (log instanceof PostTagChangeLog tag) { - dto.setOldTags(mapTags(tag.getOldTags())); - dto.setNewTags(mapTags(tag.getNewTags())); - } else if (log instanceof PostClosedChangeLog cl) { - dto.setOldClosed(cl.isOldClosed()); - dto.setNewClosed(cl.isNewClosed()); - } else if (log instanceof PostPinnedChangeLog p) { - dto.setOldPinnedAt(p.getOldPinnedAt()); - dto.setNewPinnedAt(p.getNewPinnedAt()); - } else if (log instanceof PostFeaturedChangeLog f) { - dto.setOldFeatured(f.isOldFeatured()); - dto.setNewFeatured(f.isNewFeatured()); - } + public PostChangeLogDto toDto(PostChangeLog log) { + PostChangeLogDto dto = new PostChangeLogDto(); + dto.setId(log.getId()); + if (log.getUser() != null) { + dto.setUsername(log.getUser().getUsername()); + dto.setUserAvatar(log.getUser().getAvatar()); + } + dto.setType(log.getType()); + dto.setTime(log.getCreatedAt()); + if (log instanceof PostTitleChangeLog t) { + dto.setOldTitle(t.getOldTitle()); + dto.setNewTitle(t.getNewTitle()); + } else if (log instanceof PostContentChangeLog c) { + dto.setOldContent(c.getOldContent()); + dto.setNewContent(c.getNewContent()); + } else if (log instanceof PostCategoryChangeLog cat) { + dto.setOldCategory(mapCategory(cat.getOldCategory())); + dto.setNewCategory(mapCategory(cat.getNewCategory())); + } else if (log instanceof PostTagChangeLog tag) { + dto.setOldTags(mapTags(tag.getOldTags())); + dto.setNewTags(mapTags(tag.getNewTags())); + } else if (log instanceof PostClosedChangeLog cl) { + dto.setOldClosed(cl.isOldClosed()); + dto.setNewClosed(cl.isNewClosed()); + } else if (log instanceof PostPinnedChangeLog p) { + dto.setOldPinnedAt(p.getOldPinnedAt()); + dto.setNewPinnedAt(p.getNewPinnedAt()); + } else if (log instanceof PostFeaturedChangeLog f) { + dto.setOldFeatured(f.isOldFeatured()); + dto.setNewFeatured(f.isNewFeatured()); + } + return dto; + } + + private CategoryDto mapCategory(String name) { + if (name == null) { + return null; + } + return categoryRepository + .findByName(name) + .map(categoryMapper::toDto) + .orElseGet(() -> { + CategoryDto dto = new CategoryDto(); + dto.setName(name); return dto; - } + }); + } - private CategoryDto mapCategory(String name) { - if (name == null) { - return null; - } - return categoryRepository.findByName(name) - .map(categoryMapper::toDto) - .orElseGet(() -> { - CategoryDto dto = new CategoryDto(); - dto.setName(name); - return dto; - }); + private List mapTags(String tags) { + if (tags == null || tags.isBlank()) { + return Collections.emptyList(); } + return Arrays.stream(tags.split(",")) + .map(String::trim) + .map(this::mapTag) + .collect(Collectors.toList()); + } - private List mapTags(String tags) { - if (tags == null || tags.isBlank()) { - return Collections.emptyList(); - } - return Arrays.stream(tags.split(",")) - .map(String::trim) - .map(this::mapTag) - .collect(Collectors.toList()); - } - - private TagDto mapTag(String name) { - return tagRepository.findByName(name) - .map(tagMapper::toDto) - .orElseGet(() -> { - TagDto dto = new TagDto(); - dto.setName(name); - return dto; - }); - } + private TagDto mapTag(String name) { + return tagRepository + .findByName(name) + .map(tagMapper::toDto) + .orElseGet(() -> { + TagDto dto = new TagDto(); + dto.setName(name); + return dto; + }); + } } diff --git a/backend/src/main/java/com/openisle/mapper/PostMapper.java b/backend/src/main/java/com/openisle/mapper/PostMapper.java index 2b688d57d..d09e0e896 100644 --- a/backend/src/main/java/com/openisle/mapper/PostMapper.java +++ b/backend/src/main/java/com/openisle/mapper/PostMapper.java @@ -55,10 +55,7 @@ public class PostMapper { .map(commentMapper::toDtoWithReplies) .collect(Collectors.toList()); dto.setComments(comments); - dto.setSubscribed( - viewer != null && - subscriptionService.isPostSubscribed(viewer, post.getId()) - ); + dto.setSubscribed(viewer != null && subscriptionService.isPostSubscribed(viewer, post.getId())); return dto; } @@ -70,9 +67,7 @@ public class PostMapper { dto.setCreatedAt(post.getCreatedAt()); dto.setAuthor(userMapper.toAuthorDto(post.getAuthor())); dto.setCategory(categoryMapper.toDto(post.getCategory())); - dto.setTags( - post.getTags().stream().map(tagMapper::toDto).collect(Collectors.toList()) - ); + dto.setTags(post.getTags().stream().map(tagMapper::toDto).collect(Collectors.toList())); dto.setViews(post.getViews()); dto.setStatus(post.getStatus()); dto.setPinnedAt(post.getPinnedAt()); @@ -88,10 +83,7 @@ public class PostMapper { List participants = commentService.getParticipants(post.getId(), 5); dto.setParticipants( - participants - .stream() - .map(userMapper::toAuthorDto) - .collect(Collectors.toList()) + participants.stream().map(userMapper::toAuthorDto).collect(Collectors.toList()) ); LocalDateTime last = post.getLastReplyAt(); @@ -113,18 +105,10 @@ public class PostMapper { l.setStartTime(lp.getStartTime()); l.setEndTime(lp.getEndTime()); l.setParticipants( - lp - .getParticipants() - .stream() - .map(userMapper::toAuthorDto) - .collect(Collectors.toList()) + lp.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList()) ); l.setWinners( - lp - .getWinners() - .stream() - .map(userMapper::toAuthorDto) - .collect(Collectors.toList()) + lp.getWinners().stream().map(userMapper::toAuthorDto).collect(Collectors.toList()) ); dto.setLottery(l); } @@ -135,11 +119,7 @@ public class PostMapper { p.setVotes(pp.getVotes()); p.setEndTime(pp.getEndTime()); p.setParticipants( - pp - .getParticipants() - .stream() - .map(userMapper::toAuthorDto) - .collect(Collectors.toList()) + pp.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList()) ); Map> optionParticipants = pollVoteRepository .findByPostId(pp.getId()) @@ -147,10 +127,7 @@ public class PostMapper { .collect( Collectors.groupingBy( PollVote::getOptionIndex, - Collectors.mapping( - v -> userMapper.toAuthorDto(v.getUser()), - Collectors.toList() - ) + Collectors.mapping(v -> userMapper.toAuthorDto(v.getUser()), Collectors.toList()) ) ); p.setOptionParticipants(optionParticipants); diff --git a/backend/src/main/java/com/openisle/mapper/ReactionMapper.java b/backend/src/main/java/com/openisle/mapper/ReactionMapper.java index 8e23fb0b5..c4055c76d 100644 --- a/backend/src/main/java/com/openisle/mapper/ReactionMapper.java +++ b/backend/src/main/java/com/openisle/mapper/ReactionMapper.java @@ -8,21 +8,21 @@ import org.springframework.stereotype.Component; @Component public class ReactionMapper { - public ReactionDto toDto(Reaction reaction) { - ReactionDto dto = new ReactionDto(); - dto.setId(reaction.getId()); - dto.setType(reaction.getType()); - dto.setUser(reaction.getUser().getUsername()); - if (reaction.getPost() != null) { - dto.setPostId(reaction.getPost().getId()); - } - if (reaction.getComment() != null) { - dto.setCommentId(reaction.getComment().getId()); - } - if (reaction.getMessage() != null) { - dto.setMessageId(reaction.getMessage().getId()); - } - dto.setReward(0); - return dto; + public ReactionDto toDto(Reaction reaction) { + ReactionDto dto = new ReactionDto(); + dto.setId(reaction.getId()); + dto.setType(reaction.getType()); + dto.setUser(reaction.getUser().getUsername()); + if (reaction.getPost() != null) { + dto.setPostId(reaction.getPost().getId()); } + if (reaction.getComment() != null) { + dto.setCommentId(reaction.getComment().getId()); + } + if (reaction.getMessage() != null) { + dto.setMessageId(reaction.getMessage().getId()); + } + dto.setReward(0); + return dto; + } } diff --git a/backend/src/main/java/com/openisle/mapper/TagMapper.java b/backend/src/main/java/com/openisle/mapper/TagMapper.java index b19fbfffa..fcca984aa 100644 --- a/backend/src/main/java/com/openisle/mapper/TagMapper.java +++ b/backend/src/main/java/com/openisle/mapper/TagMapper.java @@ -8,19 +8,19 @@ import org.springframework.stereotype.Component; @Component public class TagMapper { - public TagDto toDto(Tag tag) { - return toDto(tag, null); - } + public TagDto toDto(Tag tag) { + return toDto(tag, null); + } - public TagDto toDto(Tag tag, Long count) { - TagDto dto = new TagDto(); - dto.setId(tag.getId()); - dto.setName(tag.getName()); - dto.setDescription(tag.getDescription()); - dto.setIcon(tag.getIcon()); - dto.setSmallIcon(tag.getSmallIcon()); - dto.setCreatedAt(tag.getCreatedAt()); - dto.setCount(count); - return dto; - } + public TagDto toDto(Tag tag, Long count) { + TagDto dto = new TagDto(); + dto.setId(tag.getId()); + dto.setName(tag.getName()); + dto.setDescription(tag.getDescription()); + dto.setIcon(tag.getIcon()); + dto.setSmallIcon(tag.getSmallIcon()); + dto.setCreatedAt(tag.getCreatedAt()); + dto.setCount(count); + return dto; + } } diff --git a/backend/src/main/java/com/openisle/mapper/UserMapper.java b/backend/src/main/java/com/openisle/mapper/UserMapper.java index e96c64a11..ea5e85b67 100644 --- a/backend/src/main/java/com/openisle/mapper/UserMapper.java +++ b/backend/src/main/java/com/openisle/mapper/UserMapper.java @@ -15,95 +15,97 @@ import org.springframework.stereotype.Component; @RequiredArgsConstructor public class UserMapper { - private final SubscriptionService subscriptionService; - private final PostService postService; - private final CommentService commentService; - private final ReactionService reactionService; - private final UserVisitService userVisitService; - private final PostReadService postReadService; - private final LevelService levelService; - private final MedalService medalService; + private final SubscriptionService subscriptionService; + private final PostService postService; + private final CommentService commentService; + private final ReactionService reactionService; + private final UserVisitService userVisitService; + private final PostReadService postReadService; + private final LevelService levelService; + private final MedalService medalService; - @Value("${app.snippet-length:50}") - private int snippetLength; + @Value("${app.snippet-length:50}") + private int snippetLength; - public AuthorDto toAuthorDto(User user) { - medalService.ensureDisplayMedal(user); - AuthorDto dto = new AuthorDto(); - dto.setId(user.getId()); - dto.setUsername(user.getUsername()); - dto.setAvatar(user.getAvatar()); - dto.setDisplayMedal(user.getDisplayMedal()); - return dto; + public AuthorDto toAuthorDto(User user) { + medalService.ensureDisplayMedal(user); + AuthorDto dto = new AuthorDto(); + dto.setId(user.getId()); + dto.setUsername(user.getUsername()); + dto.setAvatar(user.getAvatar()); + dto.setDisplayMedal(user.getDisplayMedal()); + return dto; + } + + public UserDto toDto(User user, Authentication viewer) { + medalService.ensureDisplayMedal(user); + UserDto dto = new UserDto(); + dto.setId(user.getId()); + dto.setUsername(user.getUsername()); + dto.setEmail(user.getEmail()); + dto.setAvatar(user.getAvatar()); + dto.setRole(user.getRole().name()); + dto.setIntroduction(user.getIntroduction()); + dto.setFollowers(subscriptionService.countSubscribers(user.getUsername())); + dto.setFollowing(subscriptionService.countSubscribed(user.getUsername())); + dto.setCreatedAt(user.getCreatedAt()); + dto.setLastPostTime(postService.getLastPostTime(user.getUsername())); + dto.setLastCommentTime(commentService.getLastCommentTimeOfUserByUserId(user.getId())); + dto.setTotalViews(postService.getTotalViews(user.getUsername())); + dto.setVisitedDays(userVisitService.countVisits(user.getUsername())); + dto.setReadPosts(postReadService.countReads(user.getUsername())); + dto.setLikesSent(reactionService.countLikesSent(user.getUsername())); + dto.setLikesReceived(reactionService.countLikesReceived(user.getUsername())); + dto.setExperience(user.getExperience()); + dto.setPoint(user.getPoint()); + dto.setCurrentLevel(levelService.getLevel(user.getExperience())); + dto.setNextLevelExp(levelService.nextLevelExp(user.getExperience())); + if (viewer != null) { + dto.setSubscribed(subscriptionService.isSubscribed(viewer.getName(), user.getUsername())); + } else { + dto.setSubscribed(false); } + return dto; + } - public UserDto toDto(User user, Authentication viewer) { - medalService.ensureDisplayMedal(user); - UserDto dto = new UserDto(); - dto.setId(user.getId()); - dto.setUsername(user.getUsername()); - dto.setEmail(user.getEmail()); - dto.setAvatar(user.getAvatar()); - dto.setRole(user.getRole().name()); - dto.setIntroduction(user.getIntroduction()); - dto.setFollowers(subscriptionService.countSubscribers(user.getUsername())); - dto.setFollowing(subscriptionService.countSubscribed(user.getUsername())); - dto.setCreatedAt(user.getCreatedAt()); - dto.setLastPostTime(postService.getLastPostTime(user.getUsername())); - dto.setLastCommentTime(commentService.getLastCommentTimeOfUserByUserId(user.getId())); - dto.setTotalViews(postService.getTotalViews(user.getUsername())); - dto.setVisitedDays(userVisitService.countVisits(user.getUsername())); - dto.setReadPosts(postReadService.countReads(user.getUsername())); - dto.setLikesSent(reactionService.countLikesSent(user.getUsername())); - dto.setLikesReceived(reactionService.countLikesReceived(user.getUsername())); - dto.setExperience(user.getExperience()); - dto.setPoint(user.getPoint()); - dto.setCurrentLevel(levelService.getLevel(user.getExperience())); - dto.setNextLevelExp(levelService.nextLevelExp(user.getExperience())); - if (viewer != null) { - dto.setSubscribed(subscriptionService.isSubscribed(viewer.getName(), user.getUsername())); - } else { - dto.setSubscribed(false); - } - return dto; - } + public UserDto toDto(User user) { + return toDto(user, null); + } - public UserDto toDto(User user) { - return toDto(user, null); + public PostMetaDto toMetaDto(Post post) { + PostMetaDto dto = new PostMetaDto(); + dto.setId(post.getId()); + dto.setTitle(post.getTitle()); + String content = post.getContent(); + if (content == null) { + content = ""; } + if (snippetLength >= 0) { + dto.setSnippet( + content.length() > snippetLength ? content.substring(0, snippetLength) : content + ); + } else { + dto.setSnippet(content); + } + dto.setCreatedAt(post.getCreatedAt()); + dto.setCategory(post.getCategory().getName()); + dto.setViews(post.getViews()); + return dto; + } - public PostMetaDto toMetaDto(Post post) { - PostMetaDto dto = new PostMetaDto(); - dto.setId(post.getId()); - dto.setTitle(post.getTitle()); - String content = post.getContent(); - if (content == null) { - content = ""; - } - if (snippetLength >= 0) { - dto.setSnippet(content.length() > snippetLength ? content.substring(0, snippetLength) : content); - } else { - dto.setSnippet(content); - } - dto.setCreatedAt(post.getCreatedAt()); - dto.setCategory(post.getCategory().getName()); - dto.setViews(post.getViews()); - return dto; - } - - public CommentInfoDto toCommentInfoDto(Comment comment) { - CommentInfoDto dto = new CommentInfoDto(); - dto.setId(comment.getId()); - dto.setContent(comment.getContent()); - dto.setCreatedAt(comment.getCreatedAt()); - dto.setPost(toMetaDto(comment.getPost())); - if (comment.getParent() != null) { - ParentCommentDto pc = new ParentCommentDto(); - pc.setId(comment.getParent().getId()); - pc.setAuthor(comment.getParent().getAuthor().getUsername()); - pc.setContent(comment.getParent().getContent()); - dto.setParentComment(pc); - } - return dto; + public CommentInfoDto toCommentInfoDto(Comment comment) { + CommentInfoDto dto = new CommentInfoDto(); + dto.setId(comment.getId()); + dto.setContent(comment.getContent()); + dto.setCreatedAt(comment.getCreatedAt()); + dto.setPost(toMetaDto(comment.getPost())); + if (comment.getParent() != null) { + ParentCommentDto pc = new ParentCommentDto(); + pc.setId(comment.getParent().getId()); + pc.setAuthor(comment.getParent().getAuthor().getUsername()); + pc.setContent(comment.getParent().getContent()); + dto.setParentComment(pc); } + return dto; + } } diff --git a/backend/src/main/java/com/openisle/model/Activity.java b/backend/src/main/java/com/openisle/model/Activity.java index 715231c9a..eed463d5f 100644 --- a/backend/src/main/java/com/openisle/model/Activity.java +++ b/backend/src/main/java/com/openisle/model/Activity.java @@ -1,15 +1,14 @@ package com.openisle.model; import jakarta.persistence.*; +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Set; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import org.hibernate.annotations.CreationTimestamp; -import java.time.LocalDateTime; -import java.util.HashSet; -import java.util.Set; - /** Generic activity entity. */ @Entity @Getter @@ -17,34 +16,37 @@ import java.util.Set; @NoArgsConstructor @Table(name = "activities") public class Activity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - @Column(nullable = false) - private String title; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - private String icon; + @Column(nullable = false) + private String title; - private String content; + private String icon; - @Column(name = "start_time", nullable = false) - @CreationTimestamp - private LocalDateTime startTime; + private String content; - @Column(name = "end_time") - private LocalDateTime endTime; + @Column(name = "start_time", nullable = false) + @CreationTimestamp + private LocalDateTime startTime; - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private ActivityType type = ActivityType.NORMAL; + @Column(name = "end_time") + private LocalDateTime endTime; - @ManyToMany(fetch = FetchType.LAZY) - @JoinTable(name = "activity_participants", - joinColumns = @JoinColumn(name = "activity_id"), - inverseJoinColumns = @JoinColumn(name = "user_id")) - private Set participants = new HashSet<>(); + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ActivityType type = ActivityType.NORMAL; - @Column(nullable = false) - private boolean ended = false; + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable( + name = "activity_participants", + joinColumns = @JoinColumn(name = "activity_id"), + inverseJoinColumns = @JoinColumn(name = "user_id") + ) + private Set participants = new HashSet<>(); + + @Column(nullable = false) + private boolean ended = false; } diff --git a/backend/src/main/java/com/openisle/model/ActivityType.java b/backend/src/main/java/com/openisle/model/ActivityType.java index 1312f0d4d..a5f176e81 100644 --- a/backend/src/main/java/com/openisle/model/ActivityType.java +++ b/backend/src/main/java/com/openisle/model/ActivityType.java @@ -2,7 +2,7 @@ package com.openisle.model; /** Activity type enumeration. */ public enum ActivityType { - NORMAL, - MILK_TEA, - INVITE_POINTS + NORMAL, + MILK_TEA, + INVITE_POINTS, } diff --git a/backend/src/main/java/com/openisle/model/AiFormatUsage.java b/backend/src/main/java/com/openisle/model/AiFormatUsage.java index 7c000a94f..7c545309e 100644 --- a/backend/src/main/java/com/openisle/model/AiFormatUsage.java +++ b/backend/src/main/java/com/openisle/model/AiFormatUsage.java @@ -1,31 +1,33 @@ package com.openisle.model; import jakarta.persistence.*; +import java.time.LocalDate; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -import java.time.LocalDate; - /** Daily count of AI markdown formatting usage for a user. */ @Entity @Getter @Setter @NoArgsConstructor -@Table(name = "ai_format_usage", - uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "use_date"})) +@Table( + name = "ai_format_usage", + uniqueConstraints = @UniqueConstraint(columnNames = { "user_id", "use_date" }) +) public class AiFormatUsage { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "user_id") - private User user; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @Column(name = "use_date", nullable = false) - private LocalDate useDate; + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id") + private User user; - @Column(nullable = false) - private int count; + @Column(name = "use_date", nullable = false) + private LocalDate useDate; + + @Column(nullable = false) + private int count; } diff --git a/backend/src/main/java/com/openisle/model/Category.java b/backend/src/main/java/com/openisle/model/Category.java index f768bde33..73b34fb5a 100644 --- a/backend/src/main/java/com/openisle/model/Category.java +++ b/backend/src/main/java/com/openisle/model/Category.java @@ -11,19 +11,20 @@ import lombok.Setter; @NoArgsConstructor @Table(name = "categories") public class Category { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - @Column(nullable = false, unique = true) - private String name; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @Column(nullable = false) - private String icon; + @Column(nullable = false, unique = true) + private String name; - @Column - private String smallIcon; + @Column(nullable = false) + private String icon; - @Column(name = "description", nullable = false) - private String description; + @Column + private String smallIcon; + + @Column(name = "description", nullable = false) + private String description; } diff --git a/backend/src/main/java/com/openisle/model/Comment.java b/backend/src/main/java/com/openisle/model/Comment.java index 377ff4537..7e32694dd 100644 --- a/backend/src/main/java/com/openisle/model/Comment.java +++ b/backend/src/main/java/com/openisle/model/Comment.java @@ -1,6 +1,7 @@ package com.openisle.model; import jakarta.persistence.*; +import java.time.LocalDateTime; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @@ -8,8 +9,6 @@ import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.SQLDelete; import org.hibernate.annotations.Where; -import java.time.LocalDateTime; - @Entity @Getter @Setter @@ -18,34 +17,37 @@ import java.time.LocalDateTime; @SQLDelete(sql = "UPDATE comments SET deleted_at = CURRENT_TIMESTAMP(6) WHERE id = ?") @Where(clause = "deleted_at IS NULL") public class Comment { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - @Column(nullable = false, columnDefinition = "TEXT") - private String content; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @CreationTimestamp - @Column(nullable = false, updatable = false, - columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)") - private LocalDateTime createdAt; + @Column(nullable = false, columnDefinition = "TEXT") + private String content; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "author_id") - private User author; + @CreationTimestamp + @Column( + nullable = false, + updatable = false, + columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)" + ) + private LocalDateTime createdAt; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "post_id") - private Post post; + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "author_id") + private User author; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "parent_id") - private Comment parent; + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "post_id") + private Post post; - @Column - private LocalDateTime pinnedAt; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_id") + private Comment parent; - @Column(name = "deleted_at") - private LocalDateTime deletedAt; + @Column + private LocalDateTime pinnedAt; + @Column(name = "deleted_at") + private LocalDateTime deletedAt; } diff --git a/backend/src/main/java/com/openisle/model/CommentSort.java b/backend/src/main/java/com/openisle/model/CommentSort.java index 515a5465e..6d745971b 100644 --- a/backend/src/main/java/com/openisle/model/CommentSort.java +++ b/backend/src/main/java/com/openisle/model/CommentSort.java @@ -4,7 +4,7 @@ package com.openisle.model; * Sort options for comments. */ public enum CommentSort { - NEWEST, - OLDEST, - MOST_INTERACTIONS + NEWEST, + OLDEST, + MOST_INTERACTIONS, } diff --git a/backend/src/main/java/com/openisle/model/CommentSubscription.java b/backend/src/main/java/com/openisle/model/CommentSubscription.java index a301da175..6c0bff773 100644 --- a/backend/src/main/java/com/openisle/model/CommentSubscription.java +++ b/backend/src/main/java/com/openisle/model/CommentSubscription.java @@ -10,18 +10,21 @@ import lombok.Setter; @Getter @Setter @NoArgsConstructor -@Table(name = "comment_subscriptions", - uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "comment_id"})) +@Table( + name = "comment_subscriptions", + uniqueConstraints = @UniqueConstraint(columnNames = { "user_id", "comment_id" }) +) public class CommentSubscription { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "user_id") - private User user; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "comment_id") - private Comment comment; + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "comment_id") + private Comment comment; } diff --git a/backend/src/main/java/com/openisle/model/ContributorConfig.java b/backend/src/main/java/com/openisle/model/ContributorConfig.java index de94b0aa5..908cc4ba9 100644 --- a/backend/src/main/java/com/openisle/model/ContributorConfig.java +++ b/backend/src/main/java/com/openisle/model/ContributorConfig.java @@ -11,17 +11,17 @@ import lombok.Setter; @NoArgsConstructor @Table(name = "contributor_configs") public class ContributorConfig { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - @Column(nullable = false, unique = true) - private String userIname; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @Column(nullable = false, unique = true) - private String githubId; + @Column(nullable = false, unique = true) + private String userIname; - @Column(nullable = false) - private long contributionLines = 0; + @Column(nullable = false, unique = true) + private String githubId; + + @Column(nullable = false) + private long contributionLines = 0; } - diff --git a/backend/src/main/java/com/openisle/model/Draft.java b/backend/src/main/java/com/openisle/model/Draft.java index a40b33460..101135475 100644 --- a/backend/src/main/java/com/openisle/model/Draft.java +++ b/backend/src/main/java/com/openisle/model/Draft.java @@ -1,41 +1,41 @@ package com.openisle.model; import jakarta.persistence.*; +import java.util.HashSet; +import java.util.Set; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -import java.util.HashSet; -import java.util.Set; - @Entity @Getter @Setter @NoArgsConstructor -@Table(name = "drafts", uniqueConstraints = { - @UniqueConstraint(columnNames = {"author_id"}) -}) +@Table(name = "drafts", uniqueConstraints = { @UniqueConstraint(columnNames = { "author_id" }) }) public class Draft { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - private String title; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @Column(columnDefinition = "LONGTEXT") - private String content; + private String title; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "author_id") - private User author; + @Column(columnDefinition = "LONGTEXT") + private String content; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "category_id") - private Category category; + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "author_id") + private User author; - @ManyToMany(fetch = FetchType.LAZY) - @JoinTable(name = "draft_tags", - joinColumns = @JoinColumn(name = "draft_id"), - inverseJoinColumns = @JoinColumn(name = "tag_id")) - private Set tags = new HashSet<>(); + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "category_id") + private Category category; + + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable( + name = "draft_tags", + joinColumns = @JoinColumn(name = "draft_id"), + inverseJoinColumns = @JoinColumn(name = "tag_id") + ) + private Set tags = new HashSet<>(); } diff --git a/backend/src/main/java/com/openisle/model/ExperienceLog.java b/backend/src/main/java/com/openisle/model/ExperienceLog.java index 4edb53d55..342fff845 100644 --- a/backend/src/main/java/com/openisle/model/ExperienceLog.java +++ b/backend/src/main/java/com/openisle/model/ExperienceLog.java @@ -1,37 +1,39 @@ package com.openisle.model; import jakarta.persistence.*; +import java.time.LocalDate; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -import java.time.LocalDate; - /** Daily experience gain counts for a user. */ @Entity @Getter @Setter @NoArgsConstructor -@Table(name = "experience_logs", - uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "log_date"})) +@Table( + name = "experience_logs", + uniqueConstraints = @UniqueConstraint(columnNames = { "user_id", "log_date" }) +) public class ExperienceLog { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "user_id") - private User user; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @Column(name = "log_date", nullable = false) - private LocalDate logDate; + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id") + private User user; - @Column(name = "post_count", nullable = false) - private int postCount; + @Column(name = "log_date", nullable = false) + private LocalDate logDate; - @Column(name = "comment_count", nullable = false) - private int commentCount; + @Column(name = "post_count", nullable = false) + private int postCount; - @Column(name = "reaction_count", nullable = false) - private int reactionCount; + @Column(name = "comment_count", nullable = false) + private int commentCount; + + @Column(name = "reaction_count", nullable = false) + private int reactionCount; } diff --git a/backend/src/main/java/com/openisle/model/Image.java b/backend/src/main/java/com/openisle/model/Image.java index 4df308330..38f241be0 100644 --- a/backend/src/main/java/com/openisle/model/Image.java +++ b/backend/src/main/java/com/openisle/model/Image.java @@ -14,13 +14,14 @@ import lombok.Setter; @NoArgsConstructor @Table(name = "images") public class Image { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - @Column(nullable = false, unique = true, length = 512) - private String url; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @Column(nullable = false) - private long refCount = 0; + @Column(nullable = false, unique = true, length = 512) + private String url; + + @Column(nullable = false) + private long refCount = 0; } diff --git a/backend/src/main/java/com/openisle/model/InviteToken.java b/backend/src/main/java/com/openisle/model/InviteToken.java index 4a1180dc8..41d606ff8 100644 --- a/backend/src/main/java/com/openisle/model/InviteToken.java +++ b/backend/src/main/java/com/openisle/model/InviteToken.java @@ -1,9 +1,8 @@ package com.openisle.model; import jakarta.persistence.*; -import lombok.Data; - import java.time.LocalDate; +import lombok.Data; /** * Invite token entity tracking usage counts. @@ -11,20 +10,21 @@ import java.time.LocalDate; @Data @Entity public class InviteToken { - @Id - private String token; - /** - * Short token used in invite links. Existing records may have this field null - * and fall back to {@link #token} for backward compatibility. - */ - @Column(unique = true) - private String shortToken; + @Id + private String token; - @ManyToOne - private User inviter; + /** + * Short token used in invite links. Existing records may have this field null + * and fall back to {@link #token} for backward compatibility. + */ + @Column(unique = true) + private String shortToken; - private LocalDate createdDate; + @ManyToOne + private User inviter; - private int usageCount; + private LocalDate createdDate; + + private int usageCount; } diff --git a/backend/src/main/java/com/openisle/model/LotteryPost.java b/backend/src/main/java/com/openisle/model/LotteryPost.java index f7579714c..8d4ba7beb 100644 --- a/backend/src/main/java/com/openisle/model/LotteryPost.java +++ b/backend/src/main/java/com/openisle/model/LotteryPost.java @@ -1,13 +1,12 @@ package com.openisle.model; import jakarta.persistence.*; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - import java.time.LocalDateTime; import java.util.HashSet; import java.util.Set; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; @Entity @Table(name = "lottery_posts") @@ -17,33 +16,37 @@ import java.util.Set; @PrimaryKeyJoinColumn(name = "post_id") public class LotteryPost extends Post { - @Column - private String prizeDescription; + @Column + private String prizeDescription; - @Column - private String prizeIcon; + @Column + private String prizeIcon; - @Column(nullable = false) - private int prizeCount; + @Column(nullable = false) + private int prizeCount; - @Column(nullable = false) - private int pointCost; + @Column(nullable = false) + private int pointCost; - @Column - private LocalDateTime startTime; + @Column + private LocalDateTime startTime; - @Column - private LocalDateTime endTime; + @Column + private LocalDateTime endTime; - @ManyToMany - @JoinTable(name = "lottery_participants", - joinColumns = @JoinColumn(name = "post_id"), - inverseJoinColumns = @JoinColumn(name = "user_id")) - private Set participants = new HashSet<>(); + @ManyToMany + @JoinTable( + name = "lottery_participants", + joinColumns = @JoinColumn(name = "post_id"), + inverseJoinColumns = @JoinColumn(name = "user_id") + ) + private Set participants = new HashSet<>(); - @ManyToMany - @JoinTable(name = "lottery_winners", - joinColumns = @JoinColumn(name = "post_id"), - inverseJoinColumns = @JoinColumn(name = "user_id")) - private Set winners = new HashSet<>(); + @ManyToMany + @JoinTable( + name = "lottery_winners", + joinColumns = @JoinColumn(name = "post_id"), + inverseJoinColumns = @JoinColumn(name = "user_id") + ) + private Set winners = new HashSet<>(); } diff --git a/backend/src/main/java/com/openisle/model/MedalType.java b/backend/src/main/java/com/openisle/model/MedalType.java index c6509cebb..33ab37959 100644 --- a/backend/src/main/java/com/openisle/model/MedalType.java +++ b/backend/src/main/java/com/openisle/model/MedalType.java @@ -1,10 +1,10 @@ package com.openisle.model; public enum MedalType { - COMMENT, - POST, - FEATURED, - CONTRIBUTOR, - SEED, - PIONEER + COMMENT, + POST, + FEATURED, + CONTRIBUTOR, + SEED, + PIONEER, } diff --git a/backend/src/main/java/com/openisle/model/Message.java b/backend/src/main/java/com/openisle/model/Message.java index 314c8f2ca..ae2a14867 100644 --- a/backend/src/main/java/com/openisle/model/Message.java +++ b/backend/src/main/java/com/openisle/model/Message.java @@ -2,40 +2,40 @@ package com.openisle.model; import com.fasterxml.jackson.annotation.JsonBackReference; import jakarta.persistence.*; +import java.time.LocalDateTime; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import org.hibernate.annotations.CreationTimestamp; -import java.time.LocalDateTime; - @Entity @Getter @Setter @NoArgsConstructor @Table(name = "messages") public class Message { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - @ManyToOne(optional = false, fetch = FetchType.LAZY) - @JoinColumn(name = "conversation_id") - @JsonBackReference - private MessageConversation conversation; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @ManyToOne(optional = false, fetch = FetchType.LAZY) - @JoinColumn(name = "sender_id") - private User sender; + @ManyToOne(optional = false, fetch = FetchType.LAZY) + @JoinColumn(name = "conversation_id") + @JsonBackReference + private MessageConversation conversation; - @Column(nullable = false, columnDefinition = "TEXT") - private String content; + @ManyToOne(optional = false, fetch = FetchType.LAZY) + @JoinColumn(name = "sender_id") + private User sender; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "reply_to_id") - private Message replyTo; + @Column(nullable = false, columnDefinition = "TEXT") + private String content; - @CreationTimestamp - @Column(nullable = false, updatable = false) - private LocalDateTime createdAt; -} \ No newline at end of file + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reply_to_id") + private Message replyTo; + + @CreationTimestamp + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/openisle/model/MessageConversation.java b/backend/src/main/java/com/openisle/model/MessageConversation.java index 638cfd39a..fafa74ad4 100644 --- a/backend/src/main/java/com/openisle/model/MessageConversation.java +++ b/backend/src/main/java/com/openisle/model/MessageConversation.java @@ -3,50 +3,50 @@ package com.openisle.model; import com.fasterxml.jackson.annotation.JsonBackReference; import com.fasterxml.jackson.annotation.JsonManagedReference; import jakarta.persistence.*; +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Set; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import org.hibernate.annotations.CreationTimestamp; -import java.time.LocalDateTime; -import java.util.HashSet; -import java.util.Set; - @Entity @Getter @Setter @NoArgsConstructor @Table(name = "message_conversations") public class MessageConversation { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - // Indicates whether this conversation represents a public channel - @Column(nullable = false) - private boolean channel = false; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - // Channel metadata - private String name; + // Indicates whether this conversation represents a public channel + @Column(nullable = false) + private boolean channel = false; - @Column(columnDefinition = "TEXT") - private String description; + // Channel metadata + private String name; - private String avatar; + @Column(columnDefinition = "TEXT") + private String description; - @CreationTimestamp - @Column(nullable = false, updatable = false) - private LocalDateTime createdAt; + private String avatar; - @OneToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "last_message_id") - private Message lastMessage; + @CreationTimestamp + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; - @OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, orphanRemoval = true) - @JsonBackReference - private Set participants = new HashSet<>(); + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "last_message_id") + private Message lastMessage; - @OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, orphanRemoval = true) - @JsonBackReference - private Set messages = new HashSet<>(); -} \ No newline at end of file + @OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, orphanRemoval = true) + @JsonBackReference + private Set participants = new HashSet<>(); + + @OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, orphanRemoval = true) + @JsonBackReference + private Set messages = new HashSet<>(); +} diff --git a/backend/src/main/java/com/openisle/model/MessageParticipant.java b/backend/src/main/java/com/openisle/model/MessageParticipant.java index bb1805819..8f498e814 100644 --- a/backend/src/main/java/com/openisle/model/MessageParticipant.java +++ b/backend/src/main/java/com/openisle/model/MessageParticipant.java @@ -2,31 +2,31 @@ package com.openisle.model; import com.fasterxml.jackson.annotation.JsonBackReference; import jakarta.persistence.*; +import java.time.LocalDateTime; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -import java.time.LocalDateTime; - @Entity @Getter @Setter @NoArgsConstructor @Table(name = "message_participants") public class MessageParticipant { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - @ManyToOne(optional = false, fetch = FetchType.LAZY) - @JoinColumn(name = "conversation_id") - @JsonBackReference - private MessageConversation conversation; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @ManyToOne(optional = false, fetch = FetchType.LAZY) - @JoinColumn(name = "user_id") - private User user; + @ManyToOne(optional = false, fetch = FetchType.LAZY) + @JoinColumn(name = "conversation_id") + @JsonBackReference + private MessageConversation conversation; - @Column - private LocalDateTime lastReadAt; -} \ No newline at end of file + @ManyToOne(optional = false, fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @Column + private LocalDateTime lastReadAt; +} diff --git a/backend/src/main/java/com/openisle/model/Notification.java b/backend/src/main/java/com/openisle/model/Notification.java index 7ee0aaf94..60a1accd6 100644 --- a/backend/src/main/java/com/openisle/model/Notification.java +++ b/backend/src/main/java/com/openisle/model/Notification.java @@ -1,13 +1,12 @@ package com.openisle.model; import jakarta.persistence.*; +import java.time.LocalDateTime; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import org.hibernate.annotations.CreationTimestamp; -import java.time.LocalDateTime; - /** * Entity representing a user notification. */ @@ -17,45 +16,49 @@ import java.time.LocalDateTime; @NoArgsConstructor @Table(name = "notifications") public class Notification { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - @Enumerated(EnumType.STRING) - @Column(nullable = false, length = 50) - private NotificationType type; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "user_id") - private User user; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 50) + private NotificationType type; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "post_id") - private Post post; + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id") + private User user; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "comment_id") - private Comment comment; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id") + private Post post; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "from_user_id") - private User fromUser; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "comment_id") + private Comment comment; - @Enumerated(EnumType.STRING) - @Column(name = "reaction_type") - private ReactionType reactionType; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "from_user_id") + private User fromUser; - @Column(length = 1000) - private String content; + @Enumerated(EnumType.STRING) + @Column(name = "reaction_type") + private ReactionType reactionType; - @Column - private Boolean approved; + @Column(length = 1000) + private String content; - @Column(name = "is_read", nullable = false) - private boolean read = false; + @Column + private Boolean approved; - @CreationTimestamp - @Column(nullable = false, updatable = false, - columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)") - private LocalDateTime createdAt; + @Column(name = "is_read", nullable = false) + private boolean read = false; + + @CreationTimestamp + @Column( + nullable = false, + updatable = false, + columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)" + ) + private LocalDateTime createdAt; } diff --git a/backend/src/main/java/com/openisle/model/NotificationType.java b/backend/src/main/java/com/openisle/model/NotificationType.java index ccfd57eb7..0e96cfc20 100644 --- a/backend/src/main/java/com/openisle/model/NotificationType.java +++ b/backend/src/main/java/com/openisle/model/NotificationType.java @@ -4,50 +4,50 @@ package com.openisle.model; * Types of user notifications. */ public enum NotificationType { - /** Someone viewed your post */ - POST_VIEWED, - /** Someone replied to your post or comment */ - COMMENT_REPLY, - /** Someone reacted to your post or comment */ - REACTION, - /** A new post is waiting for review */ - POST_REVIEW_REQUEST, - /** Your post under review was approved or rejected */ - POST_REVIEWED, - /** An administrator deleted your post */ - POST_DELETED, - /** A subscribed post received a new comment */ - POST_UPDATED, - /** Someone subscribed to your post */ - POST_SUBSCRIBED, - /** Someone unsubscribed from your post */ - POST_UNSUBSCRIBED, - /** Someone you follow published a new post */ - FOLLOWED_POST, - /** Someone started following you */ - USER_FOLLOWED, - /** Someone unfollowed you */ - USER_UNFOLLOWED, - /** A user you subscribe to created a post or comment */ - USER_ACTIVITY, - /** A user requested registration approval */ - REGISTER_REQUEST, - /** A user redeemed an activity reward */ - ACTIVITY_REDEEM, - /** A user redeemed a point good */ - POINT_REDEEM, - /** You won a lottery post */ - LOTTERY_WIN, - /** Your lottery post was drawn */ - LOTTERY_DRAW, - /** Someone participated in your poll */ - POLL_VOTE, - /** Your poll post has concluded */ - POLL_RESULT_OWNER, - /** A poll you participated in has concluded */ - POLL_RESULT_PARTICIPANT, - /** Your post was featured */ - POST_FEATURED, - /** You were mentioned in a post or comment */ - MENTION + /** Someone viewed your post */ + POST_VIEWED, + /** Someone replied to your post or comment */ + COMMENT_REPLY, + /** Someone reacted to your post or comment */ + REACTION, + /** A new post is waiting for review */ + POST_REVIEW_REQUEST, + /** Your post under review was approved or rejected */ + POST_REVIEWED, + /** An administrator deleted your post */ + POST_DELETED, + /** A subscribed post received a new comment */ + POST_UPDATED, + /** Someone subscribed to your post */ + POST_SUBSCRIBED, + /** Someone unsubscribed from your post */ + POST_UNSUBSCRIBED, + /** Someone you follow published a new post */ + FOLLOWED_POST, + /** Someone started following you */ + USER_FOLLOWED, + /** Someone unfollowed you */ + USER_UNFOLLOWED, + /** A user you subscribe to created a post or comment */ + USER_ACTIVITY, + /** A user requested registration approval */ + REGISTER_REQUEST, + /** A user redeemed an activity reward */ + ACTIVITY_REDEEM, + /** A user redeemed a point good */ + POINT_REDEEM, + /** You won a lottery post */ + LOTTERY_WIN, + /** Your lottery post was drawn */ + LOTTERY_DRAW, + /** Someone participated in your poll */ + POLL_VOTE, + /** Your poll post has concluded */ + POLL_RESULT_OWNER, + /** A poll you participated in has concluded */ + POLL_RESULT_PARTICIPANT, + /** Your post was featured */ + POST_FEATURED, + /** You were mentioned in a post or comment */ + MENTION, } diff --git a/backend/src/main/java/com/openisle/model/PasswordStrength.java b/backend/src/main/java/com/openisle/model/PasswordStrength.java index 3d60fac28..b209aa1d9 100644 --- a/backend/src/main/java/com/openisle/model/PasswordStrength.java +++ b/backend/src/main/java/com/openisle/model/PasswordStrength.java @@ -1,7 +1,7 @@ package com.openisle.model; public enum PasswordStrength { - LOW, - MEDIUM, - HIGH + LOW, + MEDIUM, + HIGH, } diff --git a/backend/src/main/java/com/openisle/model/PointGood.java b/backend/src/main/java/com/openisle/model/PointGood.java index b93d73d14..a44eeada1 100644 --- a/backend/src/main/java/com/openisle/model/PointGood.java +++ b/backend/src/main/java/com/openisle/model/PointGood.java @@ -12,15 +12,16 @@ import lombok.Setter; @NoArgsConstructor @Table(name = "point_goods") public class PointGood { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - @Column(nullable = false) - private String name; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @Column(nullable = false) - private int cost; + @Column(nullable = false) + private String name; - private String image; + @Column(nullable = false) + private int cost; + + private String image; } diff --git a/backend/src/main/java/com/openisle/model/PointHistory.java b/backend/src/main/java/com/openisle/model/PointHistory.java index 133c23f1f..c130fb627 100644 --- a/backend/src/main/java/com/openisle/model/PointHistory.java +++ b/backend/src/main/java/com/openisle/model/PointHistory.java @@ -1,14 +1,13 @@ package com.openisle.model; import jakarta.persistence.*; +import java.time.LocalDateTime; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import org.hibernate.annotations.SQLDelete; import org.hibernate.annotations.Where; -import java.time.LocalDateTime; - /** Point change history for a user. */ @Entity @Getter @@ -18,39 +17,40 @@ import java.time.LocalDateTime; @SQLDelete(sql = "UPDATE point_histories SET deleted_at = CURRENT_TIMESTAMP(6) WHERE id = ?") @Where(clause = "deleted_at IS NULL") public class PointHistory { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "user_id") - private User user; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private PointHistoryType type; + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id") + private User user; - @Column(nullable = false) - private int amount; + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private PointHistoryType type; - @Column(nullable = false) - private int balance; + @Column(nullable = false) + private int amount; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "post_id") - private Post post; + @Column(nullable = false) + private int balance; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "comment_id") - private Comment comment; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id") + private Post post; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "from_user_id") - private User fromUser; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "comment_id") + private Comment comment; - @Column(name = "created_at", nullable = false) - private LocalDateTime createdAt; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "from_user_id") + private User fromUser; - @Column(name = "deleted_at") - private LocalDateTime deletedAt; + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; } diff --git a/backend/src/main/java/com/openisle/model/PointHistoryType.java b/backend/src/main/java/com/openisle/model/PointHistoryType.java index 7b2bd59b6..689f73c9c 100644 --- a/backend/src/main/java/com/openisle/model/PointHistoryType.java +++ b/backend/src/main/java/com/openisle/model/PointHistoryType.java @@ -1,16 +1,16 @@ package com.openisle.model; public enum PointHistoryType { - POST, - COMMENT, - POST_LIKED, - COMMENT_LIKED, - POST_LIKE_CANCELLED, - COMMENT_LIKE_CANCELLED, - INVITE, - FEATURE, - SYSTEM_ONLINE, - REDEEM, - LOTTERY_JOIN, - LOTTERY_REWARD + POST, + COMMENT, + POST_LIKED, + COMMENT_LIKED, + POST_LIKE_CANCELLED, + COMMENT_LIKE_CANCELLED, + INVITE, + FEATURE, + SYSTEM_ONLINE, + REDEEM, + LOTTERY_JOIN, + LOTTERY_REWARD, } diff --git a/backend/src/main/java/com/openisle/model/PointLog.java b/backend/src/main/java/com/openisle/model/PointLog.java index 466400331..0c5cfbcec 100644 --- a/backend/src/main/java/com/openisle/model/PointLog.java +++ b/backend/src/main/java/com/openisle/model/PointLog.java @@ -1,37 +1,39 @@ package com.openisle.model; import jakarta.persistence.*; +import java.time.LocalDate; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -import java.time.LocalDate; - /** Daily experience gain counts for a user. */ @Entity @Getter @Setter @NoArgsConstructor -@Table(name = "point_logs", - uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "log_date"})) +@Table( + name = "point_logs", + uniqueConstraints = @UniqueConstraint(columnNames = { "user_id", "log_date" }) +) public class PointLog { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "user_id") - private User user; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @Column(name = "log_date", nullable = false) - private LocalDate logDate; + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id") + private User user; - @Column(name = "post_count", nullable = false) - private int postCount; + @Column(name = "log_date", nullable = false) + private LocalDate logDate; - @Column(name = "comment_count", nullable = false) - private int commentCount; + @Column(name = "post_count", nullable = false) + private int postCount; - @Column(name = "reaction_count", nullable = false) - private int reactionCount; + @Column(name = "comment_count", nullable = false) + private int commentCount; + + @Column(name = "reaction_count", nullable = false) + private int reactionCount; } diff --git a/backend/src/main/java/com/openisle/model/PollPost.java b/backend/src/main/java/com/openisle/model/PollPost.java index 494796f5d..c240489ae 100644 --- a/backend/src/main/java/com/openisle/model/PollPost.java +++ b/backend/src/main/java/com/openisle/model/PollPost.java @@ -1,13 +1,12 @@ package com.openisle.model; import jakarta.persistence.*; +import java.time.LocalDateTime; +import java.util.*; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -import java.time.LocalDateTime; -import java.util.*; - @Entity @Table(name = "poll_posts") @Getter @@ -15,29 +14,32 @@ import java.util.*; @NoArgsConstructor @PrimaryKeyJoinColumn(name = "post_id") public class PollPost extends Post { - @ElementCollection - @CollectionTable(name = "poll_post_options", joinColumns = @JoinColumn(name = "post_id")) - @Column(name = "option_text") - private List options = new ArrayList<>(); - @ElementCollection - @CollectionTable(name = "poll_post_votes", joinColumns = @JoinColumn(name = "post_id")) - @MapKeyColumn(name = "option_index") - @Column(name = "vote_count") - private Map votes = new HashMap<>(); + @ElementCollection + @CollectionTable(name = "poll_post_options", joinColumns = @JoinColumn(name = "post_id")) + @Column(name = "option_text") + private List options = new ArrayList<>(); - @ManyToMany - @JoinTable(name = "poll_participants", - joinColumns = @JoinColumn(name = "post_id"), - inverseJoinColumns = @JoinColumn(name = "user_id")) - private Set participants = new HashSet<>(); + @ElementCollection + @CollectionTable(name = "poll_post_votes", joinColumns = @JoinColumn(name = "post_id")) + @MapKeyColumn(name = "option_index") + @Column(name = "vote_count") + private Map votes = new HashMap<>(); - @Column - private Boolean multiple = false; + @ManyToMany + @JoinTable( + name = "poll_participants", + joinColumns = @JoinColumn(name = "post_id"), + inverseJoinColumns = @JoinColumn(name = "user_id") + ) + private Set participants = new HashSet<>(); - @Column - private LocalDateTime endTime; + @Column + private Boolean multiple = false; - @Column - private boolean resultAnnounced = false; + @Column + private LocalDateTime endTime; + + @Column + private boolean resultAnnounced = false; } diff --git a/backend/src/main/java/com/openisle/model/PollVote.java b/backend/src/main/java/com/openisle/model/PollVote.java index 41994dc90..fa03db429 100644 --- a/backend/src/main/java/com/openisle/model/PollVote.java +++ b/backend/src/main/java/com/openisle/model/PollVote.java @@ -6,23 +6,27 @@ import lombok.NoArgsConstructor; import lombok.Setter; @Entity -@Table(name = "poll_votes", uniqueConstraints = @UniqueConstraint(columnNames = {"post_id", "user_id", "option_index"})) +@Table( + name = "poll_votes", + uniqueConstraints = @UniqueConstraint(columnNames = { "post_id", "user_id", "option_index" }) +) @Getter @Setter @NoArgsConstructor public class PollVote { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "post_id") - private PollPost post; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "user_id") - private User user; + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "post_id") + private PollPost post; - @Column(name = "option_index", nullable = false) - private int optionIndex; + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id") + private User user; + + @Column(name = "option_index", nullable = false) + private int optionIndex; } diff --git a/backend/src/main/java/com/openisle/model/Post.java b/backend/src/main/java/com/openisle/model/Post.java index e52a50a1b..b3ecb4a03 100644 --- a/backend/src/main/java/com/openisle/model/Post.java +++ b/backend/src/main/java/com/openisle/model/Post.java @@ -1,19 +1,15 @@ package com.openisle.model; +import com.openisle.model.Tag; import jakarta.persistence.*; +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Set; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import org.hibernate.annotations.CreationTimestamp; -import java.util.HashSet; -import java.util.Set; - -import java.time.LocalDateTime; - -import com.openisle.model.Tag; - - /** * Post entity representing an article posted by a user. */ @@ -24,58 +20,64 @@ import com.openisle.model.Tag; @Table(name = "posts") @Inheritance(strategy = InheritanceType.JOINED) public class Post { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - @Column(nullable = false) - private String title; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @Column(nullable = false, columnDefinition = "LONGTEXT") - private String content; + @Column(nullable = false) + private String title; - @CreationTimestamp - @Column(nullable = false, updatable = false, - columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)") - private LocalDateTime createdAt; + @Column(nullable = false, columnDefinition = "LONGTEXT") + private String content; - @ManyToOne(optional = false, fetch = FetchType.EAGER) - @JoinColumn(name = "author_id") - private User author; + @CreationTimestamp + @Column( + nullable = false, + updatable = false, + columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)" + ) + private LocalDateTime createdAt; - @ManyToOne(optional = false, fetch = FetchType.EAGER) - @JoinColumn(name = "category_id") - private Category category; + @ManyToOne(optional = false, fetch = FetchType.EAGER) + @JoinColumn(name = "author_id") + private User author; - @ManyToMany(fetch = FetchType.EAGER) - @JoinTable(name = "post_tags", - joinColumns = @JoinColumn(name = "post_id"), - inverseJoinColumns = @JoinColumn(name = "tag_id")) - private Set tags = new HashSet<>(); + @ManyToOne(optional = false, fetch = FetchType.EAGER) + @JoinColumn(name = "category_id") + private Category category; - @Column(nullable = false) - private long views = 0; + @ManyToMany(fetch = FetchType.EAGER) + @JoinTable( + name = "post_tags", + joinColumns = @JoinColumn(name = "post_id"), + inverseJoinColumns = @JoinColumn(name = "tag_id") + ) + private Set tags = new HashSet<>(); - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private PostStatus status = PostStatus.PUBLISHED; + @Column(nullable = false) + private long views = 0; - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private PostType type = PostType.NORMAL; + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private PostStatus status = PostStatus.PUBLISHED; - @Column(nullable = false) - private boolean closed = false; + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private PostType type = PostType.NORMAL; - @Column - private LocalDateTime pinnedAt; + @Column(nullable = false) + private boolean closed = false; - @Column(nullable = true) - private Boolean rssExcluded = true; + @Column + private LocalDateTime pinnedAt; - @Column(nullable = false) - private long commentCount = 0; + @Column(nullable = true) + private Boolean rssExcluded = true; - @Column(nullable = true) - private LocalDateTime lastReplyAt; + @Column(nullable = false) + private long commentCount = 0; + + @Column(nullable = true) + private LocalDateTime lastReplyAt; } diff --git a/backend/src/main/java/com/openisle/model/PostCategoryChangeLog.java b/backend/src/main/java/com/openisle/model/PostCategoryChangeLog.java index 20264e449..127c21cac 100644 --- a/backend/src/main/java/com/openisle/model/PostCategoryChangeLog.java +++ b/backend/src/main/java/com/openisle/model/PostCategoryChangeLog.java @@ -12,6 +12,7 @@ import lombok.Setter; @Entity @Table(name = "post_category_change_logs") public class PostCategoryChangeLog extends PostChangeLog { - private String oldCategory; - private String newCategory; + + private String oldCategory; + private String newCategory; } diff --git a/backend/src/main/java/com/openisle/model/PostChangeLog.java b/backend/src/main/java/com/openisle/model/PostChangeLog.java index a30cd3b00..60120880c 100644 --- a/backend/src/main/java/com/openisle/model/PostChangeLog.java +++ b/backend/src/main/java/com/openisle/model/PostChangeLog.java @@ -1,13 +1,12 @@ package com.openisle.model; import jakarta.persistence.*; +import java.time.LocalDateTime; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import org.hibernate.annotations.CreationTimestamp; -import java.time.LocalDateTime; - @Getter @Setter @NoArgsConstructor @@ -15,23 +14,24 @@ import java.time.LocalDateTime; @Table(name = "post_change_logs") @Inheritance(strategy = InheritanceType.JOINED) public abstract class PostChangeLog { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "post_id") - private Post post; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @ManyToOne(fetch = FetchType.LAZY, optional = true) - @JoinColumn(name = "user_id") - private User user; + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "post_id") + private Post post; - @CreationTimestamp - @Column(nullable = false, updatable = false) - private LocalDateTime createdAt; + @ManyToOne(fetch = FetchType.LAZY, optional = true) + @JoinColumn(name = "user_id") + private User user; - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private PostChangeType type; + @CreationTimestamp + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private PostChangeType type; } diff --git a/backend/src/main/java/com/openisle/model/PostChangeType.java b/backend/src/main/java/com/openisle/model/PostChangeType.java index 68b869f7f..c09a5555a 100644 --- a/backend/src/main/java/com/openisle/model/PostChangeType.java +++ b/backend/src/main/java/com/openisle/model/PostChangeType.java @@ -1,13 +1,13 @@ package com.openisle.model; public enum PostChangeType { - CONTENT, - TITLE, - CATEGORY, - TAG, - CLOSED, - PINNED, - FEATURED, - VOTE_RESULT, - LOTTERY_RESULT + CONTENT, + TITLE, + CATEGORY, + TAG, + CLOSED, + PINNED, + FEATURED, + VOTE_RESULT, + LOTTERY_RESULT, } diff --git a/backend/src/main/java/com/openisle/model/PostClosedChangeLog.java b/backend/src/main/java/com/openisle/model/PostClosedChangeLog.java index 20bbc9cb9..f7fdd305d 100644 --- a/backend/src/main/java/com/openisle/model/PostClosedChangeLog.java +++ b/backend/src/main/java/com/openisle/model/PostClosedChangeLog.java @@ -12,6 +12,7 @@ import lombok.Setter; @Entity @Table(name = "post_closed_change_logs") public class PostClosedChangeLog extends PostChangeLog { - private boolean oldClosed; - private boolean newClosed; + + private boolean oldClosed; + private boolean newClosed; } diff --git a/backend/src/main/java/com/openisle/model/PostContentChangeLog.java b/backend/src/main/java/com/openisle/model/PostContentChangeLog.java index 1f4170577..22ceda5b6 100644 --- a/backend/src/main/java/com/openisle/model/PostContentChangeLog.java +++ b/backend/src/main/java/com/openisle/model/PostContentChangeLog.java @@ -13,9 +13,10 @@ import lombok.Setter; @Entity @Table(name = "post_content_change_logs") public class PostContentChangeLog extends PostChangeLog { - @Column(name = "old_content", columnDefinition = "LONGTEXT") - private String oldContent; - @Column(name = "new_content", columnDefinition = "LONGTEXT") - private String newContent; + @Column(name = "old_content", columnDefinition = "LONGTEXT") + private String oldContent; + + @Column(name = "new_content", columnDefinition = "LONGTEXT") + private String newContent; } diff --git a/backend/src/main/java/com/openisle/model/PostFeaturedChangeLog.java b/backend/src/main/java/com/openisle/model/PostFeaturedChangeLog.java index 522dba086..416f11c9d 100644 --- a/backend/src/main/java/com/openisle/model/PostFeaturedChangeLog.java +++ b/backend/src/main/java/com/openisle/model/PostFeaturedChangeLog.java @@ -12,6 +12,7 @@ import lombok.Setter; @Entity @Table(name = "post_featured_change_logs") public class PostFeaturedChangeLog extends PostChangeLog { - private boolean oldFeatured; - private boolean newFeatured; + + private boolean oldFeatured; + private boolean newFeatured; } diff --git a/backend/src/main/java/com/openisle/model/PostLotteryResultChangeLog.java b/backend/src/main/java/com/openisle/model/PostLotteryResultChangeLog.java index 138d9d9cb..4be7ac039 100644 --- a/backend/src/main/java/com/openisle/model/PostLotteryResultChangeLog.java +++ b/backend/src/main/java/com/openisle/model/PostLotteryResultChangeLog.java @@ -11,6 +11,4 @@ import lombok.Setter; @NoArgsConstructor @Entity @Table(name = "post_lottery_result_change_logs") -public class PostLotteryResultChangeLog extends PostChangeLog { -} - +public class PostLotteryResultChangeLog extends PostChangeLog {} diff --git a/backend/src/main/java/com/openisle/model/PostPinnedChangeLog.java b/backend/src/main/java/com/openisle/model/PostPinnedChangeLog.java index 395e0f499..ad3df9b56 100644 --- a/backend/src/main/java/com/openisle/model/PostPinnedChangeLog.java +++ b/backend/src/main/java/com/openisle/model/PostPinnedChangeLog.java @@ -2,18 +2,18 @@ package com.openisle.model; import jakarta.persistence.Entity; import jakarta.persistence.Table; +import java.time.LocalDateTime; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -import java.time.LocalDateTime; - @Getter @Setter @NoArgsConstructor @Entity @Table(name = "post_pinned_change_logs") public class PostPinnedChangeLog extends PostChangeLog { - private LocalDateTime oldPinnedAt; - private LocalDateTime newPinnedAt; + + private LocalDateTime oldPinnedAt; + private LocalDateTime newPinnedAt; } diff --git a/backend/src/main/java/com/openisle/model/PostRead.java b/backend/src/main/java/com/openisle/model/PostRead.java index fd71ba0b3..82a69234a 100644 --- a/backend/src/main/java/com/openisle/model/PostRead.java +++ b/backend/src/main/java/com/openisle/model/PostRead.java @@ -1,32 +1,34 @@ package com.openisle.model; import jakarta.persistence.*; +import java.time.LocalDateTime; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -import java.time.LocalDateTime; - /** Record of a user reading a post. */ @Entity @Getter @Setter @NoArgsConstructor -@Table(name = "post_reads", - uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "post_id"})) +@Table( + name = "post_reads", + uniqueConstraints = @UniqueConstraint(columnNames = { "user_id", "post_id" }) +) public class PostRead { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "user_id") - private User user; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "post_id") - private Post post; + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id") + private User user; - @Column(name = "last_read_at") - private LocalDateTime lastReadAt; + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "post_id") + private Post post; + + @Column(name = "last_read_at") + private LocalDateTime lastReadAt; } diff --git a/backend/src/main/java/com/openisle/model/PostStatus.java b/backend/src/main/java/com/openisle/model/PostStatus.java index 90b921a5b..7948e80fc 100644 --- a/backend/src/main/java/com/openisle/model/PostStatus.java +++ b/backend/src/main/java/com/openisle/model/PostStatus.java @@ -4,7 +4,7 @@ package com.openisle.model; * Status of a post during its lifecycle. */ public enum PostStatus { - PUBLISHED, - PENDING, - REJECTED + PUBLISHED, + PENDING, + REJECTED, } diff --git a/backend/src/main/java/com/openisle/model/PostSubscription.java b/backend/src/main/java/com/openisle/model/PostSubscription.java index c993c359e..1917d5739 100644 --- a/backend/src/main/java/com/openisle/model/PostSubscription.java +++ b/backend/src/main/java/com/openisle/model/PostSubscription.java @@ -10,18 +10,21 @@ import lombok.Setter; @Getter @Setter @NoArgsConstructor -@Table(name = "post_subscriptions", - uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "post_id"})) +@Table( + name = "post_subscriptions", + uniqueConstraints = @UniqueConstraint(columnNames = { "user_id", "post_id" }) +) public class PostSubscription { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "user_id") - private User user; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "post_id") - private Post post; + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "post_id") + private Post post; } diff --git a/backend/src/main/java/com/openisle/model/PostTagChangeLog.java b/backend/src/main/java/com/openisle/model/PostTagChangeLog.java index f17f53ad3..3d02df8fa 100644 --- a/backend/src/main/java/com/openisle/model/PostTagChangeLog.java +++ b/backend/src/main/java/com/openisle/model/PostTagChangeLog.java @@ -13,9 +13,10 @@ import lombok.Setter; @Entity @Table(name = "post_tag_change_logs") public class PostTagChangeLog extends PostChangeLog { - @Column(name = "old_tags") - private String oldTags; - @Column(name = "new_tags") - private String newTags; + @Column(name = "old_tags") + private String oldTags; + + @Column(name = "new_tags") + private String newTags; } diff --git a/backend/src/main/java/com/openisle/model/PostTitleChangeLog.java b/backend/src/main/java/com/openisle/model/PostTitleChangeLog.java index f7e6dcb2e..ae3672275 100644 --- a/backend/src/main/java/com/openisle/model/PostTitleChangeLog.java +++ b/backend/src/main/java/com/openisle/model/PostTitleChangeLog.java @@ -12,6 +12,7 @@ import lombok.Setter; @Entity @Table(name = "post_title_change_logs") public class PostTitleChangeLog extends PostChangeLog { - private String oldTitle; - private String newTitle; + + private String oldTitle; + private String newTitle; } diff --git a/backend/src/main/java/com/openisle/model/PostType.java b/backend/src/main/java/com/openisle/model/PostType.java index b8becaca0..7e675dafc 100644 --- a/backend/src/main/java/com/openisle/model/PostType.java +++ b/backend/src/main/java/com/openisle/model/PostType.java @@ -1,7 +1,7 @@ package com.openisle.model; public enum PostType { - NORMAL, - LOTTERY, - POLL + NORMAL, + LOTTERY, + POLL, } diff --git a/backend/src/main/java/com/openisle/model/PostVoteResultChangeLog.java b/backend/src/main/java/com/openisle/model/PostVoteResultChangeLog.java index 32a61c210..51cf6241e 100644 --- a/backend/src/main/java/com/openisle/model/PostVoteResultChangeLog.java +++ b/backend/src/main/java/com/openisle/model/PostVoteResultChangeLog.java @@ -11,6 +11,4 @@ import lombok.Setter; @NoArgsConstructor @Entity @Table(name = "post_vote_result_change_logs") -public class PostVoteResultChangeLog extends PostChangeLog { -} - +public class PostVoteResultChangeLog extends PostChangeLog {} diff --git a/backend/src/main/java/com/openisle/model/PublishMode.java b/backend/src/main/java/com/openisle/model/PublishMode.java index 2b912cd78..970d8d51b 100644 --- a/backend/src/main/java/com/openisle/model/PublishMode.java +++ b/backend/src/main/java/com/openisle/model/PublishMode.java @@ -4,6 +4,6 @@ package com.openisle.model; * Application-wide article publish mode. */ public enum PublishMode { - DIRECT, - REVIEW + DIRECT, + REVIEW, } diff --git a/backend/src/main/java/com/openisle/model/PushSubscription.java b/backend/src/main/java/com/openisle/model/PushSubscription.java index 34888513e..5bf316a1f 100644 --- a/backend/src/main/java/com/openisle/model/PushSubscription.java +++ b/backend/src/main/java/com/openisle/model/PushSubscription.java @@ -1,13 +1,12 @@ package com.openisle.model; import jakarta.persistence.*; +import java.time.LocalDateTime; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import org.hibernate.annotations.CreationTimestamp; -import java.time.LocalDateTime; - /** * Entity storing a browser push subscription for a user. */ @@ -17,24 +16,25 @@ import java.time.LocalDateTime; @NoArgsConstructor @Table(name = "push_subscriptions") public class PushSubscription { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "user_id") - private User user; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @Column(nullable = false, length = 512) - private String endpoint; + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id") + private User user; - @Column(nullable = false, length = 256) - private String p256dh; + @Column(nullable = false, length = 512) + private String endpoint; - @Column(nullable = false, length = 256) - private String auth; + @Column(nullable = false, length = 256) + private String p256dh; - @CreationTimestamp - @Column(nullable = false, updatable = false) - private LocalDateTime createdAt; + @Column(nullable = false, length = 256) + private String auth; + + @CreationTimestamp + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; } diff --git a/backend/src/main/java/com/openisle/model/Reaction.java b/backend/src/main/java/com/openisle/model/Reaction.java index 7b7b7240e..f64bdb170 100644 --- a/backend/src/main/java/com/openisle/model/Reaction.java +++ b/backend/src/main/java/com/openisle/model/Reaction.java @@ -13,39 +13,45 @@ import org.hibernate.annotations.CreationTimestamp; @Getter @Setter @NoArgsConstructor -@Table(name = "reactions", - uniqueConstraints = { - @UniqueConstraint(columnNames = {"user_id", "post_id", "type"}), - @UniqueConstraint(columnNames = {"user_id", "comment_id", "type"}), - @UniqueConstraint(columnNames = {"user_id", "message_id", "type"}) - }) +@Table( + name = "reactions", + uniqueConstraints = { + @UniqueConstraint(columnNames = { "user_id", "post_id", "type" }), + @UniqueConstraint(columnNames = { "user_id", "comment_id", "type" }), + @UniqueConstraint(columnNames = { "user_id", "message_id", "type" }), + } +) public class Reaction { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private ReactionType type; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "user_id") - private User user; + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ReactionType type; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "post_id") - private Post post; + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id") + private User user; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "comment_id") - private Comment comment; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id") + private Post post; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "message_id") - private Message message; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "comment_id") + private Comment comment; - @CreationTimestamp - @Column(nullable = false, updatable = false, - columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)") - private java.time.LocalDateTime createdAt; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "message_id") + private Message message; + + @CreationTimestamp + @Column( + nullable = false, + updatable = false, + columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)" + ) + private java.time.LocalDateTime createdAt; } diff --git a/backend/src/main/java/com/openisle/model/ReactionType.java b/backend/src/main/java/com/openisle/model/ReactionType.java index 51afc7c3c..23ae7a298 100644 --- a/backend/src/main/java/com/openisle/model/ReactionType.java +++ b/backend/src/main/java/com/openisle/model/ReactionType.java @@ -4,29 +4,29 @@ package com.openisle.model; * Enum of possible reaction types for posts and comments. */ public enum ReactionType { - LIKE, - DISLIKE, - SMILE, - RECOMMEND, - CONGRATULATIONS, - ANGRY, - FLUSHED, - STAR_STRUCK, - ROFL, - HOLDING_BACK_TEARS, - MIND_BLOWN, - POOP, - CLOWN, - SKULL, - FIRE, - EYES, - FROWN, - HOT, - EAGLE, - SPIDER, - BAT, - CHINA, - USA, - JAPAN, - KOREA, + LIKE, + DISLIKE, + SMILE, + RECOMMEND, + CONGRATULATIONS, + ANGRY, + FLUSHED, + STAR_STRUCK, + ROFL, + HOLDING_BACK_TEARS, + MIND_BLOWN, + POOP, + CLOWN, + SKULL, + FIRE, + EYES, + FROWN, + HOT, + EAGLE, + SPIDER, + BAT, + CHINA, + USA, + JAPAN, + KOREA, } diff --git a/backend/src/main/java/com/openisle/model/RegisterMode.java b/backend/src/main/java/com/openisle/model/RegisterMode.java index d8c8fd2e4..2987ae614 100644 --- a/backend/src/main/java/com/openisle/model/RegisterMode.java +++ b/backend/src/main/java/com/openisle/model/RegisterMode.java @@ -4,6 +4,6 @@ package com.openisle.model; * Application-wide user registration mode. */ public enum RegisterMode { - DIRECT, - WHITELIST + DIRECT, + WHITELIST, } diff --git a/backend/src/main/java/com/openisle/model/Role.java b/backend/src/main/java/com/openisle/model/Role.java index b2d24e646..c1175f304 100644 --- a/backend/src/main/java/com/openisle/model/Role.java +++ b/backend/src/main/java/com/openisle/model/Role.java @@ -1,6 +1,6 @@ package com.openisle.model; public enum Role { - ADMIN, - USER + ADMIN, + USER, } diff --git a/backend/src/main/java/com/openisle/model/Tag.java b/backend/src/main/java/com/openisle/model/Tag.java index ad5d8a981..b1251248e 100644 --- a/backend/src/main/java/com/openisle/model/Tag.java +++ b/backend/src/main/java/com/openisle/model/Tag.java @@ -1,45 +1,48 @@ package com.openisle.model; import jakarta.persistence.*; +import java.time.LocalDateTime; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import org.hibernate.annotations.CreationTimestamp; -import java.time.LocalDateTime; - - @Entity @Getter @Setter @NoArgsConstructor @Table(name = "tags") public class Tag { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - @Column(nullable = false, unique = true) - private String name; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @Column - private String icon; + @Column(nullable = false, unique = true) + private String name; - @Column - private String smallIcon; + @Column + private String icon; - @Column(name = "description", nullable = false) - private String description; + @Column + private String smallIcon; - @Column(nullable = false) - private boolean approved = true; + @Column(name = "description", nullable = false) + private String description; - @CreationTimestamp - @Column(nullable = false, updatable = false, - columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)") - private LocalDateTime createdAt; - // 改用redis缓存之后选择立即加载策略 - @ManyToOne(fetch = FetchType.EAGER) - @JoinColumn(name = "creator_id") - private User creator; + @Column(nullable = false) + private boolean approved = true; + + @CreationTimestamp + @Column( + nullable = false, + updatable = false, + columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)" + ) + private LocalDateTime createdAt; + + // 改用redis缓存之后选择立即加载策略 + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "creator_id") + private User creator; } diff --git a/backend/src/main/java/com/openisle/model/User.java b/backend/src/main/java/com/openisle/model/User.java index 53b52d7a3..bf68d5507 100644 --- a/backend/src/main/java/com/openisle/model/User.java +++ b/backend/src/main/java/com/openisle/model/User.java @@ -1,15 +1,14 @@ package com.openisle.model; import jakarta.persistence.*; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; -import org.hibernate.annotations.CreationTimestamp; - import java.time.LocalDateTime; import java.util.EnumSet; import java.util.HashSet; import java.util.Set; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; /** * Simple user entity with basic fields and a role. @@ -21,67 +20,79 @@ import java.util.Set; @NoArgsConstructor @Table(name = "users") public class User { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - @Column(nullable = false, unique = true) - private String username; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @Column(nullable = false, unique = true) - private String email; + @Column(nullable = false, unique = true) + private String username; - @Column(nullable = false) - private String password; + @Column(nullable = false, unique = true) + private String email; - @Column(nullable = false) - private boolean verified = false; + @Column(nullable = false) + private String password; - private String verificationCode; + @Column(nullable = false) + private boolean verified = false; - private String passwordResetCode; + private String verificationCode; - private String avatar; + private String passwordResetCode; - @Column(nullable = false) - private int experience = 0; + private String avatar; - @Column(nullable = false) - private int point = 0; + @Column(nullable = false) + private int experience = 0; - @Column(length = 1000) - private String introduction; + @Column(nullable = false) + private int point = 0; - @Column(length = 1000) - private String registerReason; + @Column(length = 1000) + private String introduction; - @Column(nullable = false) - private boolean approved = true; + @Column(length = 1000) + private String registerReason; - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private Role role = Role.USER; + @Column(nullable = false) + private boolean approved = true; - @Enumerated(EnumType.STRING) - private MedalType displayMedal; + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Role role = Role.USER; - @ElementCollection(targetClass = NotificationType.class) - @CollectionTable(name = "user_disabled_notification_types", joinColumns = @JoinColumn(name = "user_id")) - @Column(name = "notification_type") - @Enumerated(EnumType.STRING) - private Set disabledNotificationTypes = EnumSet.of( - NotificationType.POST_VIEWED, - NotificationType.USER_ACTIVITY - ); + @Enumerated(EnumType.STRING) + private MedalType displayMedal; - @ElementCollection(targetClass = NotificationType.class) - @CollectionTable(name = "user_disabled_email_notification_types", joinColumns = @JoinColumn(name = "user_id")) - @Column(name = "notification_type") - @Enumerated(EnumType.STRING) - private Set disabledEmailNotificationTypes = EnumSet.noneOf(NotificationType.class); + @ElementCollection(targetClass = NotificationType.class) + @CollectionTable( + name = "user_disabled_notification_types", + joinColumns = @JoinColumn(name = "user_id") + ) + @Column(name = "notification_type") + @Enumerated(EnumType.STRING) + private Set disabledNotificationTypes = EnumSet.of( + NotificationType.POST_VIEWED, + NotificationType.USER_ACTIVITY + ); - @CreationTimestamp - @Column(nullable = false, updatable = false, - columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)") - private LocalDateTime createdAt; + @ElementCollection(targetClass = NotificationType.class) + @CollectionTable( + name = "user_disabled_email_notification_types", + joinColumns = @JoinColumn(name = "user_id") + ) + @Column(name = "notification_type") + @Enumerated(EnumType.STRING) + private Set disabledEmailNotificationTypes = EnumSet.noneOf( + NotificationType.class + ); + + @CreationTimestamp + @Column( + nullable = false, + updatable = false, + columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)" + ) + private LocalDateTime createdAt; } diff --git a/backend/src/main/java/com/openisle/model/UserSubscription.java b/backend/src/main/java/com/openisle/model/UserSubscription.java index 7f45d2ae1..ae323cd78 100644 --- a/backend/src/main/java/com/openisle/model/UserSubscription.java +++ b/backend/src/main/java/com/openisle/model/UserSubscription.java @@ -10,18 +10,21 @@ import lombok.Setter; @Getter @Setter @NoArgsConstructor -@Table(name = "user_subscriptions", - uniqueConstraints = @UniqueConstraint(columnNames = {"subscriber_id", "target_id"})) +@Table( + name = "user_subscriptions", + uniqueConstraints = @UniqueConstraint(columnNames = { "subscriber_id", "target_id" }) +) public class UserSubscription { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "subscriber_id") - private User subscriber; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "target_id") - private User target; + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "subscriber_id") + private User subscriber; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "target_id") + private User target; } diff --git a/backend/src/main/java/com/openisle/model/UserVisit.java b/backend/src/main/java/com/openisle/model/UserVisit.java index 6c2ecab0e..a03ee3870 100644 --- a/backend/src/main/java/com/openisle/model/UserVisit.java +++ b/backend/src/main/java/com/openisle/model/UserVisit.java @@ -1,28 +1,30 @@ package com.openisle.model; import jakarta.persistence.*; +import java.time.LocalDate; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -import java.time.LocalDate; - /** Daily visit record for a user. */ @Entity @Getter @Setter @NoArgsConstructor -@Table(name = "user_visits", - uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "visit_date"})) +@Table( + name = "user_visits", + uniqueConstraints = @UniqueConstraint(columnNames = { "user_id", "visit_date" }) +) public class UserVisit { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "user_id") - private User user; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @Column(name = "visit_date", nullable = false) - private LocalDate visitDate; + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id") + private User user; + + @Column(name = "visit_date", nullable = false) + private LocalDate visitDate; } diff --git a/backend/src/main/java/com/openisle/repository/ActivityRepository.java b/backend/src/main/java/com/openisle/repository/ActivityRepository.java index 9724d00ae..3702f7169 100644 --- a/backend/src/main/java/com/openisle/repository/ActivityRepository.java +++ b/backend/src/main/java/com/openisle/repository/ActivityRepository.java @@ -4,5 +4,5 @@ import com.openisle.model.Activity; import org.springframework.data.jpa.repository.JpaRepository; public interface ActivityRepository extends JpaRepository { - Activity findByType(com.openisle.model.ActivityType type); + Activity findByType(com.openisle.model.ActivityType type); } diff --git a/backend/src/main/java/com/openisle/repository/AiFormatUsageRepository.java b/backend/src/main/java/com/openisle/repository/AiFormatUsageRepository.java index 14c3ae680..6569de587 100644 --- a/backend/src/main/java/com/openisle/repository/AiFormatUsageRepository.java +++ b/backend/src/main/java/com/openisle/repository/AiFormatUsageRepository.java @@ -2,11 +2,10 @@ package com.openisle.repository; import com.openisle.model.AiFormatUsage; import com.openisle.model.User; -import org.springframework.data.jpa.repository.JpaRepository; - import java.time.LocalDate; import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; public interface AiFormatUsageRepository extends JpaRepository { - Optional findByUserAndUseDate(User user, LocalDate useDate); + Optional findByUserAndUseDate(User user, LocalDate useDate); } diff --git a/backend/src/main/java/com/openisle/repository/CategoryRepository.java b/backend/src/main/java/com/openisle/repository/CategoryRepository.java index ec93f05b9..0d3953495 100644 --- a/backend/src/main/java/com/openisle/repository/CategoryRepository.java +++ b/backend/src/main/java/com/openisle/repository/CategoryRepository.java @@ -1,13 +1,12 @@ package com.openisle.repository; import com.openisle.model.Category; -import org.springframework.data.jpa.repository.JpaRepository; - import java.util.List; import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; public interface CategoryRepository extends JpaRepository { - List findByNameContainingIgnoreCase(String keyword); + List findByNameContainingIgnoreCase(String keyword); - Optional findByName(String name); + Optional findByName(String name); } diff --git a/backend/src/main/java/com/openisle/repository/CommentRepository.java b/backend/src/main/java/com/openisle/repository/CommentRepository.java index c9c296bd4..180ce5c49 100644 --- a/backend/src/main/java/com/openisle/repository/CommentRepository.java +++ b/backend/src/main/java/com/openisle/repository/CommentRepository.java @@ -2,38 +2,59 @@ package com.openisle.repository; import com.openisle.model.Comment; import com.openisle.model.Post; +import com.openisle.model.User; +import java.util.List; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; -import com.openisle.model.User; - -import java.util.List; public interface CommentRepository extends JpaRepository { - List findByPostAndParentIsNullOrderByCreatedAtAsc(Post post); - List findByParentOrderByCreatedAtAsc(Comment parent); - List findByAuthorOrderByCreatedAtDesc(User author, Pageable pageable); - List findByContentContainingIgnoreCase(String keyword); + List findByPostAndParentIsNullOrderByCreatedAtAsc(Post post); + List findByParentOrderByCreatedAtAsc(Comment parent); + List findByAuthorOrderByCreatedAtDesc(User author, Pageable pageable); + List findByContentContainingIgnoreCase(String keyword); - @org.springframework.data.jpa.repository.Query("SELECT DISTINCT c.author FROM Comment c WHERE c.post = :post") - java.util.List findDistinctAuthorsByPost(@org.springframework.data.repository.query.Param("post") Post post); + @org.springframework.data.jpa.repository.Query( + "SELECT DISTINCT c.author FROM Comment c WHERE c.post = :post" + ) + java.util.List findDistinctAuthorsByPost( + @org.springframework.data.repository.query.Param("post") Post post + ); - @org.springframework.data.jpa.repository.Query("SELECT MAX(c.createdAt) FROM Comment c WHERE c.post = :post") - java.time.LocalDateTime findLastCommentTime(@org.springframework.data.repository.query.Param("post") Post post); + @org.springframework.data.jpa.repository.Query( + "SELECT MAX(c.createdAt) FROM Comment c WHERE c.post = :post" + ) + java.time.LocalDateTime findLastCommentTime( + @org.springframework.data.repository.query.Param("post") Post post + ); - @org.springframework.data.jpa.repository.Query("SELECT COUNT(c) FROM Comment c WHERE c.author.username = :username AND c.createdAt >= :start") - long countByAuthorAfter(@org.springframework.data.repository.query.Param("username") String username, - @org.springframework.data.repository.query.Param("start") java.time.LocalDateTime start); + @org.springframework.data.jpa.repository.Query( + "SELECT COUNT(c) FROM Comment c WHERE c.author.username = :username AND c.createdAt >= :start" + ) + long countByAuthorAfter( + @org.springframework.data.repository.query.Param("username") String username, + @org.springframework.data.repository.query.Param("start") java.time.LocalDateTime start + ); - @org.springframework.data.jpa.repository.Query("SELECT MAX(c.createdAt) FROM Comment c WHERE c.author.id = :userId") - java.time.LocalDateTime findLastCommentTimeOfUserByUserId(@org.springframework.data.repository.query.Param("userId") Long userId); + @org.springframework.data.jpa.repository.Query( + "SELECT MAX(c.createdAt) FROM Comment c WHERE c.author.id = :userId" + ) + java.time.LocalDateTime findLastCommentTimeOfUserByUserId( + @org.springframework.data.repository.query.Param("userId") Long userId + ); - @org.springframework.data.jpa.repository.Query("SELECT COUNT(c) FROM Comment c WHERE c.post.id = :postId") - long countByPostId(@org.springframework.data.repository.query.Param("postId") Long postId); + @org.springframework.data.jpa.repository.Query( + "SELECT COUNT(c) FROM Comment c WHERE c.post.id = :postId" + ) + long countByPostId(@org.springframework.data.repository.query.Param("postId") Long postId); - long countByAuthor_Id(Long userId); + long countByAuthor_Id(Long userId); - @org.springframework.data.jpa.repository.Query("SELECT FUNCTION('date', c.createdAt) AS d, COUNT(c) AS c FROM Comment c " + - "WHERE c.createdAt >= :start AND c.createdAt < :end GROUP BY d ORDER BY d") - java.util.List countDailyRange(@org.springframework.data.repository.query.Param("start") java.time.LocalDateTime start, - @org.springframework.data.repository.query.Param("end") java.time.LocalDateTime end); + @org.springframework.data.jpa.repository.Query( + "SELECT FUNCTION('date', c.createdAt) AS d, COUNT(c) AS c FROM Comment c " + + "WHERE c.createdAt >= :start AND c.createdAt < :end GROUP BY d ORDER BY d" + ) + java.util.List countDailyRange( + @org.springframework.data.repository.query.Param("start") java.time.LocalDateTime start, + @org.springframework.data.repository.query.Param("end") java.time.LocalDateTime end + ); } diff --git a/backend/src/main/java/com/openisle/repository/CommentSubscriptionRepository.java b/backend/src/main/java/com/openisle/repository/CommentSubscriptionRepository.java index 8d2308cc1..351f7b7ca 100644 --- a/backend/src/main/java/com/openisle/repository/CommentSubscriptionRepository.java +++ b/backend/src/main/java/com/openisle/repository/CommentSubscriptionRepository.java @@ -3,13 +3,12 @@ package com.openisle.repository; import com.openisle.model.Comment; import com.openisle.model.CommentSubscription; import com.openisle.model.User; -import org.springframework.data.jpa.repository.JpaRepository; - import java.util.List; import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; public interface CommentSubscriptionRepository extends JpaRepository { - List findByComment(Comment comment); - List findByUser(User user); - Optional findByUserAndComment(User user, Comment comment); + List findByComment(Comment comment); + List findByUser(User user); + Optional findByUserAndComment(User user, Comment comment); } diff --git a/backend/src/main/java/com/openisle/repository/ContributorConfigRepository.java b/backend/src/main/java/com/openisle/repository/ContributorConfigRepository.java index 852f142a0..dd7e3d402 100644 --- a/backend/src/main/java/com/openisle/repository/ContributorConfigRepository.java +++ b/backend/src/main/java/com/openisle/repository/ContributorConfigRepository.java @@ -1,11 +1,9 @@ package com.openisle.repository; import com.openisle.model.ContributorConfig; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; -import java.util.Optional; - public interface ContributorConfigRepository extends JpaRepository { - Optional findByUserIname(String userIname); + Optional findByUserIname(String userIname); } - diff --git a/backend/src/main/java/com/openisle/repository/DraftRepository.java b/backend/src/main/java/com/openisle/repository/DraftRepository.java index 536201796..9c1de0b6d 100644 --- a/backend/src/main/java/com/openisle/repository/DraftRepository.java +++ b/backend/src/main/java/com/openisle/repository/DraftRepository.java @@ -2,11 +2,10 @@ package com.openisle.repository; import com.openisle.model.Draft; import com.openisle.model.User; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; -import java.util.Optional; - public interface DraftRepository extends JpaRepository { - Optional findByAuthor(User author); - void deleteByAuthor(User author); + Optional findByAuthor(User author); + void deleteByAuthor(User author); } diff --git a/backend/src/main/java/com/openisle/repository/ExperienceLogRepository.java b/backend/src/main/java/com/openisle/repository/ExperienceLogRepository.java index e50d354fe..1b48a1644 100644 --- a/backend/src/main/java/com/openisle/repository/ExperienceLogRepository.java +++ b/backend/src/main/java/com/openisle/repository/ExperienceLogRepository.java @@ -2,11 +2,10 @@ package com.openisle.repository; import com.openisle.model.ExperienceLog; import com.openisle.model.User; -import org.springframework.data.jpa.repository.JpaRepository; - import java.time.LocalDate; import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; public interface ExperienceLogRepository extends JpaRepository { - Optional findByUserAndLogDate(User user, LocalDate logDate); + Optional findByUserAndLogDate(User user, LocalDate logDate); } diff --git a/backend/src/main/java/com/openisle/repository/ImageRepository.java b/backend/src/main/java/com/openisle/repository/ImageRepository.java index 0b39be747..1d3bed021 100644 --- a/backend/src/main/java/com/openisle/repository/ImageRepository.java +++ b/backend/src/main/java/com/openisle/repository/ImageRepository.java @@ -1,13 +1,12 @@ package com.openisle.repository; import com.openisle.model.Image; -import org.springframework.data.jpa.repository.JpaRepository; - import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; /** * Repository for images stored on COS. */ public interface ImageRepository extends JpaRepository { - Optional findByUrl(String url); + Optional findByUrl(String url); } diff --git a/backend/src/main/java/com/openisle/repository/InviteTokenRepository.java b/backend/src/main/java/com/openisle/repository/InviteTokenRepository.java index 8e6d45d94..a2f0da592 100644 --- a/backend/src/main/java/com/openisle/repository/InviteTokenRepository.java +++ b/backend/src/main/java/com/openisle/repository/InviteTokenRepository.java @@ -2,15 +2,14 @@ package com.openisle.repository; import com.openisle.model.InviteToken; import com.openisle.model.User; -import org.springframework.data.jpa.repository.JpaRepository; - import java.time.LocalDate; import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; public interface InviteTokenRepository extends JpaRepository { - Optional findByInviterAndCreatedDate(User inviter, LocalDate createdDate); + Optional findByInviterAndCreatedDate(User inviter, LocalDate createdDate); - Optional findByShortToken(String shortToken); + Optional findByShortToken(String shortToken); - boolean existsByShortToken(String shortToken); + boolean existsByShortToken(String shortToken); } diff --git a/backend/src/main/java/com/openisle/repository/LotteryPostRepository.java b/backend/src/main/java/com/openisle/repository/LotteryPostRepository.java index cff12b18f..5f3f49f16 100644 --- a/backend/src/main/java/com/openisle/repository/LotteryPostRepository.java +++ b/backend/src/main/java/com/openisle/repository/LotteryPostRepository.java @@ -1,13 +1,12 @@ package com.openisle.repository; import com.openisle.model.LotteryPost; -import org.springframework.data.jpa.repository.JpaRepository; - import java.time.LocalDateTime; import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; public interface LotteryPostRepository extends JpaRepository { - List findByEndTimeAfterAndWinnersIsEmpty(LocalDateTime now); + List findByEndTimeAfterAndWinnersIsEmpty(LocalDateTime now); - List findByEndTimeBeforeAndWinnersIsEmpty(LocalDateTime now); + List findByEndTimeBeforeAndWinnersIsEmpty(LocalDateTime now); } diff --git a/backend/src/main/java/com/openisle/repository/MessageConversationRepository.java b/backend/src/main/java/com/openisle/repository/MessageConversationRepository.java index 1c858af2f..c3e99184d 100644 --- a/backend/src/main/java/com/openisle/repository/MessageConversationRepository.java +++ b/backend/src/main/java/com/openisle/repository/MessageConversationRepository.java @@ -2,36 +2,46 @@ package com.openisle.repository; import com.openisle.model.MessageConversation; import com.openisle.model.User; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; -import java.util.List; - @Repository public interface MessageConversationRepository extends JpaRepository { + @Query( + "SELECT c FROM MessageConversation c LEFT JOIN FETCH c.participants p LEFT JOIN FETCH p.user WHERE c.id = :id" + ) + java.util.Optional findByIdWithParticipantsAndUsers(@Param("id") Long id); - @Query("SELECT c FROM MessageConversation c LEFT JOIN FETCH c.participants p LEFT JOIN FETCH p.user WHERE c.id = :id") - java.util.Optional findByIdWithParticipantsAndUsers(@Param("id") Long id); - @Query("SELECT c FROM MessageConversation c " + - "WHERE c.channel = false AND size(c.participants) = 2 " + - "AND EXISTS (SELECT 1 FROM c.participants p1 WHERE p1.user = :user1) " + - "AND EXISTS (SELECT 1 FROM c.participants p2 WHERE p2.user = :user2) " + - "ORDER BY c.createdAt DESC") - List findConversationsByUsers(@Param("user1") User user1, @Param("user2") User user2); - - @Query("SELECT DISTINCT c FROM MessageConversation c " + - "JOIN c.participants p " + - "LEFT JOIN FETCH c.lastMessage lm " + - "LEFT JOIN FETCH lm.sender " + - "LEFT JOIN FETCH c.participants cp " + - "LEFT JOIN FETCH cp.user " + - "WHERE p.user.id = :userId " + - "ORDER BY COALESCE(lm.createdAt, c.createdAt) DESC") - List findConversationsByUserIdOrderByLastMessageDesc(@Param("userId") Long userId); + @Query( + "SELECT c FROM MessageConversation c " + + "WHERE c.channel = false AND size(c.participants) = 2 " + + "AND EXISTS (SELECT 1 FROM c.participants p1 WHERE p1.user = :user1) " + + "AND EXISTS (SELECT 1 FROM c.participants p2 WHERE p2.user = :user2) " + + "ORDER BY c.createdAt DESC" + ) + List findConversationsByUsers( + @Param("user1") User user1, + @Param("user2") User user2 + ); - List findByChannelTrue(); + @Query( + "SELECT DISTINCT c FROM MessageConversation c " + + "JOIN c.participants p " + + "LEFT JOIN FETCH c.lastMessage lm " + + "LEFT JOIN FETCH lm.sender " + + "LEFT JOIN FETCH c.participants cp " + + "LEFT JOIN FETCH cp.user " + + "WHERE p.user.id = :userId " + + "ORDER BY COALESCE(lm.createdAt, c.createdAt) DESC" + ) + List findConversationsByUserIdOrderByLastMessageDesc( + @Param("userId") Long userId + ); - long countByChannelTrue(); + List findByChannelTrue(); + + long countByChannelTrue(); } diff --git a/backend/src/main/java/com/openisle/repository/MessageParticipantRepository.java b/backend/src/main/java/com/openisle/repository/MessageParticipantRepository.java index 3c63bccbd..cd5ac9941 100644 --- a/backend/src/main/java/com/openisle/repository/MessageParticipantRepository.java +++ b/backend/src/main/java/com/openisle/repository/MessageParticipantRepository.java @@ -1,14 +1,13 @@ package com.openisle.repository; import com.openisle.model.MessageParticipant; +import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; -import java.util.List; -import java.util.Optional; - @Repository public interface MessageParticipantRepository extends JpaRepository { - Optional findByConversationIdAndUserId(Long conversationId, Long userId); - List findByUserId(Long userId); -} \ No newline at end of file + Optional findByConversationIdAndUserId(Long conversationId, Long userId); + List findByUserId(Long userId); +} diff --git a/backend/src/main/java/com/openisle/repository/MessageRepository.java b/backend/src/main/java/com/openisle/repository/MessageRepository.java index 9c89a0247..acfdb25d4 100644 --- a/backend/src/main/java/com/openisle/repository/MessageRepository.java +++ b/backend/src/main/java/com/openisle/repository/MessageRepository.java @@ -1,21 +1,27 @@ package com.openisle.repository; import com.openisle.model.Message; -import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; -import java.util.List; - @Repository public interface MessageRepository extends JpaRepository { - List findByConversationIdOrderByCreatedAtAsc(Long conversationId); + List findByConversationIdOrderByCreatedAtAsc(Long conversationId); - Page findByConversationId(Long conversationId, Pageable pageable); + Page findByConversationId(Long conversationId, Pageable pageable); - long countByConversationIdAndCreatedAtAfter(Long conversationId, java.time.LocalDateTime createdAt); - - // 只计算不是指定用户发送的消息(即别人发给当前用户的消息) - long countByConversationIdAndCreatedAtAfterAndSenderIdNot(Long conversationId, java.time.LocalDateTime createdAt, Long senderId); -} \ No newline at end of file + long countByConversationIdAndCreatedAtAfter( + Long conversationId, + java.time.LocalDateTime createdAt + ); + + // 只计算不是指定用户发送的消息(即别人发给当前用户的消息) + long countByConversationIdAndCreatedAtAfterAndSenderIdNot( + Long conversationId, + java.time.LocalDateTime createdAt, + Long senderId + ); +} diff --git a/backend/src/main/java/com/openisle/repository/NotificationRepository.java b/backend/src/main/java/com/openisle/repository/NotificationRepository.java index 644a07686..4ce8932e2 100644 --- a/backend/src/main/java/com/openisle/repository/NotificationRepository.java +++ b/backend/src/main/java/com/openisle/repository/NotificationRepository.java @@ -1,37 +1,63 @@ package com.openisle.repository; -import com.openisle.model.Notification; -import com.openisle.model.User; -import com.openisle.model.Post; import com.openisle.model.Comment; +import com.openisle.model.Notification; import com.openisle.model.NotificationType; +import com.openisle.model.Post; import com.openisle.model.ReactionType; -import org.springframework.data.jpa.repository.JpaRepository; +import com.openisle.model.User; +import java.util.List; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; - -import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; /** Repository for Notification entities. */ public interface NotificationRepository extends JpaRepository { - List findByUserOrderByCreatedAtDesc(User user); - List findByUserAndReadOrderByCreatedAtDesc(User user, boolean read); - Page findByUserOrderByCreatedAtDesc(User user, Pageable pageable); - Page findByUserAndReadOrderByCreatedAtDesc(User user, boolean read, Pageable pageable); - Page findByUserAndTypeNotInOrderByCreatedAtDesc(User user, java.util.Collection types, Pageable pageable); - Page findByUserAndReadAndTypeNotInOrderByCreatedAtDesc(User user, boolean read, java.util.Collection types, Pageable pageable); - long countByUserAndRead(User user, boolean read); - long countByUserAndReadAndTypeNotIn(User user, boolean read, java.util.Collection types); - List findByPost(Post post); - List findByComment(Comment comment); + List findByUserOrderByCreatedAtDesc(User user); + List findByUserAndReadOrderByCreatedAtDesc(User user, boolean read); + Page findByUserOrderByCreatedAtDesc(User user, Pageable pageable); + Page findByUserAndReadOrderByCreatedAtDesc( + User user, + boolean read, + Pageable pageable + ); + Page findByUserAndTypeNotInOrderByCreatedAtDesc( + User user, + java.util.Collection types, + Pageable pageable + ); + Page findByUserAndReadAndTypeNotInOrderByCreatedAtDesc( + User user, + boolean read, + java.util.Collection types, + Pageable pageable + ); + long countByUserAndRead(User user, boolean read); + long countByUserAndReadAndTypeNotIn( + User user, + boolean read, + java.util.Collection types + ); + List findByPost(Post post); + List findByComment(Comment comment); - void deleteByTypeAndFromUser(NotificationType type, User fromUser); + void deleteByTypeAndFromUser(NotificationType type, User fromUser); - List findByTypeAndFromUser(NotificationType type, User fromUser); + List findByTypeAndFromUser(NotificationType type, User fromUser); - void deleteByTypeAndFromUserAndPost(NotificationType type, User fromUser, Post post); + void deleteByTypeAndFromUserAndPost(NotificationType type, User fromUser, Post post); - void deleteByTypeAndFromUserAndPostAndReactionType(NotificationType type, User fromUser, Post post, ReactionType reactionType); + void deleteByTypeAndFromUserAndPostAndReactionType( + NotificationType type, + User fromUser, + Post post, + ReactionType reactionType + ); - void deleteByTypeAndFromUserAndCommentAndReactionType(NotificationType type, User fromUser, Comment comment, ReactionType reactionType); + void deleteByTypeAndFromUserAndCommentAndReactionType( + NotificationType type, + User fromUser, + Comment comment, + ReactionType reactionType + ); } diff --git a/backend/src/main/java/com/openisle/repository/PointGoodRepository.java b/backend/src/main/java/com/openisle/repository/PointGoodRepository.java index b76a62476..1225fd2f4 100644 --- a/backend/src/main/java/com/openisle/repository/PointGoodRepository.java +++ b/backend/src/main/java/com/openisle/repository/PointGoodRepository.java @@ -4,5 +4,4 @@ import com.openisle.model.PointGood; import org.springframework.data.jpa.repository.JpaRepository; /** Repository for point mall goods. */ -public interface PointGoodRepository extends JpaRepository { -} +public interface PointGoodRepository extends JpaRepository {} diff --git a/backend/src/main/java/com/openisle/repository/PointHistoryRepository.java b/backend/src/main/java/com/openisle/repository/PointHistoryRepository.java index 876b183d1..197ac1a45 100644 --- a/backend/src/main/java/com/openisle/repository/PointHistoryRepository.java +++ b/backend/src/main/java/com/openisle/repository/PointHistoryRepository.java @@ -4,19 +4,21 @@ import com.openisle.model.Comment; import com.openisle.model.PointHistory; import com.openisle.model.Post; import com.openisle.model.User; -import org.springframework.data.jpa.repository.JpaRepository; - import java.time.LocalDateTime; import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; public interface PointHistoryRepository extends JpaRepository { - List findByUserOrderByIdDesc(User user); - List findByUserOrderByIdAsc(User user); - long countByUser(User user); + List findByUserOrderByIdDesc(User user); + List findByUserOrderByIdAsc(User user); + long countByUser(User user); - List findByUserAndCreatedAtAfterOrderByCreatedAtDesc(User user, LocalDateTime createdAt); + List findByUserAndCreatedAtAfterOrderByCreatedAtDesc( + User user, + LocalDateTime createdAt + ); - List findByComment(Comment comment); + List findByComment(Comment comment); - List findByPost(Post post); + List findByPost(Post post); } diff --git a/backend/src/main/java/com/openisle/repository/PointLogRepository.java b/backend/src/main/java/com/openisle/repository/PointLogRepository.java index 4f67aca2e..74c95474c 100644 --- a/backend/src/main/java/com/openisle/repository/PointLogRepository.java +++ b/backend/src/main/java/com/openisle/repository/PointLogRepository.java @@ -2,11 +2,10 @@ package com.openisle.repository; import com.openisle.model.PointLog; import com.openisle.model.User; -import org.springframework.data.jpa.repository.JpaRepository; - import java.time.LocalDate; import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; public interface PointLogRepository extends JpaRepository { - Optional findByUserAndLogDate(User user, LocalDate logDate); + Optional findByUserAndLogDate(User user, LocalDate logDate); } diff --git a/backend/src/main/java/com/openisle/repository/PollPostRepository.java b/backend/src/main/java/com/openisle/repository/PollPostRepository.java index 853009946..8d7309c64 100644 --- a/backend/src/main/java/com/openisle/repository/PollPostRepository.java +++ b/backend/src/main/java/com/openisle/repository/PollPostRepository.java @@ -1,13 +1,12 @@ package com.openisle.repository; import com.openisle.model.PollPost; -import org.springframework.data.jpa.repository.JpaRepository; - import java.time.LocalDateTime; import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; public interface PollPostRepository extends JpaRepository { - List findByEndTimeAfterAndResultAnnouncedFalse(LocalDateTime now); + List findByEndTimeAfterAndResultAnnouncedFalse(LocalDateTime now); - List findByEndTimeBeforeAndResultAnnouncedFalse(LocalDateTime now); + List findByEndTimeBeforeAndResultAnnouncedFalse(LocalDateTime now); } diff --git a/backend/src/main/java/com/openisle/repository/PollVoteRepository.java b/backend/src/main/java/com/openisle/repository/PollVoteRepository.java index b2e823e4e..41a36473e 100644 --- a/backend/src/main/java/com/openisle/repository/PollVoteRepository.java +++ b/backend/src/main/java/com/openisle/repository/PollVoteRepository.java @@ -1,10 +1,9 @@ package com.openisle.repository; import com.openisle.model.PollVote; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; -import java.util.List; - public interface PollVoteRepository extends JpaRepository { - List findByPostId(Long postId); + List findByPostId(Long postId); } diff --git a/backend/src/main/java/com/openisle/repository/PostChangeLogRepository.java b/backend/src/main/java/com/openisle/repository/PostChangeLogRepository.java index 324ac8506..eba845310 100644 --- a/backend/src/main/java/com/openisle/repository/PostChangeLogRepository.java +++ b/backend/src/main/java/com/openisle/repository/PostChangeLogRepository.java @@ -2,12 +2,11 @@ package com.openisle.repository; import com.openisle.model.Post; import com.openisle.model.PostChangeLog; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; -import java.util.List; - public interface PostChangeLogRepository extends JpaRepository { - List findByPostOrderByCreatedAtAsc(Post post); + List findByPostOrderByCreatedAtAsc(Post post); - void deleteByPost(Post post); + void deleteByPost(Post post); } diff --git a/backend/src/main/java/com/openisle/repository/PostReadRepository.java b/backend/src/main/java/com/openisle/repository/PostReadRepository.java index 5fec10a16..389d0bd50 100644 --- a/backend/src/main/java/com/openisle/repository/PostReadRepository.java +++ b/backend/src/main/java/com/openisle/repository/PostReadRepository.java @@ -3,12 +3,11 @@ package com.openisle.repository; import com.openisle.model.Post; import com.openisle.model.PostRead; import com.openisle.model.User; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; -import java.util.Optional; - public interface PostReadRepository extends JpaRepository { - Optional findByUserAndPost(User user, Post post); - long countByUser(User user); - void deleteByPost(Post post); + Optional findByUserAndPost(User user, Post post); + long countByUser(User user); + void deleteByPost(Post post); } diff --git a/backend/src/main/java/com/openisle/repository/PostRepository.java b/backend/src/main/java/com/openisle/repository/PostRepository.java index a072c83f1..a063602c5 100644 --- a/backend/src/main/java/com/openisle/repository/PostRepository.java +++ b/backend/src/main/java/com/openisle/repository/PostRepository.java @@ -1,113 +1,277 @@ package com.openisle.repository; +import com.openisle.model.Category; import com.openisle.model.Post; import com.openisle.model.PostStatus; -import com.openisle.model.User; -import com.openisle.model.Category; import com.openisle.model.Tag; +import com.openisle.model.User; +import java.time.LocalDateTime; +import java.util.List; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.List; -import java.time.LocalDateTime; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; public interface PostRepository extends JpaRepository { - List findByStatus(PostStatus status); - List findByStatus(PostStatus status, Pageable pageable); - List findByStatusOrderByCreatedAtDesc(PostStatus status); - List findByStatusOrderByCreatedAtDesc(PostStatus status, Pageable pageable); - List findByStatusOrderByViewsDesc(PostStatus status); - List findByStatusOrderByViewsDesc(PostStatus status, Pageable pageable); - List findByAuthorAndStatusOrderByCreatedAtDesc(User author, PostStatus status, Pageable pageable); - List findByCategoryInAndStatus(List categories, PostStatus status); - List findByCategoryInAndStatus(List categories, PostStatus status, Pageable pageable); - List findByCategoryInAndStatusOrderByCreatedAtDesc(List categories, PostStatus status); - List findByCategoryInAndStatusOrderByCreatedAtDesc(List categories, PostStatus status, Pageable pageable); - List findDistinctByTagsInAndStatus(List tags, PostStatus status); - List findDistinctByTagsInAndStatus(List tags, PostStatus status, Pageable pageable); - List findDistinctByTagsInAndStatusOrderByCreatedAtDesc(List tags, PostStatus status); - List findDistinctByTagsInAndStatusOrderByCreatedAtDesc(List tags, PostStatus status, Pageable pageable); - List findDistinctByCategoryInAndTagsInAndStatus(List categories, List tags, PostStatus status); - List findDistinctByCategoryInAndTagsInAndStatus(List categories, List tags, PostStatus status, Pageable pageable); - List findDistinctByCategoryInAndTagsInAndStatusOrderByCreatedAtDesc(List categories, List tags, PostStatus status); - List findDistinctByCategoryInAndTagsInAndStatusOrderByCreatedAtDesc(List categories, List tags, PostStatus status, Pageable pageable); + List findByStatus(PostStatus status); + List findByStatus(PostStatus status, Pageable pageable); + List findByStatusOrderByCreatedAtDesc(PostStatus status); + List findByStatusOrderByCreatedAtDesc(PostStatus status, Pageable pageable); + List findByStatusOrderByViewsDesc(PostStatus status); + List findByStatusOrderByViewsDesc(PostStatus status, Pageable pageable); + List findByAuthorAndStatusOrderByCreatedAtDesc( + User author, + PostStatus status, + Pageable pageable + ); + List findByCategoryInAndStatus(List categories, PostStatus status); + List findByCategoryInAndStatus( + List categories, + PostStatus status, + Pageable pageable + ); + List findByCategoryInAndStatusOrderByCreatedAtDesc( + List categories, + PostStatus status + ); + List findByCategoryInAndStatusOrderByCreatedAtDesc( + List categories, + PostStatus status, + Pageable pageable + ); + List findDistinctByTagsInAndStatus(List tags, PostStatus status); + List findDistinctByTagsInAndStatus(List tags, PostStatus status, Pageable pageable); + List findDistinctByTagsInAndStatusOrderByCreatedAtDesc(List tags, PostStatus status); + List findDistinctByTagsInAndStatusOrderByCreatedAtDesc( + List tags, + PostStatus status, + Pageable pageable + ); + List findDistinctByCategoryInAndTagsInAndStatus( + List categories, + List tags, + PostStatus status + ); + List findDistinctByCategoryInAndTagsInAndStatus( + List categories, + List tags, + PostStatus status, + Pageable pageable + ); + List findDistinctByCategoryInAndTagsInAndStatusOrderByCreatedAtDesc( + List categories, + List tags, + PostStatus status + ); + List findDistinctByCategoryInAndTagsInAndStatusOrderByCreatedAtDesc( + List categories, + List tags, + PostStatus status, + Pageable pageable + ); - // Queries requiring all provided tags to be present - @Query("SELECT p FROM Post p JOIN p.tags t WHERE t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount") - List findByAllTags(@Param("tags") List tags, @Param("status") PostStatus status, @Param("tagCount") long tagCount); + // Queries requiring all provided tags to be present + @Query( + "SELECT p FROM Post p JOIN p.tags t WHERE t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount" + ) + List findByAllTags( + @Param("tags") List tags, + @Param("status") PostStatus status, + @Param("tagCount") long tagCount + ); - @Query(value = "SELECT p FROM Post p JOIN p.tags t WHERE t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount") - List findByAllTags(@Param("tags") List tags, @Param("status") PostStatus status, @Param("tagCount") long tagCount, Pageable pageable); + @Query( + value = "SELECT p FROM Post p JOIN p.tags t WHERE t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount" + ) + List findByAllTags( + @Param("tags") List tags, + @Param("status") PostStatus status, + @Param("tagCount") long tagCount, + Pageable pageable + ); - @Query("SELECT p FROM Post p JOIN p.tags t WHERE t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.createdAt DESC") - List findByAllTagsOrderByCreatedAtDesc(@Param("tags") List tags, @Param("status") PostStatus status, @Param("tagCount") long tagCount); + @Query( + "SELECT p FROM Post p JOIN p.tags t WHERE t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.createdAt DESC" + ) + List findByAllTagsOrderByCreatedAtDesc( + @Param("tags") List tags, + @Param("status") PostStatus status, + @Param("tagCount") long tagCount + ); - @Query(value = "SELECT p FROM Post p JOIN p.tags t WHERE t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.createdAt DESC") - List findByAllTagsOrderByCreatedAtDesc(@Param("tags") List tags, @Param("status") PostStatus status, @Param("tagCount") long tagCount, Pageable pageable); + @Query( + value = "SELECT p FROM Post p JOIN p.tags t WHERE t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.createdAt DESC" + ) + List findByAllTagsOrderByCreatedAtDesc( + @Param("tags") List tags, + @Param("status") PostStatus status, + @Param("tagCount") long tagCount, + Pageable pageable + ); - @Query("SELECT p FROM Post p JOIN p.tags t WHERE t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.views DESC") - List findByAllTagsOrderByViewsDesc(@Param("tags") List tags, @Param("status") PostStatus status, @Param("tagCount") long tagCount); + @Query( + "SELECT p FROM Post p JOIN p.tags t WHERE t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.views DESC" + ) + List findByAllTagsOrderByViewsDesc( + @Param("tags") List tags, + @Param("status") PostStatus status, + @Param("tagCount") long tagCount + ); - @Query(value = "SELECT p FROM Post p JOIN p.tags t WHERE t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.views DESC") - List findByAllTagsOrderByViewsDesc(@Param("tags") List tags, @Param("status") PostStatus status, @Param("tagCount") long tagCount, Pageable pageable); + @Query( + value = "SELECT p FROM Post p JOIN p.tags t WHERE t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.views DESC" + ) + List findByAllTagsOrderByViewsDesc( + @Param("tags") List tags, + @Param("status") PostStatus status, + @Param("tagCount") long tagCount, + Pageable pageable + ); - @Query("SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount") - List findByCategoriesAndAllTags(@Param("categories") List categories, @Param("tags") List tags, @Param("status") PostStatus status, @Param("tagCount") long tagCount); + @Query( + "SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount" + ) + List findByCategoriesAndAllTags( + @Param("categories") List categories, + @Param("tags") List tags, + @Param("status") PostStatus status, + @Param("tagCount") long tagCount + ); - @Query(value = "SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount") - List findByCategoriesAndAllTags(@Param("categories") List categories, @Param("tags") List tags, @Param("status") PostStatus status, @Param("tagCount") long tagCount, Pageable pageable); + @Query( + value = "SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount" + ) + List findByCategoriesAndAllTags( + @Param("categories") List categories, + @Param("tags") List tags, + @Param("status") PostStatus status, + @Param("tagCount") long tagCount, + Pageable pageable + ); - @Query("SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.views DESC") - List findByCategoriesAndAllTagsOrderByViewsDesc(@Param("categories") List categories, @Param("tags") List tags, @Param("status") PostStatus status, @Param("tagCount") long tagCount); + @Query( + "SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.views DESC" + ) + List findByCategoriesAndAllTagsOrderByViewsDesc( + @Param("categories") List categories, + @Param("tags") List tags, + @Param("status") PostStatus status, + @Param("tagCount") long tagCount + ); - @Query(value = "SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.views DESC") - List findByCategoriesAndAllTagsOrderByViewsDesc(@Param("categories") List categories, @Param("tags") List tags, @Param("status") PostStatus status, @Param("tagCount") long tagCount, Pageable pageable); + @Query( + value = "SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.views DESC" + ) + List findByCategoriesAndAllTagsOrderByViewsDesc( + @Param("categories") List categories, + @Param("tags") List tags, + @Param("status") PostStatus status, + @Param("tagCount") long tagCount, + Pageable pageable + ); - @Query("SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.createdAt DESC") - List findByCategoriesAndAllTagsOrderByCreatedAtDesc(@Param("categories") List categories, @Param("tags") List tags, @Param("status") PostStatus status, @Param("tagCount") long tagCount); + @Query( + "SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.createdAt DESC" + ) + List findByCategoriesAndAllTagsOrderByCreatedAtDesc( + @Param("categories") List categories, + @Param("tags") List tags, + @Param("status") PostStatus status, + @Param("tagCount") long tagCount + ); - @Query(value = "SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.createdAt DESC") - List findByCategoriesAndAllTagsOrderByCreatedAtDesc(@Param("categories") List categories, @Param("tags") List tags, @Param("status") PostStatus status, @Param("tagCount") long tagCount, Pageable pageable); + @Query( + value = "SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.createdAt DESC" + ) + List findByCategoriesAndAllTagsOrderByCreatedAtDesc( + @Param("categories") List categories, + @Param("tags") List tags, + @Param("status") PostStatus status, + @Param("tagCount") long tagCount, + Pageable pageable + ); - List findByCategoryInAndStatusOrderByViewsDesc(List categories, PostStatus status); - List findByCategoryInAndStatusOrderByViewsDesc(List categories, PostStatus status, Pageable pageable); - List findDistinctByTagsInAndStatusOrderByViewsDesc(List tags, PostStatus status); - List findDistinctByTagsInAndStatusOrderByViewsDesc(List tags, PostStatus status, Pageable pageable); - List findDistinctByCategoryInAndTagsInAndStatusOrderByViewsDesc(List categories, List tags, PostStatus status); - List findDistinctByCategoryInAndTagsInAndStatusOrderByViewsDesc(List categories, List tags, PostStatus status, Pageable pageable); - List findByTitleContainingIgnoreCaseOrContentContainingIgnoreCaseAndStatus(String titleKeyword, String contentKeyword, PostStatus status); - List findByContentContainingIgnoreCaseAndStatus(String keyword, PostStatus status); - List findByTitleContainingIgnoreCaseAndStatus(String keyword, PostStatus status); + List findByCategoryInAndStatusOrderByViewsDesc( + List categories, + PostStatus status + ); + List findByCategoryInAndStatusOrderByViewsDesc( + List categories, + PostStatus status, + Pageable pageable + ); + List findDistinctByTagsInAndStatusOrderByViewsDesc(List tags, PostStatus status); + List findDistinctByTagsInAndStatusOrderByViewsDesc( + List tags, + PostStatus status, + Pageable pageable + ); + List findDistinctByCategoryInAndTagsInAndStatusOrderByViewsDesc( + List categories, + List tags, + PostStatus status + ); + List findDistinctByCategoryInAndTagsInAndStatusOrderByViewsDesc( + List categories, + List tags, + PostStatus status, + Pageable pageable + ); + List findByTitleContainingIgnoreCaseOrContentContainingIgnoreCaseAndStatus( + String titleKeyword, + String contentKeyword, + PostStatus status + ); + List findByContentContainingIgnoreCaseAndStatus(String keyword, PostStatus status); + List findByTitleContainingIgnoreCaseAndStatus(String keyword, PostStatus status); - @Query("SELECT MAX(p.createdAt) FROM Post p WHERE p.author.username = :username AND p.status = com.openisle.model.PostStatus.PUBLISHED") - LocalDateTime findLastPostTime(@Param("username") String username); + @Query( + "SELECT MAX(p.createdAt) FROM Post p WHERE p.author.username = :username AND p.status = com.openisle.model.PostStatus.PUBLISHED" + ) + LocalDateTime findLastPostTime(@Param("username") String username); - @Query("SELECT SUM(p.views) FROM Post p WHERE p.author.username = :username AND p.status = com.openisle.model.PostStatus.PUBLISHED") - Long sumViews(@Param("username") String username); + @Query( + "SELECT SUM(p.views) FROM Post p WHERE p.author.username = :username AND p.status = com.openisle.model.PostStatus.PUBLISHED" + ) + Long sumViews(@Param("username") String username); - @Query("SELECT COUNT(p) FROM Post p WHERE p.author.username = :username AND p.createdAt >= :start") - long countByAuthorAfter(@Param("username") String username, @Param("start") java.time.LocalDateTime start); + @Query( + "SELECT COUNT(p) FROM Post p WHERE p.author.username = :username AND p.createdAt >= :start" + ) + long countByAuthorAfter( + @Param("username") String username, + @Param("start") java.time.LocalDateTime start + ); - long countByCategory_Id(Long categoryId); + long countByCategory_Id(Long categoryId); - @Query("SELECT c.id, COUNT(p) FROM Post p JOIN p.category c WHERE c.id IN :categoryIds GROUP BY c.id") - List countPostsByCategoryIds(@Param("categoryIds") List categoryIds); + @Query( + "SELECT c.id, COUNT(p) FROM Post p JOIN p.category c WHERE c.id IN :categoryIds GROUP BY c.id" + ) + List countPostsByCategoryIds(@Param("categoryIds") List categoryIds); - long countDistinctByTags_Id(Long tagId); + long countDistinctByTags_Id(Long tagId); - long countByAuthor_IdAndRssExcludedFalse(Long userId); + long countByAuthor_IdAndRssExcludedFalse(Long userId); - @Query("SELECT t.id, COUNT(DISTINCT p) FROM Post p JOIN p.tags t WHERE t.id IN :tagIds GROUP BY t.id") - List countPostsByTagIds(@Param("tagIds") List tagIds); + @Query( + "SELECT t.id, COUNT(DISTINCT p) FROM Post p JOIN p.tags t WHERE t.id IN :tagIds GROUP BY t.id" + ) + List countPostsByTagIds(@Param("tagIds") List tagIds); - long countByAuthor_Id(Long userId); + long countByAuthor_Id(Long userId); - @Query("SELECT FUNCTION('date', p.createdAt) AS d, COUNT(p) AS c FROM Post p " + - "WHERE p.createdAt >= :start AND p.createdAt < :end GROUP BY d ORDER BY d") - java.util.List countDailyRange(@Param("start") LocalDateTime start, - @Param("end") LocalDateTime end); + @Query( + "SELECT FUNCTION('date', p.createdAt) AS d, COUNT(p) AS c FROM Post p " + + "WHERE p.createdAt >= :start AND p.createdAt < :end GROUP BY d ORDER BY d" + ) + java.util.List countDailyRange( + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end + ); - List findByStatusAndRssExcludedFalseOrderByCreatedAtDesc(PostStatus status, Pageable pageable); + List findByStatusAndRssExcludedFalseOrderByCreatedAtDesc( + PostStatus status, + Pageable pageable + ); } diff --git a/backend/src/main/java/com/openisle/repository/PostSubscriptionRepository.java b/backend/src/main/java/com/openisle/repository/PostSubscriptionRepository.java index 8bb66a3a7..935b9fa85 100644 --- a/backend/src/main/java/com/openisle/repository/PostSubscriptionRepository.java +++ b/backend/src/main/java/com/openisle/repository/PostSubscriptionRepository.java @@ -3,13 +3,12 @@ package com.openisle.repository; import com.openisle.model.Post; import com.openisle.model.PostSubscription; import com.openisle.model.User; -import org.springframework.data.jpa.repository.JpaRepository; - import java.util.List; import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; public interface PostSubscriptionRepository extends JpaRepository { - List findByPost(Post post); - List findByUser(User user); - Optional findByUserAndPost(User user, Post post); + List findByPost(Post post); + List findByUser(User user); + Optional findByUserAndPost(User user, Post post); } diff --git a/backend/src/main/java/com/openisle/repository/PushSubscriptionRepository.java b/backend/src/main/java/com/openisle/repository/PushSubscriptionRepository.java index 28268d203..f321f7e1d 100644 --- a/backend/src/main/java/com/openisle/repository/PushSubscriptionRepository.java +++ b/backend/src/main/java/com/openisle/repository/PushSubscriptionRepository.java @@ -2,11 +2,10 @@ package com.openisle.repository; import com.openisle.model.PushSubscription; import com.openisle.model.User; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; -import java.util.List; - public interface PushSubscriptionRepository extends JpaRepository { - List findByUser(User user); - void deleteByUserAndEndpoint(User user, String endpoint); + List findByUser(User user); + void deleteByUserAndEndpoint(User user, String endpoint); } diff --git a/backend/src/main/java/com/openisle/repository/ReactionRepository.java b/backend/src/main/java/com/openisle/repository/ReactionRepository.java index 4f07eab80..ef54efe23 100644 --- a/backend/src/main/java/com/openisle/repository/ReactionRepository.java +++ b/backend/src/main/java/com/openisle/repository/ReactionRepository.java @@ -5,55 +5,81 @@ import com.openisle.model.Message; import com.openisle.model.Post; import com.openisle.model.Reaction; import com.openisle.model.User; +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import org.springframework.data.domain.Pageable; - -import java.util.List; -import java.util.Optional; public interface ReactionRepository extends JpaRepository { - Optional findByUserAndPostAndType(User user, Post post, com.openisle.model.ReactionType type); - Optional findByUserAndCommentAndType(User user, Comment comment, com.openisle.model.ReactionType type); - Optional findByUserAndMessageAndType(User user, Message message, com.openisle.model.ReactionType type); - List findByPost(Post post); - List findByComment(Comment comment); - List findByMessage(Message message); + Optional findByUserAndPostAndType( + User user, + Post post, + com.openisle.model.ReactionType type + ); + Optional findByUserAndCommentAndType( + User user, + Comment comment, + com.openisle.model.ReactionType type + ); + Optional findByUserAndMessageAndType( + User user, + Message message, + com.openisle.model.ReactionType type + ); + List findByPost(Post post); + List findByComment(Comment comment); + List findByMessage(Message message); - @Query("SELECT r.post.id FROM Reaction r WHERE r.post IS NOT NULL AND r.post.author.username = :username AND r.type = com.openisle.model.ReactionType.LIKE GROUP BY r.post.id ORDER BY COUNT(r.id) DESC") - List findTopPostIds(@Param("username") String username, Pageable pageable); + @Query( + "SELECT r.post.id FROM Reaction r WHERE r.post IS NOT NULL AND r.post.author.username = :username AND r.type = com.openisle.model.ReactionType.LIKE GROUP BY r.post.id ORDER BY COUNT(r.id) DESC" + ) + List findTopPostIds(@Param("username") String username, Pageable pageable); - @Query("SELECT r.comment.id FROM Reaction r WHERE r.comment IS NOT NULL AND r.comment.author.username = :username AND r.type = com.openisle.model.ReactionType.LIKE GROUP BY r.comment.id ORDER BY COUNT(r.id) DESC") - List findTopCommentIds(@Param("username") String username, Pageable pageable); + @Query( + "SELECT r.comment.id FROM Reaction r WHERE r.comment IS NOT NULL AND r.comment.author.username = :username AND r.type = com.openisle.model.ReactionType.LIKE GROUP BY r.comment.id ORDER BY COUNT(r.id) DESC" + ) + List findTopCommentIds(@Param("username") String username, Pageable pageable); - @Query("SELECT COUNT(r) FROM Reaction r WHERE r.user.username = :username AND r.type = com.openisle.model.ReactionType.LIKE") - long countLikesSent(@Param("username") String username); + @Query( + "SELECT COUNT(r) FROM Reaction r WHERE r.user.username = :username AND r.type = com.openisle.model.ReactionType.LIKE" + ) + long countLikesSent(@Param("username") String username); - @Query("SELECT COUNT(r) FROM Reaction r WHERE r.user.username = :username AND r.createdAt >= :start") - long countByUserAfter(@Param("username") String username, @Param("start") java.time.LocalDateTime start); + @Query( + "SELECT COUNT(r) FROM Reaction r WHERE r.user.username = :username AND r.createdAt >= :start" + ) + long countByUserAfter( + @Param("username") String username, + @Param("start") java.time.LocalDateTime start + ); - @Query(""" - SELECT COUNT(DISTINCT r.id) - FROM Reaction r - LEFT JOIN r.post p - LEFT JOIN p.author pa - LEFT JOIN r.comment c - LEFT JOIN c.author ca - WHERE r.type = com.openisle.model.ReactionType.LIKE - AND ( - (r.post IS NOT NULL AND pa.username = :username) - OR (r.comment IS NOT NULL AND ca.username = :username) - ) - """) - long countLikesReceived(@Param("username") String username); + @Query( + """ + SELECT COUNT(DISTINCT r.id) + FROM Reaction r + LEFT JOIN r.post p + LEFT JOIN p.author pa + LEFT JOIN r.comment c + LEFT JOIN c.author ca + WHERE r.type = com.openisle.model.ReactionType.LIKE + AND ( + (r.post IS NOT NULL AND pa.username = :username) + OR (r.comment IS NOT NULL AND ca.username = :username) + ) + """ + ) + long countLikesReceived(@Param("username") String username); - @Query(""" - SELECT COUNT(r) FROM Reaction r - LEFT JOIN r.post p - LEFT JOIN r.comment c - WHERE (p IS NOT NULL AND p.author.username = :username) OR - (c IS NOT NULL AND c.author.username = :username) - """) - long countReceived(@Param("username") String username); + @Query( + """ + SELECT COUNT(r) FROM Reaction r + LEFT JOIN r.post p + LEFT JOIN r.comment c + WHERE (p IS NOT NULL AND p.author.username = :username) OR + (c IS NOT NULL AND c.author.username = :username) + """ + ) + long countReceived(@Param("username") String username); } diff --git a/backend/src/main/java/com/openisle/repository/TagRepository.java b/backend/src/main/java/com/openisle/repository/TagRepository.java index 1e2868437..021cf94e9 100644 --- a/backend/src/main/java/com/openisle/repository/TagRepository.java +++ b/backend/src/main/java/com/openisle/repository/TagRepository.java @@ -2,20 +2,19 @@ package com.openisle.repository; import com.openisle.model.Tag; import com.openisle.model.User; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.domain.Pageable; - import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; public interface TagRepository extends JpaRepository { - List findByNameContainingIgnoreCase(String keyword); - List findByApproved(boolean approved); - List findByApprovedTrue(); - List findByNameContainingIgnoreCaseAndApprovedTrue(String keyword); + List findByNameContainingIgnoreCase(String keyword); + List findByApproved(boolean approved); + List findByApprovedTrue(); + List findByNameContainingIgnoreCaseAndApprovedTrue(String keyword); - List findByCreatorOrderByCreatedAtDesc(User creator, Pageable pageable); - List findByCreator(User creator); + List findByCreatorOrderByCreatedAtDesc(User creator, Pageable pageable); + List findByCreator(User creator); - Optional findByName(String name); + Optional findByName(String name); } diff --git a/backend/src/main/java/com/openisle/repository/UserRepository.java b/backend/src/main/java/com/openisle/repository/UserRepository.java index 35847fb55..3e126857d 100644 --- a/backend/src/main/java/com/openisle/repository/UserRepository.java +++ b/backend/src/main/java/com/openisle/repository/UserRepository.java @@ -1,22 +1,26 @@ package com.openisle.repository; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; import com.openisle.model.User; import java.time.LocalDateTime; import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface UserRepository extends JpaRepository { - Optional findByUsername(String username); - Optional findByEmail(String email); - java.util.List findByUsernameContainingIgnoreCase(String keyword); - java.util.List findByRole(com.openisle.model.Role role); - long countByExperienceGreaterThanEqual(int experience); - long countByCreatedAtBefore(LocalDateTime createdAt); + Optional findByUsername(String username); + Optional findByEmail(String email); + java.util.List findByUsernameContainingIgnoreCase(String keyword); + java.util.List findByRole(com.openisle.model.Role role); + long countByExperienceGreaterThanEqual(int experience); + long countByCreatedAtBefore(LocalDateTime createdAt); - @Query("SELECT FUNCTION('date', u.createdAt) AS d, COUNT(u) AS c FROM User u " + - "WHERE u.createdAt >= :start AND u.createdAt < :end GROUP BY d ORDER BY d") - java.util.List countDailyRange(@Param("start") LocalDateTime start, - @Param("end") LocalDateTime end); + @Query( + "SELECT FUNCTION('date', u.createdAt) AS d, COUNT(u) AS c FROM User u " + + "WHERE u.createdAt >= :start AND u.createdAt < :end GROUP BY d ORDER BY d" + ) + java.util.List countDailyRange( + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end + ); } diff --git a/backend/src/main/java/com/openisle/repository/UserSubscriptionRepository.java b/backend/src/main/java/com/openisle/repository/UserSubscriptionRepository.java index d186fdcd6..43168191d 100644 --- a/backend/src/main/java/com/openisle/repository/UserSubscriptionRepository.java +++ b/backend/src/main/java/com/openisle/repository/UserSubscriptionRepository.java @@ -2,15 +2,14 @@ package com.openisle.repository; import com.openisle.model.User; import com.openisle.model.UserSubscription; -import org.springframework.data.jpa.repository.JpaRepository; - import java.util.List; import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; public interface UserSubscriptionRepository extends JpaRepository { - List findBySubscriber(User subscriber); - List findByTarget(User target); - Optional findBySubscriberAndTarget(User subscriber, User target); - long countByTarget(User target); - long countBySubscriber(User subscriber); + List findBySubscriber(User subscriber); + List findByTarget(User target); + Optional findBySubscriberAndTarget(User subscriber, User target); + long countByTarget(User target); + long countBySubscriber(User subscriber); } diff --git a/backend/src/main/java/com/openisle/repository/UserVisitRepository.java b/backend/src/main/java/com/openisle/repository/UserVisitRepository.java index afcff1885..36b09375a 100644 --- a/backend/src/main/java/com/openisle/repository/UserVisitRepository.java +++ b/backend/src/main/java/com/openisle/repository/UserVisitRepository.java @@ -2,18 +2,19 @@ package com.openisle.repository; import com.openisle.model.User; import com.openisle.model.UserVisit; +import java.time.LocalDate; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import java.time.LocalDate; -import java.util.Optional; - public interface UserVisitRepository extends JpaRepository { - Optional findByUserAndVisitDate(User user, LocalDate visitDate); - long countByUser(User user); - long countByVisitDate(LocalDate visitDate); + Optional findByUserAndVisitDate(User user, LocalDate visitDate); + long countByUser(User user); + long countByVisitDate(LocalDate visitDate); - @Query("SELECT uv.visitDate AS d, COUNT(uv) AS c FROM UserVisit uv WHERE uv.visitDate BETWEEN :start AND :end GROUP BY uv.visitDate ORDER BY uv.visitDate") - java.util.List countRange(@Param("start") LocalDate start, @Param("end") LocalDate end); + @Query( + "SELECT uv.visitDate AS d, COUNT(uv) AS c FROM UserVisit uv WHERE uv.visitDate BETWEEN :start AND :end GROUP BY uv.visitDate ORDER BY uv.visitDate" + ) + java.util.List countRange(@Param("start") LocalDate start, @Param("end") LocalDate end); } diff --git a/backend/src/main/java/com/openisle/scheduler/UserVisitScheduler.java b/backend/src/main/java/com/openisle/scheduler/UserVisitScheduler.java index 139963587..8ab85442b 100644 --- a/backend/src/main/java/com/openisle/scheduler/UserVisitScheduler.java +++ b/backend/src/main/java/com/openisle/scheduler/UserVisitScheduler.java @@ -5,15 +5,14 @@ import com.openisle.model.User; import com.openisle.model.UserVisit; import com.openisle.repository.UserRepository; import com.openisle.repository.UserVisitRepository; +import java.time.LocalDate; +import java.util.Set; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import org.springframework.util.CollectionUtils; -import java.time.LocalDate; -import java.util.Set; - /** * 执行计划 * 将每天用户访问落库 @@ -23,26 +22,27 @@ import java.util.Set; @Component @RequiredArgsConstructor public class UserVisitScheduler { - private final RedisTemplate redisTemplate; - private final UserRepository userRepository; - private final UserVisitRepository userVisitRepository; - @Scheduled(cron = "0 5 0 * * ?") // 每天 00:05 执行 - public void persistDailyVisits(){ - LocalDate yesterday = LocalDate.now().minusDays(1); - String key = CachingConfig.VISIT_CACHE_NAME + ":" + yesterday; - Set usernames = redisTemplate.opsForSet().members(key); - if (!CollectionUtils.isEmpty(usernames)) { - for(String username: usernames){ - User user = userRepository.findByUsername(username).orElse(null); - if(user != null){ - UserVisit userVisit = new UserVisit(); - userVisit.setUser(user); - userVisit.setVisitDate(yesterday); - userVisitRepository.save(userVisit); - } - } - redisTemplate.delete(key); + private final RedisTemplate redisTemplate; + private final UserRepository userRepository; + private final UserVisitRepository userVisitRepository; + + @Scheduled(cron = "0 5 0 * * ?") // 每天 00:05 执行 + public void persistDailyVisits() { + LocalDate yesterday = LocalDate.now().minusDays(1); + String key = CachingConfig.VISIT_CACHE_NAME + ":" + yesterday; + Set usernames = redisTemplate.opsForSet().members(key); + if (!CollectionUtils.isEmpty(usernames)) { + for (String username : usernames) { + User user = userRepository.findByUsername(username).orElse(null); + if (user != null) { + UserVisit userVisit = new UserVisit(); + userVisit.setUser(user); + userVisit.setVisitDate(yesterday); + userVisitRepository.save(userVisit); } + } + redisTemplate.delete(key); } + } } diff --git a/backend/src/main/java/com/openisle/service/ActivityService.java b/backend/src/main/java/com/openisle/service/ActivityService.java index 2050dc5d7..081b4f3c7 100644 --- a/backend/src/main/java/com/openisle/service/ActivityService.java +++ b/backend/src/main/java/com/openisle/service/ActivityService.java @@ -4,53 +4,53 @@ import com.openisle.exception.NotFoundException; import com.openisle.model.*; import com.openisle.repository.ActivityRepository; import com.openisle.repository.UserRepository; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import java.util.List; - @Service @RequiredArgsConstructor public class ActivityService { - private final ActivityRepository activityRepository; - private final UserRepository userRepository; - private final LevelService levelService; - private final NotificationService notificationService; - public List list() { - return activityRepository.findAll(); - } + private final ActivityRepository activityRepository; + private final UserRepository userRepository; + private final LevelService levelService; + private final NotificationService notificationService; - public Activity getByType(ActivityType type) { - Activity a = activityRepository.findByType(type); - if (a == null) throw new NotFoundException("Activity not found"); - return a; - } + public List list() { + return activityRepository.findAll(); + } - public long countLevel1Users() { - int threshold = levelService.nextLevelExp(0); - return userRepository.countByExperienceGreaterThanEqual(threshold); - } + public Activity getByType(ActivityType type) { + Activity a = activityRepository.findByType(type); + if (a == null) throw new NotFoundException("Activity not found"); + return a; + } - public void end(Activity activity) { - activity.setEnded(true); - activityRepository.save(activity); - } + public long countLevel1Users() { + int threshold = levelService.nextLevelExp(0); + return userRepository.countByExperienceGreaterThanEqual(threshold); + } - public long countParticipants(Activity activity) { - return activity.getParticipants().size(); - } + public void end(Activity activity) { + activity.setEnded(true); + activityRepository.save(activity); + } - /** - * Redeem an activity for the given user. - * - * @return true if the user redeemed for the first time, false if the - * information was simply updated - */ - public boolean redeem(Activity activity, User user, String contact) { - notificationService.createActivityRedeemNotifications(user, contact); - boolean added = activity.getParticipants().add(user); - activityRepository.save(activity); - return added; - } + public long countParticipants(Activity activity) { + return activity.getParticipants().size(); + } + + /** + * Redeem an activity for the given user. + * + * @return true if the user redeemed for the first time, false if the + * information was simply updated + */ + public boolean redeem(Activity activity, User user, String contact) { + notificationService.createActivityRedeemNotifications(user, contact); + boolean added = activity.getParticipants().add(user); + activityRepository.save(activity); + return added; + } } diff --git a/backend/src/main/java/com/openisle/service/AiUsageService.java b/backend/src/main/java/com/openisle/service/AiUsageService.java index 851cfa309..36f88a160 100644 --- a/backend/src/main/java/com/openisle/service/AiUsageService.java +++ b/backend/src/main/java/com/openisle/service/AiUsageService.java @@ -4,51 +4,55 @@ import com.openisle.model.AiFormatUsage; import com.openisle.model.User; import com.openisle.repository.AiFormatUsageRepository; import com.openisle.repository.UserRepository; +import java.time.LocalDate; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import java.time.LocalDate; - @Service @RequiredArgsConstructor public class AiUsageService { - private final AiFormatUsageRepository usageRepository; - private final UserRepository userRepository; - @Value("${app.ai.format-limit:3}") - private int formatLimit; + private final AiFormatUsageRepository usageRepository; + private final UserRepository userRepository; - public int getFormatLimit() { - return formatLimit; - } + @Value("${app.ai.format-limit:3}") + private int formatLimit; - public void setFormatLimit(int formatLimit) { - this.formatLimit = formatLimit; - } + public int getFormatLimit() { + return formatLimit; + } - public int incrementAndGetCount(String username) { - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - LocalDate today = LocalDate.now(); - AiFormatUsage usage = usageRepository.findByUserAndUseDate(user, today) - .orElseGet(() -> { - AiFormatUsage u = new AiFormatUsage(); - u.setUser(user); - u.setUseDate(today); - u.setCount(0); - return u; - }); - usage.setCount(usage.getCount() + 1); - usageRepository.save(usage); - return usage.getCount(); - } + public void setFormatLimit(int formatLimit) { + this.formatLimit = formatLimit; + } - public int getCount(String username) { - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - return usageRepository.findByUserAndUseDate(user, LocalDate.now()) - .map(AiFormatUsage::getCount) - .orElse(0); - } + public int incrementAndGetCount(String username) { + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + LocalDate today = LocalDate.now(); + AiFormatUsage usage = usageRepository + .findByUserAndUseDate(user, today) + .orElseGet(() -> { + AiFormatUsage u = new AiFormatUsage(); + u.setUser(user); + u.setUseDate(today); + u.setCount(0); + return u; + }); + usage.setCount(usage.getCount() + 1); + usageRepository.save(usage); + return usage.getCount(); + } + + public int getCount(String username) { + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + return usageRepository + .findByUserAndUseDate(user, LocalDate.now()) + .map(AiFormatUsage::getCount) + .orElse(0); + } } diff --git a/backend/src/main/java/com/openisle/service/AuthResult.java b/backend/src/main/java/com/openisle/service/AuthResult.java index 74e9d5e34..7c38fdf62 100644 --- a/backend/src/main/java/com/openisle/service/AuthResult.java +++ b/backend/src/main/java/com/openisle/service/AuthResult.java @@ -6,7 +6,7 @@ import lombok.Value; /** Result for OAuth authentication indicating whether a new user was created. */ @Value public class AuthResult { - User user; - boolean newUser; -} + User user; + boolean newUser; +} diff --git a/backend/src/main/java/com/openisle/service/AvatarGenerator.java b/backend/src/main/java/com/openisle/service/AvatarGenerator.java index 530fbc72f..81b9420a7 100644 --- a/backend/src/main/java/com/openisle/service/AvatarGenerator.java +++ b/backend/src/main/java/com/openisle/service/AvatarGenerator.java @@ -1,25 +1,24 @@ package com.openisle.service; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; - import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; @Service public class AvatarGenerator { - @Value("${app.avatar.base-url}") - private String baseUrl; + @Value("${app.avatar.base-url}") + private String baseUrl; - @Value("${app.avatar.style}") - private String style; + @Value("${app.avatar.style}") + private String style; - @Value("${app.avatar.size}") - private int size; + @Value("${app.avatar.size}") + private int size; - public String generate(String seed) { - String encoded = URLEncoder.encode(seed, StandardCharsets.UTF_8); - return String.format("%s/%s/png?seed=%s&size=%d", baseUrl, style, encoded, size); - } + public String generate(String seed) { + String encoded = URLEncoder.encode(seed, StandardCharsets.UTF_8); + return String.format("%s/%s/png?seed=%s&size=%d", baseUrl, style, encoded, size); + } } diff --git a/backend/src/main/java/com/openisle/service/CaptchaService.java b/backend/src/main/java/com/openisle/service/CaptchaService.java index 6ad04fffa..96912515f 100644 --- a/backend/src/main/java/com/openisle/service/CaptchaService.java +++ b/backend/src/main/java/com/openisle/service/CaptchaService.java @@ -4,11 +4,12 @@ package com.openisle.service; * Abstract service for verifying CAPTCHA tokens. */ public abstract class CaptchaService { - /** - * Verify the CAPTCHA token sent from client. - * - * @param token CAPTCHA token - * @return true if token is valid - */ - public abstract boolean verify(String token); + + /** + * Verify the CAPTCHA token sent from client. + * + * @param token CAPTCHA token + * @return true if token is valid + */ + public abstract boolean verify(String token); } diff --git a/backend/src/main/java/com/openisle/service/CategoryService.java b/backend/src/main/java/com/openisle/service/CategoryService.java index 309ae7f0d..3d375e6e1 100644 --- a/backend/src/main/java/com/openisle/service/CategoryService.java +++ b/backend/src/main/java/com/openisle/service/CategoryService.java @@ -3,77 +3,85 @@ package com.openisle.service; import com.openisle.config.CachingConfig; import com.openisle.model.Category; import com.openisle.repository.CategoryRepository; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; -import java.util.List; - @Service @RequiredArgsConstructor public class CategoryService { - private final CategoryRepository categoryRepository; - @CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true) - public Category createCategory(String name, String description, String icon, String smallIcon) { - Category category = new Category(); - category.setName(name); - category.setDescription(description); - category.setIcon(icon); - category.setSmallIcon(smallIcon); - return categoryRepository.save(category); - } - @CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true) - public Category updateCategory(Long id, String name, String description, String icon, String smallIcon) { - Category category = categoryRepository.findById(id) - .orElseThrow(() -> new IllegalArgumentException("Category not found")); - if (name != null) { - category.setName(name); - } - if (description != null) { - category.setDescription(description); - } - if (icon != null) { - category.setIcon(icon); - } - if (smallIcon != null) { - category.setSmallIcon(smallIcon); - } - return categoryRepository.save(category); - } - @CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true) - public void deleteCategory(Long id) { - categoryRepository.deleteById(id); - } - public Category getCategory(Long id) { - return categoryRepository.findById(id) - .orElseThrow(() -> new IllegalArgumentException("Category not found")); - } + private final CategoryRepository categoryRepository; - /** - * 该方法每次首页加载都会访问,加入缓存 - * @return - */ - @Cacheable( - value = CachingConfig.CATEGORY_CACHE_NAME, - key = "'listCategories:'" - ) - public List listCategories() { - return categoryRepository.findAll(); - } + @CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true) + public Category createCategory(String name, String description, String icon, String smallIcon) { + Category category = new Category(); + category.setName(name); + category.setDescription(description); + category.setIcon(icon); + category.setSmallIcon(smallIcon); + return categoryRepository.save(category); + } - /** - * 获取检索用的分类Id列表 - * @param categoryIds - * @param categoryId - * @return - */ - public List getSearchCategoryIds(List categoryIds, Long categoryId){ - List ids = categoryIds; - if (categoryId != null) { - ids = List.of(categoryId); - } - return ids; + @CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true) + public Category updateCategory( + Long id, + String name, + String description, + String icon, + String smallIcon + ) { + Category category = categoryRepository + .findById(id) + .orElseThrow(() -> new IllegalArgumentException("Category not found")); + if (name != null) { + category.setName(name); } + if (description != null) { + category.setDescription(description); + } + if (icon != null) { + category.setIcon(icon); + } + if (smallIcon != null) { + category.setSmallIcon(smallIcon); + } + return categoryRepository.save(category); + } + + @CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true) + public void deleteCategory(Long id) { + categoryRepository.deleteById(id); + } + + public Category getCategory(Long id) { + return categoryRepository + .findById(id) + .orElseThrow(() -> new IllegalArgumentException("Category not found")); + } + + /** + * 该方法每次首页加载都会访问,加入缓存 + * @return + */ + @Cacheable(value = CachingConfig.CATEGORY_CACHE_NAME, key = "'listCategories:'") + public List listCategories() { + return categoryRepository.findAll(); + } + + /** + * 获取检索用的分类Id列表 + * @param categoryIds + * @param categoryId + * @return + */ + public List getSearchCategoryIds(List categoryIds, Long categoryId) { + List ids = categoryIds; + if (categoryId != null) { + ids = List.of(categoryId); + } + return ids; + } } diff --git a/backend/src/main/java/com/openisle/service/ChannelService.java b/backend/src/main/java/com/openisle/service/ChannelService.java index 62b1c392b..42c872cde 100644 --- a/backend/src/main/java/com/openisle/service/ChannelService.java +++ b/backend/src/main/java/com/openisle/service/ChannelService.java @@ -11,88 +11,102 @@ import com.openisle.repository.MessageConversationRepository; import com.openisle.repository.MessageParticipantRepository; import com.openisle.repository.MessageRepository; import com.openisle.repository.UserRepository; +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; -import java.util.List; -import java.util.stream.Collectors; - @Service @RequiredArgsConstructor public class ChannelService { - private final MessageConversationRepository conversationRepository; - private final MessageParticipantRepository participantRepository; - private final MessageRepository messageRepository; - private final UserRepository userRepository; - @Transactional(readOnly = true) - public List listChannels(Long userId) { - List channels = conversationRepository.findByChannelTrue(); - return channels.stream().map(c -> toDto(c, userId)).collect(Collectors.toList()); + private final MessageConversationRepository conversationRepository; + private final MessageParticipantRepository participantRepository; + private final MessageRepository messageRepository; + private final UserRepository userRepository; + + @Transactional(readOnly = true) + public List listChannels(Long userId) { + List channels = conversationRepository.findByChannelTrue(); + return channels + .stream() + .map(c -> toDto(c, userId)) + .collect(Collectors.toList()); + } + + @Transactional + public ChannelDto joinChannel(Long channelId, Long userId) { + MessageConversation channel = conversationRepository + .findById(channelId) + .orElseThrow(() -> new IllegalArgumentException("Channel not found")); + User user = userRepository + .findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + participantRepository + .findByConversationIdAndUserId(channelId, userId) + .orElseGet(() -> { + MessageParticipant p = new MessageParticipant(); + p.setConversation(channel); + p.setUser(user); + MessageParticipant saved = participantRepository.save(p); + channel.getParticipants().add(saved); + return saved; + }); + return toDto(channel, userId); + } + + private ChannelDto toDto(MessageConversation channel, Long userId) { + ChannelDto dto = new ChannelDto(); + dto.setId(channel.getId()); + dto.setName(channel.getName()); + dto.setDescription(channel.getDescription()); + dto.setAvatar(channel.getAvatar()); + if (channel.getLastMessage() != null) { + dto.setLastMessage(toMessageDto(channel.getLastMessage())); } - - @Transactional - public ChannelDto joinChannel(Long channelId, Long userId) { - MessageConversation channel = conversationRepository.findById(channelId) - .orElseThrow(() -> new IllegalArgumentException("Channel not found")); - User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("User not found")); - participantRepository.findByConversationIdAndUserId(channelId, userId) - .orElseGet(() -> { - MessageParticipant p = new MessageParticipant(); - p.setConversation(channel); - p.setUser(user); - MessageParticipant saved = participantRepository.save(p); - channel.getParticipants().add(saved); - return saved; - }); - return toDto(channel, userId); + dto.setMemberCount(channel.getParticipants().size()); + boolean joined = channel + .getParticipants() + .stream() + .anyMatch(p -> p.getUser().getId().equals(userId)); + dto.setJoined(joined); + if (joined) { + MessageParticipant participant = channel + .getParticipants() + .stream() + .filter(p -> p.getUser().getId().equals(userId)) + .findFirst() + .orElse(null); + LocalDateTime lastRead = participant.getLastReadAt() == null + ? LocalDateTime.of(1970, 1, 1, 0, 0) + : participant.getLastReadAt(); + long unread = messageRepository.countByConversationIdAndCreatedAtAfterAndSenderIdNot( + channel.getId(), + lastRead, + userId + ); + dto.setUnreadCount(unread); + } else { + dto.setUnreadCount(0); } + return dto; + } - private ChannelDto toDto(MessageConversation channel, Long userId) { - ChannelDto dto = new ChannelDto(); - dto.setId(channel.getId()); - dto.setName(channel.getName()); - dto.setDescription(channel.getDescription()); - dto.setAvatar(channel.getAvatar()); - if (channel.getLastMessage() != null) { - dto.setLastMessage(toMessageDto(channel.getLastMessage())); - } - dto.setMemberCount(channel.getParticipants().size()); - boolean joined = channel.getParticipants().stream() - .anyMatch(p -> p.getUser().getId().equals(userId)); - dto.setJoined(joined); - if (joined) { - MessageParticipant participant = channel.getParticipants().stream() - .filter(p -> p.getUser().getId().equals(userId)) - .findFirst().orElse(null); - LocalDateTime lastRead = participant.getLastReadAt() == null - ? LocalDateTime.of(1970, 1, 1, 0, 0) - : participant.getLastReadAt(); - long unread = messageRepository - .countByConversationIdAndCreatedAtAfterAndSenderIdNot(channel.getId(), lastRead, userId); - dto.setUnreadCount(unread); - } else { - dto.setUnreadCount(0); - } - return dto; - } + private MessageDto toMessageDto(Message message) { + MessageDto dto = new MessageDto(); + dto.setId(message.getId()); + dto.setContent(message.getContent()); + dto.setConversationId(message.getConversation().getId()); + dto.setCreatedAt(message.getCreatedAt()); - private MessageDto toMessageDto(Message message) { - MessageDto dto = new MessageDto(); - dto.setId(message.getId()); - dto.setContent(message.getContent()); - dto.setConversationId(message.getConversation().getId()); - dto.setCreatedAt(message.getCreatedAt()); + UserSummaryDto userDto = new UserSummaryDto(); + userDto.setId(message.getSender().getId()); + userDto.setUsername(message.getSender().getUsername()); + userDto.setAvatar(message.getSender().getAvatar()); + dto.setSender(userDto); - UserSummaryDto userDto = new UserSummaryDto(); - userDto.setId(message.getSender().getId()); - userDto.setUsername(message.getSender().getUsername()); - userDto.setAvatar(message.getSender().getAvatar()); - dto.setSender(userDto); - - return dto; - } + return dto; + } } diff --git a/backend/src/main/java/com/openisle/service/CommentService.java b/backend/src/main/java/com/openisle/service/CommentService.java index 3f322791a..fd8d1d1f7 100644 --- a/backend/src/main/java/com/openisle/service/CommentService.java +++ b/backend/src/main/java/com/openisle/service/CommentService.java @@ -1,364 +1,441 @@ package com.openisle.service; import com.openisle.config.CachingConfig; +import com.openisle.exception.RateLimitException; import com.openisle.model.Comment; -import com.openisle.model.Post; -import com.openisle.model.User; +import com.openisle.model.CommentSort; import com.openisle.model.NotificationType; import com.openisle.model.PointHistory; -import com.openisle.model.CommentSort; +import com.openisle.model.Post; +import com.openisle.model.Role; +import com.openisle.model.User; import com.openisle.repository.CommentRepository; -import com.openisle.repository.PostRepository; -import com.openisle.repository.UserRepository; -import com.openisle.repository.ReactionRepository; import com.openisle.repository.CommentSubscriptionRepository; import com.openisle.repository.NotificationRepository; import com.openisle.repository.PointHistoryRepository; +import com.openisle.repository.PostRepository; +import com.openisle.repository.ReactionRepository; +import com.openisle.repository.UserRepository; import com.openisle.service.NotificationService; -import com.openisle.service.SubscriptionService; import com.openisle.service.PointService; -import com.openisle.model.Role; -import com.openisle.exception.RateLimitException; +import com.openisle.service.SubscriptionService; +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; -import org.springframework.stereotype.Service; - -import java.util.List; -import java.util.Set; -import java.util.HashSet; -import java.util.stream.Collectors; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; @Service @RequiredArgsConstructor @Slf4j public class CommentService { - private final CommentRepository commentRepository; - private final PostRepository postRepository; - private final UserRepository userRepository; - private final NotificationService notificationService; - private final SubscriptionService subscriptionService; - private final ReactionRepository reactionRepository; - private final CommentSubscriptionRepository commentSubscriptionRepository; - private final NotificationRepository notificationRepository; - private final PointHistoryRepository pointHistoryRepository; - private final PointService pointService; - private final ImageUploader imageUploader; - @CacheEvict( - value = CachingConfig.POST_CACHE_NAME, - allEntries = true - ) - @Transactional - public Comment addComment(String username, Long postId, String content) { - log.debug("addComment called by user {} for post {}", username, postId); - long recent = commentRepository.countByAuthorAfter(username, - java.time.LocalDateTime.now().minusMinutes(1)); - if (recent >= 3) { - log.debug("Rate limit exceeded for user {}", username); - throw new RateLimitException("Too many comments"); - } - User author = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - Post post = postRepository.findById(postId) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); - if (post.isClosed()) { - throw new IllegalStateException("Post closed"); - } - Comment comment = new Comment(); - comment.setAuthor(author); - comment.setPost(post); - comment.setContent(content); - comment = commentRepository.save(comment); - log.debug("Comment {} saved for post {}", comment.getId(), postId); - - // Update post comment statistics - updatePostCommentStats(post); - - imageUploader.addReferences(imageUploader.extractUrls(content)); - if (!author.getId().equals(post.getAuthor().getId())) { - notificationService.createNotification(post.getAuthor(), NotificationType.COMMENT_REPLY, post, comment, - null, null, null, null); - } - for (User u : subscriptionService.getPostSubscribers(postId)) { - if (!u.getId().equals(author.getId())) { - notificationService.createNotification(u, NotificationType.POST_UPDATED, post, comment, null, null, - null, null); - } - } - for (User u : subscriptionService.getSubscribers(author.getUsername())) { - if (!u.getId().equals(author.getId())) { - notificationService.createNotification(u, NotificationType.USER_ACTIVITY, post, comment, null, null, - null, null); - } - } - notificationService.notifyMentions(content, author, post, comment); - log.debug("addComment finished for comment {}", comment.getId()); - return comment; + private final CommentRepository commentRepository; + private final PostRepository postRepository; + private final UserRepository userRepository; + private final NotificationService notificationService; + private final SubscriptionService subscriptionService; + private final ReactionRepository reactionRepository; + private final CommentSubscriptionRepository commentSubscriptionRepository; + private final NotificationRepository notificationRepository; + private final PointHistoryRepository pointHistoryRepository; + private final PointService pointService; + private final ImageUploader imageUploader; + + @CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true) + @Transactional + public Comment addComment(String username, Long postId, String content) { + log.debug("addComment called by user {} for post {}", username, postId); + long recent = commentRepository.countByAuthorAfter( + username, + java.time.LocalDateTime.now().minusMinutes(1) + ); + if (recent >= 3) { + log.debug("Rate limit exceeded for user {}", username); + throw new RateLimitException("Too many comments"); + } + User author = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + Post post = postRepository + .findById(postId) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + if (post.isClosed()) { + throw new IllegalStateException("Post closed"); + } + Comment comment = new Comment(); + comment.setAuthor(author); + comment.setPost(post); + comment.setContent(content); + comment = commentRepository.save(comment); + log.debug("Comment {} saved for post {}", comment.getId(), postId); + + // Update post comment statistics + updatePostCommentStats(post); + + imageUploader.addReferences(imageUploader.extractUrls(content)); + if (!author.getId().equals(post.getAuthor().getId())) { + notificationService.createNotification( + post.getAuthor(), + NotificationType.COMMENT_REPLY, + post, + comment, + null, + null, + null, + null + ); + } + for (User u : subscriptionService.getPostSubscribers(postId)) { + if (!u.getId().equals(author.getId())) { + notificationService.createNotification( + u, + NotificationType.POST_UPDATED, + post, + comment, + null, + null, + null, + null + ); + } + } + for (User u : subscriptionService.getSubscribers(author.getUsername())) { + if (!u.getId().equals(author.getId())) { + notificationService.createNotification( + u, + NotificationType.USER_ACTIVITY, + post, + comment, + null, + null, + null, + null + ); + } + } + notificationService.notifyMentions(content, author, post, comment); + log.debug("addComment finished for comment {}", comment.getId()); + return comment; + } + + public java.time.LocalDateTime getLastCommentTimeOfUserByUserId(Long userId) { + // 根据用户id查询该用户最后回复时间 + return commentRepository.findLastCommentTimeOfUserByUserId(userId); + } + + @CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true) + @Transactional + public Comment addReply(String username, Long parentId, String content) { + log.debug("addReply called by user {} for parent comment {}", username, parentId); + long recent = commentRepository.countByAuthorAfter( + username, + java.time.LocalDateTime.now().minusMinutes(1) + ); + if (recent >= 3) { + log.debug("Rate limit exceeded for user {}", username); + throw new RateLimitException("Too many comments"); + } + User author = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + Comment parent = commentRepository + .findById(parentId) + .orElseThrow(() -> new IllegalArgumentException("Comment not found")); + if (parent.getPost().isClosed()) { + throw new IllegalStateException("Post closed"); + } + Comment comment = new Comment(); + comment.setAuthor(author); + comment.setPost(parent.getPost()); + comment.setParent(parent); + comment.setContent(content); + comment = commentRepository.save(comment); + log.debug("Reply {} saved for parent {}", comment.getId(), parentId); + + // Update post comment statistics + updatePostCommentStats(parent.getPost()); + + imageUploader.addReferences(imageUploader.extractUrls(content)); + if (!author.getId().equals(parent.getAuthor().getId())) { + notificationService.createNotification( + parent.getAuthor(), + NotificationType.COMMENT_REPLY, + parent.getPost(), + comment, + null, + null, + null, + null + ); + } + for (User u : subscriptionService.getCommentSubscribers(parentId)) { + if (!u.getId().equals(author.getId())) { + notificationService.createNotification( + u, + NotificationType.COMMENT_REPLY, + parent.getPost(), + comment, + null, + null, + null, + null + ); + } + } + for (User u : subscriptionService.getPostSubscribers(parent.getPost().getId())) { + if (!u.getId().equals(author.getId())) { + notificationService.createNotification( + u, + NotificationType.POST_UPDATED, + parent.getPost(), + comment, + null, + null, + null, + null + ); + } + } + for (User u : subscriptionService.getSubscribers(author.getUsername())) { + if (!u.getId().equals(author.getId())) { + notificationService.createNotification( + u, + NotificationType.USER_ACTIVITY, + parent.getPost(), + comment, + null, + null, + null, + null + ); + } + } + notificationService.notifyMentions(content, author, parent.getPost(), comment); + log.debug("addReply finished for comment {}", comment.getId()); + return comment; + } + + public List getCommentsForPost(Long postId, CommentSort sort) { + log.debug("getCommentsForPost called for post {} with sort {}", postId, sort); + Post post = postRepository + .findById(postId) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + List list = commentRepository.findByPostAndParentIsNullOrderByCreatedAtAsc(post); + java.util.List pinned = new java.util.ArrayList<>(); + java.util.List others = new java.util.ArrayList<>(); + for (Comment c : list) { + if (c.getPinnedAt() != null) { + pinned.add(c); + } else { + others.add(c); + } + } + pinned.sort(java.util.Comparator.comparing(Comment::getPinnedAt).reversed()); + if (sort == CommentSort.NEWEST) { + others.sort(java.util.Comparator.comparing(Comment::getCreatedAt).reversed()); + } else if (sort == CommentSort.MOST_INTERACTIONS) { + others.sort((a, b) -> Integer.compare(interactionCount(b), interactionCount(a))); + } + java.util.List result = new java.util.ArrayList<>(); + result.addAll(pinned); + result.addAll(others); + log.debug("getCommentsForPost returning {} comments", result.size()); + return result; + } + + public List getReplies(Long parentId) { + log.debug("getReplies called for parent {}", parentId); + Comment parent = commentRepository + .findById(parentId) + .orElseThrow(() -> new IllegalArgumentException("Comment not found")); + List replies = commentRepository.findByParentOrderByCreatedAtAsc(parent); + log.debug("getReplies returning {} replies for parent {}", replies.size(), parentId); + return replies; + } + + public List getRecentCommentsByUser(String username, int limit) { + log.debug("getRecentCommentsByUser called for user {} with limit {}", username, limit); + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + Pageable pageable = PageRequest.of(0, limit); + List comments = commentRepository.findByAuthorOrderByCreatedAtDesc(user, pageable); + log.debug( + "getRecentCommentsByUser returning {} comments for user {}", + comments.size(), + username + ); + return comments; + } + + public java.util.List getParticipants(Long postId, int limit) { + log.debug("getParticipants called for post {} with limit {}", postId, limit); + Post post = postRepository + .findById(postId) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + java.util.LinkedHashSet set = new java.util.LinkedHashSet<>(); + set.add(post.getAuthor()); + set.addAll(commentRepository.findDistinctAuthorsByPost(post)); + java.util.List list = new java.util.ArrayList<>(set); + java.util.List result = list.subList(0, Math.min(limit, list.size())); + log.debug("getParticipants returning {} users for post {}", result.size(), postId); + return result; + } + + public java.util.List getCommentsByIds(java.util.List ids) { + log.debug("getCommentsByIds called for ids {}", ids); + java.util.List comments = commentRepository.findAllById(ids); + log.debug("getCommentsByIds returning {} comments", comments.size()); + return comments; + } + + public java.time.LocalDateTime getLastCommentTime(Long postId) { + log.debug("getLastCommentTime called for post {}", postId); + Post post = postRepository + .findById(postId) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + java.time.LocalDateTime time = commentRepository.findLastCommentTime(post); + log.debug("getLastCommentTime for post {} is {}", postId, time); + return time; + } + + public long countComments(Long postId) { + log.debug("countComments called for post {}", postId); + long count = commentRepository.countByPostId(postId); + log.debug("countComments for post {} is {}", postId, count); + return count; + } + + @CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true) + @Transactional + public void deleteComment(String username, Long id) { + log.debug("deleteComment called by user {} for comment {}", username, id); + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + Comment comment = commentRepository + .findById(id) + .orElseThrow(() -> new IllegalArgumentException("Comment not found")); + if (!user.getId().equals(comment.getAuthor().getId()) && user.getRole() != Role.ADMIN) { + log.debug("User {} not authorized to delete comment {}", username, id); + throw new IllegalArgumentException("Unauthorized"); + } + deleteCommentCascade(comment); + log.debug("deleteComment completed for comment {}", id); + } + + @CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true) + @Transactional + public void deleteCommentCascade(Comment comment) { + log.debug("deleteCommentCascade called for comment {}", comment.getId()); + List replies = commentRepository.findByParentOrderByCreatedAtAsc(comment); + for (Comment c : replies) { + deleteCommentCascade(c); } - public java.time.LocalDateTime getLastCommentTimeOfUserByUserId(Long userId) { // 根据用户id查询该用户最后回复时间 - return commentRepository.findLastCommentTimeOfUserByUserId(userId); + // 逻辑删除相关的积分历史记录,并收集受影响的用户 + List pointHistories = pointHistoryRepository.findByComment(comment); + // 收集需要重新计算积分的用户 + Set usersToRecalculate = pointHistories + .stream() + .map(PointHistory::getUser) + .collect(Collectors.toSet()); + + // 删除其他相关数据 + reactionRepository.findByComment(comment).forEach(reactionRepository::delete); + commentSubscriptionRepository + .findByComment(comment) + .forEach(commentSubscriptionRepository::delete); + notificationRepository.deleteAll(notificationRepository.findByComment(comment)); + imageUploader.removeReferences(imageUploader.extractUrls(comment.getContent())); + + // 逻辑删除评论 + Post post = comment.getPost(); + commentRepository.delete(comment); + // 删除积分历史 + pointHistoryRepository.deleteAll(pointHistories); + + // Update post comment statistics + updatePostCommentStats(post); + + // 重新计算受影响用户的积分 + if (!usersToRecalculate.isEmpty()) { + for (User user : usersToRecalculate) { + int newPoints = pointService.recalculateUserPoints(user); + user.setPoint(newPoints); + log.debug("Recalculated points for user {}: {}", user.getUsername(), newPoints); + } + userRepository.saveAll(usersToRecalculate); } - @CacheEvict( - value = CachingConfig.POST_CACHE_NAME, - allEntries = true - ) - @Transactional - public Comment addReply(String username, Long parentId, String content) { - log.debug("addReply called by user {} for parent comment {}", username, parentId); - long recent = commentRepository.countByAuthorAfter(username, - java.time.LocalDateTime.now().minusMinutes(1)); - if (recent >= 3) { - log.debug("Rate limit exceeded for user {}", username); - throw new RateLimitException("Too many comments"); - } - User author = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - Comment parent = commentRepository.findById(parentId) - .orElseThrow(() -> new IllegalArgumentException("Comment not found")); - if (parent.getPost().isClosed()) { - throw new IllegalStateException("Post closed"); - } - Comment comment = new Comment(); - comment.setAuthor(author); - comment.setPost(parent.getPost()); - comment.setParent(parent); - comment.setContent(content); - comment = commentRepository.save(comment); - log.debug("Reply {} saved for parent {}", comment.getId(), parentId); - - // Update post comment statistics - updatePostCommentStats(parent.getPost()); - - imageUploader.addReferences(imageUploader.extractUrls(content)); - if (!author.getId().equals(parent.getAuthor().getId())) { - notificationService.createNotification(parent.getAuthor(), NotificationType.COMMENT_REPLY, parent.getPost(), - comment, null, null, null, null); - } - for (User u : subscriptionService.getCommentSubscribers(parentId)) { - if (!u.getId().equals(author.getId())) { - notificationService.createNotification(u, NotificationType.COMMENT_REPLY, parent.getPost(), comment, - null, null, null, null); - } - } - for (User u : subscriptionService.getPostSubscribers(parent.getPost().getId())) { - if (!u.getId().equals(author.getId())) { - notificationService.createNotification(u, NotificationType.POST_UPDATED, parent.getPost(), comment, - null, null, null, null); - } - } - for (User u : subscriptionService.getSubscribers(author.getUsername())) { - if (!u.getId().equals(author.getId())) { - notificationService.createNotification(u, NotificationType.USER_ACTIVITY, parent.getPost(), comment, - null, null, null, null); - } - } - notificationService.notifyMentions(content, author, parent.getPost(), comment); - log.debug("addReply finished for comment {}", comment.getId()); - return comment; + log.debug("deleteCommentCascade removed comment {}", comment.getId()); + } + + @Transactional + public Comment pinComment(String username, Long id) { + Comment c = commentRepository + .findById(id) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("Comment not found")); + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + if (!user.getId().equals(c.getPost().getAuthor().getId()) && user.getRole() != Role.ADMIN) { + throw new IllegalArgumentException("Unauthorized"); } + c.setPinnedAt(LocalDateTime.now()); + return commentRepository.save(c); + } - public List getCommentsForPost(Long postId, CommentSort sort) { - log.debug("getCommentsForPost called for post {} with sort {}", postId, sort); - Post post = postRepository.findById(postId) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); - List list = commentRepository.findByPostAndParentIsNullOrderByCreatedAtAsc(post); - java.util.List pinned = new java.util.ArrayList<>(); - java.util.List others = new java.util.ArrayList<>(); - for (Comment c : list) { - if (c.getPinnedAt() != null) { - pinned.add(c); - } else { - others.add(c); - } - } - pinned.sort(java.util.Comparator.comparing(Comment::getPinnedAt).reversed()); - if (sort == CommentSort.NEWEST) { - others.sort(java.util.Comparator.comparing(Comment::getCreatedAt).reversed()); - } else if (sort == CommentSort.MOST_INTERACTIONS) { - others.sort((a, b) -> Integer.compare(interactionCount(b), interactionCount(a))); - } - java.util.List result = new java.util.ArrayList<>(); - result.addAll(pinned); - result.addAll(others); - log.debug("getCommentsForPost returning {} comments", result.size()); - return result; + @Transactional + public Comment unpinComment(String username, Long id) { + Comment c = commentRepository + .findById(id) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("Comment not found")); + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + if (!user.getId().equals(c.getPost().getAuthor().getId()) && user.getRole() != Role.ADMIN) { + throw new IllegalArgumentException("Unauthorized"); } + c.setPinnedAt(null); + return commentRepository.save(c); + } - public List getReplies(Long parentId) { - log.debug("getReplies called for parent {}", parentId); - Comment parent = commentRepository.findById(parentId) - .orElseThrow(() -> new IllegalArgumentException("Comment not found")); - List replies = commentRepository.findByParentOrderByCreatedAtAsc(parent); - log.debug("getReplies returning {} replies for parent {}", replies.size(), parentId); - return replies; + private int interactionCount(Comment comment) { + int reactions = reactionRepository.findByComment(comment).size(); + int replies = commentRepository.findByParentOrderByCreatedAtAsc(comment).size(); + return reactions + replies; + } + + /** + * Update post comment statistics (comment count and last reply time) + */ + public void updatePostCommentStats(Post post) { + long commentCount = commentRepository.countByPostId(post.getId()); + post.setCommentCount(commentCount); + + LocalDateTime lastReplyAt = commentRepository.findLastCommentTime(post); + if (lastReplyAt == null) { + post.setLastReplyAt(post.getCreatedAt()); + } else { + post.setLastReplyAt(lastReplyAt); } + postRepository.save(post); - public List getRecentCommentsByUser(String username, int limit) { - log.debug("getRecentCommentsByUser called for user {} with limit {}", username, limit); - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - Pageable pageable = PageRequest.of(0, limit); - List comments = commentRepository.findByAuthorOrderByCreatedAtDesc(user, pageable); - log.debug("getRecentCommentsByUser returning {} comments for user {}", comments.size(), username); - return comments; - } - - public java.util.List getParticipants(Long postId, int limit) { - log.debug("getParticipants called for post {} with limit {}", postId, limit); - Post post = postRepository.findById(postId) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); - java.util.LinkedHashSet set = new java.util.LinkedHashSet<>(); - set.add(post.getAuthor()); - set.addAll(commentRepository.findDistinctAuthorsByPost(post)); - java.util.List list = new java.util.ArrayList<>(set); - java.util.List result = list.subList(0, Math.min(limit, list.size())); - log.debug("getParticipants returning {} users for post {}", result.size(), postId); - return result; - } - - public java.util.List getCommentsByIds(java.util.List ids) { - log.debug("getCommentsByIds called for ids {}", ids); - java.util.List comments = commentRepository.findAllById(ids); - log.debug("getCommentsByIds returning {} comments", comments.size()); - return comments; - } - - public java.time.LocalDateTime getLastCommentTime(Long postId) { - log.debug("getLastCommentTime called for post {}", postId); - Post post = postRepository.findById(postId) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); - java.time.LocalDateTime time = commentRepository.findLastCommentTime(post); - log.debug("getLastCommentTime for post {} is {}", postId, time); - return time; - } - - public long countComments(Long postId) { - log.debug("countComments called for post {}", postId); - long count = commentRepository.countByPostId(postId); - log.debug("countComments for post {} is {}", postId, count); - return count; - } - - @CacheEvict( - value = CachingConfig.POST_CACHE_NAME, - allEntries = true - ) - @Transactional - public void deleteComment(String username, Long id) { - log.debug("deleteComment called by user {} for comment {}", username, id); - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - Comment comment = commentRepository.findById(id) - .orElseThrow(() -> new IllegalArgumentException("Comment not found")); - if (!user.getId().equals(comment.getAuthor().getId()) && user.getRole() != Role.ADMIN) { - log.debug("User {} not authorized to delete comment {}", username, id); - throw new IllegalArgumentException("Unauthorized"); - } - deleteCommentCascade(comment); - log.debug("deleteComment completed for comment {}", id); - } - - @CacheEvict( - value = CachingConfig.POST_CACHE_NAME, - allEntries = true - ) - @Transactional - public void deleteCommentCascade(Comment comment) { - log.debug("deleteCommentCascade called for comment {}", comment.getId()); - List replies = commentRepository.findByParentOrderByCreatedAtAsc(comment); - for (Comment c : replies) { - deleteCommentCascade(c); - } - - // 逻辑删除相关的积分历史记录,并收集受影响的用户 - List pointHistories = pointHistoryRepository.findByComment(comment); - // 收集需要重新计算积分的用户 - Set usersToRecalculate = pointHistories.stream().map(PointHistory::getUser).collect(Collectors.toSet()); - - // 删除其他相关数据 - reactionRepository.findByComment(comment).forEach(reactionRepository::delete); - commentSubscriptionRepository.findByComment(comment).forEach(commentSubscriptionRepository::delete); - notificationRepository.deleteAll(notificationRepository.findByComment(comment)); - imageUploader.removeReferences(imageUploader.extractUrls(comment.getContent())); - - // 逻辑删除评论 - Post post = comment.getPost(); - commentRepository.delete(comment); - // 删除积分历史 - pointHistoryRepository.deleteAll(pointHistories); - - // Update post comment statistics - updatePostCommentStats(post); - - // 重新计算受影响用户的积分 - if (!usersToRecalculate.isEmpty()) { - for (User user : usersToRecalculate) { - int newPoints = pointService.recalculateUserPoints(user); - user.setPoint(newPoints); - log.debug("Recalculated points for user {}: {}", user.getUsername(), newPoints); - } - userRepository.saveAll(usersToRecalculate); - } - - log.debug("deleteCommentCascade removed comment {}", comment.getId()); - } - - @Transactional - public Comment pinComment(String username, Long id) { - Comment c = commentRepository.findById(id) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("Comment not found")); - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - if (!user.getId().equals(c.getPost().getAuthor().getId()) && user.getRole() != Role.ADMIN) { - throw new IllegalArgumentException("Unauthorized"); - } - c.setPinnedAt(LocalDateTime.now()); - return commentRepository.save(c); - } - - @Transactional - public Comment unpinComment(String username, Long id) { - Comment c = commentRepository.findById(id) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("Comment not found")); - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - if (!user.getId().equals(c.getPost().getAuthor().getId()) && user.getRole() != Role.ADMIN) { - throw new IllegalArgumentException("Unauthorized"); - } - c.setPinnedAt(null); - return commentRepository.save(c); - } - - private int interactionCount(Comment comment) { - int reactions = reactionRepository.findByComment(comment).size(); - int replies = commentRepository.findByParentOrderByCreatedAtAsc(comment).size(); - return reactions + replies; - } - - /** - * Update post comment statistics (comment count and last reply time) - */ - public void updatePostCommentStats(Post post) { - long commentCount = commentRepository.countByPostId(post.getId()); - post.setCommentCount(commentCount); - - LocalDateTime lastReplyAt = commentRepository.findLastCommentTime(post); - if (lastReplyAt == null) { - post.setLastReplyAt(post.getCreatedAt()); - } else { - post.setLastReplyAt(lastReplyAt); - } - postRepository.save(post); - - log.debug("Updated post {} stats: commentCount={}, lastReplyAt={}", - post.getId(), commentCount, lastReplyAt); - } + log.debug( + "Updated post {} stats: commentCount={}, lastReplyAt={}", + post.getId(), + commentCount, + lastReplyAt + ); + } } diff --git a/backend/src/main/java/com/openisle/service/ContributorService.java b/backend/src/main/java/com/openisle/service/ContributorService.java index ac3691288..85cf938c2 100644 --- a/backend/src/main/java/com/openisle/service/ContributorService.java +++ b/backend/src/main/java/com/openisle/service/ContributorService.java @@ -3,6 +3,8 @@ package com.openisle.service; import com.openisle.model.ContributorConfig; import com.openisle.repository.ContributorConfigRepository; import jakarta.annotation.PostConstruct; +import java.util.List; +import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; @@ -10,80 +12,82 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; -import java.util.List; -import java.util.Map; - @Slf4j @Service @RequiredArgsConstructor public class ContributorService { - private static final String OWNER = "nagisa77"; - private static final String REPO = "OpenIsle"; - private final ContributorConfigRepository repository; - private final RestTemplate restTemplate = new RestTemplate(); + private static final String OWNER = "nagisa77"; + private static final String REPO = "OpenIsle"; - @PostConstruct - @Scheduled(cron = "0 0 * * * *") - public void updateContributions() { - for (ContributorConfig config : repository.findAll()) { - long lines = fetchContributionLines(config.getGithubId()); - if (lines != -1) { - config.setContributionLines(lines); - repository.save(config); - } - } + private final ContributorConfigRepository repository; + private final RestTemplate restTemplate = new RestTemplate(); + + @PostConstruct + @Scheduled(cron = "0 0 * * * *") + public void updateContributions() { + for (ContributorConfig config : repository.findAll()) { + long lines = fetchContributionLines(config.getGithubId()); + if (lines != -1) { + config.setContributionLines(lines); + repository.save(config); + } } + } - private long fetchContributionLines(String githubId) { - try { - String url = String.format("https://api.github.com/repos/%s/%s/stats/contributors", OWNER, REPO); - ResponseEntity response = restTemplate.getForEntity(url, Object.class); + private long fetchContributionLines(String githubId) { + try { + String url = String.format( + "https://api.github.com/repos/%s/%s/stats/contributors", + OWNER, + REPO + ); + ResponseEntity response = restTemplate.getForEntity(url, Object.class); - // 检查是否为202,GitHub有时会返回202表示正在生成统计数据 - if (response.getStatusCodeValue() == 202) { - log.warn("GitHub API 返回202,统计数据正在生成中,githubId: {}", githubId); - return -1; - } + // 检查是否为202,GitHub有时会返回202表示正在生成统计数据 + if (response.getStatusCodeValue() == 202) { + log.warn("GitHub API 返回202,统计数据正在生成中,githubId: {}", githubId); + return -1; + } - Object body = response.getBody(); - if (!(body instanceof List)) { - // 不是List类型,直接返回0 - return 0; - } - List listBody = (List) body; - for (Object itemObj : listBody) { - if (!(itemObj instanceof Map)) continue; - Map item = (Map) itemObj; - Map author = (Map) item.get("author"); - if (author != null && githubId.equals(author.get("login"))) { - List> weeks = (List>) item.get("weeks"); - long total = 0; - if (weeks != null) { - for (Map week : weeks) { - Number a = (Number) week.get("a"); - Number d = (Number) week.get("d"); - if (a != null) { - total += a.longValue(); - } - if (d != null) { - total += d.longValue(); - } - } - } - return total; - } - } - } catch (Exception e) { - log.warn(e.getMessage()); - } + Object body = response.getBody(); + if (!(body instanceof List)) { + // 不是List类型,直接返回0 return 0; + } + List listBody = (List) body; + for (Object itemObj : listBody) { + if (!(itemObj instanceof Map)) continue; + Map item = (Map) itemObj; + Map author = (Map) item.get("author"); + if (author != null && githubId.equals(author.get("login"))) { + List> weeks = (List>) item.get("weeks"); + long total = 0; + if (weeks != null) { + for (Map week : weeks) { + Number a = (Number) week.get("a"); + Number d = (Number) week.get("d"); + if (a != null) { + total += a.longValue(); + } + if (d != null) { + total += d.longValue(); + } + } + } + return total; + } + } + } catch (Exception e) { + log.warn(e.getMessage()); } + return 0; + } - public long getContributionLines(String userIname) { - return repository.findByUserIname(userIname) - .map(ContributorConfig::getContributionLines) - .orElse(0L); - } + public long getContributionLines(String userIname) { + return repository + .findByUserIname(userIname) + .map(ContributorConfig::getContributionLines) + .orElse(0L); + } } - diff --git a/backend/src/main/java/com/openisle/service/CosImageUploader.java b/backend/src/main/java/com/openisle/service/CosImageUploader.java index 4848daa49..afb393032 100644 --- a/backend/src/main/java/com/openisle/service/CosImageUploader.java +++ b/backend/src/main/java/com/openisle/service/CosImageUploader.java @@ -4,22 +4,21 @@ import com.qcloud.cos.COSClient; import com.qcloud.cos.ClientConfig; import com.qcloud.cos.auth.BasicCOSCredentials; import com.qcloud.cos.auth.COSCredentials; -import com.qcloud.cos.model.ObjectMetadata; -import com.qcloud.cos.model.PutObjectRequest; import com.qcloud.cos.http.HttpMethodName; import com.qcloud.cos.model.GeneratePresignedUrlRequest; +import com.qcloud.cos.model.ObjectMetadata; +import com.qcloud.cos.model.PutObjectRequest; import com.qcloud.cos.region.Region; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.scheduling.concurrent.CustomizableThreadFactory; -import org.springframework.stereotype.Service; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import java.io.ByteArrayInputStream; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.concurrent.CustomizableThreadFactory; +import org.springframework.stereotype.Service; /** * ImageUploader implementation using Tencent Cloud COS. @@ -27,98 +26,107 @@ import java.util.concurrent.Executors; @Service public class CosImageUploader extends ImageUploader { - private final COSClient cosClient; - private final String bucketName; - private final String baseUrl; - private static final String UPLOAD_DIR = "dynamic_assert/"; - private static final Logger logger = LoggerFactory.getLogger(CosImageUploader.class); - private final ExecutorService executor = Executors.newFixedThreadPool(2, - new CustomizableThreadFactory("cos-upload-")); + private final COSClient cosClient; + private final String bucketName; + private final String baseUrl; + private static final String UPLOAD_DIR = "dynamic_assert/"; + private static final Logger logger = LoggerFactory.getLogger(CosImageUploader.class); + private final ExecutorService executor = Executors.newFixedThreadPool( + 2, + new CustomizableThreadFactory("cos-upload-") + ); - @org.springframework.beans.factory.annotation.Autowired - public CosImageUploader( - com.openisle.repository.ImageRepository imageRepository, - @Value("${cos.secret-id:}") String secretId, - @Value("${cos.secret-key:}") String secretKey, - @Value("${cos.region:ap-guangzhou}") String region, - @Value("${cos.bucket-name:}") String bucketName, - @Value("${cos.base-url:https://example.com}") String baseUrl) { - super(imageRepository, baseUrl); - COSCredentials cred = new BasicCOSCredentials(secretId, secretKey); - ClientConfig config = new ClientConfig(new Region(region)); - this.cosClient = new COSClient(cred, config); - this.bucketName = bucketName; - this.baseUrl = baseUrl; - logger.debug("COS client initialized for region {} with bucket {}", region, bucketName); - } + @org.springframework.beans.factory.annotation.Autowired + public CosImageUploader( + com.openisle.repository.ImageRepository imageRepository, + @Value("${cos.secret-id:}") String secretId, + @Value("${cos.secret-key:}") String secretKey, + @Value("${cos.region:ap-guangzhou}") String region, + @Value("${cos.bucket-name:}") String bucketName, + @Value("${cos.base-url:https://example.com}") String baseUrl + ) { + super(imageRepository, baseUrl); + COSCredentials cred = new BasicCOSCredentials(secretId, secretKey); + ClientConfig config = new ClientConfig(new Region(region)); + this.cosClient = new COSClient(cred, config); + this.bucketName = bucketName; + this.baseUrl = baseUrl; + logger.debug("COS client initialized for region {} with bucket {}", region, bucketName); + } - // for tests - CosImageUploader(COSClient cosClient, - com.openisle.repository.ImageRepository imageRepository, - String bucketName, - String baseUrl) { - super(imageRepository, baseUrl); - this.cosClient = cosClient; - this.bucketName = bucketName; - this.baseUrl = baseUrl; - logger.debug("COS client provided directly with bucket {}", bucketName); - } + // for tests + CosImageUploader( + COSClient cosClient, + com.openisle.repository.ImageRepository imageRepository, + String bucketName, + String baseUrl + ) { + super(imageRepository, baseUrl); + this.cosClient = cosClient; + this.bucketName = bucketName; + this.baseUrl = baseUrl; + logger.debug("COS client provided directly with bucket {}", bucketName); + } - @Override - protected CompletableFuture doUpload(byte[] data, String filename) { - return CompletableFuture.supplyAsync(() -> { - logger.debug("Uploading {} bytes as {}", data.length, filename); - String ext = ""; - int dot = filename.lastIndexOf('.'); - if (dot != -1) { - ext = filename.substring(dot); - } - String randomName = UUID.randomUUID().toString().replace("-", "") + ext; - String objectKey = UPLOAD_DIR + randomName; - logger.debug("Generated object key {}", objectKey); - - ObjectMetadata meta = new ObjectMetadata(); - meta.setContentLength(data.length); - PutObjectRequest req = new PutObjectRequest( - bucketName, - objectKey, - new ByteArrayInputStream(data), - meta); - logger.debug("Sending PutObject request to bucket {}", bucketName); - cosClient.putObject(req); - String url = baseUrl + "/" + objectKey; - logger.debug("Upload successful, accessible at {}", url); - return url; - }, executor); - } - - @Override - protected void deleteFromStore(String key) { - try { - cosClient.deleteObject(bucketName, key); - } catch (Exception e) { - logger.warn("Failed to delete image {} from COS", key, e); - } - } - - @Override - public java.util.Map presignUpload(String filename) { + @Override + protected CompletableFuture doUpload(byte[] data, String filename) { + return CompletableFuture.supplyAsync( + () -> { + logger.debug("Uploading {} bytes as {}", data.length, filename); String ext = ""; int dot = filename.lastIndexOf('.'); if (dot != -1) { - ext = filename.substring(dot); + ext = filename.substring(dot); } - String randomName = java.util.UUID.randomUUID().toString().replace("-", "") + ext; + String randomName = UUID.randomUUID().toString().replace("-", "") + ext; String objectKey = UPLOAD_DIR + randomName; - java.util.Date expiration = new java.util.Date(System.currentTimeMillis() + 15 * 60 * 1000L); - GeneratePresignedUrlRequest req = new GeneratePresignedUrlRequest(bucketName, objectKey, HttpMethodName.PUT); - req.setExpiration(expiration); - java.net.URL url = cosClient.generatePresignedUrl(req); - String fileUrl = baseUrl + "/" + objectKey; - return java.util.Map.of( - "uploadUrl", url.toString(), - "fileUrl", fileUrl, - "key", objectKey + logger.debug("Generated object key {}", objectKey); + + ObjectMetadata meta = new ObjectMetadata(); + meta.setContentLength(data.length); + PutObjectRequest req = new PutObjectRequest( + bucketName, + objectKey, + new ByteArrayInputStream(data), + meta ); + logger.debug("Sending PutObject request to bucket {}", bucketName); + cosClient.putObject(req); + String url = baseUrl + "/" + objectKey; + logger.debug("Upload successful, accessible at {}", url); + return url; + }, + executor + ); + } + + @Override + protected void deleteFromStore(String key) { + try { + cosClient.deleteObject(bucketName, key); + } catch (Exception e) { + logger.warn("Failed to delete image {} from COS", key, e); } + } + + @Override + public java.util.Map presignUpload(String filename) { + String ext = ""; + int dot = filename.lastIndexOf('.'); + if (dot != -1) { + ext = filename.substring(dot); + } + String randomName = java.util.UUID.randomUUID().toString().replace("-", "") + ext; + String objectKey = UPLOAD_DIR + randomName; + java.util.Date expiration = new java.util.Date(System.currentTimeMillis() + 15 * 60 * 1000L); + GeneratePresignedUrlRequest req = new GeneratePresignedUrlRequest( + bucketName, + objectKey, + HttpMethodName.PUT + ); + req.setExpiration(expiration); + java.net.URL url = cosClient.generatePresignedUrl(req); + String fileUrl = baseUrl + "/" + objectKey; + return java.util.Map.of("uploadUrl", url.toString(), "fileUrl", fileUrl, "key", objectKey); + } } diff --git a/backend/src/main/java/com/openisle/service/DiscordAuthService.java b/backend/src/main/java/com/openisle/service/DiscordAuthService.java index 3661fbba7..c0e88a653 100644 --- a/backend/src/main/java/com/openisle/service/DiscordAuthService.java +++ b/backend/src/main/java/com/openisle/service/DiscordAuthService.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.openisle.model.Role; import com.openisle.model.User; import com.openisle.repository.UserRepository; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.*; @@ -12,96 +13,123 @@ import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.client.RestTemplate; -import java.util.Optional; - @Service @RequiredArgsConstructor public class DiscordAuthService { - private final UserRepository userRepository; - private final RestTemplate restTemplate = new RestTemplate(); - @Value("${discord.client-id:}") - private String clientId; + private final UserRepository userRepository; + private final RestTemplate restTemplate = new RestTemplate(); - @Value("${discord.client-secret:}") - private String clientSecret; + @Value("${discord.client-id:}") + private String clientId; - public Optional authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri, boolean viaInvite) { - try { - String tokenUrl = "https://discord.com/api/oauth2/token"; - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + @Value("${discord.client-secret:}") + private String clientSecret; - MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("client_id", clientId); - body.add("client_secret", clientSecret); - body.add("grant_type", "authorization_code"); - body.add("code", code); - if (redirectUri != null) { - body.add("redirect_uri", redirectUri); - } - body.add("scope", "identify email"); + public Optional authenticate( + String code, + com.openisle.model.RegisterMode mode, + String redirectUri, + boolean viaInvite + ) { + try { + String tokenUrl = "https://discord.com/api/oauth2/token"; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); - HttpEntity> request = new HttpEntity<>(body, headers); - ResponseEntity tokenRes = restTemplate.postForEntity(tokenUrl, request, JsonNode.class); - if (!tokenRes.getStatusCode().is2xxSuccessful() || tokenRes.getBody() == null || !tokenRes.getBody().has("access_token")) { - return Optional.empty(); - } - String accessToken = tokenRes.getBody().get("access_token").asText(); - HttpHeaders authHeaders = new HttpHeaders(); - authHeaders.setBearerAuth(accessToken); - HttpEntity entity = new HttpEntity<>(authHeaders); - ResponseEntity userRes = restTemplate.exchange( - "https://discord.com/api/users/@me", HttpMethod.GET, entity, JsonNode.class); - if (!userRes.getStatusCode().is2xxSuccessful() || userRes.getBody() == null) { - return Optional.empty(); - } - JsonNode userNode = userRes.getBody(); - String email = userNode.hasNonNull("email") ? userNode.get("email").asText() : null; - String username = userNode.hasNonNull("username") ? userNode.get("username").asText() : null; - String id = userNode.hasNonNull("id") ? userNode.get("id").asText() : null; - String avatar = null; - if (userNode.hasNonNull("avatar") && id != null) { - avatar = "https://cdn.discordapp.com/avatars/" + id + "/" + userNode.get("avatar").asText() + ".png"; - } - if (email == null) { - email = (username != null ? username : id) + "@users.noreply.discord.com"; - } - return Optional.of(processUser(email, username, avatar, mode, viaInvite)); - } catch (Exception e) { - return Optional.empty(); - } + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("client_id", clientId); + body.add("client_secret", clientSecret); + body.add("grant_type", "authorization_code"); + body.add("code", code); + if (redirectUri != null) { + body.add("redirect_uri", redirectUri); + } + body.add("scope", "identify email"); + + HttpEntity> request = new HttpEntity<>(body, headers); + ResponseEntity tokenRes = restTemplate.postForEntity( + tokenUrl, + request, + JsonNode.class + ); + if ( + !tokenRes.getStatusCode().is2xxSuccessful() || + tokenRes.getBody() == null || + !tokenRes.getBody().has("access_token") + ) { + return Optional.empty(); + } + String accessToken = tokenRes.getBody().get("access_token").asText(); + HttpHeaders authHeaders = new HttpHeaders(); + authHeaders.setBearerAuth(accessToken); + HttpEntity entity = new HttpEntity<>(authHeaders); + ResponseEntity userRes = restTemplate.exchange( + "https://discord.com/api/users/@me", + HttpMethod.GET, + entity, + JsonNode.class + ); + if (!userRes.getStatusCode().is2xxSuccessful() || userRes.getBody() == null) { + return Optional.empty(); + } + JsonNode userNode = userRes.getBody(); + String email = userNode.hasNonNull("email") ? userNode.get("email").asText() : null; + String username = userNode.hasNonNull("username") ? userNode.get("username").asText() : null; + String id = userNode.hasNonNull("id") ? userNode.get("id").asText() : null; + String avatar = null; + if (userNode.hasNonNull("avatar") && id != null) { + avatar = + "https://cdn.discordapp.com/avatars/" + + id + + "/" + + userNode.get("avatar").asText() + + ".png"; + } + if (email == null) { + email = (username != null ? username : id) + "@users.noreply.discord.com"; + } + return Optional.of(processUser(email, username, avatar, mode, viaInvite)); + } catch (Exception e) { + return Optional.empty(); } + } - private AuthResult processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) { - Optional existing = userRepository.findByEmail(email); - if (existing.isPresent()) { - User user = existing.get(); - if (!user.isVerified()) { - user.setVerified(true); - user.setVerificationCode(null); - userRepository.save(user); - } - return new AuthResult(user, false); - } - String baseUsername = username != null ? username : email.split("@")[0]; - String finalUsername = baseUsername; - int suffix = 1; - while (userRepository.findByUsername(finalUsername).isPresent()) { - finalUsername = baseUsername + suffix++; - } - User user = new User(); - user.setUsername(finalUsername); - user.setEmail(email); - user.setPassword(""); - user.setRole(Role.USER); + private AuthResult processUser( + String email, + String username, + String avatar, + com.openisle.model.RegisterMode mode, + boolean viaInvite + ) { + Optional existing = userRepository.findByEmail(email); + if (existing.isPresent()) { + User user = existing.get(); + if (!user.isVerified()) { user.setVerified(true); - user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite); - if (avatar != null) { - user.setAvatar(avatar); - } else { - user.setAvatar("https://cdn.discordapp.com/embed/avatars/0.png"); - } - return new AuthResult(userRepository.save(user), true); + user.setVerificationCode(null); + userRepository.save(user); + } + return new AuthResult(user, false); } + String baseUsername = username != null ? username : email.split("@")[0]; + String finalUsername = baseUsername; + int suffix = 1; + while (userRepository.findByUsername(finalUsername).isPresent()) { + finalUsername = baseUsername + suffix++; + } + User user = new User(); + user.setUsername(finalUsername); + user.setEmail(email); + user.setPassword(""); + user.setRole(Role.USER); + user.setVerified(true); + user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite); + if (avatar != null) { + user.setAvatar(avatar); + } else { + user.setAvatar("https://cdn.discordapp.com/embed/avatars/0.png"); + } + return new AuthResult(userRepository.save(user), true); + } } diff --git a/backend/src/main/java/com/openisle/service/DraftService.java b/backend/src/main/java/com/openisle/service/DraftService.java index f14086218..83d855e7a 100644 --- a/backend/src/main/java/com/openisle/service/DraftService.java +++ b/backend/src/main/java/com/openisle/service/DraftService.java @@ -9,68 +9,79 @@ import com.openisle.repository.DraftRepository; import com.openisle.repository.TagRepository; import com.openisle.repository.UserRepository; import com.openisle.service.ImageUploader; -import org.springframework.transaction.annotation.Transactional; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - import java.util.HashSet; import java.util.List; import java.util.Optional; import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor public class DraftService { - private final DraftRepository draftRepository; - private final UserRepository userRepository; - private final CategoryRepository categoryRepository; - private final TagRepository tagRepository; - private final ImageUploader imageUploader; - @Transactional - public Draft saveDraft(String username, Long categoryId, String title, String content, List tagIds) { - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - Draft draft = draftRepository.findByAuthor(user).orElse(new Draft()); - String oldContent = draft.getContent(); - boolean existing = draft.getId() != null; - draft.setAuthor(user); - draft.setTitle(title); - draft.setContent(content); - if (categoryId != null) { - Category category = categoryRepository.findById(categoryId) - .orElseThrow(() -> new IllegalArgumentException("Category not found")); - draft.setCategory(category); - } else { - draft.setCategory(null); - } - Set tags = new HashSet<>(); - if (tagIds != null && !tagIds.isEmpty()) { - tags.addAll(tagRepository.findAllById(tagIds)); - } - draft.setTags(tags); - Draft saved = draftRepository.save(draft); - if (existing) { - imageUploader.adjustReferences(oldContent == null ? "" : oldContent, content); - } else { - imageUploader.addReferences(imageUploader.extractUrls(content)); - } - return saved; - } + private final DraftRepository draftRepository; + private final UserRepository userRepository; + private final CategoryRepository categoryRepository; + private final TagRepository tagRepository; + private final ImageUploader imageUploader; - @Transactional(readOnly = true) - public Optional getDraft(String username) { - return userRepository.findByUsername(username) - .flatMap(draftRepository::findByAuthor); + @Transactional + public Draft saveDraft( + String username, + Long categoryId, + String title, + String content, + List tagIds + ) { + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + Draft draft = draftRepository.findByAuthor(user).orElse(new Draft()); + String oldContent = draft.getContent(); + boolean existing = draft.getId() != null; + draft.setAuthor(user); + draft.setTitle(title); + draft.setContent(content); + if (categoryId != null) { + Category category = categoryRepository + .findById(categoryId) + .orElseThrow(() -> new IllegalArgumentException("Category not found")); + draft.setCategory(category); + } else { + draft.setCategory(null); } + Set tags = new HashSet<>(); + if (tagIds != null && !tagIds.isEmpty()) { + tags.addAll(tagRepository.findAllById(tagIds)); + } + draft.setTags(tags); + Draft saved = draftRepository.save(draft); + if (existing) { + imageUploader.adjustReferences(oldContent == null ? "" : oldContent, content); + } else { + imageUploader.addReferences(imageUploader.extractUrls(content)); + } + return saved; + } - @Transactional - public void deleteDraft(String username) { - userRepository.findByUsername(username).ifPresent(user -> - draftRepository.findByAuthor(user).ifPresent(draft -> { - imageUploader.removeReferences(imageUploader.extractUrls(draft.getContent())); - draftRepository.delete(draft); - }) - ); - } + @Transactional(readOnly = true) + public Optional getDraft(String username) { + return userRepository.findByUsername(username).flatMap(draftRepository::findByAuthor); + } + + @Transactional + public void deleteDraft(String username) { + userRepository + .findByUsername(username) + .ifPresent(user -> + draftRepository + .findByAuthor(user) + .ifPresent(draft -> { + imageUploader.removeReferences(imageUploader.extractUrls(draft.getContent())); + draftRepository.delete(draft); + }) + ); + } } diff --git a/backend/src/main/java/com/openisle/service/EmailSender.java b/backend/src/main/java/com/openisle/service/EmailSender.java index c0705c7e2..befe86fbd 100644 --- a/backend/src/main/java/com/openisle/service/EmailSender.java +++ b/backend/src/main/java/com/openisle/service/EmailSender.java @@ -4,11 +4,12 @@ package com.openisle.service; * Abstract email sender used to deliver emails. */ public abstract class EmailSender { - /** - * Send an email to a recipient. - * @param to recipient email address - * @param subject email subject - * @param text email body - */ - public abstract void sendEmail(String to, String subject, String text); + + /** + * Send an email to a recipient. + * @param to recipient email address + * @param subject email subject + * @param text email body + */ + public abstract void sendEmail(String to, String subject, String text); } diff --git a/backend/src/main/java/com/openisle/service/GithubAuthService.java b/backend/src/main/java/com/openisle/service/GithubAuthService.java index c43d7069a..2062c14d7 100644 --- a/backend/src/main/java/com/openisle/service/GithubAuthService.java +++ b/backend/src/main/java/com/openisle/service/GithubAuthService.java @@ -4,123 +4,156 @@ import com.fasterxml.jackson.databind.JsonNode; import com.openisle.model.Role; import com.openisle.model.User; import com.openisle.repository.UserRepository; +import com.openisle.service.AvatarGenerator; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.*; import org.springframework.stereotype.Service; -import org.springframework.web.client.RestTemplate; import org.springframework.web.client.HttpClientErrorException; -import com.openisle.service.AvatarGenerator; - -import java.util.Collections; -import java.util.Map; -import java.util.HashMap; -import java.util.Optional; +import org.springframework.web.client.RestTemplate; @Service @RequiredArgsConstructor public class GithubAuthService { - private final UserRepository userRepository; - private final RestTemplate restTemplate = new RestTemplate(); - private final AvatarGenerator avatarGenerator; - @Value("${github.client-id:}") - private String clientId; + private final UserRepository userRepository; + private final RestTemplate restTemplate = new RestTemplate(); + private final AvatarGenerator avatarGenerator; - @Value("${github.client-secret:}") - private String clientSecret; + @Value("${github.client-id:}") + private String clientId; - public Optional authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri, boolean viaInvite) { + @Value("${github.client-secret:}") + private String clientSecret; + + public Optional authenticate( + String code, + com.openisle.model.RegisterMode mode, + String redirectUri, + boolean viaInvite + ) { + try { + String tokenUrl = "https://github.com/login/oauth/access_token"; + HttpHeaders headers = new HttpHeaders(); + headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + headers.setContentType(MediaType.APPLICATION_JSON); + + Map body = new HashMap<>(); + body.put("client_id", clientId); + body.put("client_secret", clientSecret); + body.put("code", code); + if (redirectUri != null) { + body.put("redirect_uri", redirectUri); + } + + HttpEntity> request = new HttpEntity<>(body, headers); + ResponseEntity tokenRes = restTemplate.postForEntity( + tokenUrl, + request, + JsonNode.class + ); + if ( + !tokenRes.getStatusCode().is2xxSuccessful() || + tokenRes.getBody() == null || + !tokenRes.getBody().has("access_token") + ) { + return Optional.empty(); + } + String accessToken = tokenRes.getBody().get("access_token").asText(); + HttpHeaders authHeaders = new HttpHeaders(); + authHeaders.setBearerAuth(accessToken); + authHeaders.set(HttpHeaders.USER_AGENT, "OpenIsle"); + HttpEntity entity = new HttpEntity<>(authHeaders); + ResponseEntity userRes = restTemplate.exchange( + "https://api.github.com/user", + HttpMethod.GET, + entity, + JsonNode.class + ); + if (!userRes.getStatusCode().is2xxSuccessful() || userRes.getBody() == null) { + return Optional.empty(); + } + JsonNode userNode = userRes.getBody(); + String username = userNode.hasNonNull("login") ? userNode.get("login").asText() : null; + String avatarUrl = userNode.hasNonNull("avatar_url") + ? userNode.get("avatar_url").asText() + : null; + String email = null; + if (userNode.hasNonNull("email")) { + email = userNode.get("email").asText(); + } + if (email == null || email.isEmpty()) { try { - String tokenUrl = "https://github.com/login/oauth/access_token"; - HttpHeaders headers = new HttpHeaders(); - headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); - headers.setContentType(MediaType.APPLICATION_JSON); - - Map body = new HashMap<>(); - body.put("client_id", clientId); - body.put("client_secret", clientSecret); - body.put("code", code); - if (redirectUri != null) { - body.put("redirect_uri", redirectUri); + ResponseEntity emailsRes = restTemplate.exchange( + "https://api.github.com/user/emails", + HttpMethod.GET, + entity, + JsonNode.class + ); + if ( + emailsRes.getStatusCode().is2xxSuccessful() && + emailsRes.getBody() != null && + emailsRes.getBody().isArray() + ) { + for (JsonNode n : emailsRes.getBody()) { + if (n.has("primary") && n.get("primary").asBoolean()) { + email = n.get("email").asText(); + break; + } } - - HttpEntity> request = new HttpEntity<>(body, headers); - ResponseEntity tokenRes = restTemplate.postForEntity(tokenUrl, request, JsonNode.class); - if (!tokenRes.getStatusCode().is2xxSuccessful() || tokenRes.getBody() == null || !tokenRes.getBody().has("access_token")) { - return Optional.empty(); - } - String accessToken = tokenRes.getBody().get("access_token").asText(); - HttpHeaders authHeaders = new HttpHeaders(); - authHeaders.setBearerAuth(accessToken); - authHeaders.set(HttpHeaders.USER_AGENT, "OpenIsle"); - HttpEntity entity = new HttpEntity<>(authHeaders); - ResponseEntity userRes = restTemplate.exchange( - "https://api.github.com/user", HttpMethod.GET, entity, JsonNode.class); - if (!userRes.getStatusCode().is2xxSuccessful() || userRes.getBody() == null) { - return Optional.empty(); - } - JsonNode userNode = userRes.getBody(); - String username = userNode.hasNonNull("login") ? userNode.get("login").asText() : null; - String avatarUrl = userNode.hasNonNull("avatar_url") ? userNode.get("avatar_url").asText() : null; - String email = null; - if (userNode.hasNonNull("email")) { - email = userNode.get("email").asText(); - } - if (email == null || email.isEmpty()) { - try { - ResponseEntity emailsRes = restTemplate.exchange( - "https://api.github.com/user/emails", HttpMethod.GET, entity, JsonNode.class); - if (emailsRes.getStatusCode().is2xxSuccessful() && emailsRes.getBody() != null && emailsRes.getBody().isArray()) { - for (JsonNode n : emailsRes.getBody()) { - if (n.has("primary") && n.get("primary").asBoolean()) { - email = n.get("email").asText(); - break; - } - } - } - } catch (HttpClientErrorException ignored) { - // ignore when the email API is not accessible - } - } - if (email == null) { - email = username + "@users.noreply.github.com"; - } - return Optional.of(processUser(email, username, avatarUrl, mode, viaInvite)); - } catch (Exception e) { - return Optional.empty(); + } + } catch (HttpClientErrorException ignored) { + // ignore when the email API is not accessible } + } + if (email == null) { + email = username + "@users.noreply.github.com"; + } + return Optional.of(processUser(email, username, avatarUrl, mode, viaInvite)); + } catch (Exception e) { + return Optional.empty(); } + } - private AuthResult processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) { - Optional existing = userRepository.findByEmail(email); - if (existing.isPresent()) { - User user = existing.get(); - if (!user.isVerified()) { - user.setVerified(true); - user.setVerificationCode(null); - userRepository.save(user); - } - return new AuthResult(user, false); - } - String baseUsername = username != null ? username : email.split("@")[0]; - String finalUsername = baseUsername; - int suffix = 1; - while (userRepository.findByUsername(finalUsername).isPresent()) { - finalUsername = baseUsername + suffix++; - } - User user = new User(); - user.setUsername(finalUsername); - user.setEmail(email); - user.setPassword(""); - user.setRole(Role.USER); + private AuthResult processUser( + String email, + String username, + String avatar, + com.openisle.model.RegisterMode mode, + boolean viaInvite + ) { + Optional existing = userRepository.findByEmail(email); + if (existing.isPresent()) { + User user = existing.get(); + if (!user.isVerified()) { user.setVerified(true); - user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite); - if (avatar != null) { - user.setAvatar(avatar); - } else { - user.setAvatar(avatarGenerator.generate(finalUsername)); - } - return new AuthResult(userRepository.save(user), true); + user.setVerificationCode(null); + userRepository.save(user); + } + return new AuthResult(user, false); } + String baseUsername = username != null ? username : email.split("@")[0]; + String finalUsername = baseUsername; + int suffix = 1; + while (userRepository.findByUsername(finalUsername).isPresent()) { + finalUsername = baseUsername + suffix++; + } + User user = new User(); + user.setUsername(finalUsername); + user.setEmail(email); + user.setPassword(""); + user.setRole(Role.USER); + user.setVerified(true); + user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite); + if (avatar != null) { + user.setAvatar(avatar); + } else { + user.setAvatar(avatarGenerator.generate(finalUsername)); + } + return new AuthResult(userRepository.save(user), true); + } } diff --git a/backend/src/main/java/com/openisle/service/GoogleAuthService.java b/backend/src/main/java/com/openisle/service/GoogleAuthService.java index 6dc5bdf3d..b5a825050 100644 --- a/backend/src/main/java/com/openisle/service/GoogleAuthService.java +++ b/backend/src/main/java/com/openisle/service/GoogleAuthService.java @@ -7,72 +7,84 @@ import com.google.api.client.json.jackson2.JacksonFactory; import com.openisle.model.Role; import com.openisle.model.User; import com.openisle.repository.UserRepository; +import com.openisle.service.AvatarGenerator; +import java.util.Collections; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import com.openisle.service.AvatarGenerator; - -import java.util.Collections; -import java.util.Optional; @Service @RequiredArgsConstructor public class GoogleAuthService { - private final UserRepository userRepository; - private final AvatarGenerator avatarGenerator; + private final UserRepository userRepository; + private final AvatarGenerator avatarGenerator; - @Value("${google.client-id:}") - private String clientId; + @Value("${google.client-id:}") + private String clientId; - public Optional authenticate(String idTokenString, com.openisle.model.RegisterMode mode, boolean viaInvite) { - GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new JacksonFactory()) - .setAudience(Collections.singletonList(clientId)) - .build(); - try { - GoogleIdToken idToken = verifier.verify(idTokenString); - if (idToken == null) { - return Optional.empty(); - } - GoogleIdToken.Payload payload = idToken.getPayload(); - String email = payload.getEmail(); - String name = (String) payload.get("name"); - String picture = (String) payload.get("picture"); - return Optional.of(processUser(email, name, picture, mode, viaInvite)); - } catch (Exception e) { - return Optional.empty(); - } + public Optional authenticate( + String idTokenString, + com.openisle.model.RegisterMode mode, + boolean viaInvite + ) { + GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder( + new NetHttpTransport(), + new JacksonFactory() + ) + .setAudience(Collections.singletonList(clientId)) + .build(); + try { + GoogleIdToken idToken = verifier.verify(idTokenString); + if (idToken == null) { + return Optional.empty(); + } + GoogleIdToken.Payload payload = idToken.getPayload(); + String email = payload.getEmail(); + String name = (String) payload.get("name"); + String picture = (String) payload.get("picture"); + return Optional.of(processUser(email, name, picture, mode, viaInvite)); + } catch (Exception e) { + return Optional.empty(); } + } - private AuthResult processUser(String email, String name, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) { - Optional existing = userRepository.findByEmail(email); - if (existing.isPresent()) { - User user = existing.get(); - if (!user.isVerified()) { - user.setVerified(true); - user.setVerificationCode(null); - userRepository.save(user); - } - return new AuthResult(user, false); - } - User user = new User(); - String baseUsername = email.split("@")[0]; - String username = baseUsername; - int suffix = 1; - while (userRepository.findByUsername(username).isPresent()) { - username = baseUsername + suffix++; - } - user.setUsername(username); - user.setEmail(email); - user.setPassword(""); - user.setRole(Role.USER); + private AuthResult processUser( + String email, + String name, + String avatar, + com.openisle.model.RegisterMode mode, + boolean viaInvite + ) { + Optional existing = userRepository.findByEmail(email); + if (existing.isPresent()) { + User user = existing.get(); + if (!user.isVerified()) { user.setVerified(true); - user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite); - if (avatar != null) { - user.setAvatar(avatar); - } else { - user.setAvatar(avatarGenerator.generate(username)); - } - return new AuthResult(userRepository.save(user), true); + user.setVerificationCode(null); + userRepository.save(user); + } + return new AuthResult(user, false); } + User user = new User(); + String baseUsername = email.split("@")[0]; + String username = baseUsername; + int suffix = 1; + while (userRepository.findByUsername(username).isPresent()) { + username = baseUsername + suffix++; + } + user.setUsername(username); + user.setEmail(email); + user.setPassword(""); + user.setRole(Role.USER); + user.setVerified(true); + user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite); + if (avatar != null) { + user.setAvatar(avatar); + } else { + user.setAvatar(avatarGenerator.generate(username)); + } + return new AuthResult(userRepository.save(user), true); + } } diff --git a/backend/src/main/java/com/openisle/service/ImageUploader.java b/backend/src/main/java/com/openisle/service/ImageUploader.java index e7a84b6de..c8e5d50d3 100644 --- a/backend/src/main/java/com/openisle/service/ImageUploader.java +++ b/backend/src/main/java/com/openisle/service/ImageUploader.java @@ -2,7 +2,6 @@ package com.openisle.service; import com.openisle.model.Image; import com.openisle.repository.ImageRepository; - import java.util.HashSet; import java.util.Set; import java.util.concurrent.CompletableFuture; @@ -13,94 +12,102 @@ import java.util.regex.Pattern; * Abstract service for uploading images and tracking their references. */ public abstract class ImageUploader { - private final ImageRepository imageRepository; - private final String baseUrl; - private final Pattern urlPattern; - protected ImageUploader(ImageRepository imageRepository, String baseUrl) { - this.imageRepository = imageRepository; - if (baseUrl.endsWith("/")) { - this.baseUrl = baseUrl.substring(0, baseUrl.length() - 1); + private final ImageRepository imageRepository; + private final String baseUrl; + private final Pattern urlPattern; + + protected ImageUploader(ImageRepository imageRepository, String baseUrl) { + this.imageRepository = imageRepository; + if (baseUrl.endsWith("/")) { + this.baseUrl = baseUrl.substring(0, baseUrl.length() - 1); + } else { + this.baseUrl = baseUrl; + } + this.urlPattern = Pattern.compile(Pattern.quote(this.baseUrl) + "/[^\\s)]+"); + } + + /** + * Upload an image asynchronously and return a future of its accessible URL. + */ + public CompletableFuture upload(byte[] data, String filename) { + return doUpload(data, filename).thenApply(url -> url); + } + + protected abstract CompletableFuture doUpload(byte[] data, String filename); + + protected abstract void deleteFromStore(String key); + + /** + * Generate a presigned PUT URL for direct browser upload. + * Default implementation is unsupported. + */ + public java.util.Map presignUpload(String filename) { + throw new UnsupportedOperationException("presignUpload not supported"); + } + + /** Extract COS URLs from text. */ + public Set extractUrls(String text) { + Set set = new HashSet<>(); + if (text == null) return set; + Matcher m = urlPattern.matcher(text); + while (m.find()) { + set.add(m.group()); + } + return set; + } + + public void addReferences(Set urls) { + for (String u : urls) addReference(u); + } + + public void removeReferences(Set urls) { + for (String u : urls) removeReference(u); + } + + public void adjustReferences(String oldText, String newText) { + Set oldUrls = extractUrls(oldText); + Set newUrls = extractUrls(newText); + for (String u : newUrls) { + if (!oldUrls.contains(u)) addReference(u); + } + for (String u : oldUrls) { + if (!newUrls.contains(u)) removeReference(u); + } + } + + private void addReference(String url) { + if (!url.startsWith(baseUrl)) return; + imageRepository + .findByUrl(url) + .ifPresentOrElse( + img -> { + img.setRefCount(img.getRefCount() + 1); + imageRepository.save(img); + }, + () -> { + Image img = new Image(); + img.setUrl(url); + img.setRefCount(1); + imageRepository.save(img); + } + ); + } + + private void removeReference(String url) { + if (!url.startsWith(baseUrl)) return; + imageRepository + .findByUrl(url) + .ifPresent(img -> { + long count = img.getRefCount() - 1; + if (count <= 0) { + imageRepository.delete(img); + String key = url.substring(baseUrl.length() + 1); + deleteFromStore(key); } else { - this.baseUrl = baseUrl; + img.setRefCount(count); + imageRepository.save(img); } - this.urlPattern = Pattern.compile(Pattern.quote(this.baseUrl) + "/[^\\s)]+"); - } - - /** - * Upload an image asynchronously and return a future of its accessible URL. - */ - public CompletableFuture upload(byte[] data, String filename) { - return doUpload(data, filename).thenApply(url -> url); - } - - protected abstract CompletableFuture doUpload(byte[] data, String filename); - - protected abstract void deleteFromStore(String key); - - /** - * Generate a presigned PUT URL for direct browser upload. - * Default implementation is unsupported. - */ - public java.util.Map presignUpload(String filename) { - throw new UnsupportedOperationException("presignUpload not supported"); - } - - /** Extract COS URLs from text. */ - public Set extractUrls(String text) { - Set set = new HashSet<>(); - if (text == null) return set; - Matcher m = urlPattern.matcher(text); - while (m.find()) { - set.add(m.group()); - } - return set; - } - - public void addReferences(Set urls) { - for (String u : urls) addReference(u); - } - - public void removeReferences(Set urls) { - for (String u : urls) removeReference(u); - } - - public void adjustReferences(String oldText, String newText) { - Set oldUrls = extractUrls(oldText); - Set newUrls = extractUrls(newText); - for (String u : newUrls) { - if (!oldUrls.contains(u)) addReference(u); - } - for (String u : oldUrls) { - if (!newUrls.contains(u)) removeReference(u); - } - } - - private void addReference(String url) { - if (!url.startsWith(baseUrl)) return; - imageRepository.findByUrl(url).ifPresentOrElse(img -> { - img.setRefCount(img.getRefCount() + 1); - imageRepository.save(img); - }, () -> { - Image img = new Image(); - img.setUrl(url); - img.setRefCount(1); - imageRepository.save(img); - }); - } - - private void removeReference(String url) { - if (!url.startsWith(baseUrl)) return; - imageRepository.findByUrl(url).ifPresent(img -> { - long count = img.getRefCount() - 1; - if (count <= 0) { - imageRepository.delete(img); - String key = url.substring(baseUrl.length() + 1); - deleteFromStore(key); - } else { - img.setRefCount(count); - imageRepository.save(img); - } - }); - } + }); + } } diff --git a/backend/src/main/java/com/openisle/service/InviteService.java b/backend/src/main/java/com/openisle/service/InviteService.java index 94e5a38f4..f93bcd6b5 100644 --- a/backend/src/main/java/com/openisle/service/InviteService.java +++ b/backend/src/main/java/com/openisle/service/InviteService.java @@ -4,81 +4,88 @@ import com.openisle.model.InviteToken; import com.openisle.model.User; import com.openisle.repository.InviteTokenRepository; import com.openisle.repository.UserRepository; +import java.time.LocalDate; +import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.Value; import org.springframework.stereotype.Service; -import java.time.LocalDate; -import java.util.Optional; - @Service @RequiredArgsConstructor public class InviteService { - private final InviteTokenRepository inviteTokenRepository; - private final UserRepository userRepository; - private final JwtService jwtService; - private final PointService pointService; - @Value - public class InviteValidateResult { - InviteToken inviteToken; - boolean validate; + private final InviteTokenRepository inviteTokenRepository; + private final UserRepository userRepository; + private final JwtService jwtService; + private final PointService pointService; + + @Value + public class InviteValidateResult { + + InviteToken inviteToken; + boolean validate; + } + + public String generate(String username) { + User inviter = userRepository.findByUsername(username).orElseThrow(); + LocalDate today = LocalDate.now(); + Optional existing = inviteTokenRepository.findByInviterAndCreatedDate( + inviter, + today + ); + if (existing.isPresent()) { + InviteToken inviteToken = existing.get(); + return inviteToken.getShortToken() != null + ? inviteToken.getShortToken() + : inviteToken.getToken(); } - public String generate(String username) { - User inviter = userRepository.findByUsername(username).orElseThrow(); - LocalDate today = LocalDate.now(); - Optional existing = inviteTokenRepository.findByInviterAndCreatedDate(inviter, today); - if (existing.isPresent()) { - InviteToken inviteToken = existing.get(); - return inviteToken.getShortToken() != null ? inviteToken.getShortToken() : inviteToken.getToken(); - } + String token = jwtService.generateInviteToken(username); + String shortToken; + do { + shortToken = java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 8); + } while (inviteTokenRepository.existsByShortToken(shortToken)); - String token = jwtService.generateInviteToken(username); - String shortToken; - do { - shortToken = java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 8); - } while (inviteTokenRepository.existsByShortToken(shortToken)); + InviteToken inviteToken = new InviteToken(); + inviteToken.setToken(token); + inviteToken.setShortToken(shortToken); + inviteToken.setInviter(inviter); + inviteToken.setCreatedDate(today); + inviteToken.setUsageCount(0); + inviteTokenRepository.save(inviteToken); + return shortToken; + } - InviteToken inviteToken = new InviteToken(); - inviteToken.setToken(token); - inviteToken.setShortToken(shortToken); - inviteToken.setInviter(inviter); - inviteToken.setCreatedDate(today); - inviteToken.setUsageCount(0); - inviteTokenRepository.save(inviteToken); - return shortToken; + public InviteValidateResult validate(String token) { + if (token == null || token.isEmpty()) { + return new InviteValidateResult(null, false); } - public InviteValidateResult validate(String token) { - if (token == null || token.isEmpty()) { - return new InviteValidateResult(null, false); - } - - InviteToken invite = inviteTokenRepository.findById(token).orElse(null); - String realToken = token; - if (invite == null) { - invite = inviteTokenRepository.findByShortToken(token).orElse(null); - if (invite == null) { - return new InviteValidateResult(null, false); - } - realToken = invite.getToken(); - } - - try { - jwtService.validateAndGetSubjectForInvite(realToken); - } catch (Exception e) { - return new InviteValidateResult(null, false); - } - - return new InviteValidateResult(invite, invite.getUsageCount() < 3); + InviteToken invite = inviteTokenRepository.findById(token).orElse(null); + String realToken = token; + if (invite == null) { + invite = inviteTokenRepository.findByShortToken(token).orElse(null); + if (invite == null) { + return new InviteValidateResult(null, false); + } + realToken = invite.getToken(); } - public void consume(String token, String newUserName) { - InviteToken invite = inviteTokenRepository.findById(token) - .orElseGet(() -> inviteTokenRepository.findByShortToken(token).orElseThrow()); - invite.setUsageCount(invite.getUsageCount() + 1); - inviteTokenRepository.save(invite); - pointService.awardForInvite(invite.getInviter().getUsername(), newUserName); + try { + jwtService.validateAndGetSubjectForInvite(realToken); + } catch (Exception e) { + return new InviteValidateResult(null, false); } + + return new InviteValidateResult(invite, invite.getUsageCount() < 3); + } + + public void consume(String token, String newUserName) { + InviteToken invite = inviteTokenRepository + .findById(token) + .orElseGet(() -> inviteTokenRepository.findByShortToken(token).orElseThrow()); + invite.setUsageCount(invite.getUsageCount() + 1); + inviteTokenRepository.save(invite); + pointService.awardForInvite(invite.getInviter().getUsername(), newUserName); + } } diff --git a/backend/src/main/java/com/openisle/service/JwtService.java b/backend/src/main/java/com/openisle/service/JwtService.java index ba7d0bbdd..c37643421 100644 --- a/backend/src/main/java/com/openisle/service/JwtService.java +++ b/backend/src/main/java/com/openisle/service/JwtService.java @@ -3,120 +3,119 @@ package com.openisle.service; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Date; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; - -import java.security.Key; -import java.util.Date; - @Service public class JwtService { - @Value("${app.jwt.secret}") - private String secret; - @Value("${app.jwt.reason-secret}") - private String reasonSecret; + @Value("${app.jwt.secret}") + private String secret; - @Value("${app.jwt.reset-secret}") - private String resetSecret; + @Value("${app.jwt.reason-secret}") + private String reasonSecret; - @Value("${app.jwt.invite-secret}") - private String inviteSecret; + @Value("${app.jwt.reset-secret}") + private String resetSecret; - @Value("${app.jwt.expiration}") - private long expiration; + @Value("${app.jwt.invite-secret}") + private String inviteSecret; - private Key getSigningKeyForSecret(String signSecret) { - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] keyBytes = digest.digest(signSecret.getBytes(StandardCharsets.UTF_8)); - return Keys.hmacShaKeyFor(keyBytes); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException("SHA-256 not available", e); - } + @Value("${app.jwt.expiration}") + private long expiration; + + private Key getSigningKeyForSecret(String signSecret) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] keyBytes = digest.digest(signSecret.getBytes(StandardCharsets.UTF_8)); + return Keys.hmacShaKeyFor(keyBytes); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); } + } - public String generateToken(String subject) { - Date now = new Date(); - Date expiryDate = new Date(now.getTime() + expiration); - return Jwts.builder() - .setSubject(subject) - .setIssuedAt(now) - .setExpiration(expiryDate) - .signWith(getSigningKeyForSecret(secret)) - .compact(); - } + public String generateToken(String subject) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + expiration); + return Jwts.builder() + .setSubject(subject) + .setIssuedAt(now) + .setExpiration(expiryDate) + .signWith(getSigningKeyForSecret(secret)) + .compact(); + } - public String generateReasonToken(String subject) { - Date now = new Date(); - Date expiryDate = new Date(now.getTime() + expiration); - return Jwts.builder() - .setSubject(subject) - .setIssuedAt(now) - .setExpiration(expiryDate) - .signWith(getSigningKeyForSecret(reasonSecret)) - .compact(); - } + public String generateReasonToken(String subject) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + expiration); + return Jwts.builder() + .setSubject(subject) + .setIssuedAt(now) + .setExpiration(expiryDate) + .signWith(getSigningKeyForSecret(reasonSecret)) + .compact(); + } - public String generateResetToken(String subject) { - Date now = new Date(); - Date expiryDate = new Date(now.getTime() + expiration); - return Jwts.builder() - .setSubject(subject) - .setIssuedAt(now) - .setExpiration(expiryDate) - .signWith(getSigningKeyForSecret(resetSecret)) - .compact(); - } + public String generateResetToken(String subject) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + expiration); + return Jwts.builder() + .setSubject(subject) + .setIssuedAt(now) + .setExpiration(expiryDate) + .signWith(getSigningKeyForSecret(resetSecret)) + .compact(); + } - public String generateInviteToken(String subject) { - Date now = new Date(); - Date expiryDate = new Date(now.getTime() + expiration); - return Jwts.builder() - .setSubject(subject) - .setIssuedAt(now) - .setExpiration(expiryDate) - .signWith(getSigningKeyForSecret(inviteSecret)) - .compact(); - } + public String generateInviteToken(String subject) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + expiration); + return Jwts.builder() + .setSubject(subject) + .setIssuedAt(now) + .setExpiration(expiryDate) + .signWith(getSigningKeyForSecret(inviteSecret)) + .compact(); + } - public String validateAndGetSubject(String token) { - Claims claims = Jwts.parserBuilder() - .setSigningKey(getSigningKeyForSecret(secret)) - .build() - .parseClaimsJws(token) - .getBody(); - return claims.getSubject(); - } + public String validateAndGetSubject(String token) { + Claims claims = Jwts.parserBuilder() + .setSigningKey(getSigningKeyForSecret(secret)) + .build() + .parseClaimsJws(token) + .getBody(); + return claims.getSubject(); + } - public String validateAndGetSubjectForReason(String token) { - Claims claims = Jwts.parserBuilder() - .setSigningKey(getSigningKeyForSecret(reasonSecret)) - .build() - .parseClaimsJws(token) - .getBody(); - return claims.getSubject(); - } + public String validateAndGetSubjectForReason(String token) { + Claims claims = Jwts.parserBuilder() + .setSigningKey(getSigningKeyForSecret(reasonSecret)) + .build() + .parseClaimsJws(token) + .getBody(); + return claims.getSubject(); + } - public String validateAndGetSubjectForReset(String token) { - Claims claims = Jwts.parserBuilder() - .setSigningKey(getSigningKeyForSecret(resetSecret)) - .build() - .parseClaimsJws(token) - .getBody(); - return claims.getSubject(); - } + public String validateAndGetSubjectForReset(String token) { + Claims claims = Jwts.parserBuilder() + .setSigningKey(getSigningKeyForSecret(resetSecret)) + .build() + .parseClaimsJws(token) + .getBody(); + return claims.getSubject(); + } - public String validateAndGetSubjectForInvite(String token) { - Claims claims = Jwts.parserBuilder() - .setSigningKey(getSigningKeyForSecret(inviteSecret)) - .build() - .parseClaimsJws(token) - .getBody(); - return claims.getSubject(); - } + public String validateAndGetSubjectForInvite(String token) { + Claims claims = Jwts.parserBuilder() + .setSigningKey(getSigningKeyForSecret(inviteSecret)) + .build() + .parseClaimsJws(token) + .getBody(); + return claims.getSubject(); + } } diff --git a/backend/src/main/java/com/openisle/service/LevelService.java b/backend/src/main/java/com/openisle/service/LevelService.java index b6d84e213..0a8c54874 100644 --- a/backend/src/main/java/com/openisle/service/LevelService.java +++ b/backend/src/main/java/com/openisle/service/LevelService.java @@ -1,90 +1,92 @@ package com.openisle.service; -import com.openisle.model.User; -import com.openisle.repository.UserRepository; -import com.openisle.repository.ExperienceLogRepository; import com.openisle.model.ExperienceLog; +import com.openisle.model.User; +import com.openisle.repository.ExperienceLogRepository; +import com.openisle.repository.UserRepository; +import java.time.LocalDate; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import java.time.LocalDate; - @Service @RequiredArgsConstructor public class LevelService { - private final UserRepository userRepository; - // repositories for experience-related entities - private final ExperienceLogRepository experienceLogRepository; - private final UserVisitService userVisitService; - private static final int[] LEVEL_EXP = {100,200,300,600,1200,10000}; + private final UserRepository userRepository; + // repositories for experience-related entities + private final ExperienceLogRepository experienceLogRepository; + private final UserVisitService userVisitService; - private ExperienceLog getTodayLog(User user) { - LocalDate today = LocalDate.now(); - return experienceLogRepository.findByUserAndLogDate(user, today) - .orElseGet(() -> { - ExperienceLog log = new ExperienceLog(); - log.setUser(user); - log.setLogDate(today); - log.setPostCount(0); - log.setCommentCount(0); - log.setReactionCount(0); - return experienceLogRepository.save(log); - }); + private static final int[] LEVEL_EXP = { 100, 200, 300, 600, 1200, 10000 }; + + private ExperienceLog getTodayLog(User user) { + LocalDate today = LocalDate.now(); + return experienceLogRepository + .findByUserAndLogDate(user, today) + .orElseGet(() -> { + ExperienceLog log = new ExperienceLog(); + log.setUser(user); + log.setLogDate(today); + log.setPostCount(0); + log.setCommentCount(0); + log.setReactionCount(0); + return experienceLogRepository.save(log); + }); + } + + private int addExperience(User user, int amount) { + user.setExperience(user.getExperience() + amount); + userRepository.save(user); + return amount; + } + + public int awardForPost(String username) { + User user = userRepository.findByUsername(username).orElseThrow(); + ExperienceLog log = getTodayLog(user); + if (log.getPostCount() > 1) return 0; + log.setPostCount(log.getPostCount() + 1); + experienceLogRepository.save(log); + return addExperience(user, 30); + } + + public int awardForComment(String username) { + User user = userRepository.findByUsername(username).orElseThrow(); + ExperienceLog log = getTodayLog(user); + if (log.getCommentCount() > 3) return 0; + log.setCommentCount(log.getCommentCount() + 1); + experienceLogRepository.save(log); + return addExperience(user, 10); + } + + public int awardForReaction(String username) { + User user = userRepository.findByUsername(username).orElseThrow(); + ExperienceLog log = getTodayLog(user); + if (log.getReactionCount() > 3) return 0; + log.setReactionCount(log.getReactionCount() + 1); + experienceLogRepository.save(log); + return addExperience(user, 5); + } + + public int awardForSignin(String username) { + boolean first = userVisitService.recordVisit(username); + if (!first) return 0; + User user = userRepository.findByUsername(username).orElseThrow(); + return addExperience(user, 5); + } + + public int getLevel(int exp) { + int level = 0; + for (int t : LEVEL_EXP) { + if (exp >= t) level++; + else break; } + return level; + } - private int addExperience(User user, int amount) { - user.setExperience(user.getExperience() + amount); - userRepository.save(user); - return amount; - } - - public int awardForPost(String username) { - User user = userRepository.findByUsername(username).orElseThrow(); - ExperienceLog log = getTodayLog(user); - if (log.getPostCount() > 1) return 0; - log.setPostCount(log.getPostCount() + 1); - experienceLogRepository.save(log); - return addExperience(user,30); - } - - public int awardForComment(String username) { - User user = userRepository.findByUsername(username).orElseThrow(); - ExperienceLog log = getTodayLog(user); - if (log.getCommentCount() > 3) return 0; - log.setCommentCount(log.getCommentCount() + 1); - experienceLogRepository.save(log); - return addExperience(user,10); - } - - public int awardForReaction(String username) { - User user = userRepository.findByUsername(username).orElseThrow(); - ExperienceLog log = getTodayLog(user); - if (log.getReactionCount() > 3) return 0; - log.setReactionCount(log.getReactionCount() + 1); - experienceLogRepository.save(log); - return addExperience(user,5); - } - - public int awardForSignin(String username) { - boolean first = userVisitService.recordVisit(username); - if (!first) return 0; - User user = userRepository.findByUsername(username).orElseThrow(); - return addExperience(user,5); - } - - public int getLevel(int exp) { - int level = 0; - for (int t : LEVEL_EXP) { - if (exp >= t) level++; else break; - } - return level; - } - - public int nextLevelExp(int exp) { - for (int t : LEVEL_EXP) { - if (exp < t) return t; - } - return LEVEL_EXP[LEVEL_EXP.length-1]; + public int nextLevelExp(int exp) { + for (int t : LEVEL_EXP) { + if (exp < t) return t; } + return LEVEL_EXP[LEVEL_EXP.length - 1]; + } } diff --git a/backend/src/main/java/com/openisle/service/MedalService.java b/backend/src/main/java/com/openisle/service/MedalService.java index 1f43caccd..f10549fdd 100644 --- a/backend/src/main/java/com/openisle/service/MedalService.java +++ b/backend/src/main/java/com/openisle/service/MedalService.java @@ -2,188 +2,201 @@ package com.openisle.service; import com.openisle.dto.CommentMedalDto; import com.openisle.dto.ContributorMedalDto; +import com.openisle.dto.FeaturedMedalDto; import com.openisle.dto.MedalDto; +import com.openisle.dto.PioneerMedalDto; import com.openisle.dto.PostMedalDto; import com.openisle.dto.SeedUserMedalDto; -import com.openisle.dto.PioneerMedalDto; -import com.openisle.dto.FeaturedMedalDto; import com.openisle.model.MedalType; import com.openisle.model.User; import com.openisle.repository.CommentRepository; import com.openisle.repository.PostRepository; import com.openisle.repository.UserRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor public class MedalService { - private static final long COMMENT_TARGET = 100; - private static final long POST_TARGET = 100; - private static final LocalDateTime SEED_USER_DEADLINE = LocalDateTime.of(2025, 9, 16, 0, 0); - private static final long CONTRIBUTION_TARGET = 1; - private static final long PIONEER_LIMIT = 1000; - private final CommentRepository commentRepository; - private final PostRepository postRepository; - private final UserRepository userRepository; - private final ContributorService contributorService; + private static final long COMMENT_TARGET = 100; + private static final long POST_TARGET = 100; + private static final LocalDateTime SEED_USER_DEADLINE = LocalDateTime.of(2025, 9, 16, 0, 0); + private static final long CONTRIBUTION_TARGET = 1; + private static final long PIONEER_LIMIT = 1000; - public List getMedals(Long userId) { - List medals = new ArrayList<>(); - User user = null; - if (userId != null) { - user = userRepository.findById(userId).orElse(null); + private final CommentRepository commentRepository; + private final PostRepository postRepository; + private final UserRepository userRepository; + private final ContributorService contributorService; + + public List getMedals(Long userId) { + List medals = new ArrayList<>(); + User user = null; + if (userId != null) { + user = userRepository.findById(userId).orElse(null); + } + MedalType selected = user != null ? user.getDisplayMedal() : null; + + CommentMedalDto commentMedal = new CommentMedalDto(); + commentMedal.setIcon( + "https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_comment.png" + ); + commentMedal.setTitle("评论达人"); + commentMedal.setDescription("评论超过100条"); + commentMedal.setType(MedalType.COMMENT); + commentMedal.setTargetCommentCount(COMMENT_TARGET); + if (user != null) { + long count = commentRepository.countByAuthor_Id(userId); + commentMedal.setCurrentCommentCount(count); + commentMedal.setCompleted(count >= COMMENT_TARGET); + } else { + commentMedal.setCurrentCommentCount(0); + commentMedal.setCompleted(false); + } + commentMedal.setSelected(selected == MedalType.COMMENT); + medals.add(commentMedal); + + PostMedalDto postMedal = new PostMedalDto(); + postMedal.setIcon( + "https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_post.png" + ); + postMedal.setTitle("发帖达人"); + postMedal.setDescription("发帖超过100条"); + postMedal.setType(MedalType.POST); + postMedal.setTargetPostCount(POST_TARGET); + if (user != null) { + long count = postRepository.countByAuthor_Id(userId); + postMedal.setCurrentPostCount(count); + postMedal.setCompleted(count >= POST_TARGET); + } else { + postMedal.setCurrentPostCount(0); + postMedal.setCompleted(false); + } + postMedal.setSelected(selected == MedalType.POST); + medals.add(postMedal); + + FeaturedMedalDto featuredMedal = new FeaturedMedalDto(); + featuredMedal.setIcon( + "https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_rss.png" + ); + featuredMedal.setTitle("精选作者"); + featuredMedal.setDescription("至少有1篇文章被收录为精选"); + featuredMedal.setType(MedalType.FEATURED); + featuredMedal.setTargetFeaturedCount(1); + if (user != null) { + long count = postRepository.countByAuthor_IdAndRssExcludedFalse(user.getId()); + featuredMedal.setCurrentFeaturedCount(count); + featuredMedal.setCompleted(count >= 1); + } else { + featuredMedal.setCurrentFeaturedCount(0); + featuredMedal.setCompleted(false); + } + featuredMedal.setSelected(selected == MedalType.FEATURED); + medals.add(featuredMedal); + + ContributorMedalDto contributorMedal = new ContributorMedalDto(); + contributorMedal.setIcon( + "https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_coder.png" + ); + contributorMedal.setTitle("贡献者"); + contributorMedal.setDescription("对仓库贡献超过1行代码"); + contributorMedal.setType(MedalType.CONTRIBUTOR); + contributorMedal.setTargetContributionLines(CONTRIBUTION_TARGET); + if (user != null) { + long lines = contributorService.getContributionLines(user.getUsername()); + contributorMedal.setCurrentContributionLines(lines); + contributorMedal.setCompleted(lines >= CONTRIBUTION_TARGET); + } else { + contributorMedal.setCurrentContributionLines(0); + contributorMedal.setCompleted(false); + } + contributorMedal.setSelected(selected == MedalType.CONTRIBUTOR); + medals.add(contributorMedal); + + SeedUserMedalDto seedUserMedal = new SeedUserMedalDto(); + seedUserMedal.setIcon( + "https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_seed.png" + ); + seedUserMedal.setTitle("种子用户"); + seedUserMedal.setDescription("2025.9.16前注册的用户"); + seedUserMedal.setType(MedalType.SEED); + if (user != null) { + seedUserMedal.setRegisterDate(user.getCreatedAt()); + seedUserMedal.setCompleted(user.getCreatedAt().isBefore(SEED_USER_DEADLINE)); + } else { + seedUserMedal.setCompleted(false); + } + seedUserMedal.setSelected(selected == MedalType.SEED); + medals.add(seedUserMedal); + + PioneerMedalDto pioneerMedal = new PioneerMedalDto(); + pioneerMedal.setIcon( + "https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_pioneer.png" + ); + pioneerMedal.setTitle("开山鼻祖"); + pioneerMedal.setDescription("前1000位加入的用户"); + pioneerMedal.setType(MedalType.PIONEER); + if (user != null) { + long rank = userRepository.countByCreatedAtBefore(user.getCreatedAt()) + 1; + pioneerMedal.setRank(rank); + pioneerMedal.setCompleted(rank <= PIONEER_LIMIT); + } else { + pioneerMedal.setCompleted(false); + } + pioneerMedal.setSelected(selected == MedalType.PIONEER); + medals.add(pioneerMedal); + if (user != null && selected == null) { + for (MedalDto medal : medals) { + if (medal.isCompleted()) { + medal.setSelected(true); + user.setDisplayMedal(medal.getType()); + userRepository.save(user); + break; } - MedalType selected = user != null ? user.getDisplayMedal() : null; - - CommentMedalDto commentMedal = new CommentMedalDto(); - commentMedal.setIcon("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_comment.png"); - commentMedal.setTitle("评论达人"); - commentMedal.setDescription("评论超过100条"); - commentMedal.setType(MedalType.COMMENT); - commentMedal.setTargetCommentCount(COMMENT_TARGET); - if (user != null) { - long count = commentRepository.countByAuthor_Id(userId); - commentMedal.setCurrentCommentCount(count); - commentMedal.setCompleted(count >= COMMENT_TARGET); - } else { - commentMedal.setCurrentCommentCount(0); - commentMedal.setCompleted(false); - } - commentMedal.setSelected(selected == MedalType.COMMENT); - medals.add(commentMedal); - - PostMedalDto postMedal = new PostMedalDto(); - postMedal.setIcon("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_post.png"); - postMedal.setTitle("发帖达人"); - postMedal.setDescription("发帖超过100条"); - postMedal.setType(MedalType.POST); - postMedal.setTargetPostCount(POST_TARGET); - if (user != null) { - long count = postRepository.countByAuthor_Id(userId); - postMedal.setCurrentPostCount(count); - postMedal.setCompleted(count >= POST_TARGET); - } else { - postMedal.setCurrentPostCount(0); - postMedal.setCompleted(false); - } - postMedal.setSelected(selected == MedalType.POST); - medals.add(postMedal); - - FeaturedMedalDto featuredMedal = new FeaturedMedalDto(); - featuredMedal.setIcon("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_rss.png"); - featuredMedal.setTitle("精选作者"); - featuredMedal.setDescription("至少有1篇文章被收录为精选"); - featuredMedal.setType(MedalType.FEATURED); - featuredMedal.setTargetFeaturedCount(1); - if (user != null) { - long count = postRepository.countByAuthor_IdAndRssExcludedFalse(user.getId()); - featuredMedal.setCurrentFeaturedCount(count); - featuredMedal.setCompleted(count >= 1); - } else { - featuredMedal.setCurrentFeaturedCount(0); - featuredMedal.setCompleted(false); - } - featuredMedal.setSelected(selected == MedalType.FEATURED); - medals.add(featuredMedal); - - ContributorMedalDto contributorMedal = new ContributorMedalDto(); - contributorMedal.setIcon("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_coder.png"); - contributorMedal.setTitle("贡献者"); - contributorMedal.setDescription("对仓库贡献超过1行代码"); - contributorMedal.setType(MedalType.CONTRIBUTOR); - contributorMedal.setTargetContributionLines(CONTRIBUTION_TARGET); - if (user != null) { - long lines = contributorService.getContributionLines(user.getUsername()); - contributorMedal.setCurrentContributionLines(lines); - contributorMedal.setCompleted(lines >= CONTRIBUTION_TARGET); - } else { - contributorMedal.setCurrentContributionLines(0); - contributorMedal.setCompleted(false); - } - contributorMedal.setSelected(selected == MedalType.CONTRIBUTOR); - medals.add(contributorMedal); - - SeedUserMedalDto seedUserMedal = new SeedUserMedalDto(); - seedUserMedal.setIcon("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_seed.png"); - seedUserMedal.setTitle("种子用户"); - seedUserMedal.setDescription("2025.9.16前注册的用户"); - seedUserMedal.setType(MedalType.SEED); - if (user != null) { - seedUserMedal.setRegisterDate(user.getCreatedAt()); - seedUserMedal.setCompleted(user.getCreatedAt().isBefore(SEED_USER_DEADLINE)); - } else { - seedUserMedal.setCompleted(false); - } - seedUserMedal.setSelected(selected == MedalType.SEED); - medals.add(seedUserMedal); - - PioneerMedalDto pioneerMedal = new PioneerMedalDto(); - pioneerMedal.setIcon("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_pioneer.png"); - pioneerMedal.setTitle("开山鼻祖"); - pioneerMedal.setDescription("前1000位加入的用户"); - pioneerMedal.setType(MedalType.PIONEER); - if (user != null) { - long rank = userRepository.countByCreatedAtBefore(user.getCreatedAt()) + 1; - pioneerMedal.setRank(rank); - pioneerMedal.setCompleted(rank <= PIONEER_LIMIT); - } else { - pioneerMedal.setCompleted(false); - } - pioneerMedal.setSelected(selected == MedalType.PIONEER); - medals.add(pioneerMedal); - if (user != null && selected == null) { - for (MedalDto medal : medals) { - if (medal.isCompleted()) { - medal.setSelected(true); - user.setDisplayMedal(medal.getType()); - userRepository.save(user); - break; - } - } - } - - return medals; + } } - public void ensureDisplayMedal(User user) { - if (user == null || user.getDisplayMedal() != null) { - return; - } - if (commentRepository.countByAuthor_Id(user.getId()) >= COMMENT_TARGET) { - user.setDisplayMedal(MedalType.COMMENT); - } else if (postRepository.countByAuthor_Id(user.getId()) >= POST_TARGET) { - user.setDisplayMedal(MedalType.POST); - } else if (postRepository.countByAuthor_IdAndRssExcludedFalse(user.getId()) >= 1) { - user.setDisplayMedal(MedalType.FEATURED); - } else if (contributorService.getContributionLines(user.getUsername()) >= CONTRIBUTION_TARGET) { - user.setDisplayMedal(MedalType.CONTRIBUTOR); - } else if (userRepository.countByCreatedAtBefore(user.getCreatedAt()) < PIONEER_LIMIT) { - user.setDisplayMedal(MedalType.PIONEER); - } else if (user.getCreatedAt().isBefore(SEED_USER_DEADLINE)) { - user.setDisplayMedal(MedalType.SEED); - } - if (user.getDisplayMedal() != null) { - userRepository.save(user); - } - } + return medals; + } - public void selectMedal(String username, MedalType type) { - User user = userRepository.findByUsername(username).orElseThrow(); - boolean completed = getMedals(user.getId()).stream() - .filter(m -> m.getType() == type) - .findFirst() - .map(MedalDto::isCompleted) - .orElse(false); - if (!completed) { - throw new IllegalArgumentException("Medal not completed"); - } - user.setDisplayMedal(type); - userRepository.save(user); + public void ensureDisplayMedal(User user) { + if (user == null || user.getDisplayMedal() != null) { + return; } + if (commentRepository.countByAuthor_Id(user.getId()) >= COMMENT_TARGET) { + user.setDisplayMedal(MedalType.COMMENT); + } else if (postRepository.countByAuthor_Id(user.getId()) >= POST_TARGET) { + user.setDisplayMedal(MedalType.POST); + } else if (postRepository.countByAuthor_IdAndRssExcludedFalse(user.getId()) >= 1) { + user.setDisplayMedal(MedalType.FEATURED); + } else if (contributorService.getContributionLines(user.getUsername()) >= CONTRIBUTION_TARGET) { + user.setDisplayMedal(MedalType.CONTRIBUTOR); + } else if (userRepository.countByCreatedAtBefore(user.getCreatedAt()) < PIONEER_LIMIT) { + user.setDisplayMedal(MedalType.PIONEER); + } else if (user.getCreatedAt().isBefore(SEED_USER_DEADLINE)) { + user.setDisplayMedal(MedalType.SEED); + } + if (user.getDisplayMedal() != null) { + userRepository.save(user); + } + } + + public void selectMedal(String username, MedalType type) { + User user = userRepository.findByUsername(username).orElseThrow(); + boolean completed = getMedals(user.getId()) + .stream() + .filter(m -> m.getType() == type) + .findFirst() + .map(MedalDto::isCompleted) + .orElse(false); + if (!completed) { + throw new IllegalArgumentException("Medal not completed"); + } + user.setDisplayMedal(type); + userRepository.save(user); + } } diff --git a/backend/src/main/java/com/openisle/service/MessageService.java b/backend/src/main/java/com/openisle/service/MessageService.java index 2f451e46b..68ce3c3f9 100644 --- a/backend/src/main/java/com/openisle/service/MessageService.java +++ b/backend/src/main/java/com/openisle/service/MessageService.java @@ -1,22 +1,27 @@ package com.openisle.service; -import com.openisle.model.Message; -import com.openisle.model.MessageConversation; -import com.openisle.model.MessageParticipant; -import com.openisle.model.User; -import com.openisle.model.Reaction; -import com.openisle.repository.MessageConversationRepository; -import com.openisle.repository.MessageParticipantRepository; -import com.openisle.repository.MessageRepository; -import com.openisle.repository.UserRepository; -import com.openisle.repository.ReactionRepository; import com.openisle.dto.ConversationDetailDto; import com.openisle.dto.ConversationDto; import com.openisle.dto.MessageDto; +import com.openisle.dto.MessageNotificationPayload; import com.openisle.dto.ReactionDto; import com.openisle.dto.UserSummaryDto; import com.openisle.mapper.ReactionMapper; -import com.openisle.dto.MessageNotificationPayload; +import com.openisle.model.Message; +import com.openisle.model.MessageConversation; +import com.openisle.model.MessageParticipant; +import com.openisle.model.Reaction; +import com.openisle.model.User; +import com.openisle.repository.MessageConversationRepository; +import com.openisle.repository.MessageParticipantRepository; +import com.openisle.repository.MessageRepository; +import com.openisle.repository.ReactionRepository; +import com.openisle.repository.UserRepository; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -24,326 +29,403 @@ import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - @Service @RequiredArgsConstructor @Slf4j public class MessageService { - private final MessageRepository messageRepository; - private final MessageConversationRepository conversationRepository; - private final MessageParticipantRepository participantRepository; - private final UserRepository userRepository; - private final NotificationProducer notificationProducer; - private final ReactionRepository reactionRepository; - private final ReactionMapper reactionMapper; + private final MessageRepository messageRepository; + private final MessageConversationRepository conversationRepository; + private final MessageParticipantRepository participantRepository; + private final UserRepository userRepository; + private final NotificationProducer notificationProducer; + private final ReactionRepository reactionRepository; + private final ReactionMapper reactionMapper; - @Transactional - public Message sendMessage(Long senderId, Long recipientId, String content, Long replyToId) { - log.info("Attempting to send message from user {} to user {}", senderId, recipientId); - User sender = userRepository.findById(senderId) - .orElseThrow(() -> new IllegalArgumentException("Sender not found")); - User recipient = userRepository.findById(recipientId) - .orElseThrow(() -> new IllegalArgumentException("Recipient not found")); + @Transactional + public Message sendMessage(Long senderId, Long recipientId, String content, Long replyToId) { + log.info("Attempting to send message from user {} to user {}", senderId, recipientId); + User sender = userRepository + .findById(senderId) + .orElseThrow(() -> new IllegalArgumentException("Sender not found")); + User recipient = userRepository + .findById(recipientId) + .orElseThrow(() -> new IllegalArgumentException("Recipient not found")); - log.info("Finding or creating conversation for users {} and {}", sender.getUsername(), recipient.getUsername()); - MessageConversation conversation = findOrCreateConversation(sender, recipient); - log.info("Conversation found or created with ID: {}", conversation.getId()); + log.info( + "Finding or creating conversation for users {} and {}", + sender.getUsername(), + recipient.getUsername() + ); + MessageConversation conversation = findOrCreateConversation(sender, recipient); + log.info("Conversation found or created with ID: {}", conversation.getId()); - Message message = new Message(); - message.setConversation(conversation); - message.setSender(sender); - message.setContent(content); - if (replyToId != null) { - Message replyTo = messageRepository.findById(replyToId) - .orElseThrow(() -> new IllegalArgumentException("Message not found")); - message.setReplyTo(replyTo); - } - message = messageRepository.save(message); - log.info("Message saved with ID: {}", message.getId()); + Message message = new Message(); + message.setConversation(conversation); + message.setSender(sender); + message.setContent(content); + if (replyToId != null) { + Message replyTo = messageRepository + .findById(replyToId) + .orElseThrow(() -> new IllegalArgumentException("Message not found")); + message.setReplyTo(replyTo); + } + message = messageRepository.save(message); + log.info("Message saved with ID: {}", message.getId()); - conversation.setLastMessage(message); - conversationRepository.save(conversation); - log.info("Conversation {} updated with last message ID {}", conversation.getId(), message.getId()); + conversation.setLastMessage(message); + conversationRepository.save(conversation); + log.info( + "Conversation {} updated with last message ID {}", + conversation.getId(), + message.getId() + ); - - try { - MessageDto messageDto = toDto(message); - - long unreadCount = getUnreadMessageCount(recipientId); + try { + MessageDto messageDto = toDto(message); - // 创建包含对话和参与者信息的完整payload - Map conversationInfo = new HashMap<>(); - conversationInfo.put("id", conversation.getId()); - conversationInfo.put("participants", conversation.getParticipants().stream() - .map(p -> { - Map participantInfo = new HashMap<>(); - participantInfo.put("userId", p.getUser().getId()); - participantInfo.put("username", p.getUser().getUsername()); - return participantInfo; - }).collect(Collectors.toList())); - - Map combinedPayload = new HashMap<>(); - combinedPayload.put("message", messageDto); - combinedPayload.put("unreadCount", unreadCount); - combinedPayload.put("conversation", conversationInfo); - combinedPayload.put("senderId", senderId); - if (notificationProducer != null) { - log.info("NotificationProducer is available"); - } else { - log.info("ERROR: NotificationProducer is NULL!"); - return message; - } - log.info("Recipient username: {}", recipient.getUsername()); - - notificationProducer.sendNotification(new MessageNotificationPayload(recipient.getUsername(), combinedPayload)); - log.info("=== Notification call completed ==="); - } catch (Exception e) { - log.error("=== Error in notification process ===", e); - } + long unreadCount = getUnreadMessageCount(recipientId); + // 创建包含对话和参与者信息的完整payload + Map conversationInfo = new HashMap<>(); + conversationInfo.put("id", conversation.getId()); + conversationInfo.put( + "participants", + conversation + .getParticipants() + .stream() + .map(p -> { + Map participantInfo = new HashMap<>(); + participantInfo.put("userId", p.getUser().getId()); + participantInfo.put("username", p.getUser().getUsername()); + return participantInfo; + }) + .collect(Collectors.toList()) + ); + + Map combinedPayload = new HashMap<>(); + combinedPayload.put("message", messageDto); + combinedPayload.put("unreadCount", unreadCount); + combinedPayload.put("conversation", conversationInfo); + combinedPayload.put("senderId", senderId); + if (notificationProducer != null) { + log.info("NotificationProducer is available"); + } else { + log.info("ERROR: NotificationProducer is NULL!"); return message; + } + log.info("Recipient username: {}", recipient.getUsername()); + + notificationProducer.sendNotification( + new MessageNotificationPayload(recipient.getUsername(), combinedPayload) + ); + log.info("=== Notification call completed ==="); + } catch (Exception e) { + log.error("=== Error in notification process ===", e); } - @Transactional - public Message sendMessageToConversation(Long senderId, Long conversationId, String content, Long replyToId) { - User sender = userRepository.findById(senderId) - .orElseThrow(() -> new IllegalArgumentException("Sender not found")); - MessageConversation conversation = conversationRepository.findByIdWithParticipantsAndUsers(conversationId) - .orElseThrow(() -> new IllegalArgumentException("Conversation not found")); + return message; + } - // Join the conversation if not already a participant (useful for channels) - participantRepository.findByConversationIdAndUserId(conversationId, senderId) - .orElseGet(() -> { - MessageParticipant p = new MessageParticipant(); - p.setConversation(conversation); - p.setUser(sender); - return participantRepository.save(p); - }); + @Transactional + public Message sendMessageToConversation( + Long senderId, + Long conversationId, + String content, + Long replyToId + ) { + User sender = userRepository + .findById(senderId) + .orElseThrow(() -> new IllegalArgumentException("Sender not found")); + MessageConversation conversation = conversationRepository + .findByIdWithParticipantsAndUsers(conversationId) + .orElseThrow(() -> new IllegalArgumentException("Conversation not found")); - Message message = new Message(); - message.setConversation(conversation); - message.setSender(sender); - message.setContent(content); - if (replyToId != null) { - Message replyTo = messageRepository.findById(replyToId) - .orElseThrow(() -> new IllegalArgumentException("Message not found")); - message.setReplyTo(replyTo); - } - message = messageRepository.save(message); + // Join the conversation if not already a participant (useful for channels) + participantRepository + .findByConversationIdAndUserId(conversationId, senderId) + .orElseGet(() -> { + MessageParticipant p = new MessageParticipant(); + p.setConversation(conversation); + p.setUser(sender); + return participantRepository.save(p); + }); - conversation.setLastMessage(message); - conversationRepository.save(conversation); + Message message = new Message(); + message.setConversation(conversation); + message.setSender(sender); + message.setContent(content); + if (replyToId != null) { + Message replyTo = messageRepository + .findById(replyToId) + .orElseThrow(() -> new IllegalArgumentException("Message not found")); + message.setReplyTo(replyTo); + } + message = messageRepository.save(message); - MessageDto messageDto = toDto(message); + conversation.setLastMessage(message); + conversationRepository.save(conversation); - // Build participant payloads once to avoid duplicate broadcasts - java.util.List> participantInfos = conversation.getParticipants().stream() - .filter(p -> !p.getUser().getId().equals(senderId)) - .map(p -> { - Map info = new HashMap<>(); - info.put("userId", p.getUser().getId()); - info.put("username", p.getUser().getUsername()); - info.put("unreadCount", getUnreadMessageCount(p.getUser().getId())); - info.put("channelUnread", getUnreadChannelCount(p.getUser().getId())); - return info; - }).collect(Collectors.toList()); + MessageDto messageDto = toDto(message); - Map conversationInfo = new HashMap<>(); - conversationInfo.put("id", conversation.getId()); - conversationInfo.put("participants", participantInfos); + // Build participant payloads once to avoid duplicate broadcasts + java.util.List> participantInfos = conversation + .getParticipants() + .stream() + .filter(p -> !p.getUser().getId().equals(senderId)) + .map(p -> { + Map info = new HashMap<>(); + info.put("userId", p.getUser().getId()); + info.put("username", p.getUser().getUsername()); + info.put("unreadCount", getUnreadMessageCount(p.getUser().getId())); + info.put("channelUnread", getUnreadChannelCount(p.getUser().getId())); + return info; + }) + .collect(Collectors.toList()); - Map combinedPayload = new HashMap<>(); - combinedPayload.put("message", messageDto); - combinedPayload.put("conversation", conversationInfo); - combinedPayload.put("senderId", senderId); + Map conversationInfo = new HashMap<>(); + conversationInfo.put("id", conversation.getId()); + conversationInfo.put("participants", participantInfos); - // Use sender's username for sharding; only one notification is needed - notificationProducer.sendNotification(new MessageNotificationPayload(sender.getUsername(), combinedPayload)); + Map combinedPayload = new HashMap<>(); + combinedPayload.put("message", messageDto); + combinedPayload.put("conversation", conversationInfo); + combinedPayload.put("senderId", senderId); - return message; + // Use sender's username for sharding; only one notification is needed + notificationProducer.sendNotification( + new MessageNotificationPayload(sender.getUsername(), combinedPayload) + ); + + return message; + } + + public MessageDto toDto(Message message) { + MessageDto dto = new MessageDto(); + dto.setId(message.getId()); + dto.setContent(message.getContent()); + dto.setConversationId(message.getConversation().getId()); + dto.setCreatedAt(message.getCreatedAt()); + + UserSummaryDto userSummaryDto = new UserSummaryDto(); + userSummaryDto.setId(message.getSender().getId()); + userSummaryDto.setUsername(message.getSender().getUsername()); + userSummaryDto.setAvatar(message.getSender().getAvatar()); + dto.setSender(userSummaryDto); + + if (message.getReplyTo() != null) { + Message reply = message.getReplyTo(); + MessageDto replyDto = new MessageDto(); + replyDto.setId(reply.getId()); + replyDto.setContent(reply.getContent()); + UserSummaryDto replySender = new UserSummaryDto(); + replySender.setId(reply.getSender().getId()); + replySender.setUsername(reply.getSender().getUsername()); + replySender.setAvatar(reply.getSender().getAvatar()); + replyDto.setSender(replySender); + dto.setReplyTo(replyDto); } - public MessageDto toDto(Message message) { - MessageDto dto = new MessageDto(); - dto.setId(message.getId()); - dto.setContent(message.getContent()); - dto.setConversationId(message.getConversation().getId()); - dto.setCreatedAt(message.getCreatedAt()); + java.util.List reactions = reactionRepository.findByMessage(message); + java.util.List reactionDtos = reactions + .stream() + .map(reactionMapper::toDto) + .collect(Collectors.toList()); + dto.setReactions(reactionDtos); - UserSummaryDto userSummaryDto = new UserSummaryDto(); - userSummaryDto.setId(message.getSender().getId()); - userSummaryDto.setUsername(message.getSender().getUsername()); - userSummaryDto.setAvatar(message.getSender().getAvatar()); - dto.setSender(userSummaryDto); + return dto; + } - if (message.getReplyTo() != null) { - Message reply = message.getReplyTo(); - MessageDto replyDto = new MessageDto(); - replyDto.setId(reply.getId()); - replyDto.setContent(reply.getContent()); - UserSummaryDto replySender = new UserSummaryDto(); - replySender.setId(reply.getSender().getId()); - replySender.setUsername(reply.getSender().getUsername()); - replySender.setAvatar(reply.getSender().getAvatar()); - replyDto.setSender(replySender); - dto.setReplyTo(replyDto); - } + public MessageConversation findOrCreateConversation(Long user1Id, Long user2Id) { + User user1 = userRepository + .findById(user1Id) + .orElseThrow(() -> new IllegalArgumentException("User1 not found")); + User user2 = userRepository + .findById(user2Id) + .orElseThrow(() -> new IllegalArgumentException("User2 not found")); + return findOrCreateConversation(user1, user2); + } - java.util.List reactions = reactionRepository.findByMessage(message); - java.util.List reactionDtos = reactions.stream() - .map(reactionMapper::toDto) - .collect(Collectors.toList()); - dto.setReactions(reactionDtos); + private MessageConversation findOrCreateConversation(User user1, User user2) { + log.info( + "Searching for existing conversation between {} and {}", + user1.getUsername(), + user2.getUsername() + ); + return conversationRepository + .findConversationsByUsers(user1, user2) + .stream() + .findFirst() + .orElseGet(() -> { + log.info("No existing conversation found. Creating a new one."); + MessageConversation conversation = new MessageConversation(); + conversation = conversationRepository.save(conversation); + log.info("New conversation created with ID: {}", conversation.getId()); - return dto; + MessageParticipant participant1 = new MessageParticipant(); + participant1.setConversation(conversation); + participant1.setUser(user1); + participantRepository.save(participant1); + log.info( + "Participant {} added to conversation {}", + user1.getUsername(), + conversation.getId() + ); + + MessageParticipant participant2 = new MessageParticipant(); + participant2.setConversation(conversation); + participant2.setUser(user2); + participantRepository.save(participant2); + log.info( + "Participant {} added to conversation {}", + user2.getUsername(), + conversation.getId() + ); + + return conversation; + }); + } + + @Transactional(readOnly = true) + public List getConversations(Long userId) { + List conversations = + conversationRepository.findConversationsByUserIdOrderByLastMessageDesc(userId); + return conversations + .stream() + .filter(c -> !c.isChannel()) + .map(c -> toDto(c, userId)) + .collect(Collectors.toList()); + } + + private ConversationDto toDto(MessageConversation conversation, Long userId) { + ConversationDto dto = new ConversationDto(); + dto.setId(conversation.getId()); + dto.setChannel(conversation.isChannel()); + dto.setName(conversation.getName()); + dto.setAvatar(conversation.getAvatar()); + dto.setCreatedAt(conversation.getCreatedAt()); + if (conversation.getLastMessage() != null) { + dto.setLastMessage(toDto(conversation.getLastMessage())); } + dto.setParticipants( + conversation + .getParticipants() + .stream() + .map(p -> { + UserSummaryDto userDto = new UserSummaryDto(); + userDto.setId(p.getUser().getId()); + userDto.setUsername(p.getUser().getUsername()); + userDto.setAvatar(p.getUser().getAvatar()); + return userDto; + }) + .collect(Collectors.toList()) + ); - public MessageConversation findOrCreateConversation(Long user1Id, Long user2Id) { - User user1 = userRepository.findById(user1Id) - .orElseThrow(() -> new IllegalArgumentException("User1 not found")); - User user2 = userRepository.findById(user2Id) - .orElseThrow(() -> new IllegalArgumentException("User2 not found")); - return findOrCreateConversation(user1, user2); + MessageParticipant self = conversation + .getParticipants() + .stream() + .filter(p -> p.getUser().getId().equals(userId)) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Participant not found in conversation")); + + LocalDateTime lastRead = self.getLastReadAt() == null + ? LocalDateTime.of(1970, 1, 1, 0, 0) + : self.getLastReadAt(); + // 只计算别人发送给当前用户的未读消息 + long unreadCount = messageRepository.countByConversationIdAndCreatedAtAfterAndSenderIdNot( + conversation.getId(), + lastRead, + userId + ); + dto.setUnreadCount(unreadCount); + + return dto; + } + + @Transactional + public ConversationDetailDto getConversationDetails( + Long conversationId, + Long userId, + Pageable pageable + ) { + markConversationAsRead(conversationId, userId); + + MessageConversation conversation = conversationRepository + .findById(conversationId) + .orElseThrow(() -> new IllegalArgumentException("Conversation not found")); + + Page messagesPage = messageRepository.findByConversationId(conversationId, pageable); + Page messageDtoPage = messagesPage.map(this::toDto); + + List participants = conversation + .getParticipants() + .stream() + .map(p -> { + UserSummaryDto userDto = new UserSummaryDto(); + userDto.setId(p.getUser().getId()); + userDto.setUsername(p.getUser().getUsername()); + userDto.setAvatar(p.getUser().getAvatar()); + return userDto; + }) + .collect(Collectors.toList()); + + ConversationDetailDto detailDto = new ConversationDetailDto(); + detailDto.setId(conversation.getId()); + detailDto.setName(conversation.getName()); + detailDto.setChannel(conversation.isChannel()); + detailDto.setAvatar(conversation.getAvatar()); + detailDto.setParticipants(participants); + detailDto.setMessages(messageDtoPage); + + return detailDto; + } + + @Transactional + public void markConversationAsRead(Long conversationId, Long userId) { + MessageParticipant participant = participantRepository + .findByConversationIdAndUserId(conversationId, userId) + .orElseThrow(() -> new IllegalArgumentException("Participant not found")); + participant.setLastReadAt(LocalDateTime.now()); + participantRepository.save(participant); + } + + @Transactional(readOnly = true) + public long getUnreadMessageCount(Long userId) { + List participations = participantRepository.findByUserId(userId); + long totalUnreadCount = 0; + for (MessageParticipant p : participations) { + if (p.getConversation().isChannel()) continue; + LocalDateTime lastRead = p.getLastReadAt() == null + ? LocalDateTime.of(1970, 1, 1, 0, 0) + : p.getLastReadAt(); + // 只计算别人发送给当前用户的未读消息 + totalUnreadCount += messageRepository.countByConversationIdAndCreatedAtAfterAndSenderIdNot( + p.getConversation().getId(), + lastRead, + userId + ); } + return totalUnreadCount; + } - private MessageConversation findOrCreateConversation(User user1, User user2) { - log.info("Searching for existing conversation between {} and {}", user1.getUsername(), user2.getUsername()); - return conversationRepository.findConversationsByUsers(user1, user2).stream() - .findFirst() - .orElseGet(() -> { - log.info("No existing conversation found. Creating a new one."); - MessageConversation conversation = new MessageConversation(); - conversation = conversationRepository.save(conversation); - log.info("New conversation created with ID: {}", conversation.getId()); - - MessageParticipant participant1 = new MessageParticipant(); - participant1.setConversation(conversation); - participant1.setUser(user1); - participantRepository.save(participant1); - log.info("Participant {} added to conversation {}", user1.getUsername(), conversation.getId()); - - MessageParticipant participant2 = new MessageParticipant(); - participant2.setConversation(conversation); - participant2.setUser(user2); - participantRepository.save(participant2); - log.info("Participant {} added to conversation {}", user2.getUsername(), conversation.getId()); - - return conversation; - }); + @Transactional(readOnly = true) + public long getUnreadChannelCount(Long userId) { + List participations = participantRepository.findByUserId(userId); + long unreadChannelCount = 0; + for (MessageParticipant p : participations) { + if (!p.getConversation().isChannel()) continue; + LocalDateTime lastRead = p.getLastReadAt() == null + ? LocalDateTime.of(1970, 1, 1, 0, 0) + : p.getLastReadAt(); + long unread = messageRepository.countByConversationIdAndCreatedAtAfterAndSenderIdNot( + p.getConversation().getId(), + lastRead, + userId + ); + if (unread > 0) { + unreadChannelCount++; + } } - - @Transactional(readOnly = true) - public List getConversations(Long userId) { - List conversations = conversationRepository.findConversationsByUserIdOrderByLastMessageDesc(userId); - return conversations.stream() - .filter(c -> !c.isChannel()) - .map(c -> toDto(c, userId)) - .collect(Collectors.toList()); - } - - private ConversationDto toDto(MessageConversation conversation, Long userId) { - ConversationDto dto = new ConversationDto(); - dto.setId(conversation.getId()); - dto.setChannel(conversation.isChannel()); - dto.setName(conversation.getName()); - dto.setAvatar(conversation.getAvatar()); - dto.setCreatedAt(conversation.getCreatedAt()); - if (conversation.getLastMessage() != null) { - dto.setLastMessage(toDto(conversation.getLastMessage())); - } - dto.setParticipants(conversation.getParticipants().stream() - .map(p -> { - UserSummaryDto userDto = new UserSummaryDto(); - userDto.setId(p.getUser().getId()); - userDto.setUsername(p.getUser().getUsername()); - userDto.setAvatar(p.getUser().getAvatar()); - return userDto; - }) - .collect(Collectors.toList())); - - MessageParticipant self = conversation.getParticipants().stream() - .filter(p -> p.getUser().getId().equals(userId)) - .findFirst() - .orElseThrow(() -> new IllegalStateException("Participant not found in conversation")); - - LocalDateTime lastRead = self.getLastReadAt() == null ? LocalDateTime.of(1970, 1, 1, 0, 0) : self.getLastReadAt(); - // 只计算别人发送给当前用户的未读消息 - long unreadCount = messageRepository.countByConversationIdAndCreatedAtAfterAndSenderIdNot(conversation.getId(), lastRead, userId); - dto.setUnreadCount(unreadCount); - - return dto; - } - - @Transactional - public ConversationDetailDto getConversationDetails(Long conversationId, Long userId, Pageable pageable) { - markConversationAsRead(conversationId, userId); - - MessageConversation conversation = conversationRepository.findById(conversationId) - .orElseThrow(() -> new IllegalArgumentException("Conversation not found")); - - Page messagesPage = messageRepository.findByConversationId(conversationId, pageable); - Page messageDtoPage = messagesPage.map(this::toDto); - - List participants = conversation.getParticipants().stream() - .map(p -> { - UserSummaryDto userDto = new UserSummaryDto(); - userDto.setId(p.getUser().getId()); - userDto.setUsername(p.getUser().getUsername()); - userDto.setAvatar(p.getUser().getAvatar()); - return userDto; - }) - .collect(Collectors.toList()); - - ConversationDetailDto detailDto = new ConversationDetailDto(); - detailDto.setId(conversation.getId()); - detailDto.setName(conversation.getName()); - detailDto.setChannel(conversation.isChannel()); - detailDto.setAvatar(conversation.getAvatar()); - detailDto.setParticipants(participants); - detailDto.setMessages(messageDtoPage); - - return detailDto; - } - - @Transactional - public void markConversationAsRead(Long conversationId, Long userId) { - MessageParticipant participant = participantRepository.findByConversationIdAndUserId(conversationId, userId) - .orElseThrow(() -> new IllegalArgumentException("Participant not found")); - participant.setLastReadAt(LocalDateTime.now()); - participantRepository.save(participant); - } - - @Transactional(readOnly = true) - public long getUnreadMessageCount(Long userId) { - List participations = participantRepository.findByUserId(userId); - long totalUnreadCount = 0; - for (MessageParticipant p : participations) { - if (p.getConversation().isChannel()) continue; - LocalDateTime lastRead = p.getLastReadAt() == null ? LocalDateTime.of(1970, 1, 1, 0, 0) : p.getLastReadAt(); - // 只计算别人发送给当前用户的未读消息 - totalUnreadCount += messageRepository.countByConversationIdAndCreatedAtAfterAndSenderIdNot(p.getConversation().getId(), lastRead, userId); - } - return totalUnreadCount; - } - - @Transactional(readOnly = true) - public long getUnreadChannelCount(Long userId) { - List participations = participantRepository.findByUserId(userId); - long unreadChannelCount = 0; - for (MessageParticipant p : participations) { - if (!p.getConversation().isChannel()) continue; - LocalDateTime lastRead = p.getLastReadAt() == null ? LocalDateTime.of(1970, 1, 1, 0, 0) : p.getLastReadAt(); - long unread = messageRepository.countByConversationIdAndCreatedAtAfterAndSenderIdNot(p.getConversation().getId(), lastRead, userId); - if (unread > 0) { - unreadChannelCount++; - } - } - return unreadChannelCount; - } -} \ No newline at end of file + return unreadChannelCount; + } +} diff --git a/backend/src/main/java/com/openisle/service/NotificationProducer.java b/backend/src/main/java/com/openisle/service/NotificationProducer.java index f64f1a6f9..996b6489b 100644 --- a/backend/src/main/java/com/openisle/service/NotificationProducer.java +++ b/backend/src/main/java/com/openisle/service/NotificationProducer.java @@ -15,49 +15,45 @@ import org.springframework.stereotype.Service; @Slf4j public class NotificationProducer { - private final RabbitTemplate rabbitTemplate; - private final ShardingStrategy shardingStrategy; - - @Value("${rabbitmq.sharding.enabled}") - private boolean shardingEnabled; + private final RabbitTemplate rabbitTemplate; + private final ShardingStrategy shardingStrategy; - public void sendNotification(MessageNotificationPayload payload) { - String targetUsername = payload.getTargetUsername(); - - try { - if (shardingEnabled) { - // 使用分片策略发送消息 - sendShardedNotification(payload, targetUsername); - } else { - // 使用原始单队列方式发送(向后兼容) - sendLegacyNotification(payload); - } - } catch (Exception e) { - log.error("Failed to send message to RabbitMQ for user: {}", targetUsername, e); - throw e; - } + @Value("${rabbitmq.sharding.enabled}") + private boolean shardingEnabled; + + public void sendNotification(MessageNotificationPayload payload) { + String targetUsername = payload.getTargetUsername(); + + try { + if (shardingEnabled) { + // 使用分片策略发送消息 + sendShardedNotification(payload, targetUsername); + } else { + // 使用原始单队列方式发送(向后兼容) + sendLegacyNotification(payload); + } + } catch (Exception e) { + log.error("Failed to send message to RabbitMQ for user: {}", targetUsername, e); + throw e; } - - /** - * 使用分片策略发送消息 - */ - private void sendShardedNotification(MessageNotificationPayload payload, String targetUsername) { - ShardInfo shardInfo = shardingStrategy.getShardInfo(targetUsername); - rabbitTemplate.convertAndSend( - RabbitMQConfig.EXCHANGE_NAME, - shardInfo.getRoutingKey(), - payload - ); - } - - /** - * 使用原始单队列方式发送消息(向后兼容) - */ - private void sendLegacyNotification(MessageNotificationPayload payload) { - rabbitTemplate.convertAndSend( - RabbitMQConfig.EXCHANGE_NAME, - RabbitMQConfig.ROUTING_KEY, - payload - ); - } -} \ No newline at end of file + } + + /** + * 使用分片策略发送消息 + */ + private void sendShardedNotification(MessageNotificationPayload payload, String targetUsername) { + ShardInfo shardInfo = shardingStrategy.getShardInfo(targetUsername); + rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, shardInfo.getRoutingKey(), payload); + } + + /** + * 使用原始单队列方式发送消息(向后兼容) + */ + private void sendLegacyNotification(MessageNotificationPayload payload) { + rabbitTemplate.convertAndSend( + RabbitMQConfig.EXCHANGE_NAME, + RabbitMQConfig.ROUTING_KEY, + payload + ); + } +} diff --git a/backend/src/main/java/com/openisle/service/NotificationService.java b/backend/src/main/java/com/openisle/service/NotificationService.java index dede4c2f7..c46045fa9 100644 --- a/backend/src/main/java/com/openisle/service/NotificationService.java +++ b/backend/src/main/java/com/openisle/service/NotificationService.java @@ -1,288 +1,378 @@ package com.openisle.service; -import com.openisle.model.*; +import com.fasterxml.jackson.databind.ObjectMapper; import com.openisle.dto.NotificationPreferenceDto; +import com.openisle.model.*; import com.openisle.repository.NotificationRepository; import com.openisle.repository.ReactionRepository; import com.openisle.repository.UserRepository; -import lombok.RequiredArgsConstructor; import com.openisle.service.EmailSender; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Executor; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; -import java.util.Map; - -import java.util.regex.Pattern; -import java.util.regex.Matcher; -import java.util.Set; -import java.util.HashSet; -import java.util.EnumSet; - -import java.util.List; -import java.util.ArrayList; -import java.util.concurrent.Executor; /** Service for creating and retrieving notifications. */ @Service @RequiredArgsConstructor public class NotificationService { - private final NotificationRepository notificationRepository; - private final UserRepository userRepository; - private final EmailSender emailSender; - private final PushNotificationService pushNotificationService; - private final ReactionRepository reactionRepository; - private final Executor notificationExecutor; - @Value("${app.website-url}") - private String websiteUrl; + private final NotificationRepository notificationRepository; + private final UserRepository userRepository; + private final EmailSender emailSender; + private final PushNotificationService pushNotificationService; + private final ReactionRepository reactionRepository; + private final Executor notificationExecutor; - private static final Pattern MENTION_PATTERN = Pattern.compile("@\\[([^\\]]+)\\]"); + @Value("${app.website-url}") + private String websiteUrl; - private static final Set EMAIL_TYPES = EnumSet.of( - NotificationType.COMMENT_REPLY, - NotificationType.LOTTERY_WIN, - NotificationType.LOTTERY_DRAW - ); + private static final Pattern MENTION_PATTERN = Pattern.compile("@\\[([^\\]]+)\\]"); - private String buildPayload(String body, String url) { - // Ensure push notifications contain a link to the related resource so - // that verifications can assert its presence and users can navigate - // directly from the notification. - if (url == null || url.isBlank()) { - return body; - } - return body + ", 点击以查看: " + url; + private static final Set EMAIL_TYPES = EnumSet.of( + NotificationType.COMMENT_REPLY, + NotificationType.LOTTERY_WIN, + NotificationType.LOTTERY_DRAW + ); + + private String buildPayload(String body, String url) { + // Ensure push notifications contain a link to the related resource so + // that verifications can assert its presence and users can navigate + // directly from the notification. + if (url == null || url.isBlank()) { + return body; } + return body + ", 点击以查看: " + url; + } - public void sendCustomPush(User user, String body, String url) { - pushNotificationService.sendNotification(user, buildPayload(body, url)); + public void sendCustomPush(User user, String body, String url) { + pushNotificationService.sendNotification(user, buildPayload(body, url)); + } + + public Notification createNotification( + User user, + NotificationType type, + Post post, + Comment comment, + Boolean approved + ) { + return createNotification(user, type, post, comment, approved, null, null, null); + } + + public Notification createNotification( + User user, + NotificationType type, + Post post, + Comment comment, + Boolean approved, + User fromUser, + ReactionType reactionType, + String content + ) { + Notification n = new Notification(); + n.setUser(user); + n.setType(type); + n.setPost(post); + n.setComment(comment); + n.setApproved(approved); + n.setFromUser(fromUser); + n.setReactionType(reactionType); + n.setContent(content); + if (type == NotificationType.POST_VIEWED && fromUser != null && post != null) { + notificationRepository.deleteByTypeAndFromUserAndPost(type, fromUser, post); } + n = notificationRepository.save(n); - public Notification createNotification(User user, NotificationType type, Post post, Comment comment, Boolean approved) { - return createNotification(user, type, post, comment, approved, null, null, null); + // Runnable asyncTask = () -> { + if ( + type == NotificationType.COMMENT_REPLY && + user.getEmail() != null && + post != null && + comment != null && + !user.getDisabledEmailNotificationTypes().contains(NotificationType.COMMENT_REPLY) + ) { + String url = String.format( + "%s/posts/%d#comment-%d", + websiteUrl, + post.getId(), + comment.getId() + ); + emailSender.sendEmail(user.getEmail(), "有人回复了你", url); + sendCustomPush(user, "有人回复了你", url); + } else if (type == NotificationType.REACTION && comment != null) { + // long count = reactionRepository.countReceived(comment.getAuthor().getUsername()); + // if (count % 5 == 0) { + // String url = websiteUrl + "/messages"; + // sendCustomPush(comment.getAuthor(), "你有新的互动", url); + // if (comment.getAuthor().getEmail() != null) { + // emailSender.sendEmail(comment.getAuthor().getEmail(), "你有新的互动", "你有新的互动, 点击以查看: " + url); + // } + // } + } else if (type == NotificationType.REACTION && post != null) { + // long count = reactionRepository.countReceived(post.getAuthor().getUsername()); + // if (count % 5 == 0) { + // String url = websiteUrl + "/messages"; + // sendCustomPush(post.getAuthor(), "你有新的互动", url); + // if (post.getAuthor().getEmail() != null) { + // emailSender.sendEmail(post.getAuthor().getEmail(), "你有新的互动", "你有新的互动, 点击以查看: " + url); + // } + // } } + // }; - public Notification createNotification(User user, NotificationType type, Post post, Comment comment, Boolean approved, - User fromUser, ReactionType reactionType, String content) { - Notification n = new Notification(); - n.setUser(user); - n.setType(type); - n.setPost(post); - n.setComment(comment); - n.setApproved(approved); - n.setFromUser(fromUser); - n.setReactionType(reactionType); - n.setContent(content); - if (type == NotificationType.POST_VIEWED && fromUser != null && post != null) { - notificationRepository.deleteByTypeAndFromUserAndPost(type, fromUser, post); - } - n = notificationRepository.save(n); + // if (TransactionSynchronizationManager.isSynchronizationActive()) { + // TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + // @Override + // public void afterCommit() { + // notificationExecutor.execute(asyncTask); + // } + // }); + // } else { + // notificationExecutor.execute(asyncTask); + // } -// Runnable asyncTask = () -> { - if (type == NotificationType.COMMENT_REPLY && user.getEmail() != null && post != null && comment != null - && !user.getDisabledEmailNotificationTypes().contains(NotificationType.COMMENT_REPLY)) { - String url = String.format("%s/posts/%d#comment-%d", websiteUrl, post.getId(), comment.getId()); - emailSender.sendEmail(user.getEmail(), "有人回复了你", url); - sendCustomPush(user, "有人回复了你", url); - } else if (type == NotificationType.REACTION && comment != null) { -// long count = reactionRepository.countReceived(comment.getAuthor().getUsername()); -// if (count % 5 == 0) { -// String url = websiteUrl + "/messages"; -// sendCustomPush(comment.getAuthor(), "你有新的互动", url); -// if (comment.getAuthor().getEmail() != null) { -// emailSender.sendEmail(comment.getAuthor().getEmail(), "你有新的互动", "你有新的互动, 点击以查看: " + url); -// } -// } - } else if (type == NotificationType.REACTION && post != null) { -// long count = reactionRepository.countReceived(post.getAuthor().getUsername()); -// if (count % 5 == 0) { -// String url = websiteUrl + "/messages"; -// sendCustomPush(post.getAuthor(), "你有新的互动", url); -// if (post.getAuthor().getEmail() != null) { -// emailSender.sendEmail(post.getAuthor().getEmail(), "你有新的互动", "你有新的互动, 点击以查看: " + url); -// } -// } - } -// }; + return n; + } -// if (TransactionSynchronizationManager.isSynchronizationActive()) { -// TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { -// @Override -// public void afterCommit() { -// notificationExecutor.execute(asyncTask); -// } -// }); -// } else { -// notificationExecutor.execute(asyncTask); -// } - - return n; + public void deleteReactionNotification( + User fromUser, + Post post, + Comment comment, + ReactionType reactionType + ) { + if (post != null) { + notificationRepository.deleteByTypeAndFromUserAndPostAndReactionType( + NotificationType.REACTION, + fromUser, + post, + reactionType + ); + } else if (comment != null) { + notificationRepository.deleteByTypeAndFromUserAndCommentAndReactionType( + NotificationType.REACTION, + fromUser, + comment, + reactionType + ); } + } - public void deleteReactionNotification(User fromUser, Post post, Comment comment, ReactionType reactionType) { - if (post != null) { - notificationRepository.deleteByTypeAndFromUserAndPostAndReactionType(NotificationType.REACTION, fromUser, post, reactionType); - } else if (comment != null) { - notificationRepository.deleteByTypeAndFromUserAndCommentAndReactionType(NotificationType.REACTION, fromUser, comment, reactionType); - } + /** + * Create notifications for all admins when a user submits a register request. + * Old register request notifications from the same applicant are removed first. + */ + @org.springframework.transaction.annotation.Transactional + public void createRegisterRequestNotifications(User applicant, String reason) { + notificationRepository.deleteByTypeAndFromUser(NotificationType.REGISTER_REQUEST, applicant); + for (User admin : userRepository.findByRole(Role.ADMIN)) { + createNotification( + admin, + NotificationType.REGISTER_REQUEST, + null, + null, + null, + applicant, + null, + reason + ); } + } - /** - * Create notifications for all admins when a user submits a register request. - * Old register request notifications from the same applicant are removed first. - */ - @org.springframework.transaction.annotation.Transactional - public void createRegisterRequestNotifications(User applicant, String reason) { - notificationRepository.deleteByTypeAndFromUser(NotificationType.REGISTER_REQUEST, applicant); - for (User admin : userRepository.findByRole(Role.ADMIN)) { - createNotification(admin, NotificationType.REGISTER_REQUEST, null, null, - null, applicant, null, reason); - } + /** + * Create notifications for all admins when a user redeems an activity. + * Old redeem notifications from the same user are removed first. + */ + @org.springframework.transaction.annotation.Transactional + public void createActivityRedeemNotifications(User user, String content) { + notificationRepository.deleteByTypeAndFromUser(NotificationType.ACTIVITY_REDEEM, user); + for (User admin : userRepository.findByRole(Role.ADMIN)) { + createNotification( + admin, + NotificationType.ACTIVITY_REDEEM, + null, + null, + null, + user, + null, + content + ); } + } - /** - * Create notifications for all admins when a user redeems an activity. - * Old redeem notifications from the same user are removed first. - */ - @org.springframework.transaction.annotation.Transactional - public void createActivityRedeemNotifications(User user, String content) { - notificationRepository.deleteByTypeAndFromUser(NotificationType.ACTIVITY_REDEEM, user); - for (User admin : userRepository.findByRole(Role.ADMIN)) { - createNotification(admin, NotificationType.ACTIVITY_REDEEM, null, null, - null, user, null, content); - } + /** + * Create notifications for all admins when a user redeems a point good. + * Old redeem notifications from the same user are removed first. + */ + @org.springframework.transaction.annotation.Transactional + public void createPointRedeemNotifications(User user, String content) { + // notificationRepository.deleteByTypeAndFromUser(NotificationType.POINT_REDEEM, user); + for (User admin : userRepository.findByRole(Role.ADMIN)) { + createNotification( + admin, + NotificationType.POINT_REDEEM, + null, + null, + null, + user, + null, + content + ); } + } - /** - * Create notifications for all admins when a user redeems a point good. - * Old redeem notifications from the same user are removed first. - */ - @org.springframework.transaction.annotation.Transactional - public void createPointRedeemNotifications(User user, String content) { -// notificationRepository.deleteByTypeAndFromUser(NotificationType.POINT_REDEEM, user); - for (User admin : userRepository.findByRole(Role.ADMIN)) { - createNotification(admin, NotificationType.POINT_REDEEM, null, null, - null, user, null, content); - } + public List listPreferences(String username) { + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + Set disabled = user.getDisabledNotificationTypes(); + List prefs = new ArrayList<>(); + for (NotificationType nt : NotificationType.values()) { + NotificationPreferenceDto dto = new NotificationPreferenceDto(); + dto.setType(nt); + dto.setEnabled(!disabled.contains(nt)); + prefs.add(dto); } + return prefs; + } - public List listPreferences(String username) { - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - Set disabled = user.getDisabledNotificationTypes(); - List prefs = new ArrayList<>(); - for (NotificationType nt : NotificationType.values()) { - NotificationPreferenceDto dto = new NotificationPreferenceDto(); - dto.setType(nt); - dto.setEnabled(!disabled.contains(nt)); - prefs.add(dto); - } - return prefs; + public void updatePreference(String username, NotificationType type, boolean enabled) { + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + Set disabled = user.getDisabledNotificationTypes(); + if (enabled) { + disabled.remove(type); + } else { + disabled.add(type); } + userRepository.save(user); + } - public void updatePreference(String username, NotificationType type, boolean enabled) { - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - Set disabled = user.getDisabledNotificationTypes(); - if (enabled) { - disabled.remove(type); - } else { - disabled.add(type); - } - userRepository.save(user); + public List listEmailPreferences(String username) { + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + Set disabled = user.getDisabledEmailNotificationTypes(); + List prefs = new ArrayList<>(); + for (NotificationType nt : EMAIL_TYPES) { + NotificationPreferenceDto dto = new NotificationPreferenceDto(); + dto.setType(nt); + dto.setEnabled(!disabled.contains(nt)); + prefs.add(dto); } + return prefs; + } - public List listEmailPreferences(String username) { - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - Set disabled = user.getDisabledEmailNotificationTypes(); - List prefs = new ArrayList<>(); - for (NotificationType nt : EMAIL_TYPES) { - NotificationPreferenceDto dto = new NotificationPreferenceDto(); - dto.setType(nt); - dto.setEnabled(!disabled.contains(nt)); - prefs.add(dto); - } - return prefs; + public void updateEmailPreference(String username, NotificationType type, boolean enabled) { + if (!EMAIL_TYPES.contains(type)) { + return; } + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + Set disabled = user.getDisabledEmailNotificationTypes(); + if (enabled) { + disabled.remove(type); + } else { + disabled.add(type); + } + userRepository.save(user); + } - public void updateEmailPreference(String username, NotificationType type, boolean enabled) { - if (!EMAIL_TYPES.contains(type)) { - return; - } - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - Set disabled = user.getDisabledEmailNotificationTypes(); - if (enabled) { - disabled.remove(type); - } else { - disabled.add(type); - } - userRepository.save(user); + public List listNotifications(String username, Boolean read, int page, int size) { + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + Set disabled = user.getDisabledNotificationTypes(); + org.springframework.data.domain.Pageable pageable = + org.springframework.data.domain.PageRequest.of(page, size); + org.springframework.data.domain.Page result; + if (read == null) { + if (disabled.isEmpty()) { + result = notificationRepository.findByUserOrderByCreatedAtDesc(user, pageable); + } else { + result = notificationRepository.findByUserAndTypeNotInOrderByCreatedAtDesc( + user, + disabled, + pageable + ); + } + } else { + if (disabled.isEmpty()) { + result = notificationRepository.findByUserAndReadOrderByCreatedAtDesc(user, read, pageable); + } else { + result = notificationRepository.findByUserAndReadAndTypeNotInOrderByCreatedAtDesc( + user, + read, + disabled, + pageable + ); + } } + return result.getContent(); + } - public List listNotifications(String username, Boolean read, int page, int size) { - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - Set disabled = user.getDisabledNotificationTypes(); - org.springframework.data.domain.Pageable pageable = org.springframework.data.domain.PageRequest.of(page, size); - org.springframework.data.domain.Page result; - if (read == null) { - if (disabled.isEmpty()) { - result = notificationRepository.findByUserOrderByCreatedAtDesc(user, pageable); - } else { - result = notificationRepository.findByUserAndTypeNotInOrderByCreatedAtDesc(user, disabled, pageable); - } - } else { - if (disabled.isEmpty()) { - result = notificationRepository.findByUserAndReadOrderByCreatedAtDesc(user, read, pageable); - } else { - result = notificationRepository.findByUserAndReadAndTypeNotInOrderByCreatedAtDesc(user, read, disabled, pageable); - } - } - return result.getContent(); + public void markRead(String username, List ids) { + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + List notifs = notificationRepository.findAllById(ids); + for (Notification n : notifs) { + if (n.getUser().getId().equals(user.getId())) { + n.setRead(true); + } } + notificationRepository.saveAll(notifs); + } - public void markRead(String username, List ids) { - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - List notifs = notificationRepository.findAllById(ids); - for (Notification n : notifs) { - if (n.getUser().getId().equals(user.getId())) { - n.setRead(true); - } - } - notificationRepository.saveAll(notifs); + public long countUnread(String username) { + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + Set disabled = user.getDisabledNotificationTypes(); + if (disabled.isEmpty()) { + return notificationRepository.countByUserAndRead(user, false); } + return notificationRepository.countByUserAndReadAndTypeNotIn(user, false, disabled); + } - public long countUnread(String username) { - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - Set disabled = user.getDisabledNotificationTypes(); - if (disabled.isEmpty()) { - return notificationRepository.countByUserAndRead(user, false); - } - return notificationRepository.countByUserAndReadAndTypeNotIn(user, false, disabled); + public void notifyMentions(String content, User fromUser, Post post, Comment comment) { + if (content == null || fromUser == null) { + return; } - - public void notifyMentions(String content, User fromUser, Post post, Comment comment) { - if (content == null || fromUser == null) { - return; - } - Matcher matcher = MENTION_PATTERN.matcher(content); - Set names = new HashSet<>(); - while (matcher.find()) { - names.add(matcher.group(1)); - } - for (String name : names) { - userRepository.findByUsername(name).ifPresent(target -> { - if (!target.getId().equals(fromUser.getId())) { - createNotification(target, NotificationType.MENTION, post, comment, null, fromUser, null, null); - } - }); - } + Matcher matcher = MENTION_PATTERN.matcher(content); + Set names = new HashSet<>(); + while (matcher.find()) { + names.add(matcher.group(1)); } + for (String name : names) { + userRepository + .findByUsername(name) + .ifPresent(target -> { + if (!target.getId().equals(fromUser.getId())) { + createNotification( + target, + NotificationType.MENTION, + post, + comment, + null, + fromUser, + null, + null + ); + } + }); + } + } } diff --git a/backend/src/main/java/com/openisle/service/OpenAiService.java b/backend/src/main/java/com/openisle/service/OpenAiService.java index 8d3b8c42a..974118af4 100644 --- a/backend/src/main/java/com/openisle/service/OpenAiService.java +++ b/backend/src/main/java/com/openisle/service/OpenAiService.java @@ -1,5 +1,6 @@ package com.openisle.service; +import java.util.*; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; @@ -9,57 +10,56 @@ import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; -import java.util.*; - @Service public class OpenAiService { - @Value("${openai.api-key:}") - private String apiKey; + @Value("${openai.api-key:}") + private String apiKey; - @Value("${openai.model:gpt-4o}") - private String model; + @Value("${openai.model:gpt-4o}") + private String model; - private final RestTemplate restTemplate = new RestTemplate(); + private final RestTemplate restTemplate = new RestTemplate(); - public Optional formatMarkdown(String text) { - if (apiKey == null || apiKey.isBlank()) { - return Optional.empty(); - } - String url = "https://api.openai.com/v1/chat/completions"; - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.set("Authorization", "Bearer " + apiKey); - - Map body = new HashMap<>(); - body.put("model", model); - List> messages = new ArrayList<>(); - messages.add(Map.of("role", "system", "content", "请优化以下 Markdown 文本的格式,不改变其内容。")); - messages.add(Map.of("role", "user", "content", text)); - body.put("messages", messages); - - HttpEntity> entity = new HttpEntity<>(body, headers); - try { - ResponseEntity resp = restTemplate.exchange(url, HttpMethod.POST, entity, Map.class); - Map respBody = resp.getBody(); - if (respBody != null) { - Object choicesObj = respBody.get("choices"); - if (choicesObj instanceof List choices && !choices.isEmpty()) { - Object first = choices.get(0); - if (first instanceof Map firstMap) { - Object messageObj = firstMap.get("message"); - if (messageObj instanceof Map message) { - Object content = message.get("content"); - if (content instanceof String str) { - return Optional.of(str.trim()); - } - } - } - } - } - } catch (Exception ignored) { - } - return Optional.empty(); + public Optional formatMarkdown(String text) { + if (apiKey == null || apiKey.isBlank()) { + return Optional.empty(); } + String url = "https://api.openai.com/v1/chat/completions"; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Authorization", "Bearer " + apiKey); + + Map body = new HashMap<>(); + body.put("model", model); + List> messages = new ArrayList<>(); + messages.add( + Map.of("role", "system", "content", "请优化以下 Markdown 文本的格式,不改变其内容。") + ); + messages.add(Map.of("role", "user", "content", text)); + body.put("messages", messages); + + HttpEntity> entity = new HttpEntity<>(body, headers); + try { + ResponseEntity resp = restTemplate.exchange(url, HttpMethod.POST, entity, Map.class); + Map respBody = resp.getBody(); + if (respBody != null) { + Object choicesObj = respBody.get("choices"); + if (choicesObj instanceof List choices && !choices.isEmpty()) { + Object first = choices.get(0); + if (first instanceof Map firstMap) { + Object messageObj = firstMap.get("message"); + if (messageObj instanceof Map message) { + Object content = message.get("content"); + if (content instanceof String str) { + return Optional.of(str.trim()); + } + } + } + } + } + } catch (Exception ignored) {} + return Optional.empty(); + } } diff --git a/backend/src/main/java/com/openisle/service/PasswordValidator.java b/backend/src/main/java/com/openisle/service/PasswordValidator.java index c8979edae..09cc3ef5c 100644 --- a/backend/src/main/java/com/openisle/service/PasswordValidator.java +++ b/backend/src/main/java/com/openisle/service/PasswordValidator.java @@ -1,73 +1,74 @@ package com.openisle.service; -import com.openisle.model.PasswordStrength; import com.openisle.exception.FieldException; +import com.openisle.model.PasswordStrength; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @Service public class PasswordValidator { - private PasswordStrength strength; - public PasswordValidator(@Value("${app.password.strength:LOW}") PasswordStrength strength) { - this.strength = strength; - } + private PasswordStrength strength; - public PasswordStrength getStrength() { - return strength; - } + public PasswordValidator(@Value("${app.password.strength:LOW}") PasswordStrength strength) { + this.strength = strength; + } - public void setStrength(PasswordStrength strength) { - this.strength = strength; - } + public PasswordStrength getStrength() { + return strength; + } - public void validate(String password) { - if (password == null || password.isEmpty()) { - throw new FieldException("password", "Password cannot be empty"); - } - switch (strength) { - case MEDIUM: - checkMedium(password); - break; - case HIGH: - checkHigh(password); - break; - default: - checkLow(password); - break; - } - } + public void setStrength(PasswordStrength strength) { + this.strength = strength; + } - private void checkLow(String password) { - if (password.length() < 6) { - throw new FieldException("password", "Password must be at least 6 characters long"); - } + public void validate(String password) { + if (password == null || password.isEmpty()) { + throw new FieldException("password", "Password cannot be empty"); } + switch (strength) { + case MEDIUM: + checkMedium(password); + break; + case HIGH: + checkHigh(password); + break; + default: + checkLow(password); + break; + } + } - private void checkMedium(String password) { - if (password.length() < 8) { - throw new FieldException("password", "Password must be at least 8 characters long"); - } - if (!password.matches(".*[A-Za-z].*") || !password.matches(".*\\d.*")) { - throw new FieldException("password", "Password must contain letters and numbers"); - } + private void checkLow(String password) { + if (password.length() < 6) { + throw new FieldException("password", "Password must be at least 6 characters long"); } + } - private void checkHigh(String password) { - if (password.length() < 12) { - throw new FieldException("password", "Password must be at least 12 characters long"); - } - if (!password.matches(".*[A-Z].*")) { - throw new FieldException("password", "Password must contain uppercase letters"); - } - if (!password.matches(".*[a-z].*")) { - throw new FieldException("password", "Password must contain lowercase letters"); - } - if (!password.matches(".*\\d.*")) { - throw new FieldException("password", "Password must contain numbers"); - } - if (!password.matches(".*[^A-Za-z0-9].*")) { - throw new FieldException("password", "Password must contain special characters"); - } + private void checkMedium(String password) { + if (password.length() < 8) { + throw new FieldException("password", "Password must be at least 8 characters long"); } + if (!password.matches(".*[A-Za-z].*") || !password.matches(".*\\d.*")) { + throw new FieldException("password", "Password must contain letters and numbers"); + } + } + + private void checkHigh(String password) { + if (password.length() < 12) { + throw new FieldException("password", "Password must be at least 12 characters long"); + } + if (!password.matches(".*[A-Z].*")) { + throw new FieldException("password", "Password must contain uppercase letters"); + } + if (!password.matches(".*[a-z].*")) { + throw new FieldException("password", "Password must contain lowercase letters"); + } + if (!password.matches(".*\\d.*")) { + throw new FieldException("password", "Password must contain numbers"); + } + if (!password.matches(".*[^A-Za-z0-9].*")) { + throw new FieldException("password", "Password must contain special characters"); + } + } } diff --git a/backend/src/main/java/com/openisle/service/PointMallService.java b/backend/src/main/java/com/openisle/service/PointMallService.java index 0f3965b52..2c38035ed 100644 --- a/backend/src/main/java/com/openisle/service/PointMallService.java +++ b/backend/src/main/java/com/openisle/service/PointMallService.java @@ -9,40 +9,41 @@ import com.openisle.model.User; import com.openisle.repository.PointGoodRepository; import com.openisle.repository.PointHistoryRepository; import com.openisle.repository.UserRepository; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import java.util.List; - /** Service for point mall operations. */ @Service @RequiredArgsConstructor public class PointMallService { - private final PointGoodRepository pointGoodRepository; - private final UserRepository userRepository; - private final NotificationService notificationService; - private final PointHistoryRepository pointHistoryRepository; - public List listGoods() { - return pointGoodRepository.findAll(); - } + private final PointGoodRepository pointGoodRepository; + private final UserRepository userRepository; + private final NotificationService notificationService; + private final PointHistoryRepository pointHistoryRepository; - public int redeem(User user, Long goodId, String contact) { - PointGood good = pointGoodRepository.findById(goodId) - .orElseThrow(() -> new NotFoundException("Good not found")); - if (user.getPoint() < good.getCost()) { - throw new FieldException("point", "Insufficient points"); - } - user.setPoint(user.getPoint() - good.getCost()); - userRepository.save(user); - notificationService.createPointRedeemNotifications(user, good.getName() + ": " + contact); - PointHistory history = new PointHistory(); - history.setUser(user); - history.setType(PointHistoryType.REDEEM); - history.setAmount(-good.getCost()); - history.setBalance(user.getPoint()); - history.setCreatedAt(java.time.LocalDateTime.now()); - pointHistoryRepository.save(history); - return user.getPoint(); + public List listGoods() { + return pointGoodRepository.findAll(); + } + + public int redeem(User user, Long goodId, String contact) { + PointGood good = pointGoodRepository + .findById(goodId) + .orElseThrow(() -> new NotFoundException("Good not found")); + if (user.getPoint() < good.getCost()) { + throw new FieldException("point", "Insufficient points"); } + user.setPoint(user.getPoint() - good.getCost()); + userRepository.save(user); + notificationService.createPointRedeemNotifications(user, good.getName() + ": " + contact); + PointHistory history = new PointHistory(); + history.setUser(user); + history.setType(PointHistoryType.REDEEM); + history.setAmount(-good.getCost()); + history.setBalance(user.getPoint()); + history.setCreatedAt(java.time.LocalDateTime.now()); + pointHistoryRepository.save(history); + return user.getPoint(); + } } diff --git a/backend/src/main/java/com/openisle/service/PointService.java b/backend/src/main/java/com/openisle/service/PointService.java index f1b1db081..0a8349a53 100644 --- a/backend/src/main/java/com/openisle/service/PointService.java +++ b/backend/src/main/java/com/openisle/service/PointService.java @@ -1,253 +1,275 @@ package com.openisle.service; +import com.openisle.exception.FieldException; import com.openisle.model.*; import com.openisle.repository.*; -import com.openisle.exception.FieldException; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - import java.time.LocalDate; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor public class PointService { - private final UserRepository userRepository; - private final PointLogRepository pointLogRepository; - private final PostRepository postRepository; - private final CommentRepository commentRepository; - private final PointHistoryRepository pointHistoryRepository; + private final UserRepository userRepository; + private final PointLogRepository pointLogRepository; + private final PostRepository postRepository; + private final CommentRepository commentRepository; + private final PointHistoryRepository pointHistoryRepository; - public int awardForPost(String userName, Long postId) { - User user = userRepository.findByUsername(userName).orElseThrow(); - PointLog log = getTodayLog(user); - if (log.getPostCount() > 1) return 0; - log.setPostCount(log.getPostCount() + 1); + public int awardForPost(String userName, Long postId) { + User user = userRepository.findByUsername(userName).orElseThrow(); + PointLog log = getTodayLog(user); + if (log.getPostCount() > 1) return 0; + log.setPostCount(log.getPostCount() + 1); + pointLogRepository.save(log); + Post post = postRepository.findById(postId).orElseThrow(); + return addPoint(user, 30, PointHistoryType.POST, post, null, null); + } + + public int awardForInvite(String userName, String inviteeName) { + User user = userRepository.findByUsername(userName).orElseThrow(); + User invitee = userRepository.findByUsername(inviteeName).orElseThrow(); + return addPoint(user, 500, PointHistoryType.INVITE, null, null, invitee); + } + + public int awardForFeatured(String userName, Long postId) { + User user = userRepository.findByUsername(userName).orElseThrow(); + Post post = postRepository.findById(postId).orElseThrow(); + return addPoint(user, 500, PointHistoryType.FEATURE, post, null, null); + } + + public void processLotteryJoin(User participant, LotteryPost post) { + int cost = post.getPointCost(); + if (cost > 0) { + if (participant.getPoint() < cost) { + throw new FieldException("point", "积分不足"); + } + addPoint(participant, -cost, PointHistoryType.LOTTERY_JOIN, post, null, post.getAuthor()); + addPoint(post.getAuthor(), cost, PointHistoryType.LOTTERY_REWARD, post, null, participant); + } + } + + private PointLog getTodayLog(User user) { + LocalDate today = LocalDate.now(); + return pointLogRepository + .findByUserAndLogDate(user, today) + .orElseGet(() -> { + PointLog log = new PointLog(); + log.setUser(user); + log.setLogDate(today); + log.setPostCount(0); + log.setCommentCount(0); + log.setReactionCount(0); + return pointLogRepository.save(log); + }); + } + + private int addPoint( + User user, + int amount, + PointHistoryType type, + Post post, + Comment comment, + User fromUser + ) { + if (pointHistoryRepository.countByUser(user) == 0) { + recordHistory(user, PointHistoryType.SYSTEM_ONLINE, 0, null, null, null); + } + user.setPoint(user.getPoint() + amount); + userRepository.save(user); + recordHistory(user, type, amount, post, comment, fromUser); + return amount; + } + + private void recordHistory( + User user, + PointHistoryType type, + int amount, + Post post, + Comment comment, + User fromUser + ) { + PointHistory history = new PointHistory(); + history.setUser(user); + history.setType(type); + history.setAmount(amount); + history.setBalance(user.getPoint()); + history.setPost(post); + history.setComment(comment); + history.setFromUser(fromUser); + history.setCreatedAt(java.time.LocalDateTime.now()); + pointHistoryRepository.save(history); + } + + // 同时为评论者和发帖人增加积分,返回值为评论者增加的积分数 + // 注意需要考虑发帖和回复是同一人的场景 + public int awardForComment(String commenterName, Long postId, Long commentId) { + // 标记评论者是否已达到积分奖励上限 + boolean isTheRewardCapped = false; + + // 根据帖子id找到发帖人 + Post post = postRepository.findById(postId).orElseThrow(); + User poster = post.getAuthor(); + Comment comment = commentRepository.findById(commentId).orElseThrow(); + + // 获取评论者的加分日志 + User commenter = userRepository.findByUsername(commenterName).orElseThrow(); + PointLog log = getTodayLog(commenter); + if (log.getCommentCount() > 3) { + isTheRewardCapped = true; + } + + // 如果发帖人与评论者是同一个,则只计算单次加分 + if (poster.getId().equals(commenter.getId())) { + if (isTheRewardCapped) { + return 0; + } else { + log.setCommentCount(log.getCommentCount() + 1); pointLogRepository.save(log); - Post post = postRepository.findById(postId).orElseThrow(); - return addPoint(user, 30, PointHistoryType.POST, post, null, null); + return addPoint(commenter, 10, PointHistoryType.COMMENT, post, comment, null); + } + } else { + addPoint(poster, 10, PointHistoryType.COMMENT, post, comment, commenter); + // 如果发帖人与评论者不是同一个,则根据是否达到积分上限来判断评论者加分情况 + if (isTheRewardCapped) { + return 0; + } else { + return addPoint(commenter, 10, PointHistoryType.COMMENT, post, comment, null); + } + } + } + + // 需要考虑点赞者和发帖人是同一个的情况 + public int awardForReactionOfPost(String reactionerName, Long postId) { + // 根据帖子id找到发帖人 + User poster = postRepository.findById(postId).orElseThrow().getAuthor(); + + // 获取点赞者信息 + User reactioner = userRepository.findByUsername(reactionerName).orElseThrow(); + + // 如果发帖人与点赞者是同一个,则不加分 + if (poster.getId().equals(reactioner.getId())) { + return 0; } - public int awardForInvite(String userName, String inviteeName) { - User user = userRepository.findByUsername(userName).orElseThrow(); - User invitee = userRepository.findByUsername(inviteeName).orElseThrow(); - return addPoint(user, 500, PointHistoryType.INVITE, null, null, invitee); + // 如果不是同一个,则为发帖人加分 + Post post = postRepository.findById(postId).orElseThrow(); + return addPoint(poster, 10, PointHistoryType.POST_LIKED, post, null, reactioner); + } + + public int deductForReactionOfPost(String reactionerName, Long postId) { + User poster = postRepository.findById(postId).orElseThrow().getAuthor(); + User reactioner = userRepository.findByUsername(reactionerName).orElseThrow(); + if (poster.getId().equals(reactioner.getId())) { + return 0; + } + Post post = postRepository.findById(postId).orElseThrow(); + return addPoint(poster, -10, PointHistoryType.POST_LIKE_CANCELLED, post, null, reactioner); + } + + // 考虑点赞者和评论者是同一个的情况 + public int awardForReactionOfComment(String reactionerName, Long commentId) { + // 根据帖子id找到评论者 + User commenter = commentRepository.findById(commentId).orElseThrow().getAuthor(); + + // 获取点赞者信息 + User reactioner = userRepository.findByUsername(reactionerName).orElseThrow(); + + // 如果评论者与点赞者是同一个,则不加分 + if (commenter.getId().equals(reactioner.getId())) { + return 0; } - public int awardForFeatured(String userName, Long postId) { - User user = userRepository.findByUsername(userName).orElseThrow(); - Post post = postRepository.findById(postId).orElseThrow(); - return addPoint(user, 500, PointHistoryType.FEATURE, post, null, null); + // 如果不是同一个,则为发帖人加分 + Comment comment = commentRepository.findById(commentId).orElseThrow(); + Post post = comment.getPost(); + return addPoint(commenter, 10, PointHistoryType.COMMENT_LIKED, post, comment, reactioner); + } + + public int deductForReactionOfComment(String reactionerName, Long commentId) { + User commenter = commentRepository.findById(commentId).orElseThrow().getAuthor(); + User reactioner = userRepository.findByUsername(reactionerName).orElseThrow(); + if (commenter.getId().equals(reactioner.getId())) { + return 0; + } + Comment comment = commentRepository.findById(commentId).orElseThrow(); + Post post = comment.getPost(); + return addPoint( + commenter, + -10, + PointHistoryType.COMMENT_LIKE_CANCELLED, + post, + comment, + reactioner + ); + } + + public java.util.List listHistory(String userName) { + User user = userRepository.findByUsername(userName).orElseThrow(); + if (pointHistoryRepository.countByUser(user) == 0) { + recordHistory(user, PointHistoryType.SYSTEM_ONLINE, 0, null, null, null); + } + return pointHistoryRepository.findByUserOrderByIdDesc(user); + } + + public List> trend(String userName, int days) { + if (days < 1) days = 1; + User user = userRepository.findByUsername(userName).orElseThrow(); + LocalDate end = LocalDate.now(); + LocalDate start = end.minusDays(days - 1L); + var histories = pointHistoryRepository.findByUserAndCreatedAtAfterOrderByCreatedAtDesc( + user, + start.atStartOfDay() + ); + int idx = 0; + int balance = user.getPoint(); + List> result = new ArrayList<>(); + for (LocalDate day = end; !day.isBefore(start); day = day.minusDays(1)) { + result.add(Map.of("date", day.toString(), "value", balance)); + while ( + idx < histories.size() && histories.get(idx).getCreatedAt().toLocalDate().isEqual(day) + ) { + balance -= histories.get(idx).getAmount(); + idx++; + } + } + Collections.reverse(result); + return result; + } + + /** + * 重新计算用户的积分总数 + * 通过累加所有积分历史记录来重新计算用户的当前积分 + */ + public int recalculateUserPoints(User user) { + // 获取用户所有的积分历史记录(由于@Where注解,已删除的记录会被自动过滤) + List histories = pointHistoryRepository.findByUserOrderByIdAsc(user); + + int totalPoints = 0; + for (PointHistory history : histories) { + totalPoints += history.getAmount(); + // 重新计算每条历史记录的余额 + history.setBalance(totalPoints); } - public void processLotteryJoin(User participant, LotteryPost post) { - int cost = post.getPointCost(); - if (cost > 0) { - if (participant.getPoint() < cost) { - throw new FieldException("point", "积分不足"); - } - addPoint(participant, -cost, PointHistoryType.LOTTERY_JOIN, post, null, post.getAuthor()); - addPoint(post.getAuthor(), cost, PointHistoryType.LOTTERY_REWARD, post, null, participant); - } - } + // 批量更新历史记录及用户积分 + pointHistoryRepository.saveAll(histories); + user.setPoint(totalPoints); + userRepository.save(user); - private PointLog getTodayLog(User user) { - LocalDate today = LocalDate.now(); - return pointLogRepository.findByUserAndLogDate(user, today) - .orElseGet(() -> { - PointLog log = new PointLog(); - log.setUser(user); - log.setLogDate(today); - log.setPostCount(0); - log.setCommentCount(0); - log.setReactionCount(0); - return pointLogRepository.save(log); - }); - } - - private int addPoint(User user, int amount, PointHistoryType type, - Post post, Comment comment, User fromUser) { - if (pointHistoryRepository.countByUser(user) == 0) { - recordHistory(user, PointHistoryType.SYSTEM_ONLINE, 0, null, null, null); - } - user.setPoint(user.getPoint() + amount); - userRepository.save(user); - recordHistory(user, type, amount, post, comment, fromUser); - return amount; - } - - private void recordHistory(User user, PointHistoryType type, int amount, - Post post, Comment comment, User fromUser) { - PointHistory history = new PointHistory(); - history.setUser(user); - history.setType(type); - history.setAmount(amount); - history.setBalance(user.getPoint()); - history.setPost(post); - history.setComment(comment); - history.setFromUser(fromUser); - history.setCreatedAt(java.time.LocalDateTime.now()); - pointHistoryRepository.save(history); - } - - // 同时为评论者和发帖人增加积分,返回值为评论者增加的积分数 - // 注意需要考虑发帖和回复是同一人的场景 - public int awardForComment(String commenterName, Long postId, Long commentId) { - // 标记评论者是否已达到积分奖励上限 - boolean isTheRewardCapped = false; - - // 根据帖子id找到发帖人 - Post post = postRepository.findById(postId).orElseThrow(); - User poster = post.getAuthor(); - Comment comment = commentRepository.findById(commentId).orElseThrow(); - - // 获取评论者的加分日志 - User commenter = userRepository.findByUsername(commenterName).orElseThrow(); - PointLog log = getTodayLog(commenter); - if (log.getCommentCount() > 3) { - isTheRewardCapped = true; - } - - // 如果发帖人与评论者是同一个,则只计算单次加分 - if (poster.getId().equals(commenter.getId())) { - if (isTheRewardCapped) { - return 0; - } else { - log.setCommentCount(log.getCommentCount() + 1); - pointLogRepository.save(log); - return addPoint(commenter, 10, PointHistoryType.COMMENT, post, comment, null); - } - } else { - addPoint(poster, 10, PointHistoryType.COMMENT, post, comment, commenter); - // 如果发帖人与评论者不是同一个,则根据是否达到积分上限来判断评论者加分情况 - if (isTheRewardCapped) { - return 0; - } else { - return addPoint(commenter, 10, PointHistoryType.COMMENT, post, comment, null); - } - } - } - - // 需要考虑点赞者和发帖人是同一个的情况 - public int awardForReactionOfPost(String reactionerName, Long postId) { - // 根据帖子id找到发帖人 - User poster = postRepository.findById(postId).orElseThrow().getAuthor(); - - // 获取点赞者信息 - User reactioner = userRepository.findByUsername(reactionerName).orElseThrow(); - - // 如果发帖人与点赞者是同一个,则不加分 - if (poster.getId().equals(reactioner.getId())) { - return 0; - } - - // 如果不是同一个,则为发帖人加分 - Post post = postRepository.findById(postId).orElseThrow(); - return addPoint(poster, 10, PointHistoryType.POST_LIKED, post, null, reactioner); - } - - public int deductForReactionOfPost(String reactionerName, Long postId) { - User poster = postRepository.findById(postId).orElseThrow().getAuthor(); - User reactioner = userRepository.findByUsername(reactionerName).orElseThrow(); - if (poster.getId().equals(reactioner.getId())) { - return 0; - } - Post post = postRepository.findById(postId).orElseThrow(); - return addPoint(poster, -10, PointHistoryType.POST_LIKE_CANCELLED, post, null, reactioner); - } - - // 考虑点赞者和评论者是同一个的情况 - public int awardForReactionOfComment(String reactionerName, Long commentId) { - // 根据帖子id找到评论者 - User commenter = commentRepository.findById(commentId).orElseThrow().getAuthor(); - - // 获取点赞者信息 - User reactioner = userRepository.findByUsername(reactionerName).orElseThrow(); - - // 如果评论者与点赞者是同一个,则不加分 - if (commenter.getId().equals(reactioner.getId())) { - return 0; - } - - // 如果不是同一个,则为发帖人加分 - Comment comment = commentRepository.findById(commentId).orElseThrow(); - Post post = comment.getPost(); - return addPoint(commenter, 10, PointHistoryType.COMMENT_LIKED, post, comment, reactioner); - } - - public int deductForReactionOfComment(String reactionerName, Long commentId) { - User commenter = commentRepository.findById(commentId).orElseThrow().getAuthor(); - User reactioner = userRepository.findByUsername(reactionerName).orElseThrow(); - if (commenter.getId().equals(reactioner.getId())) { - return 0; - } - Comment comment = commentRepository.findById(commentId).orElseThrow(); - Post post = comment.getPost(); - return addPoint(commenter, -10, PointHistoryType.COMMENT_LIKE_CANCELLED, post, comment, reactioner); - } - - public java.util.List listHistory(String userName) { - User user = userRepository.findByUsername(userName).orElseThrow(); - if (pointHistoryRepository.countByUser(user) == 0) { - recordHistory(user, PointHistoryType.SYSTEM_ONLINE, 0, null, null, null); - } - return pointHistoryRepository.findByUserOrderByIdDesc(user); - } - - public List> trend(String userName, int days) { - if (days < 1) days = 1; - User user = userRepository.findByUsername(userName).orElseThrow(); - LocalDate end = LocalDate.now(); - LocalDate start = end.minusDays(days - 1L); - var histories = pointHistoryRepository.findByUserAndCreatedAtAfterOrderByCreatedAtDesc( - user, start.atStartOfDay()); - int idx = 0; - int balance = user.getPoint(); - List> result = new ArrayList<>(); - for (LocalDate day = end; !day.isBefore(start); day = day.minusDays(1)) { - result.add(Map.of("date", day.toString(), "value", balance)); - while (idx < histories.size() && histories.get(idx).getCreatedAt().toLocalDate().isEqual(day)) { - balance -= histories.get(idx).getAmount(); - idx++; - } - } - Collections.reverse(result); - return result; - } - - /** - * 重新计算用户的积分总数 - * 通过累加所有积分历史记录来重新计算用户的当前积分 - */ - public int recalculateUserPoints(User user) { - // 获取用户所有的积分历史记录(由于@Where注解,已删除的记录会被自动过滤) - List histories = pointHistoryRepository.findByUserOrderByIdAsc(user); - - int totalPoints = 0; - for (PointHistory history : histories) { - totalPoints += history.getAmount(); - // 重新计算每条历史记录的余额 - history.setBalance(totalPoints); - } - - // 批量更新历史记录及用户积分 - pointHistoryRepository.saveAll(histories); - user.setPoint(totalPoints); - userRepository.save(user); - - return totalPoints; - } - - /** - * 重新计算用户的积分总数(通过用户名) - */ - public int recalculateUserPoints(String userName) { - User user = userRepository.findByUsername(userName).orElseThrow(); - return recalculateUserPoints(user); - } + return totalPoints; + } + /** + * 重新计算用户的积分总数(通过用户名) + */ + public int recalculateUserPoints(String userName) { + User user = userRepository.findByUsername(userName).orElseThrow(); + return recalculateUserPoints(user); + } } diff --git a/backend/src/main/java/com/openisle/service/PostChangeLogService.java b/backend/src/main/java/com/openisle/service/PostChangeLogService.java index ec15508d1..df78a266d 100644 --- a/backend/src/main/java/com/openisle/service/PostChangeLogService.java +++ b/backend/src/main/java/com/openisle/service/PostChangeLogService.java @@ -4,118 +4,125 @@ import com.openisle.model.*; import com.openisle.repository.PostChangeLogRepository; import com.openisle.repository.PostRepository; import com.openisle.repository.UserRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - import java.util.List; import java.util.Set; import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor public class PostChangeLogService { - private final PostChangeLogRepository logRepository; - private final PostRepository postRepository; - private final UserRepository userRepository; - private User getSystemUser() { - return userRepository.findByUsername("system") - .orElseThrow(() -> new IllegalStateException("System user not found")); - } + private final PostChangeLogRepository logRepository; + private final PostRepository postRepository; + private final UserRepository userRepository; - public void recordContentChange(Post post, User user, String oldContent, String newContent) { - PostContentChangeLog log = new PostContentChangeLog(); - log.setPost(post); - log.setUser(user); - log.setType(PostChangeType.CONTENT); - log.setOldContent(oldContent); - log.setNewContent(newContent); - logRepository.save(log); - } + private User getSystemUser() { + return userRepository + .findByUsername("system") + .orElseThrow(() -> new IllegalStateException("System user not found")); + } - public void recordTitleChange(Post post, User user, String oldTitle, String newTitle) { - PostTitleChangeLog log = new PostTitleChangeLog(); - log.setPost(post); - log.setUser(user); - log.setType(PostChangeType.TITLE); - log.setOldTitle(oldTitle); - log.setNewTitle(newTitle); - logRepository.save(log); - } + public void recordContentChange(Post post, User user, String oldContent, String newContent) { + PostContentChangeLog log = new PostContentChangeLog(); + log.setPost(post); + log.setUser(user); + log.setType(PostChangeType.CONTENT); + log.setOldContent(oldContent); + log.setNewContent(newContent); + logRepository.save(log); + } - public void recordCategoryChange(Post post, User user, String oldCategory, String newCategory) { - PostCategoryChangeLog log = new PostCategoryChangeLog(); - log.setPost(post); - log.setUser(user); - log.setType(PostChangeType.CATEGORY); - log.setOldCategory(oldCategory); - log.setNewCategory(newCategory); - logRepository.save(log); - } + public void recordTitleChange(Post post, User user, String oldTitle, String newTitle) { + PostTitleChangeLog log = new PostTitleChangeLog(); + log.setPost(post); + log.setUser(user); + log.setType(PostChangeType.TITLE); + log.setOldTitle(oldTitle); + log.setNewTitle(newTitle); + logRepository.save(log); + } - public void recordTagChange(Post post, User user, Set oldTags, Set newTags) { - PostTagChangeLog log = new PostTagChangeLog(); - log.setPost(post); - log.setUser(user); - log.setType(PostChangeType.TAG); - log.setOldTags(oldTags.stream().map(Tag::getName).collect(Collectors.joining(","))); - log.setNewTags(newTags.stream().map(Tag::getName).collect(Collectors.joining(","))); - logRepository.save(log); - } + public void recordCategoryChange(Post post, User user, String oldCategory, String newCategory) { + PostCategoryChangeLog log = new PostCategoryChangeLog(); + log.setPost(post); + log.setUser(user); + log.setType(PostChangeType.CATEGORY); + log.setOldCategory(oldCategory); + log.setNewCategory(newCategory); + logRepository.save(log); + } - public void recordClosedChange(Post post, User user, boolean oldClosed, boolean newClosed) { - PostClosedChangeLog log = new PostClosedChangeLog(); - log.setPost(post); - log.setUser(user); - log.setType(PostChangeType.CLOSED); - log.setOldClosed(oldClosed); - log.setNewClosed(newClosed); - logRepository.save(log); - } + public void recordTagChange(Post post, User user, Set oldTags, Set newTags) { + PostTagChangeLog log = new PostTagChangeLog(); + log.setPost(post); + log.setUser(user); + log.setType(PostChangeType.TAG); + log.setOldTags(oldTags.stream().map(Tag::getName).collect(Collectors.joining(","))); + log.setNewTags(newTags.stream().map(Tag::getName).collect(Collectors.joining(","))); + logRepository.save(log); + } - public void recordPinnedChange(Post post, User user, java.time.LocalDateTime oldPinnedAt, java.time.LocalDateTime newPinnedAt) { - PostPinnedChangeLog log = new PostPinnedChangeLog(); - log.setPost(post); - log.setUser(user); - log.setType(PostChangeType.PINNED); - log.setOldPinnedAt(oldPinnedAt); - log.setNewPinnedAt(newPinnedAt); - logRepository.save(log); - } + public void recordClosedChange(Post post, User user, boolean oldClosed, boolean newClosed) { + PostClosedChangeLog log = new PostClosedChangeLog(); + log.setPost(post); + log.setUser(user); + log.setType(PostChangeType.CLOSED); + log.setOldClosed(oldClosed); + log.setNewClosed(newClosed); + logRepository.save(log); + } - public void recordFeaturedChange(Post post, User user, boolean oldFeatured, boolean newFeatured) { - PostFeaturedChangeLog log = new PostFeaturedChangeLog(); - log.setPost(post); - log.setUser(user); - log.setType(PostChangeType.FEATURED); - log.setOldFeatured(oldFeatured); - log.setNewFeatured(newFeatured); - logRepository.save(log); - } + public void recordPinnedChange( + Post post, + User user, + java.time.LocalDateTime oldPinnedAt, + java.time.LocalDateTime newPinnedAt + ) { + PostPinnedChangeLog log = new PostPinnedChangeLog(); + log.setPost(post); + log.setUser(user); + log.setType(PostChangeType.PINNED); + log.setOldPinnedAt(oldPinnedAt); + log.setNewPinnedAt(newPinnedAt); + logRepository.save(log); + } - public void recordVoteResult(Post post) { - PostVoteResultChangeLog log = new PostVoteResultChangeLog(); - log.setPost(post); - log.setUser(getSystemUser()); - log.setType(PostChangeType.VOTE_RESULT); - logRepository.save(log); - } + public void recordFeaturedChange(Post post, User user, boolean oldFeatured, boolean newFeatured) { + PostFeaturedChangeLog log = new PostFeaturedChangeLog(); + log.setPost(post); + log.setUser(user); + log.setType(PostChangeType.FEATURED); + log.setOldFeatured(oldFeatured); + log.setNewFeatured(newFeatured); + logRepository.save(log); + } - public void recordLotteryResult(Post post) { - PostLotteryResultChangeLog log = new PostLotteryResultChangeLog(); - log.setPost(post); - log.setUser(getSystemUser()); - log.setType(PostChangeType.LOTTERY_RESULT); - logRepository.save(log); - } + public void recordVoteResult(Post post) { + PostVoteResultChangeLog log = new PostVoteResultChangeLog(); + log.setPost(post); + log.setUser(getSystemUser()); + log.setType(PostChangeType.VOTE_RESULT); + logRepository.save(log); + } - public void deleteLogsForPost(Post post) { - logRepository.deleteByPost(post); - } + public void recordLotteryResult(Post post) { + PostLotteryResultChangeLog log = new PostLotteryResultChangeLog(); + log.setPost(post); + log.setUser(getSystemUser()); + log.setType(PostChangeType.LOTTERY_RESULT); + logRepository.save(log); + } - public List listLogs(Long postId) { - Post post = postRepository.findById(postId) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); - return logRepository.findByPostOrderByCreatedAtAsc(post); - } + public void deleteLogsForPost(Post post) { + logRepository.deleteByPost(post); + } + + public List listLogs(Long postId) { + Post post = postRepository + .findById(postId) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + return logRepository.findByPostOrderByCreatedAtAsc(post); + } } diff --git a/backend/src/main/java/com/openisle/service/PostReadService.java b/backend/src/main/java/com/openisle/service/PostReadService.java index 35fcddf69..f827311f9 100644 --- a/backend/src/main/java/com/openisle/service/PostReadService.java +++ b/backend/src/main/java/com/openisle/service/PostReadService.java @@ -6,44 +6,52 @@ import com.openisle.model.User; import com.openisle.repository.PostReadRepository; import com.openisle.repository.PostRepository; import com.openisle.repository.UserRepository; +import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import java.time.LocalDateTime; - @Service @RequiredArgsConstructor public class PostReadService { - private final PostReadRepository postReadRepository; - private final UserRepository userRepository; - private final PostRepository postRepository; - public void recordRead(String username, Long postId) { - if (username == null) return; - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - Post post = postRepository.findById(postId) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); - postReadRepository.findByUserAndPost(user, post).ifPresentOrElse(pr -> { - pr.setLastReadAt(LocalDateTime.now()); - postReadRepository.save(pr); - }, () -> { - PostRead pr = new PostRead(); - pr.setUser(user); - pr.setPost(post); - pr.setLastReadAt(LocalDateTime.now()); - postReadRepository.save(pr); - }); - } + private final PostReadRepository postReadRepository; + private final UserRepository userRepository; + private final PostRepository postRepository; - public long countReads(String username) { - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - return postReadRepository.countByUser(user); - } + public void recordRead(String username, Long postId) { + if (username == null) return; + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + Post post = postRepository + .findById(postId) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + postReadRepository + .findByUserAndPost(user, post) + .ifPresentOrElse( + pr -> { + pr.setLastReadAt(LocalDateTime.now()); + postReadRepository.save(pr); + }, + () -> { + PostRead pr = new PostRead(); + pr.setUser(user); + pr.setPost(post); + pr.setLastReadAt(LocalDateTime.now()); + postReadRepository.save(pr); + } + ); + } - @org.springframework.transaction.annotation.Transactional - public void deleteByPost(Post post) { - postReadRepository.deleteByPost(post); - } + public long countReads(String username) { + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + return postReadRepository.countByUser(user); + } + + @org.springframework.transaction.annotation.Transactional + public void deleteByPost(Post post) { + postReadRepository.deleteByPost(post); + } } diff --git a/backend/src/main/java/com/openisle/service/PostService.java b/backend/src/main/java/com/openisle/service/PostService.java index c332b74b4..029c82882 100644 --- a/backend/src/main/java/com/openisle/service/PostService.java +++ b/backend/src/main/java/com/openisle/service/PostService.java @@ -1,976 +1,1201 @@ package com.openisle.service; import com.openisle.config.CachingConfig; +import com.openisle.exception.RateLimitException; import com.openisle.mapper.PostMapper; import com.openisle.model.*; -import com.openisle.repository.PostRepository; -import com.openisle.repository.LotteryPostRepository; -import com.openisle.repository.PollPostRepository; -import com.openisle.repository.UserRepository; import com.openisle.repository.CategoryRepository; -import com.openisle.repository.TagRepository; import com.openisle.repository.CommentRepository; -import com.openisle.repository.ReactionRepository; -import com.openisle.repository.PostSubscriptionRepository; +import com.openisle.repository.LotteryPostRepository; import com.openisle.repository.NotificationRepository; -import com.openisle.repository.PollVoteRepository; import com.openisle.repository.PointHistoryRepository; -import com.openisle.exception.RateLimitException; -import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.StringUtils; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.cache.annotation.CacheEvict; -import org.springframework.cache.annotation.Cacheable; -import org.springframework.context.ApplicationContext; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.stereotype.Service; -import org.springframework.scheduling.TaskScheduler; +import com.openisle.repository.PollPostRepository; +import com.openisle.repository.PollVoteRepository; +import com.openisle.repository.PostRepository; +import com.openisle.repository.PostSubscriptionRepository; +import com.openisle.repository.ReactionRepository; +import com.openisle.repository.TagRepository; +import com.openisle.repository.UserRepository; import com.openisle.service.EmailSender; - import java.time.Duration; +import java.time.LocalDateTime; import java.time.ZoneId; import java.time.ZoneOffset; import java.util.*; - -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.CollectionUtils; - -import java.time.LocalDateTime; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ScheduledFuture; import java.util.stream.Collectors; - +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.context.ApplicationContext; import org.springframework.context.event.EventListener; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; @Slf4j @Service public class PostService { - private final PostRepository postRepository; - private final UserRepository userRepository; - private final CategoryRepository categoryRepository; - private final TagRepository tagRepository; - private final LotteryPostRepository lotteryPostRepository; - private final PollPostRepository pollPostRepository; - private final PollVoteRepository pollVoteRepository; - private PublishMode publishMode; - private final NotificationService notificationService; - private final SubscriptionService subscriptionService; - private final CommentService commentService; - private final CommentRepository commentRepository; - private final ReactionRepository reactionRepository; - private final PostSubscriptionRepository postSubscriptionRepository; - private final NotificationRepository notificationRepository; - private final PostReadService postReadService; - private final ImageUploader imageUploader; - private final TaskScheduler taskScheduler; - private final EmailSender emailSender; - private final ApplicationContext applicationContext; - private final PointService pointService; - private final PostChangeLogService postChangeLogService; - private final PointHistoryRepository pointHistoryRepository; - private final ConcurrentMap> scheduledFinalizations = new ConcurrentHashMap<>(); - @Value("${app.website-url:https://www.open-isle.com}") - private String websiteUrl; - private final RedisTemplate redisTemplate; + private final PostRepository postRepository; + private final UserRepository userRepository; + private final CategoryRepository categoryRepository; + private final TagRepository tagRepository; + private final LotteryPostRepository lotteryPostRepository; + private final PollPostRepository pollPostRepository; + private final PollVoteRepository pollVoteRepository; + private PublishMode publishMode; + private final NotificationService notificationService; + private final SubscriptionService subscriptionService; + private final CommentService commentService; + private final CommentRepository commentRepository; + private final ReactionRepository reactionRepository; + private final PostSubscriptionRepository postSubscriptionRepository; + private final NotificationRepository notificationRepository; + private final PostReadService postReadService; + private final ImageUploader imageUploader; + private final TaskScheduler taskScheduler; + private final EmailSender emailSender; + private final ApplicationContext applicationContext; + private final PointService pointService; + private final PostChangeLogService postChangeLogService; + private final PointHistoryRepository pointHistoryRepository; + private final ConcurrentMap> scheduledFinalizations = + new ConcurrentHashMap<>(); - @org.springframework.beans.factory.annotation.Autowired - public PostService(PostRepository postRepository, - UserRepository userRepository, - CategoryRepository categoryRepository, - TagRepository tagRepository, - LotteryPostRepository lotteryPostRepository, - PollPostRepository pollPostRepository, - PollVoteRepository pollVoteRepository, - NotificationService notificationService, - SubscriptionService subscriptionService, - CommentService commentService, - CommentRepository commentRepository, - ReactionRepository reactionRepository, - PostSubscriptionRepository postSubscriptionRepository, - NotificationRepository notificationRepository, - PostReadService postReadService, - ImageUploader imageUploader, - TaskScheduler taskScheduler, - EmailSender emailSender, - ApplicationContext applicationContext, - PointService pointService, - PostChangeLogService postChangeLogService, - PointHistoryRepository pointHistoryRepository, - @Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode, - RedisTemplate redisTemplate) { - this.postRepository = postRepository; - this.userRepository = userRepository; - this.categoryRepository = categoryRepository; - this.tagRepository = tagRepository; - this.lotteryPostRepository = lotteryPostRepository; - this.pollPostRepository = pollPostRepository; - this.pollVoteRepository = pollVoteRepository; - this.notificationService = notificationService; - this.subscriptionService = subscriptionService; - this.commentService = commentService; - this.commentRepository = commentRepository; - this.reactionRepository = reactionRepository; - this.postSubscriptionRepository = postSubscriptionRepository; - this.notificationRepository = notificationRepository; - this.postReadService = postReadService; - this.imageUploader = imageUploader; - this.taskScheduler = taskScheduler; - this.emailSender = emailSender; - this.applicationContext = applicationContext; - this.pointService = pointService; - this.postChangeLogService = postChangeLogService; - this.pointHistoryRepository = pointHistoryRepository; - this.publishMode = publishMode; + @Value("${app.website-url:https://www.open-isle.com}") + private String websiteUrl; - this.redisTemplate = redisTemplate; + private final RedisTemplate redisTemplate; + + @org.springframework.beans.factory.annotation.Autowired + public PostService( + PostRepository postRepository, + UserRepository userRepository, + CategoryRepository categoryRepository, + TagRepository tagRepository, + LotteryPostRepository lotteryPostRepository, + PollPostRepository pollPostRepository, + PollVoteRepository pollVoteRepository, + NotificationService notificationService, + SubscriptionService subscriptionService, + CommentService commentService, + CommentRepository commentRepository, + ReactionRepository reactionRepository, + PostSubscriptionRepository postSubscriptionRepository, + NotificationRepository notificationRepository, + PostReadService postReadService, + ImageUploader imageUploader, + TaskScheduler taskScheduler, + EmailSender emailSender, + ApplicationContext applicationContext, + PointService pointService, + PostChangeLogService postChangeLogService, + PointHistoryRepository pointHistoryRepository, + @Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode, + RedisTemplate redisTemplate + ) { + this.postRepository = postRepository; + this.userRepository = userRepository; + this.categoryRepository = categoryRepository; + this.tagRepository = tagRepository; + this.lotteryPostRepository = lotteryPostRepository; + this.pollPostRepository = pollPostRepository; + this.pollVoteRepository = pollVoteRepository; + this.notificationService = notificationService; + this.subscriptionService = subscriptionService; + this.commentService = commentService; + this.commentRepository = commentRepository; + this.reactionRepository = reactionRepository; + this.postSubscriptionRepository = postSubscriptionRepository; + this.notificationRepository = notificationRepository; + this.postReadService = postReadService; + this.imageUploader = imageUploader; + this.taskScheduler = taskScheduler; + this.emailSender = emailSender; + this.applicationContext = applicationContext; + this.pointService = pointService; + this.postChangeLogService = postChangeLogService; + this.pointHistoryRepository = pointHistoryRepository; + this.publishMode = publishMode; + + this.redisTemplate = redisTemplate; + } + + @EventListener(ApplicationReadyEvent.class) + public void rescheduleLotteries() { + LocalDateTime now = LocalDateTime.now(); + for (LotteryPost lp : lotteryPostRepository.findByEndTimeAfterAndWinnersIsEmpty(now)) { + ScheduledFuture future = taskScheduler.schedule( + () -> applicationContext.getBean(PostService.class).finalizeLottery(lp.getId()), + java.util.Date.from(lp.getEndTime().atZone(ZoneId.systemDefault()).toInstant()) + ); + scheduledFinalizations.put(lp.getId(), future); } - - @EventListener(ApplicationReadyEvent.class) - public void rescheduleLotteries() { - LocalDateTime now = LocalDateTime.now(); - for (LotteryPost lp : lotteryPostRepository.findByEndTimeAfterAndWinnersIsEmpty(now)) { - ScheduledFuture future = taskScheduler.schedule( - () -> applicationContext.getBean(PostService.class).finalizeLottery(lp.getId()), - java.util.Date.from(lp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())); - scheduledFinalizations.put(lp.getId(), future); - } - for (LotteryPost lp : lotteryPostRepository.findByEndTimeBeforeAndWinnersIsEmpty(now)) { - applicationContext.getBean(PostService.class).finalizeLottery(lp.getId()); - } - for (PollPost pp : pollPostRepository.findByEndTimeAfterAndResultAnnouncedFalse(now)) { - ScheduledFuture future = taskScheduler.schedule( - () -> applicationContext.getBean(PostService.class).finalizePoll(pp.getId()), - java.util.Date.from(pp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())); - scheduledFinalizations.put(pp.getId(), future); - } - for (PollPost pp : pollPostRepository.findByEndTimeBeforeAndResultAnnouncedFalse(now)) { - applicationContext.getBean(PostService.class).finalizePoll(pp.getId()); - } + for (LotteryPost lp : lotteryPostRepository.findByEndTimeBeforeAndWinnersIsEmpty(now)) { + applicationContext.getBean(PostService.class).finalizeLottery(lp.getId()); } - - public PublishMode getPublishMode() { - return publishMode; + for (PollPost pp : pollPostRepository.findByEndTimeAfterAndResultAnnouncedFalse(now)) { + ScheduledFuture future = taskScheduler.schedule( + () -> applicationContext.getBean(PostService.class).finalizePoll(pp.getId()), + java.util.Date.from(pp.getEndTime().atZone(ZoneId.systemDefault()).toInstant()) + ); + scheduledFinalizations.put(pp.getId(), future); } - - public void setPublishMode(PublishMode publishMode) { - this.publishMode = publishMode; + for (PollPost pp : pollPostRepository.findByEndTimeBeforeAndResultAnnouncedFalse(now)) { + applicationContext.getBean(PostService.class).finalizePoll(pp.getId()); } + } - public List listLatestRssPosts(int limit) { - Pageable pageable = PageRequest.of(0, limit, Sort.by(Sort.Direction.DESC, "createdAt")); - return postRepository.findByStatusAndRssExcludedFalseOrderByCreatedAtDesc(PostStatus.PUBLISHED, pageable); - } + public PublishMode getPublishMode() { + return publishMode; + } - public Post excludeFromRss(Long id, String username) { - Post post = postRepository.findById(id).orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - boolean oldFeatured = !Boolean.TRUE.equals(post.getRssExcluded()); - post.setRssExcluded(true); - Post saved = postRepository.save(post); - postChangeLogService.recordFeaturedChange(saved, user, oldFeatured, false); - return saved; - } + public void setPublishMode(PublishMode publishMode) { + this.publishMode = publishMode; + } - public Post includeInRss(Long id, String username) { - Post post = postRepository.findById(id).orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - boolean oldFeatured = !Boolean.TRUE.equals(post.getRssExcluded()); - post.setRssExcluded(false); - Post saved = postRepository.save(post); - postChangeLogService.recordFeaturedChange(saved, user, oldFeatured, true); - notificationService.createNotification(saved.getAuthor(), NotificationType.POST_FEATURED, saved, null, null, null, null, null); - pointService.awardForFeatured(saved.getAuthor().getUsername(), saved.getId()); - return saved; + public List listLatestRssPosts(int limit) { + Pageable pageable = PageRequest.of(0, limit, Sort.by(Sort.Direction.DESC, "createdAt")); + return postRepository.findByStatusAndRssExcludedFalseOrderByCreatedAtDesc( + PostStatus.PUBLISHED, + pageable + ); + } + + public Post excludeFromRss(Long id, String username) { + Post post = postRepository + .findById(id) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + boolean oldFeatured = !Boolean.TRUE.equals(post.getRssExcluded()); + post.setRssExcluded(true); + Post saved = postRepository.save(post); + postChangeLogService.recordFeaturedChange(saved, user, oldFeatured, false); + return saved; + } + + public Post includeInRss(Long id, String username) { + Post post = postRepository + .findById(id) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + boolean oldFeatured = !Boolean.TRUE.equals(post.getRssExcluded()); + post.setRssExcluded(false); + Post saved = postRepository.save(post); + postChangeLogService.recordFeaturedChange(saved, user, oldFeatured, true); + notificationService.createNotification( + saved.getAuthor(), + NotificationType.POST_FEATURED, + saved, + null, + null, + null, + null, + null + ); + pointService.awardForFeatured(saved.getAuthor().getUsername(), saved.getId()); + return saved; + } + + @CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true) + public Post createPost( + String username, + Long categoryId, + String title, + String content, + List tagIds, + PostType type, + String prizeDescription, + String prizeIcon, + Integer prizeCount, + Integer pointCost, + LocalDateTime startTime, + LocalDateTime endTime, + java.util.List options, + Boolean multiple + ) { + // 限制访问次数 + boolean limitResult = postRateLimit(username); + if (!limitResult) { + throw new RateLimitException("Too many posts"); } - @CacheEvict( - value = CachingConfig.POST_CACHE_NAME, allEntries = true - ) - public Post createPost(String username, - Long categoryId, - String title, - String content, - List tagIds, - PostType type, - String prizeDescription, - String prizeIcon, - Integer prizeCount, - Integer pointCost, - LocalDateTime startTime, - LocalDateTime endTime, - java.util.List options, - Boolean multiple) { - // 限制访问次数 - boolean limitResult = postRateLimit(username); - if (!limitResult) { - throw new RateLimitException("Too many posts"); + if (tagIds == null || tagIds.isEmpty()) { + throw new IllegalArgumentException("At least one tag required"); + } + if (tagIds.size() > 2) { + throw new IllegalArgumentException("At most two tags allowed"); + } + User author = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + Category category = categoryRepository + .findById(categoryId) + .orElseThrow(() -> new IllegalArgumentException("Category not found")); + java.util.List tags = tagRepository.findAllById(tagIds); + if (tags.isEmpty()) { + throw new IllegalArgumentException("Tag not found"); + } + PostType actualType = type != null ? type : PostType.NORMAL; + Post post; + if (actualType == PostType.LOTTERY) { + if (pointCost != null && (pointCost < 0 || pointCost > 100)) { + throw new IllegalArgumentException("pointCost must be between 0 and 100"); + } + LotteryPost lp = new LotteryPost(); + lp.setPrizeDescription(prizeDescription); + lp.setPrizeIcon(prizeIcon); + lp.setPrizeCount(prizeCount != null ? prizeCount : 0); + lp.setPointCost(pointCost != null ? pointCost : 0); + lp.setStartTime(startTime); + lp.setEndTime(endTime); + post = lp; + } else if (actualType == PostType.POLL) { + if (options == null || options.size() < 2) { + throw new IllegalArgumentException("At least two options required"); + } + PollPost pp = new PollPost(); + pp.setOptions(options); + pp.setEndTime(endTime); + pp.setMultiple(multiple != null && multiple); + post = pp; + } else { + post = new Post(); + } + post.setType(actualType); + post.setTitle(title); + post.setContent(content); + post.setAuthor(author); + post.setCategory(category); + post.setTags(new HashSet<>(tags)); + post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED); + if (post instanceof LotteryPost) { + post = lotteryPostRepository.save((LotteryPost) post); + } else if (post instanceof PollPost) { + post = pollPostRepository.save((PollPost) post); + } else { + post = postRepository.save(post); + } + imageUploader.addReferences(imageUploader.extractUrls(content)); + if (post.getStatus() == PostStatus.PENDING) { + java.util.List admins = userRepository.findByRole(com.openisle.model.Role.ADMIN); + for (User admin : admins) { + notificationService.createNotification( + admin, + NotificationType.POST_REVIEW_REQUEST, + post, + null, + null, + author, + null, + null + ); + } + notificationService.createNotification( + author, + NotificationType.POST_REVIEW_REQUEST, + post, + null, + null, + null, + null, + null + ); + } + // notify followers of author + for (User u : subscriptionService.getSubscribers(author.getUsername())) { + if (!u.getId().equals(author.getId())) { + notificationService.createNotification( + u, + NotificationType.FOLLOWED_POST, + post, + null, + null, + author, + null, + null + ); + } + } + notificationService.notifyMentions(content, author, post, null); + + if (post instanceof LotteryPost lp && lp.getEndTime() != null) { + ScheduledFuture future = taskScheduler.schedule( + () -> applicationContext.getBean(PostService.class).finalizeLottery(lp.getId()), + java.util.Date.from(lp.getEndTime().atZone(ZoneId.systemDefault()).toInstant()) + ); + scheduledFinalizations.put(lp.getId(), future); + } else if (post instanceof PollPost pp && pp.getEndTime() != null) { + ScheduledFuture future = taskScheduler.schedule( + () -> applicationContext.getBean(PostService.class).finalizePoll(pp.getId()), + java.util.Date.from(pp.getEndTime().atZone(ZoneId.systemDefault()).toInstant()) + ); + scheduledFinalizations.put(pp.getId(), future); + } + return post; + } + + /** + * 限制发帖频率 + * @param username + * @return + */ + private boolean postRateLimit(String username) { + String key = CachingConfig.LIMIT_CACHE_NAME + ":posts:" + username; + String result = (String) redisTemplate.opsForValue().get(key); + //最近没有创建过文章 + if (StringUtils.isEmpty(result)) { + // 限制频率为5分钟 + redisTemplate.opsForValue().set(key, "1", Duration.ofMinutes(5)); + return true; + } + return false; + } + + @CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true) + public void joinLottery(Long postId, String username) { + LotteryPost post = lotteryPostRepository + .findById(postId) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + if (post.getParticipants().add(user)) { + pointService.processLotteryJoin(user, post); + lotteryPostRepository.save(post); + } + } + + public PollPost getPoll(Long postId) { + return pollPostRepository + .findById(postId) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + } + + @CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true) + @Transactional + public PollPost votePoll(Long postId, String username, java.util.List optionIndices) { + PollPost post = pollPostRepository + .findById(postId) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + if (post.getEndTime() != null && post.getEndTime().isBefore(LocalDateTime.now())) { + throw new IllegalStateException("Poll has ended"); + } + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + if (post.getParticipants().contains(user)) { + throw new IllegalArgumentException("User already voted"); + } + if (optionIndices == null || optionIndices.isEmpty()) { + throw new IllegalArgumentException("No options selected"); + } + java.util.Set unique = new java.util.HashSet<>(optionIndices); + for (int optionIndex : unique) { + if (optionIndex < 0 || optionIndex >= post.getOptions().size()) { + throw new IllegalArgumentException("Invalid option"); + } + } + post.getParticipants().add(user); + for (int optionIndex : unique) { + post.getVotes().merge(optionIndex, 1, Integer::sum); + PollVote vote = new PollVote(); + vote.setPost(post); + vote.setUser(user); + vote.setOptionIndex(optionIndex); + pollVoteRepository.save(vote); + } + PollPost saved = pollPostRepository.save(post); + if (post.getAuthor() != null && !post.getAuthor().getId().equals(user.getId())) { + notificationService.createNotification( + post.getAuthor(), + NotificationType.POLL_VOTE, + post, + null, + null, + user, + null, + null + ); + } + return saved; + } + + @CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true) + @Transactional + public void finalizePoll(Long postId) { + scheduledFinalizations.remove(postId); + pollPostRepository + .findById(postId) + .ifPresent(pp -> { + if (pp.isResultAnnounced()) { + return; } - if (tagIds == null || tagIds.isEmpty()) { - throw new IllegalArgumentException("At least one tag required"); + pp.setResultAnnounced(true); + pollPostRepository.save(pp); + if (pp.getAuthor() != null) { + notificationService.createNotification( + pp.getAuthor(), + NotificationType.POLL_RESULT_OWNER, + pp, + null, + null, + null, + null, + null + ); } - if (tagIds.size() > 2) { - throw new IllegalArgumentException("At most two tags allowed"); + for (User participant : pp.getParticipants()) { + notificationService.createNotification( + participant, + NotificationType.POLL_RESULT_PARTICIPANT, + pp, + null, + null, + null, + null, + null + ); } - User author = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - Category category = categoryRepository.findById(categoryId) - .orElseThrow(() -> new IllegalArgumentException("Category not found")); + postChangeLogService.recordVoteResult(pp); + }); + } + + @CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true) + @Transactional + public void finalizeLottery(Long postId) { + log.info("start to finalizeLottery for {}", postId); + scheduledFinalizations.remove(postId); + lotteryPostRepository + .findById(postId) + .ifPresent(lp -> { + List participants = new ArrayList<>(lp.getParticipants()); + if (participants.isEmpty()) { + return; + } + Collections.shuffle(participants); + int winnersCount = Math.min(lp.getPrizeCount(), participants.size()); + java.util.Set winners = new java.util.HashSet<>( + participants.subList(0, winnersCount) + ); + log.info("winner count {}", winnersCount); + lp.setWinners(winners); + lotteryPostRepository.save(lp); + for (User w : winners) { + if ( + w.getEmail() != null && + !w.getDisabledEmailNotificationTypes().contains(NotificationType.LOTTERY_WIN) + ) { + emailSender.sendEmail( + w.getEmail(), + "你中奖了", + "恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖" + ); + } + notificationService.createNotification( + w, + NotificationType.LOTTERY_WIN, + lp, + null, + null, + lp.getAuthor(), + null, + null + ); + notificationService.sendCustomPush( + w, + "你中奖了", + String.format("%s/posts/%d", websiteUrl, lp.getId()) + ); + } + if (lp.getAuthor() != null) { + if ( + lp.getAuthor().getEmail() != null && + !lp + .getAuthor() + .getDisabledEmailNotificationTypes() + .contains(NotificationType.LOTTERY_DRAW) + ) { + emailSender.sendEmail( + lp.getAuthor().getEmail(), + "抽奖已开奖", + "您的抽奖贴 \"" + lp.getTitle() + "\" 已开奖" + ); + } + notificationService.createNotification( + lp.getAuthor(), + NotificationType.LOTTERY_DRAW, + lp, + null, + null, + null, + null, + null + ); + notificationService.sendCustomPush( + lp.getAuthor(), + "抽奖已开奖", + String.format("%s/posts/%d", websiteUrl, lp.getId()) + ); + } + postChangeLogService.recordLotteryResult(lp); + }); + } + + @Transactional + public Post viewPost(Long id, String viewer) { + Post post = postRepository + .findById(id) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + if (post.getStatus() != PostStatus.PUBLISHED) { + if (viewer == null) { + throw new com.openisle.exception.NotFoundException("Post not found"); + } + User viewerUser = userRepository + .findByUsername(viewer) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + if ( + !viewerUser.getRole().equals(com.openisle.model.Role.ADMIN) && + !viewerUser.getId().equals(post.getAuthor().getId()) + ) { + throw new com.openisle.exception.NotFoundException("Post not found"); + } + } + post.setViews(post.getViews() + 1); + post = postRepository.save(post); + if (viewer != null) { + postReadService.recordRead(viewer, id); + } + if (viewer != null && !viewer.equals(post.getAuthor().getUsername())) { + User viewerUser = userRepository.findByUsername(viewer).orElse(null); + if (viewerUser != null) { + notificationRepository.deleteByTypeAndFromUserAndPost( + NotificationType.POST_VIEWED, + viewerUser, + post + ); + notificationService.createNotification( + post.getAuthor(), + NotificationType.POST_VIEWED, + post, + null, + null, + viewerUser, + null, + null + ); + } + } + return post; + } + + public List listPosts() { + return listPostsByCategories(null, null, null); + } + + public List listPostsByViews(Integer page, Integer pageSize) { + return listPostsByViews(null, null, page, pageSize); + } + + public List listPostsByViews( + java.util.List categoryIds, + java.util.List tagIds, + Integer page, + Integer pageSize + ) { + boolean hasCategories = categoryIds != null && !categoryIds.isEmpty(); + boolean hasTags = tagIds != null && !tagIds.isEmpty(); + + java.util.List posts; + + if (!hasCategories && !hasTags) { + posts = postRepository.findByStatusOrderByViewsDesc(PostStatus.PUBLISHED); + } else if (hasCategories) { + java.util.List categories = categoryRepository.findAllById(categoryIds); + if (categories.isEmpty()) { + return java.util.List.of(); + } + if (hasTags) { java.util.List tags = tagRepository.findAllById(tagIds); if (tags.isEmpty()) { - throw new IllegalArgumentException("Tag not found"); + return java.util.List.of(); } - PostType actualType = type != null ? type : PostType.NORMAL; - Post post; - if (actualType == PostType.LOTTERY) { - if (pointCost != null && (pointCost < 0 || pointCost > 100)) { - throw new IllegalArgumentException("pointCost must be between 0 and 100"); - } - LotteryPost lp = new LotteryPost(); - lp.setPrizeDescription(prizeDescription); - lp.setPrizeIcon(prizeIcon); - lp.setPrizeCount(prizeCount != null ? prizeCount : 0); - lp.setPointCost(pointCost != null ? pointCost : 0); - lp.setStartTime(startTime); - lp.setEndTime(endTime); - post = lp; - } else if (actualType == PostType.POLL) { - if (options == null || options.size() < 2) { - throw new IllegalArgumentException("At least two options required"); - } - PollPost pp = new PollPost(); - pp.setOptions(options); - pp.setEndTime(endTime); - pp.setMultiple(multiple != null && multiple); - post = pp; - } else { - post = new Post(); - } - post.setType(actualType); - post.setTitle(title); - post.setContent(content); - post.setAuthor(author); - post.setCategory(category); - post.setTags(new HashSet<>(tags)); - post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED); - if (post instanceof LotteryPost) { - post = lotteryPostRepository.save((LotteryPost) post); - } else if (post instanceof PollPost) { - post = pollPostRepository.save((PollPost) post); - } else { - post = postRepository.save(post); - } - imageUploader.addReferences(imageUploader.extractUrls(content)); - if (post.getStatus() == PostStatus.PENDING) { - java.util.List admins = userRepository.findByRole(com.openisle.model.Role.ADMIN); - for (User admin : admins) { - notificationService.createNotification(admin, - NotificationType.POST_REVIEW_REQUEST, post, null, null, author, null, null); - } - notificationService.createNotification(author, - NotificationType.POST_REVIEW_REQUEST, post, null, null, null, null, null); - } - // notify followers of author - for (User u : subscriptionService.getSubscribers(author.getUsername())) { - if (!u.getId().equals(author.getId())) { - notificationService.createNotification( - u, - NotificationType.FOLLOWED_POST, - post, - null, - null, - author, - null, - null); - } - } - notificationService.notifyMentions(content, author, post, null); - - if (post instanceof LotteryPost lp && lp.getEndTime() != null) { - ScheduledFuture future = taskScheduler.schedule( - () -> applicationContext.getBean(PostService.class).finalizeLottery(lp.getId()), - java.util.Date.from(lp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())); - scheduledFinalizations.put(lp.getId(), future); - } else if (post instanceof PollPost pp && pp.getEndTime() != null) { - ScheduledFuture future = taskScheduler.schedule( - () -> applicationContext.getBean(PostService.class).finalizePoll(pp.getId()), - java.util.Date.from(pp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())); - scheduledFinalizations.put(pp.getId(), future); - } - return post; + posts = postRepository.findByCategoriesAndAllTagsOrderByViewsDesc( + categories, + tags, + PostStatus.PUBLISHED, + tags.size() + ); + } else { + posts = postRepository.findByCategoryInAndStatusOrderByViewsDesc( + categories, + PostStatus.PUBLISHED + ); + } + } else { + java.util.List tags = tagRepository.findAllById(tagIds); + if (tags.isEmpty()) { + return java.util.List.of(); + } + posts = postRepository.findByAllTagsOrderByViewsDesc(tags, PostStatus.PUBLISHED, tags.size()); } - /** - * 限制发帖频率 - * @param username - * @return - */ - private boolean postRateLimit(String username){ - String key = CachingConfig.LIMIT_CACHE_NAME +":posts:"+username; - String result = (String)redisTemplate.opsForValue().get(key); - //最近没有创建过文章 - if(StringUtils.isEmpty(result)){ - // 限制频率为5分钟 - redisTemplate.opsForValue().set(key,"1", Duration.ofMinutes(5)); - return true; - } - return false; - } + return paginate(sortByPinnedAndViews(posts), page, pageSize); + } - @CacheEvict( - value = CachingConfig.POST_CACHE_NAME, allEntries = true - ) - public void joinLottery(Long postId, String username) { - LotteryPost post = lotteryPostRepository.findById(postId) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - if (post.getParticipants().add(user)) { - pointService.processLotteryJoin(user, post); - lotteryPostRepository.save(post); - } - } + public List listPostsByLatestReply(Integer page, Integer pageSize) { + return listPostsByLatestReply(null, null, page, pageSize); + } - public PollPost getPoll(Long postId) { - return pollPostRepository.findById(postId) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); - } + public List listPostsByLatestReply( + java.util.List categoryIds, + java.util.List tagIds, + Integer page, + Integer pageSize + ) { + boolean hasCategories = categoryIds != null && !categoryIds.isEmpty(); + boolean hasTags = tagIds != null && !tagIds.isEmpty(); - @CacheEvict( - value = CachingConfig.POST_CACHE_NAME, allEntries = true - ) - @Transactional - public PollPost votePoll(Long postId, String username, java.util.List optionIndices) { - PollPost post = pollPostRepository.findById(postId) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); - if (post.getEndTime() != null && post.getEndTime().isBefore(LocalDateTime.now())) { - throw new IllegalStateException("Poll has ended"); - } - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - if (post.getParticipants().contains(user)) { - throw new IllegalArgumentException("User already voted"); - } - if (optionIndices == null || optionIndices.isEmpty()) { - throw new IllegalArgumentException("No options selected"); - } - java.util.Set unique = new java.util.HashSet<>(optionIndices); - for (int optionIndex : unique) { - if (optionIndex < 0 || optionIndex >= post.getOptions().size()) { - throw new IllegalArgumentException("Invalid option"); - } - } - post.getParticipants().add(user); - for (int optionIndex : unique) { - post.getVotes().merge(optionIndex, 1, Integer::sum); - PollVote vote = new PollVote(); - vote.setPost(post); - vote.setUser(user); - vote.setOptionIndex(optionIndex); - pollVoteRepository.save(vote); - } - PollPost saved = pollPostRepository.save(post); - if (post.getAuthor() != null && !post.getAuthor().getId().equals(user.getId())) { - notificationService.createNotification(post.getAuthor(), NotificationType.POLL_VOTE, post, null, null, user, null, null); - } - return saved; - } - - @CacheEvict( - value = CachingConfig.POST_CACHE_NAME, allEntries = true - ) - @Transactional - public void finalizePoll(Long postId) { - scheduledFinalizations.remove(postId); - pollPostRepository.findById(postId).ifPresent(pp -> { - if (pp.isResultAnnounced()) { - return; - } - pp.setResultAnnounced(true); - pollPostRepository.save(pp); - if (pp.getAuthor() != null) { - notificationService.createNotification(pp.getAuthor(), NotificationType.POLL_RESULT_OWNER, pp, null, null, null, null, null); - } - for (User participant : pp.getParticipants()) { - notificationService.createNotification(participant, NotificationType.POLL_RESULT_PARTICIPANT, pp, null, null, null, null, null); - } - postChangeLogService.recordVoteResult(pp); - }); - } - - @CacheEvict( - value = CachingConfig.POST_CACHE_NAME, allEntries = true - ) - @Transactional - public void finalizeLottery(Long postId) { - log.info("start to finalizeLottery for {}", postId); - scheduledFinalizations.remove(postId); - lotteryPostRepository.findById(postId).ifPresent(lp -> { - List participants = new ArrayList<>(lp.getParticipants()); - if (participants.isEmpty()) { - return; - } - Collections.shuffle(participants); - int winnersCount = Math.min(lp.getPrizeCount(), participants.size()); - java.util.Set winners = new java.util.HashSet<>(participants.subList(0, winnersCount)); - log.info("winner count {}", winnersCount); - lp.setWinners(winners); - lotteryPostRepository.save(lp); - for (User w : winners) { - if (w.getEmail() != null && - !w.getDisabledEmailNotificationTypes().contains(NotificationType.LOTTERY_WIN)) { - emailSender.sendEmail(w.getEmail(), "你中奖了", "恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖"); - } - notificationService.createNotification(w, NotificationType.LOTTERY_WIN, lp, null, null, lp.getAuthor(), null, null); - notificationService.sendCustomPush(w, "你中奖了", String.format("%s/posts/%d", websiteUrl, lp.getId())); - } - if (lp.getAuthor() != null) { - if (lp.getAuthor().getEmail() != null && - !lp.getAuthor().getDisabledEmailNotificationTypes().contains(NotificationType.LOTTERY_DRAW)) { - emailSender.sendEmail(lp.getAuthor().getEmail(), "抽奖已开奖", "您的抽奖贴 \"" + lp.getTitle() + "\" 已开奖"); - } - notificationService.createNotification(lp.getAuthor(), NotificationType.LOTTERY_DRAW, lp, null, null, null, null, null); - notificationService.sendCustomPush(lp.getAuthor(), "抽奖已开奖", String.format("%s/posts/%d", websiteUrl, lp.getId())); - } - postChangeLogService.recordLotteryResult(lp); - }); - } - - @Transactional - public Post viewPost(Long id, String viewer) { - Post post = postRepository.findById(id) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); - if (post.getStatus() != PostStatus.PUBLISHED) { - if (viewer == null) { - throw new com.openisle.exception.NotFoundException("Post not found"); - } - User viewerUser = userRepository.findByUsername(viewer) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - if (!viewerUser.getRole().equals(com.openisle.model.Role.ADMIN) && !viewerUser.getId().equals(post.getAuthor().getId())) { - throw new com.openisle.exception.NotFoundException("Post not found"); - } - } - post.setViews(post.getViews() + 1); - post = postRepository.save(post); - if (viewer != null) { - postReadService.recordRead(viewer, id); - } - if (viewer != null && !viewer.equals(post.getAuthor().getUsername())) { - User viewerUser = userRepository.findByUsername(viewer).orElse(null); - if (viewerUser != null) { - notificationRepository.deleteByTypeAndFromUserAndPost(NotificationType.POST_VIEWED, viewerUser, post); - notificationService.createNotification(post.getAuthor(), NotificationType.POST_VIEWED, post, null, null, viewerUser, null, null); - } - } - return post; - } - - public List listPosts() { - return listPostsByCategories(null, null, null); - } - - public List listPostsByViews(Integer page, Integer pageSize) { - return listPostsByViews(null, null, page, pageSize); - } - - public List listPostsByViews(java.util.List categoryIds, - java.util.List tagIds, - Integer page, - Integer pageSize) { - boolean hasCategories = categoryIds != null && !categoryIds.isEmpty(); - boolean hasTags = tagIds != null && !tagIds.isEmpty(); - - java.util.List posts; - - if (!hasCategories && !hasTags) { - posts = postRepository.findByStatusOrderByViewsDesc(PostStatus.PUBLISHED); - } else if (hasCategories) { - java.util.List categories = categoryRepository.findAllById(categoryIds); - if (categories.isEmpty()) { - return java.util.List.of(); - } - if (hasTags) { - java.util.List tags = tagRepository.findAllById(tagIds); - if (tags.isEmpty()) { - return java.util.List.of(); - } - posts = postRepository.findByCategoriesAndAllTagsOrderByViewsDesc( - categories, tags, PostStatus.PUBLISHED, tags.size()); - } else { - posts = postRepository.findByCategoryInAndStatusOrderByViewsDesc(categories, PostStatus.PUBLISHED); - } - } else { - java.util.List tags = tagRepository.findAllById(tagIds); - if (tags.isEmpty()) { - return java.util.List.of(); - } - posts = postRepository.findByAllTagsOrderByViewsDesc(tags, PostStatus.PUBLISHED, tags.size()); - } - - return paginate(sortByPinnedAndViews(posts), page, pageSize); - } - - public List listPostsByLatestReply(Integer page, Integer pageSize) { - return listPostsByLatestReply(null, null, page, pageSize); - } - - public List listPostsByLatestReply(java.util.List categoryIds, - java.util.List tagIds, - Integer page, - Integer pageSize) { - boolean hasCategories = categoryIds != null && !categoryIds.isEmpty(); - boolean hasTags = tagIds != null && !tagIds.isEmpty(); - - java.util.List posts; - - if (!hasCategories && !hasTags) { - posts = postRepository.findByStatusOrderByCreatedAtDesc(PostStatus.PUBLISHED); - } else if (hasCategories) { - java.util.List categories = categoryRepository.findAllById(categoryIds); - if (categories.isEmpty()) { - return java.util.List.of(); - } - if (hasTags) { - java.util.List tags = tagRepository.findAllById(tagIds); - if (tags.isEmpty()) { - return java.util.List.of(); - } - posts = postRepository.findByCategoriesAndAllTagsOrderByCreatedAtDesc( - categories, tags, PostStatus.PUBLISHED, tags.size()); - } else { - posts = postRepository.findByCategoryInAndStatusOrderByCreatedAtDesc(categories, PostStatus.PUBLISHED); - } - } else { - List tags = tagRepository.findAllById(tagIds); - if (tags.isEmpty()) { - return new ArrayList<>(); - } - posts = postRepository.findByAllTagsOrderByCreatedAtDesc(tags, PostStatus.PUBLISHED, tags.size()); - } - - return paginate(sortByPinnedAndLastReply(posts), page, pageSize); - } - - public List listPostsByCategories(java.util.List categoryIds, - Integer page, - Integer pageSize) { - if (categoryIds == null || categoryIds.isEmpty()) { - java.util.List posts = postRepository.findByStatusOrderByCreatedAtDesc(PostStatus.PUBLISHED); - return paginate(sortByPinnedAndCreated(posts), page, pageSize); - } - - java.util.List categories = categoryRepository.findAllById(categoryIds); - java.util.List posts = postRepository.findByCategoryInAndStatusOrderByCreatedAtDesc(categories, PostStatus.PUBLISHED); - return paginate(sortByPinnedAndCreated(posts), page, pageSize); - } - - public List getRecentPostsByUser(String username, int limit) { - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - Pageable pageable = PageRequest.of(0, limit); - return postRepository.findByAuthorAndStatusOrderByCreatedAtDesc(user, PostStatus.PUBLISHED, pageable); - } - - public java.time.LocalDateTime getLastPostTime(String username) { - return postRepository.findLastPostTime(username); - } - - public long getTotalViews(String username) { - Long v = postRepository.sumViews(username); - return v != null ? v : 0; - } - - public List listPostsByTags(java.util.List tagIds, - Integer page, - Integer pageSize) { - if (tagIds == null || tagIds.isEmpty()) { - return java.util.List.of(); - } + java.util.List posts; + if (!hasCategories && !hasTags) { + posts = postRepository.findByStatusOrderByCreatedAtDesc(PostStatus.PUBLISHED); + } else if (hasCategories) { + java.util.List categories = categoryRepository.findAllById(categoryIds); + if (categories.isEmpty()) { + return java.util.List.of(); + } + if (hasTags) { java.util.List tags = tagRepository.findAllById(tagIds); if (tags.isEmpty()) { - return java.util.List.of(); + return java.util.List.of(); } - - java.util.List posts = postRepository.findByAllTagsOrderByCreatedAtDesc(tags, PostStatus.PUBLISHED, tags.size()); - return paginate(sortByPinnedAndCreated(posts), page, pageSize); + posts = postRepository.findByCategoriesAndAllTagsOrderByCreatedAtDesc( + categories, + tags, + PostStatus.PUBLISHED, + tags.size() + ); + } else { + posts = postRepository.findByCategoryInAndStatusOrderByCreatedAtDesc( + categories, + PostStatus.PUBLISHED + ); + } + } else { + List tags = tagRepository.findAllById(tagIds); + if (tags.isEmpty()) { + return new ArrayList<>(); + } + posts = postRepository.findByAllTagsOrderByCreatedAtDesc( + tags, + PostStatus.PUBLISHED, + tags.size() + ); } - public List listPostsByCategoriesAndTags(java.util.List categoryIds, - java.util.List tagIds, - Integer page, - Integer pageSize) { - if (categoryIds == null || categoryIds.isEmpty() || tagIds == null || tagIds.isEmpty()) { - return java.util.List.of(); - } + return paginate(sortByPinnedAndLastReply(posts), page, pageSize); + } - java.util.List categories = categoryRepository.findAllById(categoryIds); - java.util.List tags = tagRepository.findAllById(tagIds); - if (categories.isEmpty() || tags.isEmpty()) { - return java.util.List.of(); - } - - java.util.List posts = postRepository.findByCategoriesAndAllTagsOrderByCreatedAtDesc(categories, tags, PostStatus.PUBLISHED, tags.size()); - return paginate(sortByPinnedAndCreated(posts), page, pageSize); + public List listPostsByCategories( + java.util.List categoryIds, + Integer page, + Integer pageSize + ) { + if (categoryIds == null || categoryIds.isEmpty()) { + java.util.List posts = postRepository.findByStatusOrderByCreatedAtDesc( + PostStatus.PUBLISHED + ); + return paginate(sortByPinnedAndCreated(posts), page, pageSize); } - public List listFeaturedPosts(List categoryIds, - List tagIds, - Integer page, - Integer pageSize) { - List posts; - boolean hasCategories = categoryIds != null && !categoryIds.isEmpty(); - boolean hasTags = tagIds != null && !tagIds.isEmpty(); + java.util.List categories = categoryRepository.findAllById(categoryIds); + java.util.List posts = postRepository.findByCategoryInAndStatusOrderByCreatedAtDesc( + categories, + PostStatus.PUBLISHED + ); + return paginate(sortByPinnedAndCreated(posts), page, pageSize); + } - if (hasCategories && hasTags) { - posts = listPostsByCategoriesAndTags(categoryIds, tagIds, null, null); - } else if (hasCategories) { - posts = listPostsByCategories(categoryIds, null, null); - } else if (hasTags) { - posts = listPostsByTags(tagIds, null, null); - } else { - posts = listPosts(); - } + public List getRecentPostsByUser(String username, int limit) { + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + Pageable pageable = PageRequest.of(0, limit); + return postRepository.findByAuthorAndStatusOrderByCreatedAtDesc( + user, + PostStatus.PUBLISHED, + pageable + ); + } - // 仅保留 getRssExcluded 为 0 且不为空 - // 若字段类型是 Boolean(包装类型),0 等价于 false: - posts = posts.stream() - .filter(p -> p.getRssExcluded() != null && !p.getRssExcluded()) - .toList(); + public java.time.LocalDateTime getLastPostTime(String username) { + return postRepository.findLastPostTime(username); + } - return paginate(sortByPinnedAndCreated(posts), page, pageSize); + public long getTotalViews(String username) { + Long v = postRepository.sumViews(username); + return v != null ? v : 0; + } + + public List listPostsByTags(java.util.List tagIds, Integer page, Integer pageSize) { + if (tagIds == null || tagIds.isEmpty()) { + return java.util.List.of(); } - /** - * 默认的文章列表 - * @param ids - * @param tids - * @param page - * @param pageSize - * @return - */ - public List defaultListPosts(List ids, List tids, Integer page, Integer pageSize){ - boolean hasCategories = !CollectionUtils.isEmpty(ids); - boolean hasTags = !CollectionUtils.isEmpty(tids); - - if (hasCategories && hasTags) { - return listPostsByCategoriesAndTags(ids, tids, page, pageSize) - .stream().collect(Collectors.toList()); - } - if (hasTags) { - return listPostsByTags(tids, page, pageSize) - .stream().collect(Collectors.toList()); - } - - return listPostsByCategories(ids, page, pageSize) - .stream().collect(Collectors.toList()); + java.util.List tags = tagRepository.findAllById(tagIds); + if (tags.isEmpty()) { + return java.util.List.of(); } - public List listPendingPosts() { - return postRepository.findByStatus(PostStatus.PENDING); + java.util.List posts = postRepository.findByAllTagsOrderByCreatedAtDesc( + tags, + PostStatus.PUBLISHED, + tags.size() + ); + return paginate(sortByPinnedAndCreated(posts), page, pageSize); + } + + public List listPostsByCategoriesAndTags( + java.util.List categoryIds, + java.util.List tagIds, + Integer page, + Integer pageSize + ) { + if (categoryIds == null || categoryIds.isEmpty() || tagIds == null || tagIds.isEmpty()) { + return java.util.List.of(); } - @CacheEvict( - value = CachingConfig.POST_CACHE_NAME, - allEntries = true - ) - public Post approvePost(Long id) { - Post post = postRepository.findById(id) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); - // publish all pending tags along with the post - for (com.openisle.model.Tag tag : post.getTags()) { - if (!tag.isApproved()) { - tag.setApproved(true); - tagRepository.save(tag); - } - } - post.setStatus(PostStatus.PUBLISHED); - post = postRepository.save(post); - notificationService.createNotification(post.getAuthor(), NotificationType.POST_REVIEWED, post, null, true, null, null, null); - return post; + java.util.List categories = categoryRepository.findAllById(categoryIds); + java.util.List tags = tagRepository.findAllById(tagIds); + if (categories.isEmpty() || tags.isEmpty()) { + return java.util.List.of(); } - public Post rejectPost(Long id) { - Post post = postRepository.findById(id) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); - // remove user created tags that are only linked to this post - java.util.Set tags = new java.util.HashSet<>(post.getTags()); - for (com.openisle.model.Tag tag : tags) { - if (!tag.isApproved()) { - long count = postRepository.countDistinctByTags_Id(tag.getId()); - if (count <= 1) { - post.getTags().remove(tag); - tagRepository.delete(tag); - } - } - } - post.setStatus(PostStatus.REJECTED); - post = postRepository.save(post); - notificationService.createNotification(post.getAuthor(), NotificationType.POST_REVIEWED, post, null, false, null, null, null); - return post; + java.util.List posts = postRepository.findByCategoriesAndAllTagsOrderByCreatedAtDesc( + categories, + tags, + PostStatus.PUBLISHED, + tags.size() + ); + return paginate(sortByPinnedAndCreated(posts), page, pageSize); + } + + public List listFeaturedPosts( + List categoryIds, + List tagIds, + Integer page, + Integer pageSize + ) { + List posts; + boolean hasCategories = categoryIds != null && !categoryIds.isEmpty(); + boolean hasTags = tagIds != null && !tagIds.isEmpty(); + + if (hasCategories && hasTags) { + posts = listPostsByCategoriesAndTags(categoryIds, tagIds, null, null); + } else if (hasCategories) { + posts = listPostsByCategories(categoryIds, null, null); + } else if (hasTags) { + posts = listPostsByTags(tagIds, null, null); + } else { + posts = listPosts(); } - @CacheEvict( - value = CachingConfig.POST_CACHE_NAME, - allEntries = true - ) - public Post pinPost(Long id, String username) { - Post post = postRepository.findById(id) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - java.time.LocalDateTime oldPinned = post.getPinnedAt(); - post.setPinnedAt(java.time.LocalDateTime.now()); - Post saved = postRepository.save(post); - postChangeLogService.recordPinnedChange(saved, user, oldPinned, saved.getPinnedAt()); - return saved; + // 仅保留 getRssExcluded 为 0 且不为空 + // 若字段类型是 Boolean(包装类型),0 等价于 false: + posts = posts + .stream() + .filter(p -> p.getRssExcluded() != null && !p.getRssExcluded()) + .toList(); + + return paginate(sortByPinnedAndCreated(posts), page, pageSize); + } + + /** + * 默认的文章列表 + * @param ids + * @param tids + * @param page + * @param pageSize + * @return + */ + public List defaultListPosts( + List ids, + List tids, + Integer page, + Integer pageSize + ) { + boolean hasCategories = !CollectionUtils.isEmpty(ids); + boolean hasTags = !CollectionUtils.isEmpty(tids); + + if (hasCategories && hasTags) { + return listPostsByCategoriesAndTags(ids, tids, page, pageSize) + .stream() + .collect(Collectors.toList()); + } + if (hasTags) { + return listPostsByTags(tids, page, pageSize).stream().collect(Collectors.toList()); } - @CacheEvict( - value = CachingConfig.POST_CACHE_NAME, - allEntries = true - ) - public Post unpinPost(Long id, String username) { - Post post = postRepository.findById(id) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - java.time.LocalDateTime oldPinned = post.getPinnedAt(); - post.setPinnedAt(null); - Post saved = postRepository.save(post); - postChangeLogService.recordPinnedChange(saved, user, oldPinned, null); - return saved; - } + return listPostsByCategories(ids, page, pageSize).stream().collect(Collectors.toList()); + } - @CacheEvict( - value = CachingConfig.POST_CACHE_NAME, - allEntries = true - ) - public Post closePost(Long id, String username) { - Post post = postRepository.findById(id) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - if (!user.getId().equals(post.getAuthor().getId()) && user.getRole() != Role.ADMIN) { - throw new IllegalArgumentException("Unauthorized"); - } - boolean oldClosed = post.isClosed(); - post.setClosed(true); - Post saved = postRepository.save(post); - postChangeLogService.recordClosedChange(saved, user, oldClosed, true); - return saved; - } + public List listPendingPosts() { + return postRepository.findByStatus(PostStatus.PENDING); + } - @CacheEvict( - value = CachingConfig.POST_CACHE_NAME, - allEntries = true - ) - public Post reopenPost(Long id, String username) { - Post post = postRepository.findById(id) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - if (!user.getId().equals(post.getAuthor().getId()) && user.getRole() != Role.ADMIN) { - throw new IllegalArgumentException("Unauthorized"); - } - boolean oldClosed = post.isClosed(); - post.setClosed(false); - Post saved = postRepository.save(post); - postChangeLogService.recordClosedChange(saved, user, oldClosed, false); - return saved; + @CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true) + public Post approvePost(Long id) { + Post post = postRepository + .findById(id) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + // publish all pending tags along with the post + for (com.openisle.model.Tag tag : post.getTags()) { + if (!tag.isApproved()) { + tag.setApproved(true); + tagRepository.save(tag); + } } + post.setStatus(PostStatus.PUBLISHED); + post = postRepository.save(post); + notificationService.createNotification( + post.getAuthor(), + NotificationType.POST_REVIEWED, + post, + null, + true, + null, + null, + null + ); + return post; + } - @CacheEvict( - value = CachingConfig.POST_CACHE_NAME, - allEntries = true - ) - @Transactional - public Post updatePost(Long id, - String username, - Long categoryId, - String title, - String content, - java.util.List tagIds) { - if (tagIds == null || tagIds.isEmpty()) { - throw new IllegalArgumentException("At least one tag required"); + public Post rejectPost(Long id) { + Post post = postRepository + .findById(id) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + // remove user created tags that are only linked to this post + java.util.Set tags = new java.util.HashSet<>(post.getTags()); + for (com.openisle.model.Tag tag : tags) { + if (!tag.isApproved()) { + long count = postRepository.countDistinctByTags_Id(tag.getId()); + if (count <= 1) { + post.getTags().remove(tag); + tagRepository.delete(tag); } - if (tagIds.size() > 2) { - throw new IllegalArgumentException("At most two tags allowed"); - } - Post post = postRepository.findById(id) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - if (!user.getId().equals(post.getAuthor().getId()) && user.getRole() != Role.ADMIN) { - throw new IllegalArgumentException("Unauthorized"); - } - Category category = categoryRepository.findById(categoryId) - .orElseThrow(() -> new IllegalArgumentException("Category not found")); - java.util.List tags = tagRepository.findAllById(tagIds); - if (tags.isEmpty()) { - throw new IllegalArgumentException("Tag not found"); - } - String oldTitle = post.getTitle(); - String oldContent = post.getContent(); - Category oldCategory = post.getCategory(); - java.util.Set oldTags = new java.util.HashSet<>(post.getTags()); - post.setTitle(title); - post.setContent(content); - post.setCategory(category); - post.setTags(new java.util.HashSet<>(tags)); - Post updated = postRepository.save(post); - imageUploader.adjustReferences(oldContent, content); - notificationService.notifyMentions(content, user, updated, null); - if (!java.util.Objects.equals(oldTitle, title)) { - postChangeLogService.recordTitleChange(updated, user, oldTitle, title); - } - if (!java.util.Objects.equals(oldContent, content)) { - postChangeLogService.recordContentChange(updated, user, oldContent, content); - } - if (!java.util.Objects.equals(oldCategory.getId(), category.getId())) { - postChangeLogService.recordCategoryChange(updated, user, oldCategory.getName(), category.getName()); - } - java.util.Set newTags = new java.util.HashSet<>(tags); - if (!oldTags.equals(newTags)) { - postChangeLogService.recordTagChange(updated, user, oldTags, newTags); - } - return updated; + } } + post.setStatus(PostStatus.REJECTED); + post = postRepository.save(post); + notificationService.createNotification( + post.getAuthor(), + NotificationType.POST_REVIEWED, + post, + null, + false, + null, + null, + null + ); + return post; + } - @CacheEvict( - value = CachingConfig.POST_CACHE_NAME, - allEntries = true - ) - @Transactional - public void deletePost(Long id, String username) { - Post post = postRepository.findById(id) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - User author = post.getAuthor(); - boolean adminDeleting = !user.getId().equals(author.getId()) && user.getRole() == Role.ADMIN; - if (!user.getId().equals(author.getId()) && user.getRole() != Role.ADMIN) { - throw new IllegalArgumentException("Unauthorized"); - } - for (Comment c : commentRepository.findByPostAndParentIsNullOrderByCreatedAtAsc(post)) { - commentService.deleteCommentCascade(c); - } - reactionRepository.findByPost(post).forEach(reactionRepository::delete); - postSubscriptionRepository.findByPost(post).forEach(postSubscriptionRepository::delete); - notificationRepository.deleteAll(notificationRepository.findByPost(post)); - postReadService.deleteByPost(post); - imageUploader.removeReferences(imageUploader.extractUrls(post.getContent())); - List pointHistories = pointHistoryRepository.findByPost(post); - Set usersToRecalculate = pointHistories.stream() - .map(PointHistory::getUser) - .collect(Collectors.toSet()); - if (!pointHistories.isEmpty()) { - LocalDateTime deletedAt = LocalDateTime.now(); - for (PointHistory history : pointHistories) { - history.setDeletedAt(deletedAt); - history.setPost(null); - } - pointHistoryRepository.saveAll(pointHistories); - } - if (!usersToRecalculate.isEmpty()) { - for (User affected : usersToRecalculate) { - int newPoints = pointService.recalculateUserPoints(affected); - affected.setPoint(newPoints); - } - userRepository.saveAll(usersToRecalculate); - } - if (post instanceof LotteryPost lp) { - ScheduledFuture future = scheduledFinalizations.remove(lp.getId()); - if (future != null) { - future.cancel(false); - } - } - String title = post.getTitle(); - postChangeLogService.deleteLogsForPost(post); - postRepository.delete(post); - if (adminDeleting) { - notificationService.createNotification(author, NotificationType.POST_DELETED, - null, null, null, user, null, title); - } - } + @CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true) + public Post pinPost(Long id, String username) { + Post post = postRepository + .findById(id) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + java.time.LocalDateTime oldPinned = post.getPinnedAt(); + post.setPinnedAt(java.time.LocalDateTime.now()); + Post saved = postRepository.save(post); + postChangeLogService.recordPinnedChange(saved, user, oldPinned, saved.getPinnedAt()); + return saved; + } - public java.util.List getPostsByIds(java.util.List ids) { - return postRepository.findAllById(ids); - } + @CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true) + public Post unpinPost(Long id, String username) { + Post post = postRepository + .findById(id) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + java.time.LocalDateTime oldPinned = post.getPinnedAt(); + post.setPinnedAt(null); + Post saved = postRepository.save(post); + postChangeLogService.recordPinnedChange(saved, user, oldPinned, null); + return saved; + } - public long countPostsByCategory(Long categoryId) { - return postRepository.countByCategory_Id(categoryId); + @CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true) + public Post closePost(Long id, String username) { + Post post = postRepository + .findById(id) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + if (!user.getId().equals(post.getAuthor().getId()) && user.getRole() != Role.ADMIN) { + throw new IllegalArgumentException("Unauthorized"); } + boolean oldClosed = post.isClosed(); + post.setClosed(true); + Post saved = postRepository.save(post); + postChangeLogService.recordClosedChange(saved, user, oldClosed, true); + return saved; + } - public Map countPostsByCategoryIds(List categoryIds) { - Map result = new HashMap<>(); - var dbResult = postRepository.countPostsByCategoryIds(categoryIds); - dbResult.forEach(r -> { - result.put(((Long)r[0]), ((Long)r[1])); - }); - return result; + @CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true) + public Post reopenPost(Long id, String username) { + Post post = postRepository + .findById(id) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + if (!user.getId().equals(post.getAuthor().getId()) && user.getRole() != Role.ADMIN) { + throw new IllegalArgumentException("Unauthorized"); } + boolean oldClosed = post.isClosed(); + post.setClosed(false); + Post saved = postRepository.save(post); + postChangeLogService.recordClosedChange(saved, user, oldClosed, false); + return saved; + } - public long countPostsByTag(Long tagId) { - return postRepository.countDistinctByTags_Id(tagId); + @CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true) + @Transactional + public Post updatePost( + Long id, + String username, + Long categoryId, + String title, + String content, + java.util.List tagIds + ) { + if (tagIds == null || tagIds.isEmpty()) { + throw new IllegalArgumentException("At least one tag required"); } + if (tagIds.size() > 2) { + throw new IllegalArgumentException("At most two tags allowed"); + } + Post post = postRepository + .findById(id) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + if (!user.getId().equals(post.getAuthor().getId()) && user.getRole() != Role.ADMIN) { + throw new IllegalArgumentException("Unauthorized"); + } + Category category = categoryRepository + .findById(categoryId) + .orElseThrow(() -> new IllegalArgumentException("Category not found")); + java.util.List tags = tagRepository.findAllById(tagIds); + if (tags.isEmpty()) { + throw new IllegalArgumentException("Tag not found"); + } + String oldTitle = post.getTitle(); + String oldContent = post.getContent(); + Category oldCategory = post.getCategory(); + java.util.Set oldTags = new java.util.HashSet<>(post.getTags()); + post.setTitle(title); + post.setContent(content); + post.setCategory(category); + post.setTags(new java.util.HashSet<>(tags)); + Post updated = postRepository.save(post); + imageUploader.adjustReferences(oldContent, content); + notificationService.notifyMentions(content, user, updated, null); + if (!java.util.Objects.equals(oldTitle, title)) { + postChangeLogService.recordTitleChange(updated, user, oldTitle, title); + } + if (!java.util.Objects.equals(oldContent, content)) { + postChangeLogService.recordContentChange(updated, user, oldContent, content); + } + if (!java.util.Objects.equals(oldCategory.getId(), category.getId())) { + postChangeLogService.recordCategoryChange( + updated, + user, + oldCategory.getName(), + category.getName() + ); + } + java.util.Set newTags = new java.util.HashSet<>(tags); + if (!oldTags.equals(newTags)) { + postChangeLogService.recordTagChange(updated, user, oldTags, newTags); + } + return updated; + } - public Map countPostsByTagIds(List tagIds) { - Map result = new HashMap<>(); - if (CollectionUtils.isEmpty(tagIds)) { - return result; - } - var dbResult = postRepository.countPostsByTagIds(tagIds); - dbResult.forEach(r -> { - result.put(((Long)r[0]), ((Long)r[1])); - }); - return result; + @CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true) + @Transactional + public void deletePost(Long id, String username) { + Post post = postRepository + .findById(id) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + User author = post.getAuthor(); + boolean adminDeleting = !user.getId().equals(author.getId()) && user.getRole() == Role.ADMIN; + if (!user.getId().equals(author.getId()) && user.getRole() != Role.ADMIN) { + throw new IllegalArgumentException("Unauthorized"); } + for (Comment c : commentRepository.findByPostAndParentIsNullOrderByCreatedAtAsc(post)) { + commentService.deleteCommentCascade(c); + } + reactionRepository.findByPost(post).forEach(reactionRepository::delete); + postSubscriptionRepository.findByPost(post).forEach(postSubscriptionRepository::delete); + notificationRepository.deleteAll(notificationRepository.findByPost(post)); + postReadService.deleteByPost(post); + imageUploader.removeReferences(imageUploader.extractUrls(post.getContent())); + List pointHistories = pointHistoryRepository.findByPost(post); + Set usersToRecalculate = pointHistories + .stream() + .map(PointHistory::getUser) + .collect(Collectors.toSet()); + if (!pointHistories.isEmpty()) { + LocalDateTime deletedAt = LocalDateTime.now(); + for (PointHistory history : pointHistories) { + history.setDeletedAt(deletedAt); + history.setPost(null); + } + pointHistoryRepository.saveAll(pointHistories); + } + if (!usersToRecalculate.isEmpty()) { + for (User affected : usersToRecalculate) { + int newPoints = pointService.recalculateUserPoints(affected); + affected.setPoint(newPoints); + } + userRepository.saveAll(usersToRecalculate); + } + if (post instanceof LotteryPost lp) { + ScheduledFuture future = scheduledFinalizations.remove(lp.getId()); + if (future != null) { + future.cancel(false); + } + } + String title = post.getTitle(); + postChangeLogService.deleteLogsForPost(post); + postRepository.delete(post); + if (adminDeleting) { + notificationService.createNotification( + author, + NotificationType.POST_DELETED, + null, + null, + null, + user, + null, + title + ); + } + } - private java.util.List sortByPinnedAndCreated(java.util.List posts) { - return posts.stream() - .sorted(java.util.Comparator - .comparing(Post::getPinnedAt, java.util.Comparator.nullsLast(java.util.Comparator.reverseOrder())) - .thenComparing(Post::getCreatedAt, java.util.Comparator.reverseOrder())) - .toList(); - } + public java.util.List getPostsByIds(java.util.List ids) { + return postRepository.findAllById(ids); + } - private java.util.List sortByPinnedAndViews(java.util.List posts) { - return posts.stream() - .sorted(java.util.Comparator - .comparing(Post::getPinnedAt, java.util.Comparator.nullsLast(java.util.Comparator.reverseOrder())) - .thenComparing(Post::getViews, java.util.Comparator.reverseOrder())) - .toList(); - } + public long countPostsByCategory(Long categoryId) { + return postRepository.countByCategory_Id(categoryId); + } - private java.util.List sortByPinnedAndLastReply(java.util.List posts) { - return posts.stream() - .sorted(java.util.Comparator - .comparing(Post::getPinnedAt, java.util.Comparator.nullsLast(java.util.Comparator.reverseOrder())) - .thenComparing(p -> { - java.time.LocalDateTime t = commentRepository.findLastCommentTime(p); - return t != null ? t : p.getCreatedAt(); - }, java.util.Comparator.nullsLast(java.util.Comparator.reverseOrder()))) - .toList(); - } + public Map countPostsByCategoryIds(List categoryIds) { + Map result = new HashMap<>(); + var dbResult = postRepository.countPostsByCategoryIds(categoryIds); + dbResult.forEach(r -> { + result.put(((Long) r[0]), ((Long) r[1])); + }); + return result; + } - private List paginate(List posts, Integer page, Integer pageSize) { - if (page == null || pageSize == null) { - return posts; - } - int from = page * pageSize; - if (from >= posts.size()) { - return new ArrayList<>(); - } - int to = Math.min(from + pageSize, posts.size()); - // 这里必须将list包装为arrayList类型,否则序列化会有问题 - // list.sublist返回的是内部类 - return new ArrayList<>(posts.subList(from, to)); + public long countPostsByTag(Long tagId) { + return postRepository.countDistinctByTags_Id(tagId); + } + + public Map countPostsByTagIds(List tagIds) { + Map result = new HashMap<>(); + if (CollectionUtils.isEmpty(tagIds)) { + return result; } + var dbResult = postRepository.countPostsByTagIds(tagIds); + dbResult.forEach(r -> { + result.put(((Long) r[0]), ((Long) r[1])); + }); + return result; + } + + private java.util.List sortByPinnedAndCreated(java.util.List posts) { + return posts + .stream() + .sorted( + java.util.Comparator.comparing( + Post::getPinnedAt, + java.util.Comparator.nullsLast(java.util.Comparator.reverseOrder()) + ).thenComparing(Post::getCreatedAt, java.util.Comparator.reverseOrder()) + ) + .toList(); + } + + private java.util.List sortByPinnedAndViews(java.util.List posts) { + return posts + .stream() + .sorted( + java.util.Comparator.comparing( + Post::getPinnedAt, + java.util.Comparator.nullsLast(java.util.Comparator.reverseOrder()) + ).thenComparing(Post::getViews, java.util.Comparator.reverseOrder()) + ) + .toList(); + } + + private java.util.List sortByPinnedAndLastReply(java.util.List posts) { + return posts + .stream() + .sorted( + java.util.Comparator.comparing( + Post::getPinnedAt, + java.util.Comparator.nullsLast(java.util.Comparator.reverseOrder()) + ).thenComparing( + p -> { + java.time.LocalDateTime t = commentRepository.findLastCommentTime(p); + return t != null ? t : p.getCreatedAt(); + }, + java.util.Comparator.nullsLast(java.util.Comparator.reverseOrder()) + ) + ) + .toList(); + } + + private List paginate(List posts, Integer page, Integer pageSize) { + if (page == null || pageSize == null) { + return posts; + } + int from = page * pageSize; + if (from >= posts.size()) { + return new ArrayList<>(); + } + int to = Math.min(from + pageSize, posts.size()); + // 这里必须将list包装为arrayList类型,否则序列化会有问题 + // list.sublist返回的是内部类 + return new ArrayList<>(posts.subList(from, to)); + } } diff --git a/backend/src/main/java/com/openisle/service/PushNotificationService.java b/backend/src/main/java/com/openisle/service/PushNotificationService.java index f21d89ff8..8299a2467 100644 --- a/backend/src/main/java/com/openisle/service/PushNotificationService.java +++ b/backend/src/main/java/com/openisle/service/PushNotificationService.java @@ -3,6 +3,10 @@ package com.openisle.service; import com.openisle.model.PushSubscription; import com.openisle.model.User; import com.openisle.repository.PushSubscriptionRepository; +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.Security; +import java.util.List; import lombok.extern.slf4j.Slf4j; import nl.martijndwars.webpush.Notification; import nl.martijndwars.webpush.PushService; @@ -11,42 +15,51 @@ import org.jose4j.lang.JoseException; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import java.io.IOException; -import java.security.GeneralSecurityException; -import java.security.Security; -import java.util.List; - @Slf4j @Service public class PushNotificationService { - private final PushSubscriptionRepository subscriptionRepository; - private final PushService pushService; - public PushNotificationService(PushSubscriptionRepository subscriptionRepository, - @Value("${app.webpush.public-key:}") String publicKey, - @Value("${app.webpush.private-key:}") String privateKey) throws GeneralSecurityException { - this.subscriptionRepository = subscriptionRepository; - if (publicKey != null && !publicKey.isBlank() && privateKey != null && !privateKey.isBlank()) { - Security.addProvider(new BouncyCastleProvider()); - this.pushService = new PushService(publicKey, privateKey); - } else { - this.pushService = null; - } - } + private final PushSubscriptionRepository subscriptionRepository; + private final PushService pushService; - public void sendNotification(User user, String payload) { - if (pushService == null) { - log.warn("Push notifications are disabled because VAPID keys are not configured."); - return; - } - List subs = subscriptionRepository.findByUser(user); - for (PushSubscription sub : subs) { - try { - Notification notification = new Notification(sub.getEndpoint(), sub.getP256dh(), sub.getAuth(), payload); - pushService.send(notification); - } catch (GeneralSecurityException | IOException | JoseException | InterruptedException | java.util.concurrent.ExecutionException e) { - log.error(e.getMessage()); - } - } + public PushNotificationService( + PushSubscriptionRepository subscriptionRepository, + @Value("${app.webpush.public-key:}") String publicKey, + @Value("${app.webpush.private-key:}") String privateKey + ) throws GeneralSecurityException { + this.subscriptionRepository = subscriptionRepository; + if (publicKey != null && !publicKey.isBlank() && privateKey != null && !privateKey.isBlank()) { + Security.addProvider(new BouncyCastleProvider()); + this.pushService = new PushService(publicKey, privateKey); + } else { + this.pushService = null; } + } + + public void sendNotification(User user, String payload) { + if (pushService == null) { + log.warn("Push notifications are disabled because VAPID keys are not configured."); + return; + } + List subs = subscriptionRepository.findByUser(user); + for (PushSubscription sub : subs) { + try { + Notification notification = new Notification( + sub.getEndpoint(), + sub.getP256dh(), + sub.getAuth(), + payload + ); + pushService.send(notification); + } catch ( + GeneralSecurityException + | IOException + | JoseException + | InterruptedException + | java.util.concurrent.ExecutionException e + ) { + log.error(e.getMessage()); + } + } + } } diff --git a/backend/src/main/java/com/openisle/service/PushSubscriptionService.java b/backend/src/main/java/com/openisle/service/PushSubscriptionService.java index 5a062b302..ee797149e 100644 --- a/backend/src/main/java/com/openisle/service/PushSubscriptionService.java +++ b/backend/src/main/java/com/openisle/service/PushSubscriptionService.java @@ -4,32 +4,33 @@ import com.openisle.model.PushSubscription; import com.openisle.model.User; import com.openisle.repository.PushSubscriptionRepository; import com.openisle.repository.UserRepository; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; - @Service @RequiredArgsConstructor public class PushSubscriptionService { - private final PushSubscriptionRepository subscriptionRepository; - private final UserRepository userRepository; - @Transactional - public void saveSubscription(String username, String endpoint, String p256dh, String auth) { - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - subscriptionRepository.deleteByUserAndEndpoint(user, endpoint); - PushSubscription sub = new PushSubscription(); - sub.setUser(user); - sub.setEndpoint(endpoint); - sub.setP256dh(p256dh); - sub.setAuth(auth); - subscriptionRepository.save(sub); - } + private final PushSubscriptionRepository subscriptionRepository; + private final UserRepository userRepository; - public List listByUser(User user) { - return subscriptionRepository.findByUser(user); - } + @Transactional + public void saveSubscription(String username, String endpoint, String p256dh, String auth) { + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + subscriptionRepository.deleteByUserAndEndpoint(user, endpoint); + PushSubscription sub = new PushSubscription(); + sub.setUser(user); + sub.setEndpoint(endpoint); + sub.setP256dh(p256dh); + sub.setAuth(auth); + subscriptionRepository.save(sub); + } + + public List listByUser(User user) { + return subscriptionRepository.findByUser(user); + } } diff --git a/backend/src/main/java/com/openisle/service/ReactionService.java b/backend/src/main/java/com/openisle/service/ReactionService.java index f76ea2890..93b764f33 100644 --- a/backend/src/main/java/com/openisle/service/ReactionService.java +++ b/backend/src/main/java/com/openisle/service/ReactionService.java @@ -1,19 +1,19 @@ package com.openisle.service; import com.openisle.model.Comment; +import com.openisle.model.Message; +import com.openisle.model.NotificationType; import com.openisle.model.Post; import com.openisle.model.Reaction; import com.openisle.model.ReactionType; import com.openisle.model.User; -import com.openisle.model.NotificationType; -import com.openisle.model.Message; import com.openisle.repository.CommentRepository; +import com.openisle.repository.MessageRepository; import com.openisle.repository.PostRepository; import com.openisle.repository.ReactionRepository; import com.openisle.repository.UserRepository; -import com.openisle.repository.MessageRepository; -import com.openisle.service.NotificationService; import com.openisle.service.EmailSender; +import com.openisle.service.NotificationService; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -22,111 +22,153 @@ import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor public class ReactionService { - private final ReactionRepository reactionRepository; - private final UserRepository userRepository; - private final PostRepository postRepository; - private final CommentRepository commentRepository; - private final MessageRepository messageRepository; - private final NotificationService notificationService; - private final EmailSender emailSender; - @Value("${app.website-url}") - private String websiteUrl; + private final ReactionRepository reactionRepository; + private final UserRepository userRepository; + private final PostRepository postRepository; + private final CommentRepository commentRepository; + private final MessageRepository messageRepository; + private final NotificationService notificationService; + private final EmailSender emailSender; - @Transactional - public Reaction reactToPost(String username, Long postId, ReactionType type) { - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - Post post = postRepository.findById(postId) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); - java.util.Optional existing = - reactionRepository.findByUserAndPostAndType(user, post, type); - if (existing.isPresent()) { - notificationService.deleteReactionNotification(user, post, null, type); - reactionRepository.delete(existing.get()); - return null; - } - Reaction reaction = new Reaction(); - reaction.setUser(user); - reaction.setPost(post); - reaction.setType(type); - reaction = reactionRepository.save(reaction); - if (!user.getId().equals(post.getAuthor().getId())) { - notificationService.createNotification(post.getAuthor(), NotificationType.REACTION, post, null, null, user, type, null); - } - return reaction; + @Value("${app.website-url}") + private String websiteUrl; + + @Transactional + public Reaction reactToPost(String username, Long postId, ReactionType type) { + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + Post post = postRepository + .findById(postId) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + java.util.Optional existing = reactionRepository.findByUserAndPostAndType( + user, + post, + type + ); + if (existing.isPresent()) { + notificationService.deleteReactionNotification(user, post, null, type); + reactionRepository.delete(existing.get()); + return null; } - - @Transactional - public Reaction reactToComment(String username, Long commentId, ReactionType type) { - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - Comment comment = commentRepository.findById(commentId) - .orElseThrow(() -> new IllegalArgumentException("Comment not found")); - java.util.Optional existing = - reactionRepository.findByUserAndCommentAndType(user, comment, type); - if (existing.isPresent()) { - notificationService.deleteReactionNotification(user, null, comment, type); - reactionRepository.delete(existing.get()); - return null; - } - Reaction reaction = new Reaction(); - reaction.setUser(user); - reaction.setComment(comment); - reaction.setPost(null); - reaction.setType(type); - reaction = reactionRepository.save(reaction); - if (!user.getId().equals(comment.getAuthor().getId())) { - notificationService.createNotification(comment.getAuthor(), NotificationType.REACTION, comment.getPost(), comment, null, user, type, null); - } - return reaction; + Reaction reaction = new Reaction(); + reaction.setUser(user); + reaction.setPost(post); + reaction.setType(type); + reaction = reactionRepository.save(reaction); + if (!user.getId().equals(post.getAuthor().getId())) { + notificationService.createNotification( + post.getAuthor(), + NotificationType.REACTION, + post, + null, + null, + user, + type, + null + ); } + return reaction; + } - @Transactional - public Reaction reactToMessage(String username, Long messageId, ReactionType type) { - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - Message message = messageRepository.findById(messageId) - .orElseThrow(() -> new IllegalArgumentException("Message not found")); - java.util.Optional existing = - reactionRepository.findByUserAndMessageAndType(user, message, type); - if (existing.isPresent()) { - reactionRepository.delete(existing.get()); - return null; - } - Reaction reaction = new Reaction(); - reaction.setUser(user); - reaction.setMessage(message); - reaction.setType(type); - reaction = reactionRepository.save(reaction); - return reaction; + @Transactional + public Reaction reactToComment(String username, Long commentId, ReactionType type) { + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + Comment comment = commentRepository + .findById(commentId) + .orElseThrow(() -> new IllegalArgumentException("Comment not found")); + java.util.Optional existing = reactionRepository.findByUserAndCommentAndType( + user, + comment, + type + ); + if (existing.isPresent()) { + notificationService.deleteReactionNotification(user, null, comment, type); + reactionRepository.delete(existing.get()); + return null; } + Reaction reaction = new Reaction(); + reaction.setUser(user); + reaction.setComment(comment); + reaction.setPost(null); + reaction.setType(type); + reaction = reactionRepository.save(reaction); + if (!user.getId().equals(comment.getAuthor().getId())) { + notificationService.createNotification( + comment.getAuthor(), + NotificationType.REACTION, + comment.getPost(), + comment, + null, + user, + type, + null + ); + } + return reaction; + } - public java.util.List getReactionsForPost(Long postId) { - Post post = postRepository.findById(postId) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); - return reactionRepository.findByPost(post); + @Transactional + public Reaction reactToMessage(String username, Long messageId, ReactionType type) { + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + Message message = messageRepository + .findById(messageId) + .orElseThrow(() -> new IllegalArgumentException("Message not found")); + java.util.Optional existing = reactionRepository.findByUserAndMessageAndType( + user, + message, + type + ); + if (existing.isPresent()) { + reactionRepository.delete(existing.get()); + return null; } + Reaction reaction = new Reaction(); + reaction.setUser(user); + reaction.setMessage(message); + reaction.setType(type); + reaction = reactionRepository.save(reaction); + return reaction; + } - public java.util.List getReactionsForComment(Long commentId) { - Comment comment = commentRepository.findById(commentId) - .orElseThrow(() -> new IllegalArgumentException("Comment not found")); - return reactionRepository.findByComment(comment); - } + public java.util.List getReactionsForPost(Long postId) { + Post post = postRepository + .findById(postId) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + return reactionRepository.findByPost(post); + } - public java.util.List topPostIds(String username, int limit) { - return reactionRepository.findTopPostIds(username, org.springframework.data.domain.PageRequest.of(0, limit)); - } + public java.util.List getReactionsForComment(Long commentId) { + Comment comment = commentRepository + .findById(commentId) + .orElseThrow(() -> new IllegalArgumentException("Comment not found")); + return reactionRepository.findByComment(comment); + } - public java.util.List topCommentIds(String username, int limit) { - return reactionRepository.findTopCommentIds(username, org.springframework.data.domain.PageRequest.of(0, limit)); - } + public java.util.List topPostIds(String username, int limit) { + return reactionRepository.findTopPostIds( + username, + org.springframework.data.domain.PageRequest.of(0, limit) + ); + } - public long countLikesSent(String username) { - return reactionRepository.countLikesSent(username); - } + public java.util.List topCommentIds(String username, int limit) { + return reactionRepository.findTopCommentIds( + username, + org.springframework.data.domain.PageRequest.of(0, limit) + ); + } - public long countLikesReceived(String username) { - return reactionRepository.countLikesReceived(username); - } + public long countLikesSent(String username) { + return reactionRepository.countLikesSent(username); + } + + public long countLikesReceived(String username) { + return reactionRepository.countLikesReceived(username); + } } diff --git a/backend/src/main/java/com/openisle/service/RecaptchaService.java b/backend/src/main/java/com/openisle/service/RecaptchaService.java index d41e38282..c85d1dd13 100644 --- a/backend/src/main/java/com/openisle/service/RecaptchaService.java +++ b/backend/src/main/java/com/openisle/service/RecaptchaService.java @@ -1,35 +1,35 @@ package com.openisle.service; +import java.util.Map; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; -import java.util.Map; - /** * CaptchaService implementation using Google reCAPTCHA. */ @Service public class RecaptchaService extends CaptchaService { - @Value("${recaptcha.secret-key:}") - private String secretKey; + @Value("${recaptcha.secret-key:}") + private String secretKey; - private final RestTemplate restTemplate = new RestTemplate(); + private final RestTemplate restTemplate = new RestTemplate(); - @Override - public boolean verify(String token) { - if (token == null || token.isEmpty()) { - return false; - } - String url = "https://www.google.com/recaptcha/api/siteverify?secret={secret}&response={response}"; - try { - ResponseEntity resp = restTemplate.postForEntity(url, null, Map.class, secretKey, token); - Map body = resp.getBody(); - return body != null && Boolean.TRUE.equals(body.get("success")); - } catch (Exception e) { - return false; - } + @Override + public boolean verify(String token) { + if (token == null || token.isEmpty()) { + return false; } + String url = + "https://www.google.com/recaptcha/api/siteverify?secret={secret}&response={response}"; + try { + ResponseEntity resp = restTemplate.postForEntity(url, null, Map.class, secretKey, token); + Map body = resp.getBody(); + return body != null && Boolean.TRUE.equals(body.get("success")); + } catch (Exception e) { + return false; + } + } } diff --git a/backend/src/main/java/com/openisle/service/RegisterModeService.java b/backend/src/main/java/com/openisle/service/RegisterModeService.java index f12447a00..b375a1569 100644 --- a/backend/src/main/java/com/openisle/service/RegisterModeService.java +++ b/backend/src/main/java/com/openisle/service/RegisterModeService.java @@ -9,17 +9,18 @@ import org.springframework.stereotype.Service; */ @Service public class RegisterModeService { - private RegisterMode registerMode; - public RegisterModeService(@Value("${app.register.mode:WHITELIST}") RegisterMode registerMode) { - this.registerMode = registerMode; - } + private RegisterMode registerMode; - public RegisterMode getRegisterMode() { - return registerMode; - } + public RegisterModeService(@Value("${app.register.mode:WHITELIST}") RegisterMode registerMode) { + this.registerMode = registerMode; + } - public void setRegisterMode(RegisterMode mode) { - this.registerMode = mode; - } + public RegisterMode getRegisterMode() { + return registerMode; + } + + public void setRegisterMode(RegisterMode mode) { + this.registerMode = mode; + } } diff --git a/backend/src/main/java/com/openisle/service/ResendEmailSender.java b/backend/src/main/java/com/openisle/service/ResendEmailSender.java index ec774981d..c205584c0 100644 --- a/backend/src/main/java/com/openisle/service/ResendEmailSender.java +++ b/backend/src/main/java/com/openisle/service/ResendEmailSender.java @@ -1,44 +1,43 @@ package com.openisle.service; +import java.util.HashMap; +import java.util.Map; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; -import org.springframework.stereotype.Service; import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; -import java.util.HashMap; -import java.util.Map; - @Service public class ResendEmailSender extends EmailSender { - @Value("${resend.api.key}") - private String apiKey; + @Value("${resend.api.key}") + private String apiKey; - @Value("${resend.from.email}") - private String fromEmail; + @Value("${resend.from.email}") + private String fromEmail; - private final RestTemplate restTemplate = new RestTemplate(); + private final RestTemplate restTemplate = new RestTemplate(); - @Override - @Async("notificationExecutor") - public void sendEmail(String to, String subject, String text) { - String url = "https://api.resend.com/emails"; // hypothetical endpoint + @Override + @Async("notificationExecutor") + public void sendEmail(String to, String subject, String text) { + String url = "https://api.resend.com/emails"; // hypothetical endpoint - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.set("Authorization", "Bearer " + apiKey); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Authorization", "Bearer " + apiKey); - Map body = new HashMap<>(); - body.put("to", to); - body.put("subject", subject); - body.put("text", text); - body.put("from", "openisle <" + fromEmail + ">"); + Map body = new HashMap<>(); + body.put("to", to); + body.put("subject", subject); + body.put("text", text); + body.put("from", "openisle <" + fromEmail + ">"); - HttpEntity> entity = new HttpEntity<>(body, headers); - restTemplate.exchange(url, HttpMethod.POST, entity, String.class); - } + HttpEntity> entity = new HttpEntity<>(body, headers); + restTemplate.exchange(url, HttpMethod.POST, entity, String.class); + } } diff --git a/backend/src/main/java/com/openisle/service/SearchService.java b/backend/src/main/java/com/openisle/service/SearchService.java index c3b9c72ca..5b9485f42 100644 --- a/backend/src/main/java/com/openisle/service/SearchService.java +++ b/backend/src/main/java/com/openisle/service/SearchService.java @@ -1,171 +1,176 @@ package com.openisle.service; +import com.openisle.model.Category; +import com.openisle.model.Comment; import com.openisle.model.Post; import com.openisle.model.PostStatus; -import com.openisle.model.Comment; -import com.openisle.model.User; -import com.openisle.model.Category; import com.openisle.model.Tag; -import com.openisle.repository.PostRepository; -import com.openisle.repository.CommentRepository; -import com.openisle.repository.UserRepository; +import com.openisle.model.User; import com.openisle.repository.CategoryRepository; +import com.openisle.repository.CommentRepository; +import com.openisle.repository.PostRepository; import com.openisle.repository.TagRepository; +import com.openisle.repository.UserRepository; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import java.util.List; -import java.util.LinkedHashMap; -import java.util.stream.Stream; -import java.util.stream.Collectors; - @Service @RequiredArgsConstructor public class SearchService { - private final UserRepository userRepository; - private final PostRepository postRepository; - private final CommentRepository commentRepository; - private final CategoryRepository categoryRepository; - private final TagRepository tagRepository; - @org.springframework.beans.factory.annotation.Value("${app.snippet-length:50}") - private int snippetLength; + private final UserRepository userRepository; + private final PostRepository postRepository; + private final CommentRepository commentRepository; + private final CategoryRepository categoryRepository; + private final TagRepository tagRepository; - public List searchUsers(String keyword) { - return userRepository.findByUsernameContainingIgnoreCase(keyword); + @org.springframework.beans.factory.annotation.Value("${app.snippet-length:50}") + private int snippetLength; + + public List searchUsers(String keyword) { + return userRepository.findByUsernameContainingIgnoreCase(keyword); + } + + public List searchPosts(String keyword) { + return postRepository.findByTitleContainingIgnoreCaseOrContentContainingIgnoreCaseAndStatus( + keyword, + keyword, + PostStatus.PUBLISHED + ); + } + + public List searchPostsByContent(String keyword) { + return postRepository.findByContentContainingIgnoreCaseAndStatus(keyword, PostStatus.PUBLISHED); + } + + public List searchPostsByTitle(String keyword) { + return postRepository.findByTitleContainingIgnoreCaseAndStatus(keyword, PostStatus.PUBLISHED); + } + + public List searchComments(String keyword) { + return commentRepository.findByContentContainingIgnoreCase(keyword); + } + + public List searchCategories(String keyword) { + return categoryRepository.findByNameContainingIgnoreCase(keyword); + } + + public List searchTags(String keyword) { + return tagRepository.findByNameContainingIgnoreCaseAndApprovedTrue(keyword); + } + + public List globalSearch(String keyword) { + Stream users = searchUsers(keyword) + .stream() + .map(u -> + new SearchResult("user", u.getId(), u.getUsername(), u.getIntroduction(), null, null) + ); + + Stream categories = searchCategories(keyword) + .stream() + .map(c -> + new SearchResult("category", c.getId(), c.getName(), null, c.getDescription(), null) + ); + + Stream tags = searchTags(keyword) + .stream() + .map(t -> new SearchResult("tag", t.getId(), t.getName(), null, t.getDescription(), null)); + + // Merge post results while removing duplicates between search by content + // and search by title + List mergedPosts = Stream.concat( + searchPosts(keyword) + .stream() + .map(p -> + new SearchResult( + "post", + p.getId(), + p.getTitle(), + p.getCategory() != null ? p.getCategory().getName() : null, + extractSnippet(p.getContent(), keyword, false), + null + ) + ), + searchPostsByTitle(keyword) + .stream() + .map(p -> + new SearchResult( + "post_title", + p.getId(), + p.getTitle(), + p.getCategory() != null ? p.getCategory().getName() : null, + extractSnippet(p.getContent(), keyword, true), + null + ) + ) + ) + .collect( + java.util.stream.Collectors.toMap( + SearchResult::id, + sr -> sr, + (a, b) -> a, + java.util.LinkedHashMap::new + ) + ) + .values() + .stream() + .toList(); + + Stream comments = searchComments(keyword) + .stream() + .map(c -> + new SearchResult( + "comment", + c.getId(), + c.getPost().getTitle(), + c.getAuthor().getUsername(), + extractSnippet(c.getContent(), keyword, false), + c.getPost().getId() + ) + ); + + return Stream.of(users, categories, tags, mergedPosts.stream(), comments) + .flatMap(s -> s) + .toList(); + } + + private String extractSnippet(String content, String keyword, boolean fromStart) { + if (content == null) return ""; + int limit = snippetLength; + if (fromStart) { + if (limit < 0) { + return content; + } + return content.length() > limit ? content.substring(0, limit) : content; } - - public List searchPosts(String keyword) { - return postRepository - .findByTitleContainingIgnoreCaseOrContentContainingIgnoreCaseAndStatus(keyword, keyword, PostStatus.PUBLISHED); + String lower = content.toLowerCase(); + String kw = keyword.toLowerCase(); + int idx = lower.indexOf(kw); + if (idx == -1) { + if (limit < 0) { + return content; + } + return content.length() > limit ? content.substring(0, limit) : content; } - - public List searchPostsByContent(String keyword) { - return postRepository - .findByContentContainingIgnoreCaseAndStatus(keyword, PostStatus.PUBLISHED); + int start = Math.max(0, idx - 20); + int end = Math.min(content.length(), idx + kw.length() + 20); + String snippet = content.substring(start, end); + if (limit >= 0 && snippet.length() > limit) { + snippet = snippet.substring(0, limit); } + return snippet; + } - public List searchPostsByTitle(String keyword) { - return postRepository - .findByTitleContainingIgnoreCaseAndStatus(keyword, PostStatus.PUBLISHED); - } - - public List searchComments(String keyword) { - return commentRepository.findByContentContainingIgnoreCase(keyword); - } - - public List searchCategories(String keyword) { - return categoryRepository.findByNameContainingIgnoreCase(keyword); - } - - public List searchTags(String keyword) { - return tagRepository.findByNameContainingIgnoreCaseAndApprovedTrue(keyword); - } - - public List globalSearch(String keyword) { - Stream users = searchUsers(keyword).stream() - .map(u -> new SearchResult( - "user", - u.getId(), - u.getUsername(), - u.getIntroduction(), - null, - null - )); - - Stream categories = searchCategories(keyword).stream() - .map(c -> new SearchResult( - "category", - c.getId(), - c.getName(), - null, - c.getDescription(), - null - )); - - Stream tags = searchTags(keyword).stream() - .map(t -> new SearchResult( - "tag", - t.getId(), - t.getName(), - null, - t.getDescription(), - null - )); - - // Merge post results while removing duplicates between search by content - // and search by title - List mergedPosts = Stream.concat( - searchPosts(keyword).stream() - .map(p -> new SearchResult( - "post", - p.getId(), - p.getTitle(), - p.getCategory() != null ? p.getCategory().getName() : null, - extractSnippet(p.getContent(), keyword, false), - null - )), - searchPostsByTitle(keyword).stream() - .map(p -> new SearchResult( - "post_title", - p.getId(), - p.getTitle(), - p.getCategory() != null ? p.getCategory().getName() : null, - extractSnippet(p.getContent(), keyword, true), - null - )) - ) - .collect(java.util.stream.Collectors.toMap( - SearchResult::id, - sr -> sr, - (a, b) -> a, - java.util.LinkedHashMap::new - )) - .values() - .stream() - .toList(); - - Stream comments = searchComments(keyword).stream() - .map(c -> new SearchResult( - "comment", - c.getId(), - c.getPost().getTitle(), - c.getAuthor().getUsername(), - extractSnippet(c.getContent(), keyword, false), - c.getPost().getId() - )); - - return Stream.of(users, categories, tags, mergedPosts.stream(), comments) - .flatMap(s -> s) - .toList(); - } - - private String extractSnippet(String content, String keyword, boolean fromStart) { - if (content == null) return ""; - int limit = snippetLength; - if (fromStart) { - if (limit < 0) { - return content; - } - return content.length() > limit ? content.substring(0, limit) : content; - } - String lower = content.toLowerCase(); - String kw = keyword.toLowerCase(); - int idx = lower.indexOf(kw); - if (idx == -1) { - if (limit < 0) { - return content; - } - return content.length() > limit ? content.substring(0, limit) : content; - } - int start = Math.max(0, idx - 20); - int end = Math.min(content.length(), idx + kw.length() + 20); - String snippet = content.substring(start, end); - if (limit >= 0 && snippet.length() > limit) { - snippet = snippet.substring(0, limit); - } - return snippet; - } - - public record SearchResult(String type, Long id, String text, String subText, String extra, Long postId) {} + public record SearchResult( + String type, + Long id, + String text, + String subText, + String extra, + Long postId + ) {} } diff --git a/backend/src/main/java/com/openisle/service/StatService.java b/backend/src/main/java/com/openisle/service/StatService.java index 92276a658..762d7361a 100644 --- a/backend/src/main/java/com/openisle/service/StatService.java +++ b/backend/src/main/java/com/openisle/service/StatService.java @@ -1,56 +1,68 @@ package com.openisle.service; -import com.openisle.repository.UserRepository; -import com.openisle.repository.PostRepository; import com.openisle.repository.CommentRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - +import com.openisle.repository.PostRepository; +import com.openisle.repository.UserRepository; import java.time.LocalDate; import java.util.LinkedHashMap; import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor public class StatService { - private final UserRepository userRepository; - private final PostRepository postRepository; - private final CommentRepository commentRepository; - private Map toDateMap(LocalDate start, LocalDate end, java.util.List list) { - Map result = new LinkedHashMap<>(); - for (var obj : list) { - Object dateObj = obj[0]; - LocalDate d; - if (dateObj instanceof java.sql.Date sqlDate) { - d = sqlDate.toLocalDate(); - } else if (dateObj instanceof LocalDate localDate) { - d = localDate; - } else { - d = LocalDate.parse(dateObj.toString()); - } - Long c = ((Number) obj[1]).longValue(); - result.put(d, c); - } - for (LocalDate d = start; !d.isAfter(end); d = d.plusDays(1)) { - result.putIfAbsent(d, 0L); - } - return result; - } + private final UserRepository userRepository; + private final PostRepository postRepository; + private final CommentRepository commentRepository; - public Map countNewUsersRange(LocalDate start, LocalDate end) { - java.util.List list = userRepository.countDailyRange(start.atStartOfDay(), end.plusDays(1).atStartOfDay()); - return toDateMap(start, end, list); + private Map toDateMap( + LocalDate start, + LocalDate end, + java.util.List list + ) { + Map result = new LinkedHashMap<>(); + for (var obj : list) { + Object dateObj = obj[0]; + LocalDate d; + if (dateObj instanceof java.sql.Date sqlDate) { + d = sqlDate.toLocalDate(); + } else if (dateObj instanceof LocalDate localDate) { + d = localDate; + } else { + d = LocalDate.parse(dateObj.toString()); + } + Long c = ((Number) obj[1]).longValue(); + result.put(d, c); } + for (LocalDate d = start; !d.isAfter(end); d = d.plusDays(1)) { + result.putIfAbsent(d, 0L); + } + return result; + } - public Map countPostsRange(LocalDate start, LocalDate end) { - java.util.List list = postRepository.countDailyRange(start.atStartOfDay(), end.plusDays(1).atStartOfDay()); - return toDateMap(start, end, list); - } + public Map countNewUsersRange(LocalDate start, LocalDate end) { + java.util.List list = userRepository.countDailyRange( + start.atStartOfDay(), + end.plusDays(1).atStartOfDay() + ); + return toDateMap(start, end, list); + } - public Map countCommentsRange(LocalDate start, LocalDate end) { - java.util.List list = commentRepository.countDailyRange(start.atStartOfDay(), end.plusDays(1).atStartOfDay()); - return toDateMap(start, end, list); - } + public Map countPostsRange(LocalDate start, LocalDate end) { + java.util.List list = postRepository.countDailyRange( + start.atStartOfDay(), + end.plusDays(1).atStartOfDay() + ); + return toDateMap(start, end, list); + } + + public Map countCommentsRange(LocalDate start, LocalDate end) { + java.util.List list = commentRepository.countDailyRange( + start.atStartOfDay(), + end.plusDays(1).atStartOfDay() + ); + return toDateMap(start, end, list); + } } - diff --git a/backend/src/main/java/com/openisle/service/SubscriptionService.java b/backend/src/main/java/com/openisle/service/SubscriptionService.java index f6429ff31..18db02299 100644 --- a/backend/src/main/java/com/openisle/service/SubscriptionService.java +++ b/backend/src/main/java/com/openisle/service/SubscriptionService.java @@ -2,153 +2,194 @@ package com.openisle.service; import com.openisle.model.*; import com.openisle.repository.*; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - import java.util.List; import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor public class SubscriptionService { - private final PostSubscriptionRepository postSubRepo; - private final CommentSubscriptionRepository commentSubRepo; - private final UserSubscriptionRepository userSubRepo; - private final UserRepository userRepo; - private final PostRepository postRepo; - private final CommentRepository commentRepo; - private final NotificationService notificationService; - public void subscribePost(String username, Long postId) { - User user = userRepo.findByUsername(username).orElseThrow(); - Post post = postRepo.findById(postId).orElseThrow(); - postSubRepo.findByUserAndPost(user, post).orElseGet(() -> { - PostSubscription ps = new PostSubscription(); - ps.setUser(user); - ps.setPost(post); - if (!user.getId().equals(post.getAuthor().getId())) { - notificationService.createNotification(post.getAuthor(), - NotificationType.POST_SUBSCRIBED, post, null, null, user, null, null); - } - return postSubRepo.save(ps); - }); - } + private final PostSubscriptionRepository postSubRepo; + private final CommentSubscriptionRepository commentSubRepo; + private final UserSubscriptionRepository userSubRepo; + private final UserRepository userRepo; + private final PostRepository postRepo; + private final CommentRepository commentRepo; + private final NotificationService notificationService; - public void unsubscribePost(String username, Long postId) { - User user = userRepo.findByUsername(username).orElseThrow(); - Post post = postRepo.findById(postId).orElseThrow(); - postSubRepo.findByUserAndPost(user, post).ifPresent(ps -> { - postSubRepo.delete(ps); - if (!user.getId().equals(post.getAuthor().getId())) { - notificationService.createNotification(post.getAuthor(), - NotificationType.POST_UNSUBSCRIBED, post, null, null, user, null, null); - } - }); - } - - public void subscribeComment(String username, Long commentId) { - User user = userRepo.findByUsername(username).orElseThrow(); - Comment comment = commentRepo.findById(commentId).orElseThrow(); - commentSubRepo.findByUserAndComment(user, comment).orElseGet(() -> { - CommentSubscription cs = new CommentSubscription(); - cs.setUser(user); - cs.setComment(comment); - return commentSubRepo.save(cs); - }); - } - - public void unsubscribeComment(String username, Long commentId) { - User user = userRepo.findByUsername(username).orElseThrow(); - Comment comment = commentRepo.findById(commentId).orElseThrow(); - commentSubRepo.findByUserAndComment(user, comment).ifPresent(commentSubRepo::delete); - } - - public void subscribeUser(String username, String targetName) { - if (username.equals(targetName)) return; - User subscriber = userRepo.findByUsername(username).orElseThrow(); - User target = findUser(targetName).orElseThrow(); - userSubRepo.findBySubscriberAndTarget(subscriber, target).orElseGet(() -> { - UserSubscription us = new UserSubscription(); - us.setSubscriber(subscriber); - us.setTarget(target); - notificationService.createNotification(target, - NotificationType.USER_FOLLOWED, null, null, null, subscriber, null, null); - return userSubRepo.save(us); - }); - } - - public void unsubscribeUser(String username, String targetName) { - User subscriber = userRepo.findByUsername(username).orElseThrow(); - User target = findUser(targetName).orElseThrow(); - userSubRepo.findBySubscriberAndTarget(subscriber, target).ifPresent(us -> { - userSubRepo.delete(us); - notificationService.createNotification(target, - NotificationType.USER_UNFOLLOWED, null, null, null, subscriber, null, null); - }); - } - - public List getSubscribedUsers(String username) { - User user = userRepo.findByUsername(username).orElseThrow(); - return userSubRepo.findBySubscriber(user).stream().map(UserSubscription::getTarget).toList(); - } - - public List getSubscribers(String username) { - User user = userRepo.findByUsername(username).orElseThrow(); - return userSubRepo.findByTarget(user).stream().map(UserSubscription::getSubscriber).toList(); - } - - public List getPostSubscribers(Long postId) { - Post post = postRepo.findById(postId).orElseThrow(); - return postSubRepo.findByPost(post).stream().map(PostSubscription::getUser).toList(); - } - - public List getCommentSubscribers(Long commentId) { - Comment c = commentRepo.findById(commentId).orElseThrow(); - return commentSubRepo.findByComment(c).stream().map(CommentSubscription::getUser).toList(); - } - - public List getSubscribedPosts(String username) { - User user = userRepo.findByUsername(username).orElseThrow(); - return postSubRepo.findByUser(user).stream().map(PostSubscription::getPost).toList(); - } - - - public long countSubscribers(String username) { - User user = userRepo.findByUsername(username).orElseThrow(); - return userSubRepo.countByTarget(user); - } - - public long countSubscribed(String username) { - User user = userRepo.findByUsername(username).orElseThrow(); - return userSubRepo.countBySubscriber(user); - } - - public boolean isSubscribed(String subscriberName, String targetName) { - if (subscriberName == null || targetName == null || subscriberName.equals(targetName)) { - return false; + public void subscribePost(String username, Long postId) { + User user = userRepo.findByUsername(username).orElseThrow(); + Post post = postRepo.findById(postId).orElseThrow(); + postSubRepo + .findByUserAndPost(user, post) + .orElseGet(() -> { + PostSubscription ps = new PostSubscription(); + ps.setUser(user); + ps.setPost(post); + if (!user.getId().equals(post.getAuthor().getId())) { + notificationService.createNotification( + post.getAuthor(), + NotificationType.POST_SUBSCRIBED, + post, + null, + null, + user, + null, + null + ); } - Optional subscriber = userRepo.findByUsername(subscriberName); - Optional target = findUser(targetName); - if (subscriber.isEmpty() || target.isEmpty()) { - // 修改个人信息会出现,先不抛出错误 - return false; - } - return userSubRepo.findBySubscriberAndTarget(subscriber.get(), target.get()).isPresent(); - } + return postSubRepo.save(ps); + }); + } - public boolean isPostSubscribed(String username, Long postId) { - if (username == null || postId == null) { - return false; + public void unsubscribePost(String username, Long postId) { + User user = userRepo.findByUsername(username).orElseThrow(); + Post post = postRepo.findById(postId).orElseThrow(); + postSubRepo + .findByUserAndPost(user, post) + .ifPresent(ps -> { + postSubRepo.delete(ps); + if (!user.getId().equals(post.getAuthor().getId())) { + notificationService.createNotification( + post.getAuthor(), + NotificationType.POST_UNSUBSCRIBED, + post, + null, + null, + user, + null, + null + ); } - User user = userRepo.findByUsername(username).orElseThrow(); - Post post = postRepo.findById(postId).orElseThrow(); - return postSubRepo.findByUserAndPost(user, post).isPresent(); - } + }); + } - private Optional findUser(String identifier) { - if (identifier.matches("\\d+")) { - return userRepo.findById(Long.parseLong(identifier)); - } - return userRepo.findByUsername(identifier); + public void subscribeComment(String username, Long commentId) { + User user = userRepo.findByUsername(username).orElseThrow(); + Comment comment = commentRepo.findById(commentId).orElseThrow(); + commentSubRepo + .findByUserAndComment(user, comment) + .orElseGet(() -> { + CommentSubscription cs = new CommentSubscription(); + cs.setUser(user); + cs.setComment(comment); + return commentSubRepo.save(cs); + }); + } + + public void unsubscribeComment(String username, Long commentId) { + User user = userRepo.findByUsername(username).orElseThrow(); + Comment comment = commentRepo.findById(commentId).orElseThrow(); + commentSubRepo.findByUserAndComment(user, comment).ifPresent(commentSubRepo::delete); + } + + public void subscribeUser(String username, String targetName) { + if (username.equals(targetName)) return; + User subscriber = userRepo.findByUsername(username).orElseThrow(); + User target = findUser(targetName).orElseThrow(); + userSubRepo + .findBySubscriberAndTarget(subscriber, target) + .orElseGet(() -> { + UserSubscription us = new UserSubscription(); + us.setSubscriber(subscriber); + us.setTarget(target); + notificationService.createNotification( + target, + NotificationType.USER_FOLLOWED, + null, + null, + null, + subscriber, + null, + null + ); + return userSubRepo.save(us); + }); + } + + public void unsubscribeUser(String username, String targetName) { + User subscriber = userRepo.findByUsername(username).orElseThrow(); + User target = findUser(targetName).orElseThrow(); + userSubRepo + .findBySubscriberAndTarget(subscriber, target) + .ifPresent(us -> { + userSubRepo.delete(us); + notificationService.createNotification( + target, + NotificationType.USER_UNFOLLOWED, + null, + null, + null, + subscriber, + null, + null + ); + }); + } + + public List getSubscribedUsers(String username) { + User user = userRepo.findByUsername(username).orElseThrow(); + return userSubRepo.findBySubscriber(user).stream().map(UserSubscription::getTarget).toList(); + } + + public List getSubscribers(String username) { + User user = userRepo.findByUsername(username).orElseThrow(); + return userSubRepo.findByTarget(user).stream().map(UserSubscription::getSubscriber).toList(); + } + + public List getPostSubscribers(Long postId) { + Post post = postRepo.findById(postId).orElseThrow(); + return postSubRepo.findByPost(post).stream().map(PostSubscription::getUser).toList(); + } + + public List getCommentSubscribers(Long commentId) { + Comment c = commentRepo.findById(commentId).orElseThrow(); + return commentSubRepo.findByComment(c).stream().map(CommentSubscription::getUser).toList(); + } + + public List getSubscribedPosts(String username) { + User user = userRepo.findByUsername(username).orElseThrow(); + return postSubRepo.findByUser(user).stream().map(PostSubscription::getPost).toList(); + } + + public long countSubscribers(String username) { + User user = userRepo.findByUsername(username).orElseThrow(); + return userSubRepo.countByTarget(user); + } + + public long countSubscribed(String username) { + User user = userRepo.findByUsername(username).orElseThrow(); + return userSubRepo.countBySubscriber(user); + } + + public boolean isSubscribed(String subscriberName, String targetName) { + if (subscriberName == null || targetName == null || subscriberName.equals(targetName)) { + return false; } + Optional subscriber = userRepo.findByUsername(subscriberName); + Optional target = findUser(targetName); + if (subscriber.isEmpty() || target.isEmpty()) { + // 修改个人信息会出现,先不抛出错误 + return false; + } + return userSubRepo.findBySubscriberAndTarget(subscriber.get(), target.get()).isPresent(); + } + + public boolean isPostSubscribed(String username, Long postId) { + if (username == null || postId == null) { + return false; + } + User user = userRepo.findByUsername(username).orElseThrow(); + Post post = postRepo.findById(postId).orElseThrow(); + return postSubRepo.findByUserAndPost(user, post).isPresent(); + } + + private Optional findUser(String identifier) { + if (identifier.matches("\\d+")) { + return userRepo.findById(Long.parseLong(identifier)); + } + return userRepo.findByUsername(identifier); + } } diff --git a/backend/src/main/java/com/openisle/service/TagService.java b/backend/src/main/java/com/openisle/service/TagService.java index 02191427d..0000f9d84 100644 --- a/backend/src/main/java/com/openisle/service/TagService.java +++ b/backend/src/main/java/com/openisle/service/TagService.java @@ -5,133 +5,152 @@ import com.openisle.model.Tag; import com.openisle.model.User; import com.openisle.repository.TagRepository; import com.openisle.repository.UserRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; -import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import java.util.List; - @Service @RequiredArgsConstructor public class TagService { - private final TagRepository tagRepository; - private final TagValidator tagValidator; - private final UserRepository userRepository; - @CacheEvict(value = CachingConfig.TAG_CACHE_NAME, allEntries = true) - public Tag createTag(String name, String description, String icon, String smallIcon, boolean approved, String creatorUsername) { - tagValidator.validate(name); - Tag tag = new Tag(); - tag.setName(name); - tag.setDescription(description); - tag.setIcon(icon); - tag.setSmallIcon(smallIcon); - tag.setApproved(approved); - if (creatorUsername != null) { - User creator = userRepository.findByUsername(creatorUsername) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - tag.setCreator(creator); - } - return tagRepository.save(tag); + private final TagRepository tagRepository; + private final TagValidator tagValidator; + private final UserRepository userRepository; + + @CacheEvict(value = CachingConfig.TAG_CACHE_NAME, allEntries = true) + public Tag createTag( + String name, + String description, + String icon, + String smallIcon, + boolean approved, + String creatorUsername + ) { + tagValidator.validate(name); + Tag tag = new Tag(); + tag.setName(name); + tag.setDescription(description); + tag.setIcon(icon); + tag.setSmallIcon(smallIcon); + tag.setApproved(approved); + if (creatorUsername != null) { + User creator = userRepository + .findByUsername(creatorUsername) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + tag.setCreator(creator); + } + return tagRepository.save(tag); + } + + public Tag createTag( + String name, + String description, + String icon, + String smallIcon, + boolean approved + ) { + return createTag(name, description, icon, smallIcon, approved, null); + } + + public Tag createTag(String name, String description, String icon, String smallIcon) { + return createTag(name, description, icon, smallIcon, true, null); + } + + @CacheEvict(value = CachingConfig.TAG_CACHE_NAME, allEntries = true) + public Tag updateTag(Long id, String name, String description, String icon, String smallIcon) { + Tag tag = tagRepository + .findById(id) + .orElseThrow(() -> new IllegalArgumentException("Tag not found")); + if (name != null) { + tagValidator.validate(name); + tag.setName(name); + } + if (description != null) { + tag.setDescription(description); + } + if (icon != null) { + tag.setIcon(icon); + } + if (smallIcon != null) { + tag.setSmallIcon(smallIcon); + } + return tagRepository.save(tag); + } + + @CacheEvict(value = CachingConfig.TAG_CACHE_NAME, allEntries = true) + public void deleteTag(Long id) { + tagRepository.deleteById(id); + } + + public Tag approveTag(Long id) { + Tag tag = tagRepository + .findById(id) + .orElseThrow(() -> new IllegalArgumentException("Tag not found")); + tag.setApproved(true); + return tagRepository.save(tag); + } + + public List listPendingTags() { + return tagRepository.findByApproved(false); + } + + public Tag getTag(Long id) { + return tagRepository + .findById(id) + .orElseThrow(() -> new IllegalArgumentException("Tag not found")); + } + + public List listTags() { + return tagRepository.findByApprovedTrue(); + } + + /** + * 该方法每次首页加载都会访问,加入缓存 + * @param keyword + * @return + */ + @Cacheable( + value = CachingConfig.TAG_CACHE_NAME, + key = "'searchTags:' + (#keyword ?: '')" //keyword为null的场合返回空 + ) + public List searchTags(String keyword) { + if (keyword == null || keyword.isBlank()) { + return tagRepository.findByApprovedTrue(); } - public Tag createTag(String name, String description, String icon, String smallIcon, boolean approved) { - return createTag(name, description, icon, smallIcon, approved, null); - } + return tagRepository.findByNameContainingIgnoreCaseAndApprovedTrue(keyword); + } - public Tag createTag(String name, String description, String icon, String smallIcon) { - return createTag(name, description, icon, smallIcon, true, null); - } + public List getRecentTagsByUser(String username, int limit) { + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + Pageable pageable = PageRequest.of(0, limit); + return tagRepository.findByCreatorOrderByCreatedAtDesc(user, pageable); + } - @CacheEvict(value = CachingConfig.TAG_CACHE_NAME, allEntries = true) - public Tag updateTag(Long id, String name, String description, String icon, String smallIcon) { - Tag tag = tagRepository.findById(id) - .orElseThrow(() -> new IllegalArgumentException("Tag not found")); - if (name != null) { - tagValidator.validate(name); - tag.setName(name); - } - if (description != null) { - tag.setDescription(description); - } - if (icon != null) { - tag.setIcon(icon); - } - if (smallIcon != null) { - tag.setSmallIcon(smallIcon); - } - return tagRepository.save(tag); - } + public List getTagsByUser(String username) { + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + return tagRepository.findByCreator(user); + } - @CacheEvict(value = CachingConfig.TAG_CACHE_NAME, allEntries = true) - public void deleteTag(Long id) { - tagRepository.deleteById(id); - } - - public Tag approveTag(Long id) { - Tag tag = tagRepository.findById(id) - .orElseThrow(() -> new IllegalArgumentException("Tag not found")); - tag.setApproved(true); - return tagRepository.save(tag); - } - - public List listPendingTags() { - return tagRepository.findByApproved(false); - } - - public Tag getTag(Long id) { - return tagRepository.findById(id) - .orElseThrow(() -> new IllegalArgumentException("Tag not found")); - } - - public List listTags() { - return tagRepository.findByApprovedTrue(); - } - - /** - * 该方法每次首页加载都会访问,加入缓存 - * @param keyword - * @return - */ - @Cacheable( - value = CachingConfig.TAG_CACHE_NAME, - key = "'searchTags:' + (#keyword ?: '')"//keyword为null的场合返回空 - ) - public List searchTags(String keyword) { - if (keyword == null || keyword.isBlank()) { - return tagRepository.findByApprovedTrue(); - } - - return tagRepository.findByNameContainingIgnoreCaseAndApprovedTrue(keyword); - } - - public List getRecentTagsByUser(String username, int limit) { - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - Pageable pageable = PageRequest.of(0, limit); - return tagRepository.findByCreatorOrderByCreatedAtDesc(user, pageable); - } - - public List getTagsByUser(String username) { - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - return tagRepository.findByCreator(user); - } - - /** - * 获取检索用的标签Id列表 - * @param tagIds - * @param tagId - * @return - */ - public List getSearchTagIds(List tagIds, Long tagId){ - List ids = tagIds; - if (tagId != null) { - ids = List.of(tagId); - } - return ids; + /** + * 获取检索用的标签Id列表 + * @param tagIds + * @param tagId + * @return + */ + public List getSearchTagIds(List tagIds, Long tagId) { + List ids = tagIds; + if (tagId != null) { + ids = List.of(tagId); } + return ids; + } } diff --git a/backend/src/main/java/com/openisle/service/TagValidator.java b/backend/src/main/java/com/openisle/service/TagValidator.java index 4c650f044..0846b4d48 100644 --- a/backend/src/main/java/com/openisle/service/TagValidator.java +++ b/backend/src/main/java/com/openisle/service/TagValidator.java @@ -1,20 +1,20 @@ package com.openisle.service; import com.openisle.exception.FieldException; -import org.springframework.stereotype.Service; - import java.util.regex.Pattern; +import org.springframework.stereotype.Service; @Service public class TagValidator { - private static final Pattern ALLOWED = Pattern.compile("^[A-Za-z0-9\\u4e00-\\u9fa5]+$"); - public void validate(String name) { - if (name == null || name.isBlank()) { - throw new FieldException("name", "Tag name cannot be empty"); - } - if (!ALLOWED.matcher(name).matches()) { - throw new FieldException("name", "Tag name must be letters or numbers"); - } + private static final Pattern ALLOWED = Pattern.compile("^[A-Za-z0-9\\u4e00-\\u9fa5]+$"); + + public void validate(String name) { + if (name == null || name.isBlank()) { + throw new FieldException("name", "Tag name cannot be empty"); } + if (!ALLOWED.matcher(name).matches()) { + throw new FieldException("name", "Tag name must be letters or numbers"); + } + } } diff --git a/backend/src/main/java/com/openisle/service/TelegramAuthService.java b/backend/src/main/java/com/openisle/service/TelegramAuthService.java index d949587f0..d42e03b96 100644 --- a/backend/src/main/java/com/openisle/service/TelegramAuthService.java +++ b/backend/src/main/java/com/openisle/service/TelegramAuthService.java @@ -5,98 +5,108 @@ import com.openisle.model.RegisterMode; import com.openisle.model.Role; import com.openisle.model.User; import com.openisle.repository.UserRepository; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.*; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.util.*; - @Service @RequiredArgsConstructor public class TelegramAuthService { - private final UserRepository userRepository; - private final AvatarGenerator avatarGenerator; - @Value("${telegram.bot-token:}") - private String botToken; + private final UserRepository userRepository; + private final AvatarGenerator avatarGenerator; - public Optional authenticate(TelegramLoginRequest req, RegisterMode mode, boolean viaInvite) { - try { - if (botToken == null || botToken.isEmpty()) { - return Optional.empty(); - } - String dataCheckString = buildDataCheckString(req); - MessageDigest md = MessageDigest.getInstance("SHA-256"); - byte[] secretKey = md.digest(botToken.getBytes(StandardCharsets.UTF_8)); - Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(new SecretKeySpec(secretKey, "HmacSHA256")); - byte[] hash = mac.doFinal(dataCheckString.getBytes(StandardCharsets.UTF_8)); - String hex = bytesToHex(hash); - if (!hex.equalsIgnoreCase(req.getHash())) { - return Optional.empty(); - } - String username = req.getUsername(); - String email = (username != null ? username : req.getId()) + "@telegram.org"; - String avatar = req.getPhotoUrl(); - return Optional.of(processUser(email, username, avatar, mode, viaInvite)); - } catch (Exception e) { - return Optional.empty(); - } + @Value("${telegram.bot-token:}") + private String botToken; + + public Optional authenticate( + TelegramLoginRequest req, + RegisterMode mode, + boolean viaInvite + ) { + try { + if (botToken == null || botToken.isEmpty()) { + return Optional.empty(); + } + String dataCheckString = buildDataCheckString(req); + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] secretKey = md.digest(botToken.getBytes(StandardCharsets.UTF_8)); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secretKey, "HmacSHA256")); + byte[] hash = mac.doFinal(dataCheckString.getBytes(StandardCharsets.UTF_8)); + String hex = bytesToHex(hash); + if (!hex.equalsIgnoreCase(req.getHash())) { + return Optional.empty(); + } + String username = req.getUsername(); + String email = (username != null ? username : req.getId()) + "@telegram.org"; + String avatar = req.getPhotoUrl(); + return Optional.of(processUser(email, username, avatar, mode, viaInvite)); + } catch (Exception e) { + return Optional.empty(); } + } - private String buildDataCheckString(TelegramLoginRequest req) { - List data = new ArrayList<>(); - if (req.getAuthDate() != null) data.add("auth_date=" + req.getAuthDate()); - if (req.getFirstName() != null) data.add("first_name=" + req.getFirstName()); - if (req.getId() != null) data.add("id=" + req.getId()); - if (req.getLastName() != null) data.add("last_name=" + req.getLastName()); - if (req.getPhotoUrl() != null) data.add("photo_url=" + req.getPhotoUrl()); - if (req.getUsername() != null) data.add("username=" + req.getUsername()); - Collections.sort(data); - return String.join("\n", data); + private String buildDataCheckString(TelegramLoginRequest req) { + List data = new ArrayList<>(); + if (req.getAuthDate() != null) data.add("auth_date=" + req.getAuthDate()); + if (req.getFirstName() != null) data.add("first_name=" + req.getFirstName()); + if (req.getId() != null) data.add("id=" + req.getId()); + if (req.getLastName() != null) data.add("last_name=" + req.getLastName()); + if (req.getPhotoUrl() != null) data.add("photo_url=" + req.getPhotoUrl()); + if (req.getUsername() != null) data.add("username=" + req.getUsername()); + Collections.sort(data); + return String.join("\n", data); + } + + private String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); } + return sb.toString(); + } - private String bytesToHex(byte[] bytes) { - StringBuilder sb = new StringBuilder(); - for (byte b : bytes) { - sb.append(String.format("%02x", b)); - } - return sb.toString(); - } - - private AuthResult processUser(String email, String username, String avatar, RegisterMode mode, boolean viaInvite) { - Optional existing = userRepository.findByEmail(email); - if (existing.isPresent()) { - User user = existing.get(); - if (!user.isVerified()) { - user.setVerified(true); - user.setVerificationCode(null); - userRepository.save(user); - } - return new AuthResult(user, false); - } - String baseUsername = username != null ? username : email.split("@")[0]; - String finalUsername = baseUsername; - int suffix = 1; - while (userRepository.findByUsername(finalUsername).isPresent()) { - finalUsername = baseUsername + suffix++; - } - User user = new User(); - user.setUsername(finalUsername); - user.setEmail(email); - user.setPassword(""); - user.setRole(Role.USER); + private AuthResult processUser( + String email, + String username, + String avatar, + RegisterMode mode, + boolean viaInvite + ) { + Optional existing = userRepository.findByEmail(email); + if (existing.isPresent()) { + User user = existing.get(); + if (!user.isVerified()) { user.setVerified(true); - user.setApproved(mode == RegisterMode.DIRECT || viaInvite); - if (avatar != null) { - user.setAvatar(avatar); - } else { - user.setAvatar(avatarGenerator.generate(finalUsername)); - } - return new AuthResult(userRepository.save(user), true); + user.setVerificationCode(null); + userRepository.save(user); + } + return new AuthResult(user, false); } + String baseUsername = username != null ? username : email.split("@")[0]; + String finalUsername = baseUsername; + int suffix = 1; + while (userRepository.findByUsername(finalUsername).isPresent()) { + finalUsername = baseUsername + suffix++; + } + User user = new User(); + user.setUsername(finalUsername); + user.setEmail(email); + user.setPassword(""); + user.setRole(Role.USER); + user.setVerified(true); + user.setApproved(mode == RegisterMode.DIRECT || viaInvite); + if (avatar != null) { + user.setAvatar(avatar); + } else { + user.setAvatar(avatarGenerator.generate(finalUsername)); + } + return new AuthResult(userRepository.save(user), true); + } } diff --git a/backend/src/main/java/com/openisle/service/TwitterAuthService.java b/backend/src/main/java/com/openisle/service/TwitterAuthService.java index 5d68342fa..a48a09aaa 100644 --- a/backend/src/main/java/com/openisle/service/TwitterAuthService.java +++ b/backend/src/main/java/com/openisle/service/TwitterAuthService.java @@ -5,142 +5,157 @@ import com.openisle.model.RegisterMode; import com.openisle.model.Role; import com.openisle.model.User; import com.openisle.repository.UserRepository; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.Base64; import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.*; import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestTemplate; -import org.springframework.util.MultiValueMap; -import org.springframework.util.LinkedMultiValueMap; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import java.nio.charset.StandardCharsets; -import java.util.Base64; - -import java.util.*; @Service @RequiredArgsConstructor public class TwitterAuthService { - private final UserRepository userRepository; - private final RestTemplate restTemplate = new RestTemplate(); - private static final Logger logger = LoggerFactory.getLogger(TwitterAuthService.class); - @Value("${twitter.client-id:}") - private String clientId; + private final UserRepository userRepository; + private final RestTemplate restTemplate = new RestTemplate(); + private static final Logger logger = LoggerFactory.getLogger(TwitterAuthService.class); - @Value("${twitter.client-secret:}") - private String clientSecret; + @Value("${twitter.client-id:}") + private String clientId; - public Optional authenticate( - String code, - String codeVerifier, - RegisterMode mode, - String redirectUri, - boolean viaInvite) { + @Value("${twitter.client-secret:}") + private String clientSecret; - logger.debug("Starting authentication with code {} and verifier {}", code, codeVerifier); + public Optional authenticate( + String code, + String codeVerifier, + RegisterMode mode, + String redirectUri, + boolean viaInvite + ) { + logger.debug("Starting authentication with code {} and verifier {}", code, codeVerifier); - // 1. 交换 token - String tokenUrl = "https://api.twitter.com/2/oauth2/token"; - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); - headers.setAccept(List.of(MediaType.APPLICATION_JSON)); - if (!clientId.isEmpty() && !clientSecret.isEmpty()) { - String credentials = clientId + ":" + clientSecret; - String authHeader = "Basic " + Base64.getEncoder() - .encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); - headers.set(HttpHeaders.AUTHORIZATION, authHeader); - } - - // Twitter PKCE 要求的五个参数 - MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("client_id", clientId); - body.add("grant_type", "authorization_code"); - body.add("code", code); - body.add("code_verifier", codeVerifier); - body.add("redirect_uri", redirectUri); // 一律必填 - // 如果你的 app 属于机密客户端,必须带 client_secret - body.add("client_secret", clientSecret); - - ResponseEntity tokenRes; - try { - logger.debug("Requesting token from {}", tokenUrl); - tokenRes = restTemplate.postForEntity(tokenUrl, new HttpEntity<>(body, headers), JsonNode.class); - logger.debug("Token response: {}", tokenRes.getBody()); - } catch (HttpClientErrorException e) { - logger.warn("Token request failed with status {} and body {}", e.getStatusCode(), e.getResponseBodyAsString()); - return Optional.empty(); - } - - JsonNode tokenJson = tokenRes.getBody(); - if (tokenJson == null || !tokenJson.hasNonNull("access_token")) { - return Optional.empty(); - } - String accessToken = tokenJson.get("access_token").asText(); - - // 2. 拉取用户信息 - HttpHeaders authHeaders = new HttpHeaders(); - authHeaders.setBearerAuth(accessToken); - ResponseEntity userRes; - try { - logger.debug("Fetching user info with access token"); - userRes = restTemplate.exchange( - "https://api.twitter.com/2/users/me?user.fields=profile_image_url", - HttpMethod.GET, - new HttpEntity<>(authHeaders), - JsonNode.class); - logger.debug("User info response: {}", userRes.getBody()); - } catch (HttpClientErrorException e) { - logger.debug("User info request failed", e); - return Optional.empty(); - } - - JsonNode data = userRes.getBody() == null ? null : userRes.getBody().path("data"); - String username = data != null ? data.path("username").asText(null) : null; - String avatar = data != null ? data.path("profile_image_url").asText(null) : null; - if (username == null) { - return Optional.empty(); - } - - // Twitter v2 默认拿不到 email;如果你申请到 email.scope,可改用 /2/users/:id?user.fields=email - String email = username + "@twitter.com"; - logger.debug("Processing user {} with email {}", username, email); - return Optional.of(processUser(email, username, avatar, mode, viaInvite)); + // 1. 交换 token + String tokenUrl = "https://api.twitter.com/2/oauth2/token"; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + headers.setAccept(List.of(MediaType.APPLICATION_JSON)); + if (!clientId.isEmpty() && !clientSecret.isEmpty()) { + String credentials = clientId + ":" + clientSecret; + String authHeader = + "Basic " + Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + headers.set(HttpHeaders.AUTHORIZATION, authHeader); } - private AuthResult processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) { - Optional existing = userRepository.findByEmail(email); - if (existing.isPresent()) { - User user = existing.get(); - if (!user.isVerified()) { - user.setVerified(true); - user.setVerificationCode(null); - userRepository.save(user); - } - logger.debug("Existing user {} authenticated", user.getUsername()); - return new AuthResult(user, false); - } - String baseUsername = username != null ? username : email.split("@")[0]; - String finalUsername = baseUsername; - int suffix = 1; - while (userRepository.findByUsername(finalUsername).isPresent()) { - finalUsername = baseUsername + suffix++; - } - User user = new User(); - user.setUsername(finalUsername); - user.setEmail(email); - user.setPassword(""); - user.setRole(Role.USER); + // Twitter PKCE 要求的五个参数 + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("client_id", clientId); + body.add("grant_type", "authorization_code"); + body.add("code", code); + body.add("code_verifier", codeVerifier); + body.add("redirect_uri", redirectUri); // 一律必填 + // 如果你的 app 属于机密客户端,必须带 client_secret + body.add("client_secret", clientSecret); + + ResponseEntity tokenRes; + try { + logger.debug("Requesting token from {}", tokenUrl); + tokenRes = restTemplate.postForEntity( + tokenUrl, + new HttpEntity<>(body, headers), + JsonNode.class + ); + logger.debug("Token response: {}", tokenRes.getBody()); + } catch (HttpClientErrorException e) { + logger.warn( + "Token request failed with status {} and body {}", + e.getStatusCode(), + e.getResponseBodyAsString() + ); + return Optional.empty(); + } + + JsonNode tokenJson = tokenRes.getBody(); + if (tokenJson == null || !tokenJson.hasNonNull("access_token")) { + return Optional.empty(); + } + String accessToken = tokenJson.get("access_token").asText(); + + // 2. 拉取用户信息 + HttpHeaders authHeaders = new HttpHeaders(); + authHeaders.setBearerAuth(accessToken); + ResponseEntity userRes; + try { + logger.debug("Fetching user info with access token"); + userRes = restTemplate.exchange( + "https://api.twitter.com/2/users/me?user.fields=profile_image_url", + HttpMethod.GET, + new HttpEntity<>(authHeaders), + JsonNode.class + ); + logger.debug("User info response: {}", userRes.getBody()); + } catch (HttpClientErrorException e) { + logger.debug("User info request failed", e); + return Optional.empty(); + } + + JsonNode data = userRes.getBody() == null ? null : userRes.getBody().path("data"); + String username = data != null ? data.path("username").asText(null) : null; + String avatar = data != null ? data.path("profile_image_url").asText(null) : null; + if (username == null) { + return Optional.empty(); + } + + // Twitter v2 默认拿不到 email;如果你申请到 email.scope,可改用 /2/users/:id?user.fields=email + String email = username + "@twitter.com"; + logger.debug("Processing user {} with email {}", username, email); + return Optional.of(processUser(email, username, avatar, mode, viaInvite)); + } + + private AuthResult processUser( + String email, + String username, + String avatar, + com.openisle.model.RegisterMode mode, + boolean viaInvite + ) { + Optional existing = userRepository.findByEmail(email); + if (existing.isPresent()) { + User user = existing.get(); + if (!user.isVerified()) { user.setVerified(true); - user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite); - if (avatar != null) { - user.setAvatar(avatar); - } else { - user.setAvatar("https://twitter.com/" + finalUsername + "/profile_image"); - } - logger.debug("Creating new user {}", finalUsername); - return new AuthResult(userRepository.save(user), true); + user.setVerificationCode(null); + userRepository.save(user); + } + logger.debug("Existing user {} authenticated", user.getUsername()); + return new AuthResult(user, false); } + String baseUsername = username != null ? username : email.split("@")[0]; + String finalUsername = baseUsername; + int suffix = 1; + while (userRepository.findByUsername(finalUsername).isPresent()) { + finalUsername = baseUsername + suffix++; + } + User user = new User(); + user.setUsername(finalUsername); + user.setEmail(email); + user.setPassword(""); + user.setRole(Role.USER); + user.setVerified(true); + user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite); + if (avatar != null) { + user.setAvatar(avatar); + } else { + user.setAvatar("https://twitter.com/" + finalUsername + "/profile_image"); + } + logger.debug("Creating new user {}", finalUsername); + return new AuthResult(userRepository.save(user), true); + } } diff --git a/backend/src/main/java/com/openisle/service/UserService.java b/backend/src/main/java/com/openisle/service/UserService.java index 4bef6dbc7..d76d986dc 100644 --- a/backend/src/main/java/com/openisle/service/UserService.java +++ b/backend/src/main/java/com/openisle/service/UserService.java @@ -1,14 +1,18 @@ package com.openisle.service; import com.openisle.config.CachingConfig; -import com.openisle.model.User; +import com.openisle.exception.FieldException; import com.openisle.model.Role; +import com.openisle.model.User; +import com.openisle.repository.UserRepository; +import com.openisle.service.AvatarGenerator; import com.openisle.service.PasswordValidator; import com.openisle.service.UsernameValidator; -import com.openisle.service.AvatarGenerator; -import com.openisle.exception.FieldException; -import com.openisle.repository.UserRepository; import com.openisle.util.VerifyType; +import java.util.Objects; +import java.util.Optional; +import java.util.Random; +import java.util.concurrent.TimeUnit; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.springframework.data.redis.core.RedisTemplate; @@ -16,219 +20,230 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; -import java.util.Objects; -import java.util.Optional; -import java.util.Random; -import java.util.concurrent.TimeUnit; - @Service @RequiredArgsConstructor public class UserService { - private final UserRepository userRepository; - private final PasswordValidator passwordValidator; - private final UsernameValidator usernameValidator; - private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); - private final ImageUploader imageUploader; - private final AvatarGenerator avatarGenerator; - private final RedisTemplate redisTemplate; + private final UserRepository userRepository; + private final PasswordValidator passwordValidator; + private final UsernameValidator usernameValidator; + private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + private final ImageUploader imageUploader; + private final AvatarGenerator avatarGenerator; - private final EmailSender emailService; + private final RedisTemplate redisTemplate; - public User register(String username, String email, String password, String reason, com.openisle.model.RegisterMode mode) { - usernameValidator.validate(username); - passwordValidator.validate(password); - // ── 先按用户名查 ────────────────────────────────────────── - Optional byUsername = userRepository.findByUsername(username); - if (byUsername.isPresent()) { - User u = byUsername.get(); - if (u.isVerified()) { // 已验证 → 直接拒绝 - throw new FieldException("username", "User name already exists"); - } - // 未验证 → 允许“重注册”:覆盖必要字段并重新发验证码 - u.setEmail(email); // 若不允许改邮箱可去掉 - u.setPassword(passwordEncoder.encode(password)); -// u.setVerificationCode(genCode()); - u.setRegisterReason(reason); - u.setApproved(mode == com.openisle.model.RegisterMode.DIRECT); - return userRepository.save(u); - } + private final EmailSender emailService; - // ── 再按邮箱查 ─────────────────────────────────────────── - Optional byEmail = userRepository.findByEmail(email); - if (byEmail.isPresent()) { - User u = byEmail.get(); - if (u.isVerified()) { // 已验证 → 直接拒绝 - throw new FieldException("email", "User email already exists"); - } - // 未验证 → 允许“重注册” - u.setUsername(username); // 若不允许改用户名可去掉 - u.setPassword(passwordEncoder.encode(password)); -// u.setVerificationCode(genCode()); - u.setRegisterReason(reason); - u.setApproved(mode == com.openisle.model.RegisterMode.DIRECT); - return userRepository.save(u); - } - - // ── 完全新用户 ─────────────────────────────────────────── - User user = new User(); - user.setUsername(username); - user.setEmail(email); - user.setPassword(passwordEncoder.encode(password)); - user.setRole(Role.USER); - user.setVerified(false); -// user.setVerificationCode(genCode()); - user.setAvatar(avatarGenerator.generate(username)); - user.setRegisterReason(reason); - user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT); - return userRepository.save(user); + public User register( + String username, + String email, + String password, + String reason, + com.openisle.model.RegisterMode mode + ) { + usernameValidator.validate(username); + passwordValidator.validate(password); + // ── 先按用户名查 ────────────────────────────────────────── + Optional byUsername = userRepository.findByUsername(username); + if (byUsername.isPresent()) { + User u = byUsername.get(); + if (u.isVerified()) { + // 已验证 → 直接拒绝 + throw new FieldException("username", "User name already exists"); + } + // 未验证 → 允许“重注册”:覆盖必要字段并重新发验证码 + u.setEmail(email); // 若不允许改邮箱可去掉 + u.setPassword(passwordEncoder.encode(password)); + // u.setVerificationCode(genCode()); + u.setRegisterReason(reason); + u.setApproved(mode == com.openisle.model.RegisterMode.DIRECT); + return userRepository.save(u); } - public User registerWithInvite(String username, String email, String password) { - User user = register(username, email, password, "", com.openisle.model.RegisterMode.DIRECT); - user.setVerified(true); -// user.setVerificationCode(genCode()); - return userRepository.save(user); + // ── 再按邮箱查 ─────────────────────────────────────────── + Optional byEmail = userRepository.findByEmail(email); + if (byEmail.isPresent()) { + User u = byEmail.get(); + if (u.isVerified()) { + // 已验证 → 直接拒绝 + throw new FieldException("email", "User email already exists"); + } + // 未验证 → 允许“重注册” + u.setUsername(username); // 若不允许改用户名可去掉 + u.setPassword(passwordEncoder.encode(password)); + // u.setVerificationCode(genCode()); + u.setRegisterReason(reason); + u.setApproved(mode == com.openisle.model.RegisterMode.DIRECT); + return userRepository.save(u); } - private String genCode() { - return String.format("%06d", new Random().nextInt(1000000)); + // ── 完全新用户 ─────────────────────────────────────────── + User user = new User(); + user.setUsername(username); + user.setEmail(email); + user.setPassword(passwordEncoder.encode(password)); + user.setRole(Role.USER); + user.setVerified(false); + // user.setVerificationCode(genCode()); + user.setAvatar(avatarGenerator.generate(username)); + user.setRegisterReason(reason); + user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT); + return userRepository.save(user); + } + + public User registerWithInvite(String username, String email, String password) { + User user = register(username, email, password, "", com.openisle.model.RegisterMode.DIRECT); + user.setVerified(true); + // user.setVerificationCode(genCode()); + return userRepository.save(user); + } + + private String genCode() { + return String.format("%06d", new Random().nextInt(1000000)); + } + + /** + * 将验证码存入缓存,并发送邮件 + * @param user + */ + public void sendVerifyMail(User user, VerifyType verifyType) { + // 缓存验证码 + String code = genCode(); + String key; + String subject; + String content = "您的验证码是:" + code; + // 注册类型 + if (verifyType.equals(VerifyType.REGISTER)) { + key = CachingConfig.VERIFY_CACHE_NAME + ":register:code:" + user.getUsername(); + subject = "在网站填写验证码以验证(有效期为5分钟)"; + } else { + // 重置密码 + key = CachingConfig.VERIFY_CACHE_NAME + ":reset_password:code:" + user.getUsername(); + subject = "请填写验证码以重置密码(有效期为5分钟)"; } - /** - * 将验证码存入缓存,并发送邮件 - * @param user - */ - public void sendVerifyMail(User user, VerifyType verifyType){ - // 缓存验证码 - String code = genCode(); - String key; - String subject; - String content = "您的验证码是:" + code; - // 注册类型 - if(verifyType.equals(VerifyType.REGISTER)){ - key = CachingConfig.VERIFY_CACHE_NAME + ":register:code:" + user.getUsername(); - subject = "在网站填写验证码以验证(有效期为5分钟)"; - }else { - // 重置密码 - key = CachingConfig.VERIFY_CACHE_NAME + ":reset_password:code:" + user.getUsername(); - subject = "请填写验证码以重置密码(有效期为5分钟)"; - } + redisTemplate.opsForValue().set(key, code, 5, TimeUnit.MINUTES); // 五分钟后验证码过期 + emailService.sendEmail(user.getEmail(), subject, content); + } - redisTemplate.opsForValue().set(key, code, 5, TimeUnit.MINUTES);// 五分钟后验证码过期 - emailService.sendEmail(user.getEmail(), subject, content); + /** + * 验证code是否正确 + * @param user + * @param code + * @param verifyType + * @return + */ + public boolean verifyCode(User user, String code, VerifyType verifyType) { + // 生成key + String key1 = VerifyType.REGISTER.equals(verifyType) + ? ":register:code:" + : ":reset_password:code:"; + String key = CachingConfig.VERIFY_CACHE_NAME + key1 + user.getUsername(); + // 这里不能使用getAndDelete,需要6.x版本 + String cachedCode = (String) redisTemplate.opsForValue().get(key); + // 如果校验code过期或者不存在 + // 或者校验code不一致 + if (Objects.isNull(cachedCode) || !cachedCode.equals(code)) { + return false; } - - /** - * 验证code是否正确 - * @param user - * @param code - * @param verifyType - * @return - */ - public boolean verifyCode(User user, String code, VerifyType verifyType) { - // 生成key - String key1 = VerifyType.REGISTER.equals(verifyType)?":register:code:":":reset_password:code:"; - String key = CachingConfig.VERIFY_CACHE_NAME + key1 + user.getUsername(); - // 这里不能使用getAndDelete,需要6.x版本 - String cachedCode = (String)redisTemplate.opsForValue().get(key); - // 如果校验code过期或者不存在 - // 或者校验code不一致 - if(Objects.isNull(cachedCode) - || !cachedCode.equals(code)){ - return false; - } - // 注册模式需要设置已经确认 - if(VerifyType.REGISTER.equals(verifyType)){ - user.setVerified(true); - userRepository.save(user); - } - // 走到这里说明验证成功删除验证码 - redisTemplate.delete(key); - return true; - + // 注册模式需要设置已经确认 + if (VerifyType.REGISTER.equals(verifyType)) { + user.setVerified(true); + userRepository.save(user); } + // 走到这里说明验证成功删除验证码 + redisTemplate.delete(key); + return true; + } - public Optional authenticate(String username, String password) { - return userRepository.findByUsername(username) - .filter(User::isVerified) - .filter(User::isApproved) - .filter(user -> passwordEncoder.matches(password, user.getPassword())); - } + public Optional authenticate(String username, String password) { + return userRepository + .findByUsername(username) + .filter(User::isVerified) + .filter(User::isApproved) + .filter(user -> passwordEncoder.matches(password, user.getPassword())); + } - public boolean matchesPassword(User user, String rawPassword) { - return passwordEncoder.matches(rawPassword, user.getPassword()); - } + public boolean matchesPassword(User user, String rawPassword) { + return passwordEncoder.matches(rawPassword, user.getPassword()); + } - public Optional findByUsername(String username) { - return userRepository.findByUsername(username); - } + public Optional findByUsername(String username) { + return userRepository.findByUsername(username); + } - public Optional findByEmail(String email) { - return userRepository.findByEmail(email); - } + public Optional findByEmail(String email) { + return userRepository.findByEmail(email); + } - public Optional findById(Long id) { - return userRepository.findById(id); - } + public Optional findById(Long id) { + return userRepository.findById(id); + } - public Optional findByIdentifier(String identifier) { - if (identifier.matches("\\d+")) { - return userRepository.findById(Long.parseLong(identifier)); - } - return userRepository.findByUsername(identifier); + public Optional findByIdentifier(String identifier) { + if (identifier.matches("\\d+")) { + return userRepository.findById(Long.parseLong(identifier)); } + return userRepository.findByUsername(identifier); + } - public User updateAvatar(String username, String avatarUrl) { - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - String old = user.getAvatar(); - user.setAvatar(avatarUrl); - User saved = userRepository.save(user); - if (old != null && !old.equals(avatarUrl)) { - imageUploader.removeReferences(java.util.Set.of(old)); - } - if (avatarUrl != null) { - imageUploader.addReferences(java.util.Set.of(avatarUrl)); - } - return saved; + public User updateAvatar(String username, String avatarUrl) { + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + String old = user.getAvatar(); + user.setAvatar(avatarUrl); + User saved = userRepository.save(user); + if (old != null && !old.equals(avatarUrl)) { + imageUploader.removeReferences(java.util.Set.of(old)); } + if (avatarUrl != null) { + imageUploader.addReferences(java.util.Set.of(avatarUrl)); + } + return saved; + } - public User updateReason(String username, String reason) { - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - user.setRegisterReason(reason); - return userRepository.save(user); - } + public User updateReason(String username, String reason) { + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + user.setRegisterReason(reason); + return userRepository.save(user); + } - public User updateProfile(String currentUsername, String newUsername, String introduction) { - User user = userRepository.findByUsername(currentUsername) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - if (newUsername != null && !newUsername.equals(currentUsername)) { - usernameValidator.validate(newUsername); - userRepository.findByUsername(newUsername).ifPresent(u -> { - throw new FieldException("username", "User name already exists"); - }); - user.setUsername(newUsername); - } - if (introduction != null) { - user.setIntroduction(introduction); - } - return userRepository.save(user); + public User updateProfile(String currentUsername, String newUsername, String introduction) { + User user = userRepository + .findByUsername(currentUsername) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + if (newUsername != null && !newUsername.equals(currentUsername)) { + usernameValidator.validate(newUsername); + userRepository + .findByUsername(newUsername) + .ifPresent(u -> { + throw new FieldException("username", "User name already exists"); + }); + user.setUsername(newUsername); } + if (introduction != null) { + user.setIntroduction(introduction); + } + return userRepository.save(user); + } - public User updatePassword(String username, String newPassword) { - passwordValidator.validate(newPassword); - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - user.setPassword(passwordEncoder.encode(newPassword)); - return userRepository.save(user); - } + public User updatePassword(String username, String newPassword) { + passwordValidator.validate(newPassword); + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + user.setPassword(passwordEncoder.encode(newPassword)); + return userRepository.save(user); + } - /** - * Get all administrator accounts. - */ - public java.util.List getAdmins() { - return userRepository.findByRole(Role.ADMIN); - } + /** + * Get all administrator accounts. + */ + public java.util.List getAdmins() { + return userRepository.findByRole(Role.ADMIN); + } } diff --git a/backend/src/main/java/com/openisle/service/UserVisitService.java b/backend/src/main/java/com/openisle/service/UserVisitService.java index 9182e145f..c5a591392 100644 --- a/backend/src/main/java/com/openisle/service/UserVisitService.java +++ b/backend/src/main/java/com/openisle/service/UserVisitService.java @@ -5,92 +5,97 @@ import com.openisle.model.User; import com.openisle.model.UserVisit; import com.openisle.repository.UserRepository; import com.openisle.repository.UserVisitRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.cache.annotation.CacheConfig; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.stereotype.Service; -import org.springframework.util.CollectionUtils; - import java.time.Duration; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheConfig; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; @Service @RequiredArgsConstructor public class UserVisitService { - private final UserVisitRepository userVisitRepository; - private final UserRepository userRepository; - private final RedisTemplate redisTemplate; + private final UserVisitRepository userVisitRepository; + private final UserRepository userRepository; - public boolean recordVisit(String username) { - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - LocalDate today = LocalDate.now(); - return userVisitRepository.findByUserAndVisitDate(user, today).map(v -> false).orElseGet(() -> { - UserVisit visit = new UserVisit(); - visit.setUser(user); - visit.setVisitDate(today); - userVisitRepository.save(visit); - return true; - }); + private final RedisTemplate redisTemplate; + + public boolean recordVisit(String username) { + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + LocalDate today = LocalDate.now(); + return userVisitRepository + .findByUserAndVisitDate(user, today) + .map(v -> false) + .orElseGet(() -> { + UserVisit visit = new UserVisit(); + visit.setUser(user); + visit.setVisitDate(today); + userVisitRepository.save(visit); + return true; + }); + } + + /** + * 统计访问次数,改为从缓存获取/数据库获取 + * @param username + * @return + */ + public long countVisits(String username) { + User user = userRepository + .findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + + // 如果缓存存在就返回 + String key1 = CachingConfig.VISIT_CACHE_NAME + ":" + LocalDate.now() + ":count:" + username; + Integer cached = (Integer) redisTemplate.opsForValue().get(key1); + if (cached != null) { + return cached.longValue(); } - /** - * 统计访问次数,改为从缓存获取/数据库获取 - * @param username - * @return - */ - public long countVisits(String username) { - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + // Redis Set 检查今天是否访问 + String todayKey = CachingConfig.VISIT_CACHE_NAME + ":" + LocalDate.now(); + boolean todayVisited = redisTemplate.opsForSet().isMember(todayKey, username); - // 如果缓存存在就返回 - String key1 = CachingConfig.VISIT_CACHE_NAME + ":" +LocalDate.now() + ":count:" + username; - Integer cached = (Integer) redisTemplate.opsForValue().get(key1); - if (cached != null){ - return cached.longValue(); - } + Long visitCount = userVisitRepository.countByUser(user); + if (todayVisited) visitCount += 1; - // Redis Set 检查今天是否访问 - String todayKey = CachingConfig.VISIT_CACHE_NAME + ":" + LocalDate.now(); - boolean todayVisited = redisTemplate.opsForSet().isMember(todayKey, username); + LocalDateTime now = LocalDateTime.now(); + LocalDateTime endOfDay = now.toLocalDate().atTime(23, 59, 59); + long secondsUntilEndOfDay = Duration.between(now, endOfDay).getSeconds(); - Long visitCount = userVisitRepository.countByUser(user); - if (todayVisited) visitCount += 1; + // 写入缓存,设置 TTL,当天剩余时间 + redisTemplate.opsForValue().set(key1, visitCount, Duration.ofSeconds(secondsUntilEndOfDay)); + return visitCount; + } - LocalDateTime now = LocalDateTime.now(); - LocalDateTime endOfDay = now.toLocalDate().atTime(23, 59, 59); - long secondsUntilEndOfDay = Duration.between(now, endOfDay).getSeconds(); + public long countDau(LocalDate date) { + LocalDate d = date != null ? date : LocalDate.now(); + return userVisitRepository.countByVisitDate(d); + } - // 写入缓存,设置 TTL,当天剩余时间 - redisTemplate.opsForValue().set(key1, visitCount, Duration.ofSeconds(secondsUntilEndOfDay)); - return visitCount; + public Map countDauRange(LocalDate start, LocalDate end) { + Map result = new LinkedHashMap<>(); + if (start == null || end == null || start.isAfter(end)) { + return result; } - - public long countDau(LocalDate date) { - LocalDate d = date != null ? date : LocalDate.now(); - return userVisitRepository.countByVisitDate(d); + var list = userVisitRepository.countRange(start, end); + for (var obj : list) { + LocalDate d = (LocalDate) obj[0]; + Long c = (Long) obj[1]; + result.put(d, c); } - - public Map countDauRange(LocalDate start, LocalDate end) { - Map result = new LinkedHashMap<>(); - if (start == null || end == null || start.isAfter(end)) { - return result; - } - var list = userVisitRepository.countRange(start, end); - for (var obj : list) { - LocalDate d = (LocalDate) obj[0]; - Long c = (Long) obj[1]; - result.put(d, c); - } - // fill zero counts for missing dates - for (LocalDate d = start; !d.isAfter(end); d = d.plusDays(1)) { - result.putIfAbsent(d, 0L); - } - return result; + // fill zero counts for missing dates + for (LocalDate d = start; !d.isAfter(end); d = d.plusDays(1)) { + result.putIfAbsent(d, 0L); } + return result; + } } diff --git a/backend/src/main/java/com/openisle/service/UsernameValidator.java b/backend/src/main/java/com/openisle/service/UsernameValidator.java index 394b1562b..03405f36e 100644 --- a/backend/src/main/java/com/openisle/service/UsernameValidator.java +++ b/backend/src/main/java/com/openisle/service/UsernameValidator.java @@ -9,20 +9,19 @@ import org.springframework.stereotype.Service; */ @Service public class UsernameValidator { - /** - * Validate the username string. - * - * @param username the username to validate - */ - public void validate(String username) { - if (username == null || username.isEmpty()) { - throw new FieldException("username", "Username cannot be empty"); - } - if (NumberUtils.isDigits(username)) { - throw new FieldException("username", "Username cannot be pure number"); - } + /** + * Validate the username string. + * + * @param username the username to validate + */ + public void validate(String username) { + if (username == null || username.isEmpty()) { + throw new FieldException("username", "Username cannot be empty"); } + if (NumberUtils.isDigits(username)) { + throw new FieldException("username", "Username cannot be pure number"); + } + } } - diff --git a/backend/src/main/java/com/openisle/util/VerifyType.java b/backend/src/main/java/com/openisle/util/VerifyType.java index 885471a5c..c56995e74 100644 --- a/backend/src/main/java/com/openisle/util/VerifyType.java +++ b/backend/src/main/java/com/openisle/util/VerifyType.java @@ -6,15 +6,16 @@ package com.openisle.util; * @since 2025-09-08 */ public enum VerifyType { - REGISTER(1), - RESET_PASSWORD(2); - private final int code; + REGISTER(1), + RESET_PASSWORD(2); - VerifyType(int code) { - this.code = code; - } + private final int code; - public int getCode() { - return code; - } + VerifyType(int code) { + this.code = code; + } + + public int getCode() { + return code; + } } diff --git a/backend/src/test/java/com/openisle/controller/AdminControllerTest.java b/backend/src/test/java/com/openisle/controller/AdminControllerTest.java index 8b068e415..29416bac7 100644 --- a/backend/src/test/java/com/openisle/controller/AdminControllerTest.java +++ b/backend/src/test/java/com/openisle/controller/AdminControllerTest.java @@ -1,83 +1,90 @@ package com.openisle.controller; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.context.annotation.Import; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.boot.test.mock.mockito.MockBean; -import com.openisle.config.CustomAccessDeniedHandler; -import com.openisle.config.SecurityConfig; -import com.openisle.service.JwtService; -import com.openisle.repository.UserRepository; -import com.openisle.service.UserVisitService; -import com.openisle.model.Role; -import com.openisle.model.User; -import java.util.Optional; -import org.mockito.Mockito; - import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.openisle.config.CustomAccessDeniedHandler; +import com.openisle.config.SecurityConfig; +import com.openisle.model.Role; +import com.openisle.model.User; +import com.openisle.repository.UserRepository; +import com.openisle.service.JwtService; +import com.openisle.service.UserVisitService; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.test.web.servlet.MockMvc; + @WebMvcTest(AdminController.class) @AutoConfigureMockMvc -@Import({SecurityConfig.class, CustomAccessDeniedHandler.class}) +@Import({ SecurityConfig.class, CustomAccessDeniedHandler.class }) class AdminControllerTest { - @Autowired - private MockMvc mockMvc; - @MockBean - private JwtService jwtService; - @MockBean - private UserRepository userRepository; - @MockBean - private UserVisitService userVisitService; + @Autowired + private MockMvc mockMvc; - @Test - void adminHelloReturnsMessage() throws Exception { - Mockito.when(jwtService.validateAndGetSubject("adminToken")).thenReturn("admin"); - User admin = new User(); - admin.setUsername("admin"); - admin.setPassword("p"); - admin.setEmail("a@b.com"); - admin.setRole(Role.ADMIN); - Mockito.when(userRepository.findByUsername("admin")).thenReturn(Optional.of(admin)); + @MockBean + private JwtService jwtService; - mockMvc.perform(get("/api/admin/hello").header("Authorization", "Bearer adminToken")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("Hello, Admin User")); - } + @MockBean + private UserRepository userRepository; - @Test - void adminHelloMissingToken() throws Exception { - mockMvc.perform(get("/api/admin/hello")) - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.error").value("Missing token")); - } + @MockBean + private UserVisitService userVisitService; - @Test - void adminHelloInvalidToken() throws Exception { - Mockito.when(jwtService.validateAndGetSubject("bad")).thenThrow(new RuntimeException()); + @Test + void adminHelloReturnsMessage() throws Exception { + Mockito.when(jwtService.validateAndGetSubject("adminToken")).thenReturn("admin"); + User admin = new User(); + admin.setUsername("admin"); + admin.setPassword("p"); + admin.setEmail("a@b.com"); + admin.setRole(Role.ADMIN); + Mockito.when(userRepository.findByUsername("admin")).thenReturn(Optional.of(admin)); - mockMvc.perform(get("/api/admin/hello").header("Authorization", "Bearer bad")) - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.error").value("Invalid or expired token")); - } + mockMvc + .perform(get("/api/admin/hello").header("Authorization", "Bearer adminToken")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Hello, Admin User")); + } - @Test - void adminHelloNotAdmin() throws Exception { - Mockito.when(jwtService.validateAndGetSubject("userToken")).thenReturn("user"); - User user = new User(); - user.setUsername("user"); - user.setPassword("p"); - user.setEmail("u@example.com"); - user.setRole(Role.USER); - Mockito.when(userRepository.findByUsername("user")).thenReturn(Optional.of(user)); + @Test + void adminHelloMissingToken() throws Exception { + mockMvc + .perform(get("/api/admin/hello")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error").value("Missing token")); + } - mockMvc.perform(get("/api/admin/hello").header("Authorization", "Bearer userToken")) - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.error").value("Unauthorized")); - } + @Test + void adminHelloInvalidToken() throws Exception { + Mockito.when(jwtService.validateAndGetSubject("bad")).thenThrow(new RuntimeException()); + + mockMvc + .perform(get("/api/admin/hello").header("Authorization", "Bearer bad")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error").value("Invalid or expired token")); + } + + @Test + void adminHelloNotAdmin() throws Exception { + Mockito.when(jwtService.validateAndGetSubject("userToken")).thenReturn("user"); + User user = new User(); + user.setUsername("user"); + user.setPassword("p"); + user.setEmail("u@example.com"); + user.setRole(Role.USER); + Mockito.when(userRepository.findByUsername("user")).thenReturn(Optional.of(user)); + + mockMvc + .perform(get("/api/admin/hello").header("Authorization", "Bearer userToken")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error").value("Unauthorized")); + } } diff --git a/backend/src/test/java/com/openisle/controller/AdminUserControllerTest.java b/backend/src/test/java/com/openisle/controller/AdminUserControllerTest.java index 6c774397a..1ace9b23d 100644 --- a/backend/src/test/java/com/openisle/controller/AdminUserControllerTest.java +++ b/backend/src/test/java/com/openisle/controller/AdminUserControllerTest.java @@ -1,11 +1,18 @@ package com.openisle.controller; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + import com.openisle.model.Notification; import com.openisle.model.NotificationType; import com.openisle.model.User; import com.openisle.repository.NotificationRepository; import com.openisle.repository.UserRepository; import com.openisle.service.EmailSender; +import java.util.List; +import java.util.Optional; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -13,64 +20,59 @@ import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.web.servlet.MockMvc; -import java.util.List; -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - @WebMvcTest(AdminUserController.class) @AutoConfigureMockMvc(addFilters = false) class AdminUserControllerTest { - @Autowired - private MockMvc mockMvc; - @MockBean - private UserRepository userRepository; - @MockBean - private NotificationRepository notificationRepository; - @MockBean - private EmailSender emailSender; + @Autowired + private MockMvc mockMvc; - @Test - void approveMarksNotificationsRead() throws Exception { - User u = new User(); - u.setId(1L); - u.setEmail("a@a.com"); - when(userRepository.findById(1L)).thenReturn(Optional.of(u)); + @MockBean + private UserRepository userRepository; - Notification n = new Notification(); - n.setId(2L); - n.setRead(false); - when(notificationRepository.findByTypeAndFromUser(NotificationType.REGISTER_REQUEST, u)) - .thenReturn(List.of(n)); + @MockBean + private NotificationRepository notificationRepository; - mockMvc.perform(post("/api/admin/users/1/approve")) - .andExpect(status().isOk()); + @MockBean + private EmailSender emailSender; - assertTrue(n.isRead()); - verify(notificationRepository).saveAll(List.of(n)); - } + @Test + void approveMarksNotificationsRead() throws Exception { + User u = new User(); + u.setId(1L); + u.setEmail("a@a.com"); + when(userRepository.findById(1L)).thenReturn(Optional.of(u)); - @Test - void rejectMarksNotificationsRead() throws Exception { - User u = new User(); - u.setId(1L); - u.setEmail("a@a.com"); - when(userRepository.findById(1L)).thenReturn(Optional.of(u)); + Notification n = new Notification(); + n.setId(2L); + n.setRead(false); + when( + notificationRepository.findByTypeAndFromUser(NotificationType.REGISTER_REQUEST, u) + ).thenReturn(List.of(n)); - Notification n = new Notification(); - n.setId(2L); - n.setRead(false); - when(notificationRepository.findByTypeAndFromUser(NotificationType.REGISTER_REQUEST, u)) - .thenReturn(List.of(n)); + mockMvc.perform(post("/api/admin/users/1/approve")).andExpect(status().isOk()); - mockMvc.perform(post("/api/admin/users/1/reject")) - .andExpect(status().isOk()); + assertTrue(n.isRead()); + verify(notificationRepository).saveAll(List.of(n)); + } - assertTrue(n.isRead()); - verify(notificationRepository).saveAll(List.of(n)); - } + @Test + void rejectMarksNotificationsRead() throws Exception { + User u = new User(); + u.setId(1L); + u.setEmail("a@a.com"); + when(userRepository.findById(1L)).thenReturn(Optional.of(u)); + + Notification n = new Notification(); + n.setId(2L); + n.setRead(false); + when( + notificationRepository.findByTypeAndFromUser(NotificationType.REGISTER_REQUEST, u) + ).thenReturn(List.of(n)); + + mockMvc.perform(post("/api/admin/users/1/reject")).andExpect(status().isOk()); + + assertTrue(n.isRead()); + verify(notificationRepository).saveAll(List.of(n)); + } } diff --git a/backend/src/test/java/com/openisle/controller/AuthControllerTest.java b/backend/src/test/java/com/openisle/controller/AuthControllerTest.java index 283e1d34c..bf12f55b8 100644 --- a/backend/src/test/java/com/openisle/controller/AuthControllerTest.java +++ b/backend/src/test/java/com/openisle/controller/AuthControllerTest.java @@ -1,10 +1,18 @@ package com.openisle.controller; -import com.openisle.model.User; -import com.openisle.service.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + import com.openisle.model.RegisterMode; +import com.openisle.model.User; import com.openisle.repository.UserRepository; +import com.openisle.service.*; import com.openisle.util.VerifyType; +import java.util.Map; +import java.util.Optional; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; @@ -14,100 +22,118 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; -import java.util.Map; -import java.util.Optional; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - @WebMvcTest(AuthController.class) @AutoConfigureMockMvc(addFilters = false) class AuthControllerTest { - @Autowired - private MockMvc mockMvc; - @MockBean - private UserService userService; - @MockBean - private JwtService jwtService; - @MockBean - private EmailSender emailService; - @MockBean - private CaptchaService captchaService; - @MockBean - private GoogleAuthService googleAuthService; - @MockBean - private RegisterModeService registerModeService; - @MockBean - private GithubAuthService githubAuthService; - @MockBean - private DiscordAuthService discordAuthService; - @MockBean - private TwitterAuthService twitterAuthService; - @MockBean - private NotificationService notificationService; - @MockBean - private UserRepository userRepository; + @Autowired + private MockMvc mockMvc; - @Test - void registerSendsEmail() throws Exception { - User user = new User(); - user.setEmail("a@b.com"); - user.setUsername("u"); - user.setVerificationCode("123456"); - Mockito.when(registerModeService.getRegisterMode()).thenReturn(RegisterMode.DIRECT); - Mockito.when(userService.register(eq("u"), eq("a@b.com"), eq("p"), any(), eq(RegisterMode.DIRECT))).thenReturn(user); + @MockBean + private UserService userService; - mockMvc.perform(post("/api/auth/register") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"username\":\"u\",\"email\":\"a@b.com\",\"password\":\"p\",\"reason\":\"test reason more than twenty\"}")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").exists()); + @MockBean + private JwtService jwtService; - Mockito.verify(emailService).sendEmail(eq("a@b.com"), any(), any()); - } + @MockBean + private EmailSender emailService; - @Test - void verifyCodeEndpoint() throws Exception { - User user = new User(); - user.setUsername("u"); - Mockito.when(userService.verifyCode(user, "123", VerifyType.REGISTER)).thenReturn(true); - Mockito.when(jwtService.generateReasonToken("u")).thenReturn("reason_token"); + @MockBean + private CaptchaService captchaService; - mockMvc.perform(post("/api/auth/verify") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"username\":\"u\",\"code\":\"123\"}")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("Verified")); - } + @MockBean + private GoogleAuthService googleAuthService; - @Test - void loginReturnsToken() throws Exception { - User user = new User(); - user.setUsername("u"); - user.setVerified(true); - Mockito.when(userService.findByUsername("u")).thenReturn(Optional.of(user)); - Mockito.when(userService.matchesPassword(user, "p")).thenReturn(true); - Mockito.when(jwtService.generateToken("u")).thenReturn("token"); + @MockBean + private RegisterModeService registerModeService; - mockMvc.perform(post("/api/auth/login") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"username\":\"u\",\"password\":\"p\"}")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.token").value("token")); - } + @MockBean + private GithubAuthService githubAuthService; - @Test - void loginFails() throws Exception { - Mockito.when(userService.findByUsername("u")).thenReturn(Optional.empty()); + @MockBean + private DiscordAuthService discordAuthService; - mockMvc.perform(post("/api/auth/login") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"username\":\"u\",\"password\":\"bad\"}")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.reason_code").value("INVALID_CREDENTIALS")); - } + @MockBean + private TwitterAuthService twitterAuthService; + + @MockBean + private NotificationService notificationService; + + @MockBean + private UserRepository userRepository; + + @Test + void registerSendsEmail() throws Exception { + User user = new User(); + user.setEmail("a@b.com"); + user.setUsername("u"); + user.setVerificationCode("123456"); + Mockito.when(registerModeService.getRegisterMode()).thenReturn(RegisterMode.DIRECT); + Mockito.when( + userService.register(eq("u"), eq("a@b.com"), eq("p"), any(), eq(RegisterMode.DIRECT)) + ).thenReturn(user); + + mockMvc + .perform( + post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content( + "{\"username\":\"u\",\"email\":\"a@b.com\",\"password\":\"p\",\"reason\":\"test reason more than twenty\"}" + ) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").exists()); + + Mockito.verify(emailService).sendEmail(eq("a@b.com"), any(), any()); + } + + @Test + void verifyCodeEndpoint() throws Exception { + User user = new User(); + user.setUsername("u"); + Mockito.when(userService.verifyCode(user, "123", VerifyType.REGISTER)).thenReturn(true); + Mockito.when(jwtService.generateReasonToken("u")).thenReturn("reason_token"); + + mockMvc + .perform( + post("/api/auth/verify") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"u\",\"code\":\"123\"}") + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Verified")); + } + + @Test + void loginReturnsToken() throws Exception { + User user = new User(); + user.setUsername("u"); + user.setVerified(true); + Mockito.when(userService.findByUsername("u")).thenReturn(Optional.of(user)); + Mockito.when(userService.matchesPassword(user, "p")).thenReturn(true); + Mockito.when(jwtService.generateToken("u")).thenReturn("token"); + + mockMvc + .perform( + post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"u\",\"password\":\"p\"}") + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.token").value("token")); + } + + @Test + void loginFails() throws Exception { + Mockito.when(userService.findByUsername("u")).thenReturn(Optional.empty()); + + mockMvc + .perform( + post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"u\",\"password\":\"bad\"}") + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.reason_code").value("INVALID_CREDENTIALS")); + } } diff --git a/backend/src/test/java/com/openisle/controller/CategoryControllerTest.java b/backend/src/test/java/com/openisle/controller/CategoryControllerTest.java index 90af32f61..6e8f83428 100644 --- a/backend/src/test/java/com/openisle/controller/CategoryControllerTest.java +++ b/backend/src/test/java/com/openisle/controller/CategoryControllerTest.java @@ -1,10 +1,15 @@ package com.openisle.controller; +import static org.mockito.ArgumentMatchers.eq; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + import com.openisle.mapper.CategoryMapper; import com.openisle.mapper.PostMapper; import com.openisle.model.Category; import com.openisle.service.CategoryService; import com.openisle.service.PostService; +import java.util.List; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; @@ -15,89 +20,98 @@ import org.springframework.context.annotation.Import; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; -import java.util.List; - -import static org.mockito.ArgumentMatchers.eq; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - @WebMvcTest(CategoryController.class) @AutoConfigureMockMvc(addFilters = false) @Import(CategoryMapper.class) class CategoryControllerTest { - @Autowired - private MockMvc mockMvc; - @MockBean - private CategoryService categoryService; + @Autowired + private MockMvc mockMvc; - @MockBean - private PostService postService; + @MockBean + private CategoryService categoryService; - @MockBean - private PostMapper postMapper; + @MockBean + private PostService postService; - @Test - void createAndGetCategory() throws Exception { - Category c = new Category(); - c.setId(1L); - c.setName("tech"); - c.setDescription("d"); - c.setIcon("i"); - c.setSmallIcon("s1"); - Mockito.when(categoryService.createCategory(eq("tech"), eq("d"), eq("i"), eq("s1"))).thenReturn(c); - Mockito.when(categoryService.getCategory(1L)).thenReturn(c); + @MockBean + private PostMapper postMapper; - mockMvc.perform(post("/api/categories") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"name\":\"tech\",\"description\":\"d\",\"icon\":\"i\",\"smallIcon\":\"s1\"}")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.name").value("tech")) - .andExpect(jsonPath("$.description").value("d")) - .andExpect(jsonPath("$.icon").value("i")) - .andExpect(jsonPath("$.smallIcon").value("s1")); + @Test + void createAndGetCategory() throws Exception { + Category c = new Category(); + c.setId(1L); + c.setName("tech"); + c.setDescription("d"); + c.setIcon("i"); + c.setSmallIcon("s1"); + Mockito.when(categoryService.createCategory(eq("tech"), eq("d"), eq("i"), eq("s1"))).thenReturn( + c + ); + Mockito.when(categoryService.getCategory(1L)).thenReturn(c); - mockMvc.perform(get("/api/categories/1")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(1)); - } + mockMvc + .perform( + post("/api/categories") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"tech\",\"description\":\"d\",\"icon\":\"i\",\"smallIcon\":\"s1\"}") + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("tech")) + .andExpect(jsonPath("$.description").value("d")) + .andExpect(jsonPath("$.icon").value("i")) + .andExpect(jsonPath("$.smallIcon").value("s1")); - @Test - void listCategories() throws Exception { - Category c = new Category(); - c.setId(2L); - c.setName("life"); - c.setDescription("d2"); - c.setIcon("i2"); - c.setSmallIcon("s2"); - Mockito.when(categoryService.listCategories()).thenReturn(List.of(c)); + mockMvc + .perform(get("/api/categories/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(1)); + } - mockMvc.perform(get("/api/categories")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].name").value("life")) - .andExpect(jsonPath("$[0].description").value("d2")) - .andExpect(jsonPath("$[0].icon").value("i2")) - .andExpect(jsonPath("$[0].smallIcon").value("s2")); - } + @Test + void listCategories() throws Exception { + Category c = new Category(); + c.setId(2L); + c.setName("life"); + c.setDescription("d2"); + c.setIcon("i2"); + c.setSmallIcon("s2"); + Mockito.when(categoryService.listCategories()).thenReturn(List.of(c)); - @Test - void updateCategory() throws Exception { - Category c = new Category(); - c.setId(3L); - c.setName("tech"); - c.setDescription("d3"); - c.setIcon("i3"); - c.setSmallIcon("s3"); - Mockito.when(categoryService.updateCategory(eq(3L), eq("tech"), eq("d3"), eq("i3"), eq("s3"))).thenReturn(c); + mockMvc + .perform(get("/api/categories")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].name").value("life")) + .andExpect(jsonPath("$[0].description").value("d2")) + .andExpect(jsonPath("$[0].icon").value("i2")) + .andExpect(jsonPath("$[0].smallIcon").value("s2")); + } - mockMvc.perform(put("/api/categories/3") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"name\":\"tech\",\"description\":\"d3\",\"icon\":\"i3\",\"smallIcon\":\"s3\"}")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(3)) - .andExpect(jsonPath("$.name").value("tech")) - .andExpect(jsonPath("$.description").value("d3")) - .andExpect(jsonPath("$.icon").value("i3")) - .andExpect(jsonPath("$.smallIcon").value("s3")); - } + @Test + void updateCategory() throws Exception { + Category c = new Category(); + c.setId(3L); + c.setName("tech"); + c.setDescription("d3"); + c.setIcon("i3"); + c.setSmallIcon("s3"); + Mockito.when( + categoryService.updateCategory(eq(3L), eq("tech"), eq("d3"), eq("i3"), eq("s3")) + ).thenReturn(c); + + mockMvc + .perform( + put("/api/categories/3") + .contentType(MediaType.APPLICATION_JSON) + .content( + "{\"name\":\"tech\",\"description\":\"d3\",\"icon\":\"i3\",\"smallIcon\":\"s3\"}" + ) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(3)) + .andExpect(jsonPath("$.name").value("tech")) + .andExpect(jsonPath("$.description").value("d3")) + .andExpect(jsonPath("$.icon").value("i3")) + .andExpect(jsonPath("$.smallIcon").value("s3")); + } } diff --git a/backend/src/test/java/com/openisle/controller/CommentControllerTest.java b/backend/src/test/java/com/openisle/controller/CommentControllerTest.java index 8254ed31c..fdd82f328 100644 --- a/backend/src/test/java/com/openisle/controller/CommentControllerTest.java +++ b/backend/src/test/java/com/openisle/controller/CommentControllerTest.java @@ -1,5 +1,12 @@ package com.openisle.controller; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + import com.openisle.dto.CommentDto; import com.openisle.mapper.CommentMapper; import com.openisle.model.Comment; @@ -9,6 +16,8 @@ import com.openisle.service.CaptchaService; import com.openisle.service.CommentService; import com.openisle.service.LevelService; import com.openisle.service.ReactionService; +import java.time.LocalDateTime; +import java.util.List; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; @@ -18,84 +27,86 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.test.web.servlet.MockMvc; -import java.time.LocalDateTime; -import java.util.List; - -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.any; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - @WebMvcTest(CommentController.class) @AutoConfigureMockMvc(addFilters = false) class CommentControllerTest { - @Autowired - private MockMvc mockMvc; - @MockBean - private CommentService commentService; - @MockBean - private CaptchaService captchaService; - @MockBean - private LevelService levelService; - @MockBean - private ReactionService reactionService; - @MockBean - private CommentMapper commentMapper; + @Autowired + private MockMvc mockMvc; - private Comment createComment(Long id, String content, String authorName) { - User user = new User(); - user.setUsername(authorName); - Comment c = new Comment(); - c.setId(id); - c.setContent(content); - c.setCreatedAt(LocalDateTime.now()); - c.setAuthor(user); - c.setPost(new Post()); - return c; - } + @MockBean + private CommentService commentService; - @Test - void createAndListComments() throws Exception { - Comment comment = createComment(1L, "hi", "bob"); - Mockito.when(commentService.addComment(eq("bob"), eq(1L), eq("hi"))).thenReturn(comment); - Mockito.when(commentService.getCommentsForPost(eq(1L), any())).thenReturn(List.of(comment)); - Mockito.when(commentService.getReplies(1L)).thenReturn(List.of()); - Mockito.when(reactionService.getReactionsForComment(1L)).thenReturn(List.of()); - CommentDto dto = new CommentDto(); - dto.setId(comment.getId()); - dto.setContent(comment.getContent()); - Mockito.when(commentMapper.toDto(comment)).thenReturn(dto); - Mockito.when(commentMapper.toDtoWithReplies(comment)).thenReturn(dto); + @MockBean + private CaptchaService captchaService; - mockMvc.perform(post("/api/posts/1/comments") - .contentType("application/json") - .content("{\"content\":\"hi\"}") - .principal(new UsernamePasswordAuthenticationToken("bob", "p"))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content").value("hi")); + @MockBean + private LevelService levelService; - mockMvc.perform(get("/api/posts/1/comments")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].id").value(1)); - } + @MockBean + private ReactionService reactionService; - @Test - void replyComment() throws Exception { - Comment reply = createComment(2L, "re", "alice"); - Mockito.when(commentService.addReply(eq("alice"), eq(1L), eq("re"))).thenReturn(reply); - CommentDto dto = new CommentDto(); - dto.setId(reply.getId()); - dto.setContent(reply.getContent()); - Mockito.when(commentMapper.toDto(reply)).thenReturn(dto); + @MockBean + private CommentMapper commentMapper; - mockMvc.perform(post("/api/comments/1/replies") - .contentType("application/json") - .content("{\"content\":\"re\"}") - .principal(new UsernamePasswordAuthenticationToken("alice", "p"))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(2)); - } + private Comment createComment(Long id, String content, String authorName) { + User user = new User(); + user.setUsername(authorName); + Comment c = new Comment(); + c.setId(id); + c.setContent(content); + c.setCreatedAt(LocalDateTime.now()); + c.setAuthor(user); + c.setPost(new Post()); + return c; + } + + @Test + void createAndListComments() throws Exception { + Comment comment = createComment(1L, "hi", "bob"); + Mockito.when(commentService.addComment(eq("bob"), eq(1L), eq("hi"))).thenReturn(comment); + Mockito.when(commentService.getCommentsForPost(eq(1L), any())).thenReturn(List.of(comment)); + Mockito.when(commentService.getReplies(1L)).thenReturn(List.of()); + Mockito.when(reactionService.getReactionsForComment(1L)).thenReturn(List.of()); + CommentDto dto = new CommentDto(); + dto.setId(comment.getId()); + dto.setContent(comment.getContent()); + Mockito.when(commentMapper.toDto(comment)).thenReturn(dto); + Mockito.when(commentMapper.toDtoWithReplies(comment)).thenReturn(dto); + + mockMvc + .perform( + post("/api/posts/1/comments") + .contentType("application/json") + .content("{\"content\":\"hi\"}") + .principal(new UsernamePasswordAuthenticationToken("bob", "p")) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").value("hi")); + + mockMvc + .perform(get("/api/posts/1/comments")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(1)); + } + + @Test + void replyComment() throws Exception { + Comment reply = createComment(2L, "re", "alice"); + Mockito.when(commentService.addReply(eq("alice"), eq(1L), eq("re"))).thenReturn(reply); + CommentDto dto = new CommentDto(); + dto.setId(reply.getId()); + dto.setContent(reply.getContent()); + Mockito.when(commentMapper.toDto(reply)).thenReturn(dto); + + mockMvc + .perform( + post("/api/comments/1/replies") + .contentType("application/json") + .content("{\"content\":\"re\"}") + .principal(new UsernamePasswordAuthenticationToken("alice", "p")) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(2)); + } } diff --git a/backend/src/test/java/com/openisle/controller/HelloControllerTest.java b/backend/src/test/java/com/openisle/controller/HelloControllerTest.java index d8837feba..a94865fdc 100644 --- a/backend/src/test/java/com/openisle/controller/HelloControllerTest.java +++ b/backend/src/test/java/com/openisle/controller/HelloControllerTest.java @@ -1,68 +1,74 @@ package com.openisle.controller; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.context.annotation.Import; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.boot.test.mock.mockito.MockBean; -import com.openisle.config.CustomAccessDeniedHandler; -import com.openisle.config.SecurityConfig; -import com.openisle.service.JwtService; -import com.openisle.repository.UserRepository; -import com.openisle.service.UserVisitService; -import com.openisle.model.Role; -import com.openisle.model.User; -import java.util.Optional; -import org.mockito.Mockito; - import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.openisle.config.CustomAccessDeniedHandler; +import com.openisle.config.SecurityConfig; +import com.openisle.model.Role; +import com.openisle.model.User; +import com.openisle.repository.UserRepository; +import com.openisle.service.JwtService; +import com.openisle.service.UserVisitService; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.test.web.servlet.MockMvc; + @WebMvcTest(HelloController.class) @AutoConfigureMockMvc -@Import({SecurityConfig.class, CustomAccessDeniedHandler.class}) +@Import({ SecurityConfig.class, CustomAccessDeniedHandler.class }) class HelloControllerTest { - @Autowired - private MockMvc mockMvc; - @MockBean - private JwtService jwtService; - @MockBean - private UserRepository userRepository; - @MockBean - private UserVisitService userVisitService; + @Autowired + private MockMvc mockMvc; - @Test - void helloReturnsMessage() throws Exception { - Mockito.when(jwtService.validateAndGetSubject("token")).thenReturn("user"); - User user = new User(); - user.setUsername("user"); - user.setPassword("p"); - user.setEmail("u@example.com"); - user.setRole(Role.USER); - Mockito.when(userRepository.findByUsername("user")).thenReturn(Optional.of(user)); + @MockBean + private JwtService jwtService; - mockMvc.perform(get("/api/hello").header("Authorization", "Bearer token")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("Hello, Authenticated User")); - } + @MockBean + private UserRepository userRepository; - @Test - void helloMissingToken() throws Exception { - mockMvc.perform(get("/api/hello")) - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.error").value("Missing token")); - } + @MockBean + private UserVisitService userVisitService; - @Test - void helloInvalidToken() throws Exception { - Mockito.when(jwtService.validateAndGetSubject("bad")).thenThrow(new RuntimeException()); + @Test + void helloReturnsMessage() throws Exception { + Mockito.when(jwtService.validateAndGetSubject("token")).thenReturn("user"); + User user = new User(); + user.setUsername("user"); + user.setPassword("p"); + user.setEmail("u@example.com"); + user.setRole(Role.USER); + Mockito.when(userRepository.findByUsername("user")).thenReturn(Optional.of(user)); - mockMvc.perform(get("/api/hello").header("Authorization", "Bearer bad")) - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.error").value("Invalid or expired token")); - } + mockMvc + .perform(get("/api/hello").header("Authorization", "Bearer token")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Hello, Authenticated User")); + } + + @Test + void helloMissingToken() throws Exception { + mockMvc + .perform(get("/api/hello")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error").value("Missing token")); + } + + @Test + void helloInvalidToken() throws Exception { + Mockito.when(jwtService.validateAndGetSubject("bad")).thenThrow(new RuntimeException()); + + mockMvc + .perform(get("/api/hello").header("Authorization", "Bearer bad")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error").value("Invalid or expired token")); + } } diff --git a/backend/src/test/java/com/openisle/controller/MedalControllerTest.java b/backend/src/test/java/com/openisle/controller/MedalControllerTest.java index 1853f92b8..543f7efd1 100644 --- a/backend/src/test/java/com/openisle/controller/MedalControllerTest.java +++ b/backend/src/test/java/com/openisle/controller/MedalControllerTest.java @@ -1,8 +1,14 @@ package com.openisle.controller; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + import com.openisle.dto.CommentMedalDto; import com.openisle.model.MedalType; import com.openisle.service.MedalService; +import java.util.List; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; @@ -12,63 +18,66 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; -import java.util.List; - -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - @WebMvcTest(MedalController.class) @AutoConfigureMockMvc(addFilters = false) class MedalControllerTest { - @Autowired - private MockMvc mockMvc; - @MockBean - private MedalService medalService; + @Autowired + private MockMvc mockMvc; - @Test - void listMedals() throws Exception { - CommentMedalDto medal = new CommentMedalDto(); - medal.setTitle("评论达人"); - medal.setType(MedalType.COMMENT); - Mockito.when(medalService.getMedals(null)).thenReturn(List.of(medal)); + @MockBean + private MedalService medalService; - mockMvc.perform(get("/api/medals")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].title").value("评论达人")); - } + @Test + void listMedals() throws Exception { + CommentMedalDto medal = new CommentMedalDto(); + medal.setTitle("评论达人"); + medal.setType(MedalType.COMMENT); + Mockito.when(medalService.getMedals(null)).thenReturn(List.of(medal)); - @Test - void listMedalsWithUser() throws Exception { - CommentMedalDto medal = new CommentMedalDto(); - medal.setCompleted(true); - medal.setType(MedalType.COMMENT); - Mockito.when(medalService.getMedals(1L)).thenReturn(List.of(medal)); + mockMvc + .perform(get("/api/medals")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].title").value("评论达人")); + } - mockMvc.perform(get("/api/medals").param("userId", "1")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].completed").value(true)); - } + @Test + void listMedalsWithUser() throws Exception { + CommentMedalDto medal = new CommentMedalDto(); + medal.setCompleted(true); + medal.setType(MedalType.COMMENT); + Mockito.when(medalService.getMedals(1L)).thenReturn(List.of(medal)); - @Test - void selectMedal() throws Exception { - mockMvc.perform(post("/api/medals/select") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"type\":\"COMMENT\"}") - .principal(() -> "user")) - .andExpect(status().isOk()); - } + mockMvc + .perform(get("/api/medals").param("userId", "1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].completed").value(true)); + } - @Test - void selectMedalBadRequest() throws Exception { - Mockito.doThrow(new IllegalArgumentException()).when(medalService) - .selectMedal("user", MedalType.COMMENT); - mockMvc.perform(post("/api/medals/select") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"type\":\"COMMENT\"}") - .principal(() -> "user")) - .andExpect(status().isBadRequest()); - } + @Test + void selectMedal() throws Exception { + mockMvc + .perform( + post("/api/medals/select") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"type\":\"COMMENT\"}") + .principal(() -> "user") + ) + .andExpect(status().isOk()); + } + + @Test + void selectMedalBadRequest() throws Exception { + Mockito.doThrow(new IllegalArgumentException()) + .when(medalService) + .selectMedal("user", MedalType.COMMENT); + mockMvc + .perform( + post("/api/medals/select") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"type\":\"COMMENT\"}") + .principal(() -> "user") + ) + .andExpect(status().isBadRequest()); + } } diff --git a/backend/src/test/java/com/openisle/controller/NotificationControllerTest.java b/backend/src/test/java/com/openisle/controller/NotificationControllerTest.java index c3cea2d30..2e0d92fc8 100644 --- a/backend/src/test/java/com/openisle/controller/NotificationControllerTest.java +++ b/backend/src/test/java/com/openisle/controller/NotificationControllerTest.java @@ -1,5 +1,9 @@ package com.openisle.controller; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + import com.openisle.dto.NotificationDto; import com.openisle.dto.PostSummaryDto; import com.openisle.mapper.NotificationMapper; @@ -7,6 +11,8 @@ import com.openisle.model.Notification; import com.openisle.model.NotificationType; import com.openisle.model.Post; import com.openisle.service.NotificationService; +import java.time.LocalDateTime; +import java.util.List; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -16,88 +22,92 @@ import org.springframework.http.MediaType; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.test.web.servlet.MockMvc; -import java.time.LocalDateTime; -import java.util.List; - -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; -import static org.mockito.Mockito.*; - @WebMvcTest(NotificationController.class) @AutoConfigureMockMvc(addFilters = false) class NotificationControllerTest { - @Autowired - private MockMvc mockMvc; + @Autowired + private MockMvc mockMvc; - @MockBean - private NotificationService notificationService; + @MockBean + private NotificationService notificationService; - @MockBean - private NotificationMapper notificationMapper; + @MockBean + private NotificationMapper notificationMapper; - @Test - void listNotifications() throws Exception { - Notification n = new Notification(); - n.setId(1L); - n.setType(NotificationType.POST_VIEWED); - Post p = new Post(); - p.setId(2L); - n.setPost(p); - n.setCreatedAt(LocalDateTime.now()); - when(notificationService.listNotifications("alice", null, 0, 30)) - .thenReturn(List.of(n)); + @Test + void listNotifications() throws Exception { + Notification n = new Notification(); + n.setId(1L); + n.setType(NotificationType.POST_VIEWED); + Post p = new Post(); + p.setId(2L); + n.setPost(p); + n.setCreatedAt(LocalDateTime.now()); + when(notificationService.listNotifications("alice", null, 0, 30)).thenReturn(List.of(n)); - NotificationDto dto = new NotificationDto(); - dto.setId(1L); - PostSummaryDto ps = new PostSummaryDto(); - ps.setId(2L); - dto.setPost(ps); - when(notificationMapper.toDto(n)).thenReturn(dto); + NotificationDto dto = new NotificationDto(); + dto.setId(1L); + PostSummaryDto ps = new PostSummaryDto(); + ps.setId(2L); + dto.setPost(ps); + when(notificationMapper.toDto(n)).thenReturn(dto); - mockMvc.perform(get("/api/notifications") - .principal(new UsernamePasswordAuthenticationToken("alice","p"))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].id").value(1)) - .andExpect(jsonPath("$[0].post.id").value(2)); - } + mockMvc + .perform( + get("/api/notifications").principal(new UsernamePasswordAuthenticationToken("alice", "p")) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(1)) + .andExpect(jsonPath("$[0].post.id").value(2)); + } - @Test - void listUnreadNotifications() throws Exception { - Notification n = new Notification(); - n.setId(5L); - n.setType(NotificationType.POST_VIEWED); - when(notificationService.listNotifications("alice", false, 0, 30)) - .thenReturn(List.of(n)); + @Test + void listUnreadNotifications() throws Exception { + Notification n = new Notification(); + n.setId(5L); + n.setType(NotificationType.POST_VIEWED); + when(notificationService.listNotifications("alice", false, 0, 30)).thenReturn(List.of(n)); - NotificationDto dto = new NotificationDto(); - dto.setId(5L); - when(notificationMapper.toDto(n)).thenReturn(dto); + NotificationDto dto = new NotificationDto(); + dto.setId(5L); + when(notificationMapper.toDto(n)).thenReturn(dto); - mockMvc.perform(get("/api/notifications/unread") - .principal(new UsernamePasswordAuthenticationToken("alice","p"))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].id").value(5)); - } + mockMvc + .perform( + get("/api/notifications/unread").principal( + new UsernamePasswordAuthenticationToken("alice", "p") + ) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(5)); + } - @Test - void markReadEndpoint() throws Exception { - mockMvc.perform(post("/api/notifications/read") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"ids\":[1,2]}" ) - .principal(new UsernamePasswordAuthenticationToken("alice","p"))) - .andExpect(status().isOk()); + @Test + void markReadEndpoint() throws Exception { + mockMvc + .perform( + post("/api/notifications/read") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"ids\":[1,2]}") + .principal(new UsernamePasswordAuthenticationToken("alice", "p")) + ) + .andExpect(status().isOk()); - verify(notificationService).markRead("alice", List.of(1L,2L)); - } + verify(notificationService).markRead("alice", List.of(1L, 2L)); + } - @Test - void unreadCountEndpoint() throws Exception { - when(notificationService.countUnread("alice")).thenReturn(3L); + @Test + void unreadCountEndpoint() throws Exception { + when(notificationService.countUnread("alice")).thenReturn(3L); - mockMvc.perform(get("/api/notifications/unread-count") - .principal(new UsernamePasswordAuthenticationToken("alice","p"))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.count").value(3)); - } + mockMvc + .perform( + get("/api/notifications/unread-count").principal( + new UsernamePasswordAuthenticationToken("alice", "p") + ) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.count").value(3)); + } } diff --git a/backend/src/test/java/com/openisle/controller/PointHistoryControllerTest.java b/backend/src/test/java/com/openisle/controller/PointHistoryControllerTest.java index ca28563cf..b132f0e91 100644 --- a/backend/src/test/java/com/openisle/controller/PointHistoryControllerTest.java +++ b/backend/src/test/java/com/openisle/controller/PointHistoryControllerTest.java @@ -1,13 +1,20 @@ package com.openisle.controller; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + import com.openisle.config.CustomAccessDeniedHandler; import com.openisle.config.SecurityConfig; -import com.openisle.service.PointService; import com.openisle.mapper.PointHistoryMapper; -import com.openisle.service.JwtService; -import com.openisle.repository.UserRepository; -import com.openisle.model.User; import com.openisle.model.Role; +import com.openisle.model.User; +import com.openisle.repository.UserRepository; +import com.openisle.service.JwtService; +import com.openisle.service.PointService; +import java.util.List; +import java.util.Map; +import java.util.Optional; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; @@ -17,49 +24,47 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Import; import org.springframework.test.web.servlet.MockMvc; -import java.util.List; -import java.util.Map; -import java.util.Optional; - -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - @WebMvcTest(PointHistoryController.class) @AutoConfigureMockMvc -@Import({SecurityConfig.class, CustomAccessDeniedHandler.class}) +@Import({ SecurityConfig.class, CustomAccessDeniedHandler.class }) class PointHistoryControllerTest { - @Autowired - private MockMvc mockMvc; - @MockBean - private JwtService jwtService; - @MockBean - private UserRepository userRepository; - @MockBean - private PointService pointService; - @MockBean - private PointHistoryMapper pointHistoryMapper; + @Autowired + private MockMvc mockMvc; - @Test - void trendReturnsSeries() throws Exception { - Mockito.when(jwtService.validateAndGetSubject("token")).thenReturn("user"); - User user = new User(); - user.setUsername("user"); - user.setPassword("p"); - user.setEmail("u@example.com"); - user.setRole(Role.USER); - Mockito.when(userRepository.findByUsername("user")).thenReturn(Optional.of(user)); - List> data = List.of( - Map.of("date", java.time.LocalDate.now().minusDays(1).toString(), "value", 100), - Map.of("date", java.time.LocalDate.now().toString(), "value", 110) - ); - Mockito.when(pointService.trend(Mockito.eq("user"), Mockito.anyInt())).thenReturn(data); + @MockBean + private JwtService jwtService; - mockMvc.perform(get("/api/point-histories/trend").param("days", "2") - .header("Authorization", "Bearer token")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].value").value(100)) - .andExpect(jsonPath("$[1].value").value(110)); - } + @MockBean + private UserRepository userRepository; + + @MockBean + private PointService pointService; + + @MockBean + private PointHistoryMapper pointHistoryMapper; + + @Test + void trendReturnsSeries() throws Exception { + Mockito.when(jwtService.validateAndGetSubject("token")).thenReturn("user"); + User user = new User(); + user.setUsername("user"); + user.setPassword("p"); + user.setEmail("u@example.com"); + user.setRole(Role.USER); + Mockito.when(userRepository.findByUsername("user")).thenReturn(Optional.of(user)); + List> data = List.of( + Map.of("date", java.time.LocalDate.now().minusDays(1).toString(), "value", 100), + Map.of("date", java.time.LocalDate.now().toString(), "value", 110) + ); + Mockito.when(pointService.trend(Mockito.eq("user"), Mockito.anyInt())).thenReturn(data); + + mockMvc + .perform( + get("/api/point-histories/trend").param("days", "2").header("Authorization", "Bearer token") + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].value").value(100)) + .andExpect(jsonPath("$[1].value").value(110)); + } } diff --git a/backend/src/test/java/com/openisle/controller/PostControllerTest.java b/backend/src/test/java/com/openisle/controller/PostControllerTest.java index f755424df..3bf386ff4 100644 --- a/backend/src/test/java/com/openisle/controller/PostControllerTest.java +++ b/backend/src/test/java/com/openisle/controller/PostControllerTest.java @@ -1,5 +1,10 @@ package com.openisle.controller; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + import com.openisle.mapper.CategoryMapper; import com.openisle.mapper.CommentMapper; import com.openisle.mapper.PostMapper; @@ -8,6 +13,9 @@ import com.openisle.mapper.TagMapper; import com.openisle.mapper.UserMapper; import com.openisle.model.*; import com.openisle.service.*; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -15,297 +23,356 @@ import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Import; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.util.ReflectionTestUtils; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Set; - -import static org.mockito.ArgumentMatchers.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; -import static org.mockito.Mockito.*; +import org.springframework.test.web.servlet.MockMvc; @WebMvcTest(PostController.class) @AutoConfigureMockMvc(addFilters = false) -@Import({PostMapper.class, CommentMapper.class, ReactionMapper.class, - UserMapper.class, TagMapper.class, CategoryMapper.class}) +@Import( + { + PostMapper.class, + CommentMapper.class, + ReactionMapper.class, + UserMapper.class, + TagMapper.class, + CategoryMapper.class, + } +) class PostControllerTest { - @Autowired - private MockMvc mockMvc; - @Autowired - private PostController postController; - @MockBean - private PostService postService; - @MockBean - private CommentService commentService; - @MockBean - private ReactionService reactionService; - @MockBean - private CaptchaService captchaService; - @MockBean - private DraftService draftService; - @MockBean - private LevelService levelService; - @MockBean - private SubscriptionService subscriptionService; - @MockBean - private UserVisitService userVisitService; - @MockBean - private PostReadService postReadService; - @MockBean - private MedalService medalService; - @MockBean - private com.openisle.repository.PollVoteRepository pollVoteRepository; + @Autowired + private MockMvc mockMvc; - @Test - void createAndGetPost() throws Exception { - User user = new User(); - user.setUsername("alice"); - Category cat = new Category(); - cat.setId(1L); - cat.setName("tech"); - cat.setDescription("Technology category"); - cat.setIcon("tech-icon"); - Tag tag = new Tag(); - tag.setId(1L); - tag.setName("java"); - tag.setDescription("Java programming language"); - tag.setIcon("java-icon"); - Post post = new Post(); - post.setId(1L); - post.setTitle("t"); - post.setContent("c"); - post.setCreatedAt(LocalDateTime.now()); - post.setAuthor(user); - post.setCategory(cat); - post.setTags(Set.of(tag)); + @Autowired + private PostController postController; - when(postService.createPost(eq("alice"), eq(1L), eq("t"), eq("c"), eq(List.of(1L)), - isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull())).thenReturn(post); - when(postService.viewPost(eq(1L), any())).thenReturn(post); - when(commentService.getCommentsForPost(eq(1L), any())).thenReturn(List.of()); - when(commentService.getParticipants(anyLong(), anyInt())).thenReturn(List.of()); - when(reactionService.getReactionsForPost(1L)).thenReturn(List.of()); - when(commentService.getLastCommentTime(1L)).thenReturn(null); + @MockBean + private PostService postService; - mockMvc.perform(post("/api/posts") - .contentType("application/json") - .content("{\"title\":\"t\",\"content\":\"c\",\"categoryId\":1,\"tagIds\":[1]}") - .principal(new UsernamePasswordAuthenticationToken("alice", "p"))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.title").value("t")) - .andExpect(jsonPath("$.comments").isArray()) - .andExpect(jsonPath("$.comments").isEmpty()) - .andExpect(jsonPath("$.author.username").value("alice")) - .andExpect(jsonPath("$.category.name").value("tech")) - .andExpect(jsonPath("$.tags[0].name").value("java")) - .andExpect(jsonPath("$.subscribed").value(false)); + @MockBean + private CommentService commentService; - mockMvc.perform(get("/api/posts/1")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(1)) - .andExpect(jsonPath("$.comments").isArray()) - .andExpect(jsonPath("$.comments").isEmpty()) - .andExpect(jsonPath("$.subscribed").value(false)); - } + @MockBean + private ReactionService reactionService; - @Test - void updatePostReturnsDetailDto() throws Exception { - User user = new User(); - user.setUsername("alice"); - Category cat = new Category(); - cat.setId(1L); - cat.setName("tech"); - cat.setDescription("Technology category"); - cat.setIcon("tech-icon"); - Tag tag = new Tag(); - tag.setId(1L); - tag.setName("java"); - tag.setDescription("Java programming language"); - tag.setIcon("java-icon"); - Post post = new Post(); - post.setId(1L); - post.setTitle("t2"); - post.setContent("c2"); - post.setCreatedAt(LocalDateTime.now()); - post.setAuthor(user); - post.setCategory(cat); - post.setTags(Set.of(tag)); + @MockBean + private CaptchaService captchaService; - when(postService.updatePost(eq(1L), eq("alice"), eq(1L), eq("t2"), eq("c2"), eq(List.of(1L)))).thenReturn(post); - when(commentService.getCommentsForPost(eq(1L), any())).thenReturn(List.of()); - when(commentService.getParticipants(anyLong(), anyInt())).thenReturn(List.of()); - when(reactionService.getReactionsForPost(1L)).thenReturn(List.of()); - when(commentService.getLastCommentTime(1L)).thenReturn(null); + @MockBean + private DraftService draftService; - mockMvc.perform(put("/api/posts/1") - .contentType("application/json") - .content("{\"title\":\"t2\",\"content\":\"c2\",\"categoryId\":1,\"tagIds\":[1]}") - .principal(new UsernamePasswordAuthenticationToken("alice", "p"))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.title").value("t2")) - .andExpect(jsonPath("$.comments").isArray()) - .andExpect(jsonPath("$.comments").isEmpty()) - .andExpect(jsonPath("$.author.username").value("alice")); - } + @MockBean + private LevelService levelService; - @Test - void listPosts() throws Exception { - User user = new User(); - user.setUsername("bob"); - Category cat = new Category(); - cat.setId(1L); - cat.setName("tech"); - cat.setDescription("Technology category"); - cat.setIcon("tech-icon"); - Tag tag = new Tag(); - tag.setId(1L); - tag.setName("java"); - tag.setDescription("Java programming language"); - tag.setIcon("java-icon"); - Post post = new Post(); - post.setId(2L); - post.setTitle("hello"); - post.setContent("world"); - post.setCreatedAt(LocalDateTime.now()); - post.setAuthor(user); - post.setCategory(cat); - post.setTags(Set.of(tag)); + @MockBean + private SubscriptionService subscriptionService; - when(postService.listPostsByCategories(null, null, null)).thenReturn(List.of(post)); - when(commentService.getParticipants(anyLong(), anyInt())).thenReturn(List.of()); - when(reactionService.getReactionsForPost(anyLong())).thenReturn(List.of()); - when(commentService.getLastCommentTime(anyLong())).thenReturn(null); + @MockBean + private UserVisitService userVisitService; - mockMvc.perform(get("/api/posts")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].title").value("hello")) - .andExpect(jsonPath("$[0].comments").doesNotExist()) - .andExpect(jsonPath("$[0].author.username").value("bob")) - .andExpect(jsonPath("$[0].category.name").value("tech")) - .andExpect(jsonPath("$[0].tags[0].name").value("java")) - .andExpect(jsonPath("$[0].subscribed").value(false)); - } + @MockBean + private PostReadService postReadService; - @Test - void createPostRejectsInvalidCaptcha() throws Exception { - ReflectionTestUtils.setField(postController, "captchaEnabled", true); - ReflectionTestUtils.setField(postController, "postCaptchaEnabled", true); - when(captchaService.verify("bad")).thenReturn(false); + @MockBean + private MedalService medalService; - mockMvc.perform(post("/api/posts") - .contentType("application/json") - .content("{\"title\":\"t\",\"content\":\"c\",\"categoryId\":1,\"tagIds\":[1],\"captcha\":\"bad\"}") - .principal(new UsernamePasswordAuthenticationToken("alice", "p"))) - .andExpect(status().isBadRequest()); + @MockBean + private com.openisle.repository.PollVoteRepository pollVoteRepository; - verify(postService, never()).createPost(any(), any(), any(), any(), any(), - any(), any(), any(), any(), any(), any(), any(), any(), any()); - } + @Test + void createAndGetPost() throws Exception { + User user = new User(); + user.setUsername("alice"); + Category cat = new Category(); + cat.setId(1L); + cat.setName("tech"); + cat.setDescription("Technology category"); + cat.setIcon("tech-icon"); + Tag tag = new Tag(); + tag.setId(1L); + tag.setName("java"); + tag.setDescription("Java programming language"); + tag.setIcon("java-icon"); + Post post = new Post(); + post.setId(1L); + post.setTitle("t"); + post.setContent("c"); + post.setCreatedAt(LocalDateTime.now()); + post.setAuthor(user); + post.setCategory(cat); + post.setTags(Set.of(tag)); - @Test - void getPostWithNestedData() throws Exception { - User user = new User(); - user.setUsername("alice"); - Category cat = new Category(); - cat.setId(1L); - cat.setName("tech"); - cat.setDescription("Technology category"); - cat.setIcon("tech-icon"); - Tag tag = new Tag(); - tag.setId(1L); - tag.setName("java"); - tag.setDescription("Java programming language"); - tag.setIcon("java-icon"); - Post post = new Post(); - post.setId(1L); - post.setTitle("t"); - post.setContent("c"); - post.setCreatedAt(LocalDateTime.now()); - post.setAuthor(user); - post.setCategory(cat); - post.setTags(Set.of(tag)); + when( + postService.createPost( + eq("alice"), + eq(1L), + eq("t"), + eq("c"), + eq(List.of(1L)), + isNull(), + isNull(), + isNull(), + isNull(), + isNull(), + isNull(), + isNull(), + isNull(), + isNull() + ) + ).thenReturn(post); + when(postService.viewPost(eq(1L), any())).thenReturn(post); + when(commentService.getCommentsForPost(eq(1L), any())).thenReturn(List.of()); + when(commentService.getParticipants(anyLong(), anyInt())).thenReturn(List.of()); + when(reactionService.getReactionsForPost(1L)).thenReturn(List.of()); + when(commentService.getLastCommentTime(1L)).thenReturn(null); - com.openisle.model.Comment comment = new com.openisle.model.Comment(); - comment.setId(2L); - comment.setContent("hi"); - comment.setCreatedAt(LocalDateTime.now()); - comment.setAuthor(user); - comment.setPost(post); + mockMvc + .perform( + post("/api/posts") + .contentType("application/json") + .content("{\"title\":\"t\",\"content\":\"c\",\"categoryId\":1,\"tagIds\":[1]}") + .principal(new UsernamePasswordAuthenticationToken("alice", "p")) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("t")) + .andExpect(jsonPath("$.comments").isArray()) + .andExpect(jsonPath("$.comments").isEmpty()) + .andExpect(jsonPath("$.author.username").value("alice")) + .andExpect(jsonPath("$.category.name").value("tech")) + .andExpect(jsonPath("$.tags[0].name").value("java")) + .andExpect(jsonPath("$.subscribed").value(false)); - com.openisle.model.Comment reply = new com.openisle.model.Comment(); - reply.setId(3L); - reply.setContent("reply"); - reply.setCreatedAt(LocalDateTime.now()); - reply.setAuthor(user); - reply.setPost(post); + mockMvc + .perform(get("/api/posts/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(1)) + .andExpect(jsonPath("$.comments").isArray()) + .andExpect(jsonPath("$.comments").isEmpty()) + .andExpect(jsonPath("$.subscribed").value(false)); + } - com.openisle.model.Reaction pr = new com.openisle.model.Reaction(); - pr.setId(10L); - pr.setUser(user); - pr.setPost(post); - pr.setType(com.openisle.model.ReactionType.LIKE); + @Test + void updatePostReturnsDetailDto() throws Exception { + User user = new User(); + user.setUsername("alice"); + Category cat = new Category(); + cat.setId(1L); + cat.setName("tech"); + cat.setDescription("Technology category"); + cat.setIcon("tech-icon"); + Tag tag = new Tag(); + tag.setId(1L); + tag.setName("java"); + tag.setDescription("Java programming language"); + tag.setIcon("java-icon"); + Post post = new Post(); + post.setId(1L); + post.setTitle("t2"); + post.setContent("c2"); + post.setCreatedAt(LocalDateTime.now()); + post.setAuthor(user); + post.setCategory(cat); + post.setTags(Set.of(tag)); - com.openisle.model.Reaction cr = new com.openisle.model.Reaction(); - cr.setId(11L); - cr.setUser(user); - cr.setComment(comment); - cr.setType(com.openisle.model.ReactionType.LIKE); + when( + postService.updatePost(eq(1L), eq("alice"), eq(1L), eq("t2"), eq("c2"), eq(List.of(1L))) + ).thenReturn(post); + when(commentService.getCommentsForPost(eq(1L), any())).thenReturn(List.of()); + when(commentService.getParticipants(anyLong(), anyInt())).thenReturn(List.of()); + when(reactionService.getReactionsForPost(1L)).thenReturn(List.of()); + when(commentService.getLastCommentTime(1L)).thenReturn(null); - when(postService.viewPost(eq(1L), any())).thenReturn(post); - when(commentService.getCommentsForPost(eq(1L), any())).thenReturn(List.of(comment)); - when(commentService.getReplies(2L)).thenReturn(List.of(reply)); - when(commentService.getReplies(3L)).thenReturn(List.of()); - when(commentService.getParticipants(anyLong(), anyInt())).thenReturn(List.of()); - when(commentService.getLastCommentTime(1L)).thenReturn(null); - when(reactionService.getReactionsForPost(1L)).thenReturn(List.of(pr)); - when(reactionService.getReactionsForComment(2L)).thenReturn(List.of(cr)); - when(reactionService.getReactionsForComment(3L)).thenReturn(List.of()); + mockMvc + .perform( + put("/api/posts/1") + .contentType("application/json") + .content("{\"title\":\"t2\",\"content\":\"c2\",\"categoryId\":1,\"tagIds\":[1]}") + .principal(new UsernamePasswordAuthenticationToken("alice", "p")) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("t2")) + .andExpect(jsonPath("$.comments").isArray()) + .andExpect(jsonPath("$.comments").isEmpty()) + .andExpect(jsonPath("$.author.username").value("alice")); + } - mockMvc.perform(get("/api/posts/1")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.reactions[0].id").value(10)) - .andExpect(jsonPath("$.comments[0].replies[0].id").value(3)) - .andExpect(jsonPath("$.comments[0].reactions[0].id").value(11)) - .andExpect(jsonPath("$.author.username").value("alice")) - .andExpect(jsonPath("$.category.name").value("tech")) - .andExpect(jsonPath("$.tags[0].name").value("java")); - } + @Test + void listPosts() throws Exception { + User user = new User(); + user.setUsername("bob"); + Category cat = new Category(); + cat.setId(1L); + cat.setName("tech"); + cat.setDescription("Technology category"); + cat.setIcon("tech-icon"); + Tag tag = new Tag(); + tag.setId(1L); + tag.setName("java"); + tag.setDescription("Java programming language"); + tag.setIcon("java-icon"); + Post post = new Post(); + post.setId(2L); + post.setTitle("hello"); + post.setContent("world"); + post.setCreatedAt(LocalDateTime.now()); + post.setAuthor(user); + post.setCategory(cat); + post.setTags(Set.of(tag)); - @Test - void getPostSubscriptionStatus() throws Exception { - User user = new User(); - user.setUsername("alice"); - Category cat = new Category(); - cat.setId(1L); - cat.setName("tech"); - cat.setDescription("Technology category"); - cat.setIcon("tech-icon"); - Post post = new Post(); - post.setId(1L); - post.setTitle("t"); - post.setContent("c"); - post.setCreatedAt(LocalDateTime.now()); - post.setAuthor(user); - post.setCategory(cat); - post.setTags(Set.of()); + when(postService.listPostsByCategories(null, null, null)).thenReturn(List.of(post)); + when(commentService.getParticipants(anyLong(), anyInt())).thenReturn(List.of()); + when(reactionService.getReactionsForPost(anyLong())).thenReturn(List.of()); + when(commentService.getLastCommentTime(anyLong())).thenReturn(null); - when(postService.viewPost(eq(1L), any())).thenReturn(post); - when(commentService.getCommentsForPost(eq(1L), any())).thenReturn(List.of()); - when(commentService.getParticipants(anyLong(), anyInt())).thenReturn(List.of()); - when(reactionService.getReactionsForPost(1L)).thenReturn(List.of()); - when(commentService.getLastCommentTime(1L)).thenReturn(null); - when(subscriptionService.isPostSubscribed("alice", 1L)).thenReturn(true); + mockMvc + .perform(get("/api/posts")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].title").value("hello")) + .andExpect(jsonPath("$[0].comments").doesNotExist()) + .andExpect(jsonPath("$[0].author.username").value("bob")) + .andExpect(jsonPath("$[0].category.name").value("tech")) + .andExpect(jsonPath("$[0].tags[0].name").value("java")) + .andExpect(jsonPath("$[0].subscribed").value(false)); + } - mockMvc.perform(get("/api/posts/1").principal(new UsernamePasswordAuthenticationToken("alice", "p"))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.subscribed").value(true)); + @Test + void createPostRejectsInvalidCaptcha() throws Exception { + ReflectionTestUtils.setField(postController, "captchaEnabled", true); + ReflectionTestUtils.setField(postController, "postCaptchaEnabled", true); + when(captchaService.verify("bad")).thenReturn(false); - mockMvc.perform(get("/api/posts/1")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.subscribed").value(false)); - } + mockMvc + .perform( + post("/api/posts") + .contentType("application/json") + .content( + "{\"title\":\"t\",\"content\":\"c\",\"categoryId\":1,\"tagIds\":[1],\"captcha\":\"bad\"}" + ) + .principal(new UsernamePasswordAuthenticationToken("alice", "p")) + ) + .andExpect(status().isBadRequest()); + + verify(postService, never()).createPost( + any(), + any(), + any(), + any(), + any(), + any(), + any(), + any(), + any(), + any(), + any(), + any(), + any(), + any() + ); + } + + @Test + void getPostWithNestedData() throws Exception { + User user = new User(); + user.setUsername("alice"); + Category cat = new Category(); + cat.setId(1L); + cat.setName("tech"); + cat.setDescription("Technology category"); + cat.setIcon("tech-icon"); + Tag tag = new Tag(); + tag.setId(1L); + tag.setName("java"); + tag.setDescription("Java programming language"); + tag.setIcon("java-icon"); + Post post = new Post(); + post.setId(1L); + post.setTitle("t"); + post.setContent("c"); + post.setCreatedAt(LocalDateTime.now()); + post.setAuthor(user); + post.setCategory(cat); + post.setTags(Set.of(tag)); + + com.openisle.model.Comment comment = new com.openisle.model.Comment(); + comment.setId(2L); + comment.setContent("hi"); + comment.setCreatedAt(LocalDateTime.now()); + comment.setAuthor(user); + comment.setPost(post); + + com.openisle.model.Comment reply = new com.openisle.model.Comment(); + reply.setId(3L); + reply.setContent("reply"); + reply.setCreatedAt(LocalDateTime.now()); + reply.setAuthor(user); + reply.setPost(post); + + com.openisle.model.Reaction pr = new com.openisle.model.Reaction(); + pr.setId(10L); + pr.setUser(user); + pr.setPost(post); + pr.setType(com.openisle.model.ReactionType.LIKE); + + com.openisle.model.Reaction cr = new com.openisle.model.Reaction(); + cr.setId(11L); + cr.setUser(user); + cr.setComment(comment); + cr.setType(com.openisle.model.ReactionType.LIKE); + + when(postService.viewPost(eq(1L), any())).thenReturn(post); + when(commentService.getCommentsForPost(eq(1L), any())).thenReturn(List.of(comment)); + when(commentService.getReplies(2L)).thenReturn(List.of(reply)); + when(commentService.getReplies(3L)).thenReturn(List.of()); + when(commentService.getParticipants(anyLong(), anyInt())).thenReturn(List.of()); + when(commentService.getLastCommentTime(1L)).thenReturn(null); + when(reactionService.getReactionsForPost(1L)).thenReturn(List.of(pr)); + when(reactionService.getReactionsForComment(2L)).thenReturn(List.of(cr)); + when(reactionService.getReactionsForComment(3L)).thenReturn(List.of()); + + mockMvc + .perform(get("/api/posts/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.reactions[0].id").value(10)) + .andExpect(jsonPath("$.comments[0].replies[0].id").value(3)) + .andExpect(jsonPath("$.comments[0].reactions[0].id").value(11)) + .andExpect(jsonPath("$.author.username").value("alice")) + .andExpect(jsonPath("$.category.name").value("tech")) + .andExpect(jsonPath("$.tags[0].name").value("java")); + } + + @Test + void getPostSubscriptionStatus() throws Exception { + User user = new User(); + user.setUsername("alice"); + Category cat = new Category(); + cat.setId(1L); + cat.setName("tech"); + cat.setDescription("Technology category"); + cat.setIcon("tech-icon"); + Post post = new Post(); + post.setId(1L); + post.setTitle("t"); + post.setContent("c"); + post.setCreatedAt(LocalDateTime.now()); + post.setAuthor(user); + post.setCategory(cat); + post.setTags(Set.of()); + + when(postService.viewPost(eq(1L), any())).thenReturn(post); + when(commentService.getCommentsForPost(eq(1L), any())).thenReturn(List.of()); + when(commentService.getParticipants(anyLong(), anyInt())).thenReturn(List.of()); + when(reactionService.getReactionsForPost(1L)).thenReturn(List.of()); + when(commentService.getLastCommentTime(1L)).thenReturn(null); + when(subscriptionService.isPostSubscribed("alice", 1L)).thenReturn(true); + + mockMvc + .perform(get("/api/posts/1").principal(new UsernamePasswordAuthenticationToken("alice", "p"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.subscribed").value(true)); + + mockMvc + .perform(get("/api/posts/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.subscribed").value(false)); + } } diff --git a/backend/src/test/java/com/openisle/controller/PushSubscriptionControllerTest.java b/backend/src/test/java/com/openisle/controller/PushSubscriptionControllerTest.java index fbb31b718..214e4f8d9 100644 --- a/backend/src/test/java/com/openisle/controller/PushSubscriptionControllerTest.java +++ b/backend/src/test/java/com/openisle/controller/PushSubscriptionControllerTest.java @@ -1,5 +1,8 @@ package com.openisle.controller; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + import com.openisle.service.PushSubscriptionService; import org.junit.jupiter.api.Test; import org.mockito.Mockito; @@ -11,26 +14,26 @@ import org.springframework.http.MediaType; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.test.web.servlet.MockMvc; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - @WebMvcTest(PushSubscriptionController.class) @AutoConfigureMockMvc(addFilters = false) class PushSubscriptionControllerTest { - @Autowired - private MockMvc mockMvc; + @Autowired + private MockMvc mockMvc; - @MockBean - private PushSubscriptionService pushSubscriptionService; + @MockBean + private PushSubscriptionService pushSubscriptionService; - @Test - void subscribeEndpoint() throws Exception { - mockMvc.perform(post("/api/push/subscribe") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"endpoint\":\"e\",\"p256dh\":\"p\",\"auth\":\"a\"}") - .principal(new UsernamePasswordAuthenticationToken("u","p"))) - .andExpect(status().isOk()); - Mockito.verify(pushSubscriptionService).saveSubscription("u","e","p","a"); - } + @Test + void subscribeEndpoint() throws Exception { + mockMvc + .perform( + post("/api/push/subscribe") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"endpoint\":\"e\",\"p256dh\":\"p\",\"auth\":\"a\"}") + .principal(new UsernamePasswordAuthenticationToken("u", "p")) + ) + .andExpect(status().isOk()); + Mockito.verify(pushSubscriptionService).saveSubscription("u", "e", "p", "a"); + } } diff --git a/backend/src/test/java/com/openisle/controller/ReactionControllerTest.java b/backend/src/test/java/com/openisle/controller/ReactionControllerTest.java index 8b1ab96b6..217dde90f 100644 --- a/backend/src/test/java/com/openisle/controller/ReactionControllerTest.java +++ b/backend/src/test/java/com/openisle/controller/ReactionControllerTest.java @@ -1,14 +1,20 @@ package com.openisle.controller; +import static org.mockito.ArgumentMatchers.eq; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.openisle.mapper.ReactionMapper; import com.openisle.model.Comment; +import com.openisle.model.Message; import com.openisle.model.Post; import com.openisle.model.Reaction; import com.openisle.model.ReactionType; import com.openisle.model.User; -import com.openisle.model.Message; -import com.openisle.service.ReactionService; import com.openisle.service.LevelService; -import com.openisle.mapper.ReactionMapper; +import com.openisle.service.ReactionService; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; @@ -19,91 +25,103 @@ import org.springframework.context.annotation.Import; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.test.web.servlet.MockMvc; -import static org.mockito.ArgumentMatchers.eq; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - @WebMvcTest(ReactionController.class) @AutoConfigureMockMvc(addFilters = false) @Import(ReactionMapper.class) class ReactionControllerTest { - @Autowired - private MockMvc mockMvc; - @MockBean - private ReactionService reactionService; - @MockBean - private LevelService levelService; + @Autowired + private MockMvc mockMvc; - @Test - void reactToPost() throws Exception { - User user = new User(); - user.setUsername("u1"); - Post post = new Post(); - post.setId(1L); - Reaction reaction = new Reaction(); - reaction.setId(1L); - reaction.setUser(user); - reaction.setPost(post); - reaction.setType(ReactionType.LIKE); - Mockito.when(reactionService.reactToPost(eq("u1"), eq(1L), eq(ReactionType.LIKE))).thenReturn(reaction); + @MockBean + private ReactionService reactionService; - mockMvc.perform(post("/api/posts/1/reactions") - .contentType("application/json") - .content("{\"type\":\"LIKE\"}") - .principal(new UsernamePasswordAuthenticationToken("u1", "p"))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.postId").value(1)); - } + @MockBean + private LevelService levelService; - @Test - void reactToComment() throws Exception { - User user = new User(); - user.setUsername("u2"); - Comment comment = new Comment(); - comment.setId(2L); - Reaction reaction = new Reaction(); - reaction.setId(2L); - reaction.setUser(user); - reaction.setComment(comment); - reaction.setType(ReactionType.RECOMMEND); - Mockito.when(reactionService.reactToComment(eq("u2"), eq(2L), eq(ReactionType.RECOMMEND))).thenReturn(reaction); + @Test + void reactToPost() throws Exception { + User user = new User(); + user.setUsername("u1"); + Post post = new Post(); + post.setId(1L); + Reaction reaction = new Reaction(); + reaction.setId(1L); + reaction.setUser(user); + reaction.setPost(post); + reaction.setType(ReactionType.LIKE); + Mockito.when(reactionService.reactToPost(eq("u1"), eq(1L), eq(ReactionType.LIKE))).thenReturn( + reaction + ); - mockMvc.perform(post("/api/comments/2/reactions") - .contentType("application/json") - .content("{\"type\":\"RECOMMEND\"}") - .principal(new UsernamePasswordAuthenticationToken("u2", "p"))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.commentId").value(2)); - } + mockMvc + .perform( + post("/api/posts/1/reactions") + .contentType("application/json") + .content("{\"type\":\"LIKE\"}") + .principal(new UsernamePasswordAuthenticationToken("u1", "p")) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.postId").value(1)); + } - @Test - void reactToMessage() throws Exception { - User user = new User(); - user.setUsername("u3"); - Message message = new Message(); - message.setId(3L); - Reaction reaction = new Reaction(); - reaction.setId(3L); - reaction.setUser(user); - reaction.setMessage(message); - reaction.setType(ReactionType.LIKE); - Mockito.when(reactionService.reactToMessage(eq("u3"), eq(3L), eq(ReactionType.LIKE))).thenReturn(reaction); + @Test + void reactToComment() throws Exception { + User user = new User(); + user.setUsername("u2"); + Comment comment = new Comment(); + comment.setId(2L); + Reaction reaction = new Reaction(); + reaction.setId(2L); + reaction.setUser(user); + reaction.setComment(comment); + reaction.setType(ReactionType.RECOMMEND); + Mockito.when( + reactionService.reactToComment(eq("u2"), eq(2L), eq(ReactionType.RECOMMEND)) + ).thenReturn(reaction); - mockMvc.perform(post("/api/messages/3/reactions") - .contentType("application/json") - .content("{\"type\":\"LIKE\"}") - .principal(new UsernamePasswordAuthenticationToken("u3", "p"))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.messageId").value(3)); - } + mockMvc + .perform( + post("/api/comments/2/reactions") + .contentType("application/json") + .content("{\"type\":\"RECOMMEND\"}") + .principal(new UsernamePasswordAuthenticationToken("u2", "p")) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.commentId").value(2)); + } - @Test - void listReactionTypes() throws Exception { - mockMvc.perform(get("/api/reaction-types")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0]").value("LIKE")); - } + @Test + void reactToMessage() throws Exception { + User user = new User(); + user.setUsername("u3"); + Message message = new Message(); + message.setId(3L); + Reaction reaction = new Reaction(); + reaction.setId(3L); + reaction.setUser(user); + reaction.setMessage(message); + reaction.setType(ReactionType.LIKE); + Mockito.when( + reactionService.reactToMessage(eq("u3"), eq(3L), eq(ReactionType.LIKE)) + ).thenReturn(reaction); + + mockMvc + .perform( + post("/api/messages/3/reactions") + .contentType("application/json") + .content("{\"type\":\"LIKE\"}") + .principal(new UsernamePasswordAuthenticationToken("u3", "p")) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.messageId").value(3)); + } + + @Test + void listReactionTypes() throws Exception { + mockMvc + .perform(get("/api/reaction-types")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0]").value("LIKE")); + } } diff --git a/backend/src/test/java/com/openisle/controller/SearchControllerTest.java b/backend/src/test/java/com/openisle/controller/SearchControllerTest.java index fd6d2768c..58313baa4 100644 --- a/backend/src/test/java/com/openisle/controller/SearchControllerTest.java +++ b/backend/src/test/java/com/openisle/controller/SearchControllerTest.java @@ -1,14 +1,18 @@ package com.openisle.controller; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + import com.openisle.dto.PostSummaryDto; import com.openisle.dto.UserDto; import com.openisle.mapper.PostMapper; import com.openisle.mapper.UserMapper; import com.openisle.model.Comment; import com.openisle.model.Post; -import com.openisle.model.User; import com.openisle.model.PostStatus; +import com.openisle.model.User; import com.openisle.service.SearchService; +import java.util.List; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; @@ -17,94 +21,98 @@ import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.web.servlet.MockMvc; -import java.util.List; - -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - @WebMvcTest(SearchController.class) @AutoConfigureMockMvc(addFilters = false) class SearchControllerTest { - @Autowired - private MockMvc mockMvc; - @MockBean - private SearchService searchService; - @MockBean - private UserMapper userMapper; - @MockBean - private PostMapper postMapper; + @Autowired + private MockMvc mockMvc; - @Test - void userSearchEndpoint() throws Exception { - User user = new User(); - user.setId(1L); - user.setUsername("alice"); - Mockito.when(searchService.searchUsers("ali")).thenReturn(List.of(user)); - UserDto userDto = new UserDto(); - userDto.setId(1L); - userDto.setUsername("alice"); - Mockito.when(userMapper.toDto(user)).thenReturn(userDto); + @MockBean + private SearchService searchService; - mockMvc.perform(get("/api/search/users").param("keyword", "ali")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].username").value("alice")); - } + @MockBean + private UserMapper userMapper; - @Test - void globalSearchAggregatesTypes() throws Exception { - User u = new User(); - u.setId(1L); - u.setUsername("bob"); - Post p = new Post(); - p.setId(2L); - p.setTitle("hello"); - p.setStatus(PostStatus.PUBLISHED); - Comment c = new Comment(); - c.setId(3L); - c.setContent("nice"); - Mockito.when(searchService.globalSearch("n")).thenReturn(List.of( - new SearchService.SearchResult("user", 1L, "bob", null, null, null), - new SearchService.SearchResult("post", 2L, "hello", null, null, null), - new SearchService.SearchResult("comment", 3L, "nice", null, null, null) - )); + @MockBean + private PostMapper postMapper; - mockMvc.perform(get("/api/search/global").param("keyword", "n")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].type").value("user")) - .andExpect(jsonPath("$[1].type").value("post")) - .andExpect(jsonPath("$[2].type").value("comment")); - } + @Test + void userSearchEndpoint() throws Exception { + User user = new User(); + user.setId(1L); + user.setUsername("alice"); + Mockito.when(searchService.searchUsers("ali")).thenReturn(List.of(user)); + UserDto userDto = new UserDto(); + userDto.setId(1L); + userDto.setUsername("alice"); + Mockito.when(userMapper.toDto(user)).thenReturn(userDto); - @Test - void searchPostsByTitle() throws Exception { - Post p = new Post(); - p.setId(2L); - p.setTitle("spring"); - Mockito.when(searchService.searchPostsByTitle("spr")).thenReturn(List.of(p)); - PostSummaryDto summaryDto1 = new PostSummaryDto(); - summaryDto1.setId(2L); - summaryDto1.setTitle("spring"); - Mockito.when(postMapper.toSummaryDto(p)).thenReturn(summaryDto1); + mockMvc + .perform(get("/api/search/users").param("keyword", "ali")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].username").value("alice")); + } - mockMvc.perform(get("/api/search/posts/title").param("keyword", "spr")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].title").value("spring")); - } + @Test + void globalSearchAggregatesTypes() throws Exception { + User u = new User(); + u.setId(1L); + u.setUsername("bob"); + Post p = new Post(); + p.setId(2L); + p.setTitle("hello"); + p.setStatus(PostStatus.PUBLISHED); + Comment c = new Comment(); + c.setId(3L); + c.setContent("nice"); + Mockito.when(searchService.globalSearch("n")).thenReturn( + List.of( + new SearchService.SearchResult("user", 1L, "bob", null, null, null), + new SearchService.SearchResult("post", 2L, "hello", null, null, null), + new SearchService.SearchResult("comment", 3L, "nice", null, null, null) + ) + ); - @Test - void searchPosts() throws Exception { - Post p = new Post(); - p.setId(5L); - p.setTitle("hello"); - Mockito.when(searchService.searchPosts("he")).thenReturn(List.of(p)); - PostSummaryDto summaryDto2 = new PostSummaryDto(); - summaryDto2.setId(5L); - summaryDto2.setTitle("hello"); - Mockito.when(postMapper.toSummaryDto(p)).thenReturn(summaryDto2); + mockMvc + .perform(get("/api/search/global").param("keyword", "n")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].type").value("user")) + .andExpect(jsonPath("$[1].type").value("post")) + .andExpect(jsonPath("$[2].type").value("comment")); + } - mockMvc.perform(get("/api/search/posts").param("keyword", "he")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].id").value(5)); - } + @Test + void searchPostsByTitle() throws Exception { + Post p = new Post(); + p.setId(2L); + p.setTitle("spring"); + Mockito.when(searchService.searchPostsByTitle("spr")).thenReturn(List.of(p)); + PostSummaryDto summaryDto1 = new PostSummaryDto(); + summaryDto1.setId(2L); + summaryDto1.setTitle("spring"); + Mockito.when(postMapper.toSummaryDto(p)).thenReturn(summaryDto1); + + mockMvc + .perform(get("/api/search/posts/title").param("keyword", "spr")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].title").value("spring")); + } + + @Test + void searchPosts() throws Exception { + Post p = new Post(); + p.setId(5L); + p.setTitle("hello"); + Mockito.when(searchService.searchPosts("he")).thenReturn(List.of(p)); + PostSummaryDto summaryDto2 = new PostSummaryDto(); + summaryDto2.setId(5L); + summaryDto2.setTitle("hello"); + Mockito.when(postMapper.toSummaryDto(p)).thenReturn(summaryDto2); + + mockMvc + .perform(get("/api/search/posts").param("keyword", "he")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(5)); + } } diff --git a/backend/src/test/java/com/openisle/controller/StatControllerTest.java b/backend/src/test/java/com/openisle/controller/StatControllerTest.java index 79948f257..0cf69951e 100644 --- a/backend/src/test/java/com/openisle/controller/StatControllerTest.java +++ b/backend/src/test/java/com/openisle/controller/StatControllerTest.java @@ -1,13 +1,18 @@ package com.openisle.controller; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + import com.openisle.config.CustomAccessDeniedHandler; import com.openisle.config.SecurityConfig; -import com.openisle.service.JwtService; -import com.openisle.repository.UserRepository; -import com.openisle.service.UserVisitService; -import com.openisle.service.StatService; import com.openisle.model.Role; import com.openisle.model.User; +import com.openisle.repository.UserRepository; +import com.openisle.service.JwtService; +import com.openisle.service.StatService; +import com.openisle.service.UserVisitService; +import java.util.Optional; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; @@ -17,121 +22,132 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Import; import org.springframework.test.web.servlet.MockMvc; -import java.util.Optional; - -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - @WebMvcTest(StatController.class) @AutoConfigureMockMvc -@Import({SecurityConfig.class, CustomAccessDeniedHandler.class}) +@Import({ SecurityConfig.class, CustomAccessDeniedHandler.class }) class StatControllerTest { - @Autowired - private MockMvc mockMvc; - @MockBean - private JwtService jwtService; - @MockBean - private UserRepository userRepository; - @MockBean - private UserVisitService userVisitService; - @MockBean - private StatService statService; + @Autowired + private MockMvc mockMvc; - @Test - void dauReturnsCount() throws Exception { - Mockito.when(jwtService.validateAndGetSubject("token")).thenReturn("user"); - User user = new User(); - user.setUsername("user"); - user.setPassword("p"); - user.setEmail("u@example.com"); - user.setRole(Role.USER); - Mockito.when(userRepository.findByUsername("user")).thenReturn(Optional.of(user)); - Mockito.when(userVisitService.countDau(Mockito.any())).thenReturn(3L); + @MockBean + private JwtService jwtService; - mockMvc.perform(get("/api/stats/dau").header("Authorization", "Bearer token")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.dau").value(3)); - } + @MockBean + private UserRepository userRepository; - @Test - void dauRangeReturnsSeries() throws Exception { - Mockito.when(jwtService.validateAndGetSubject("token")).thenReturn("user"); - User user = new User(); - user.setUsername("user"); - user.setPassword("p"); - user.setEmail("u@example.com"); - user.setRole(Role.USER); - Mockito.when(userRepository.findByUsername("user")).thenReturn(Optional.of(user)); - java.util.Map map = new java.util.LinkedHashMap<>(); - map.put(java.time.LocalDate.now().minusDays(1), 1L); - map.put(java.time.LocalDate.now(), 2L); - Mockito.when(userVisitService.countDauRange(Mockito.any(), Mockito.any())).thenReturn(map); + @MockBean + private UserVisitService userVisitService; - mockMvc.perform(get("/api/stats/dau-range").param("days", "2").header("Authorization", "Bearer token")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].value").value(1)) - .andExpect(jsonPath("$[1].value").value(2)); - } + @MockBean + private StatService statService; - @Test - void newUsersRangeReturnsSeries() throws Exception { - Mockito.when(jwtService.validateAndGetSubject("token")).thenReturn("user"); - User user = new User(); - user.setUsername("user"); - user.setPassword("p"); - user.setEmail("u@example.com"); - user.setRole(Role.USER); - Mockito.when(userRepository.findByUsername("user")).thenReturn(Optional.of(user)); - java.util.Map map = new java.util.LinkedHashMap<>(); - map.put(java.time.LocalDate.now().minusDays(1), 5L); - map.put(java.time.LocalDate.now(), 6L); - Mockito.when(statService.countNewUsersRange(Mockito.any(), Mockito.any())).thenReturn(map); + @Test + void dauReturnsCount() throws Exception { + Mockito.when(jwtService.validateAndGetSubject("token")).thenReturn("user"); + User user = new User(); + user.setUsername("user"); + user.setPassword("p"); + user.setEmail("u@example.com"); + user.setRole(Role.USER); + Mockito.when(userRepository.findByUsername("user")).thenReturn(Optional.of(user)); + Mockito.when(userVisitService.countDau(Mockito.any())).thenReturn(3L); - mockMvc.perform(get("/api/stats/new-users-range").param("days", "2").header("Authorization", "Bearer token")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].value").value(5)) - .andExpect(jsonPath("$[1].value").value(6)); - } + mockMvc + .perform(get("/api/stats/dau").header("Authorization", "Bearer token")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.dau").value(3)); + } - @Test - void postsRangeReturnsSeries() throws Exception { - Mockito.when(jwtService.validateAndGetSubject("token")).thenReturn("user"); - User user = new User(); - user.setUsername("user"); - user.setPassword("p"); - user.setEmail("u@example.com"); - user.setRole(Role.USER); - Mockito.when(userRepository.findByUsername("user")).thenReturn(Optional.of(user)); - java.util.Map map = new java.util.LinkedHashMap<>(); - map.put(java.time.LocalDate.now().minusDays(1), 7L); - map.put(java.time.LocalDate.now(), 8L); - Mockito.when(statService.countPostsRange(Mockito.any(), Mockito.any())).thenReturn(map); + @Test + void dauRangeReturnsSeries() throws Exception { + Mockito.when(jwtService.validateAndGetSubject("token")).thenReturn("user"); + User user = new User(); + user.setUsername("user"); + user.setPassword("p"); + user.setEmail("u@example.com"); + user.setRole(Role.USER); + Mockito.when(userRepository.findByUsername("user")).thenReturn(Optional.of(user)); + java.util.Map map = new java.util.LinkedHashMap<>(); + map.put(java.time.LocalDate.now().minusDays(1), 1L); + map.put(java.time.LocalDate.now(), 2L); + Mockito.when(userVisitService.countDauRange(Mockito.any(), Mockito.any())).thenReturn(map); - mockMvc.perform(get("/api/stats/posts-range").param("days", "2").header("Authorization", "Bearer token")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].value").value(7)) - .andExpect(jsonPath("$[1].value").value(8)); - } + mockMvc + .perform( + get("/api/stats/dau-range").param("days", "2").header("Authorization", "Bearer token") + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].value").value(1)) + .andExpect(jsonPath("$[1].value").value(2)); + } - @Test - void commentsRangeReturnsSeries() throws Exception { - Mockito.when(jwtService.validateAndGetSubject("token")).thenReturn("user"); - User user = new User(); - user.setUsername("user"); - user.setPassword("p"); - user.setEmail("u@example.com"); - user.setRole(Role.USER); - Mockito.when(userRepository.findByUsername("user")).thenReturn(Optional.of(user)); - java.util.Map map = new java.util.LinkedHashMap<>(); - map.put(java.time.LocalDate.now().minusDays(1), 9L); - map.put(java.time.LocalDate.now(), 10L); - Mockito.when(statService.countCommentsRange(Mockito.any(), Mockito.any())).thenReturn(map); + @Test + void newUsersRangeReturnsSeries() throws Exception { + Mockito.when(jwtService.validateAndGetSubject("token")).thenReturn("user"); + User user = new User(); + user.setUsername("user"); + user.setPassword("p"); + user.setEmail("u@example.com"); + user.setRole(Role.USER); + Mockito.when(userRepository.findByUsername("user")).thenReturn(Optional.of(user)); + java.util.Map map = new java.util.LinkedHashMap<>(); + map.put(java.time.LocalDate.now().minusDays(1), 5L); + map.put(java.time.LocalDate.now(), 6L); + Mockito.when(statService.countNewUsersRange(Mockito.any(), Mockito.any())).thenReturn(map); - mockMvc.perform(get("/api/stats/comments-range").param("days", "2").header("Authorization", "Bearer token")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].value").value(9)) - .andExpect(jsonPath("$[1].value").value(10)); - } + mockMvc + .perform( + get("/api/stats/new-users-range").param("days", "2").header("Authorization", "Bearer token") + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].value").value(5)) + .andExpect(jsonPath("$[1].value").value(6)); + } + + @Test + void postsRangeReturnsSeries() throws Exception { + Mockito.when(jwtService.validateAndGetSubject("token")).thenReturn("user"); + User user = new User(); + user.setUsername("user"); + user.setPassword("p"); + user.setEmail("u@example.com"); + user.setRole(Role.USER); + Mockito.when(userRepository.findByUsername("user")).thenReturn(Optional.of(user)); + java.util.Map map = new java.util.LinkedHashMap<>(); + map.put(java.time.LocalDate.now().minusDays(1), 7L); + map.put(java.time.LocalDate.now(), 8L); + Mockito.when(statService.countPostsRange(Mockito.any(), Mockito.any())).thenReturn(map); + + mockMvc + .perform( + get("/api/stats/posts-range").param("days", "2").header("Authorization", "Bearer token") + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].value").value(7)) + .andExpect(jsonPath("$[1].value").value(8)); + } + + @Test + void commentsRangeReturnsSeries() throws Exception { + Mockito.when(jwtService.validateAndGetSubject("token")).thenReturn("user"); + User user = new User(); + user.setUsername("user"); + user.setPassword("p"); + user.setEmail("u@example.com"); + user.setRole(Role.USER); + Mockito.when(userRepository.findByUsername("user")).thenReturn(Optional.of(user)); + java.util.Map map = new java.util.LinkedHashMap<>(); + map.put(java.time.LocalDate.now().minusDays(1), 9L); + map.put(java.time.LocalDate.now(), 10L); + Mockito.when(statService.countCommentsRange(Mockito.any(), Mockito.any())).thenReturn(map); + + mockMvc + .perform( + get("/api/stats/comments-range").param("days", "2").header("Authorization", "Bearer token") + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].value").value(9)) + .andExpect(jsonPath("$[1].value").value(10)); + } } diff --git a/backend/src/test/java/com/openisle/controller/TagControllerTest.java b/backend/src/test/java/com/openisle/controller/TagControllerTest.java index 69c8be30b..c6a9fec13 100644 --- a/backend/src/test/java/com/openisle/controller/TagControllerTest.java +++ b/backend/src/test/java/com/openisle/controller/TagControllerTest.java @@ -1,11 +1,17 @@ package com.openisle.controller; -import com.openisle.mapper.TagMapper; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + import com.openisle.mapper.PostMapper; +import com.openisle.mapper.TagMapper; import com.openisle.model.Tag; import com.openisle.repository.UserRepository; -import com.openisle.service.TagService; import com.openisle.service.PostService; +import com.openisle.service.TagService; +import java.util.List; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; @@ -16,94 +22,102 @@ import org.springframework.context.annotation.Import; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; -import java.util.List; - -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isNull; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - @WebMvcTest(TagController.class) @AutoConfigureMockMvc(addFilters = false) @Import(TagMapper.class) class TagControllerTest { - @Autowired - private MockMvc mockMvc; - @MockBean - private TagService tagService; + @Autowired + private MockMvc mockMvc; - @MockBean - private PostService postService; + @MockBean + private TagService tagService; - @MockBean - private UserRepository userRepository; + @MockBean + private PostService postService; - @MockBean - private PostMapper postMapper; + @MockBean + private UserRepository userRepository; - @Test - void createAndGetTag() throws Exception { - Tag t = new Tag(); - t.setId(1L); - t.setName("java"); - t.setDescription("d"); - t.setIcon("i"); - t.setSmallIcon("s1"); - Mockito.when(tagService.createTag(eq("java"), eq("d"), eq("i"), eq("s1"), eq(true), isNull())).thenReturn(t); - Mockito.when(tagService.getTag(1L)).thenReturn(t); + @MockBean + private PostMapper postMapper; - mockMvc.perform(post("/api/tags") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"name\":\"java\",\"description\":\"d\",\"icon\":\"i\",\"smallIcon\":\"s1\"}")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.name").value("java")) - .andExpect(jsonPath("$.description").value("d")) - .andExpect(jsonPath("$.icon").value("i")) - .andExpect(jsonPath("$.smallIcon").value("s1")); + @Test + void createAndGetTag() throws Exception { + Tag t = new Tag(); + t.setId(1L); + t.setName("java"); + t.setDescription("d"); + t.setIcon("i"); + t.setSmallIcon("s1"); + Mockito.when( + tagService.createTag(eq("java"), eq("d"), eq("i"), eq("s1"), eq(true), isNull()) + ).thenReturn(t); + Mockito.when(tagService.getTag(1L)).thenReturn(t); - mockMvc.perform(get("/api/tags/1")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(1)); - } + mockMvc + .perform( + post("/api/tags") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"java\",\"description\":\"d\",\"icon\":\"i\",\"smallIcon\":\"s1\"}") + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("java")) + .andExpect(jsonPath("$.description").value("d")) + .andExpect(jsonPath("$.icon").value("i")) + .andExpect(jsonPath("$.smallIcon").value("s1")); - @Test - void listTags() throws Exception { - Tag t = new Tag(); - t.setId(2L); - t.setName("spring"); - t.setDescription("d2"); - t.setIcon("i2"); - t.setSmallIcon("s2"); - Mockito.when(tagService.searchTags(null)).thenReturn(List.of(t)); + mockMvc + .perform(get("/api/tags/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(1)); + } - mockMvc.perform(get("/api/tags")) - .andExpect(status().isOk()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].name").value("spring")) - .andExpect(jsonPath("$[0].description").value("d2")) - .andExpect(jsonPath("$[0].icon").value("i2")) - .andExpect(jsonPath("$[0].smallIcon").value("s2")); - } + @Test + void listTags() throws Exception { + Tag t = new Tag(); + t.setId(2L); + t.setName("spring"); + t.setDescription("d2"); + t.setIcon("i2"); + t.setSmallIcon("s2"); + Mockito.when(tagService.searchTags(null)).thenReturn(List.of(t)); - @Test - void updateTag() throws Exception { - Tag t = new Tag(); - t.setId(3L); - t.setName("java"); - t.setDescription("d3"); - t.setIcon("i3"); - t.setSmallIcon("s3"); - Mockito.when(tagService.updateTag(eq(3L), eq("java"), eq("d3"), eq("i3"), eq("s3"))).thenReturn(t); + mockMvc + .perform(get("/api/tags")) + .andExpect(status().isOk()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].name").value("spring")) + .andExpect(jsonPath("$[0].description").value("d2")) + .andExpect(jsonPath("$[0].icon").value("i2")) + .andExpect(jsonPath("$[0].smallIcon").value("s2")); + } - mockMvc.perform(put("/api/tags/3") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"name\":\"java\",\"description\":\"d3\",\"icon\":\"i3\",\"smallIcon\":\"s3\"}")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(3)) - .andExpect(jsonPath("$.name").value("java")) - .andExpect(jsonPath("$.description").value("d3")) - .andExpect(jsonPath("$.icon").value("i3")) - .andExpect(jsonPath("$.smallIcon").value("s3")); - } + @Test + void updateTag() throws Exception { + Tag t = new Tag(); + t.setId(3L); + t.setName("java"); + t.setDescription("d3"); + t.setIcon("i3"); + t.setSmallIcon("s3"); + Mockito.when(tagService.updateTag(eq(3L), eq("java"), eq("d3"), eq("i3"), eq("s3"))).thenReturn( + t + ); + + mockMvc + .perform( + put("/api/tags/3") + .contentType(MediaType.APPLICATION_JSON) + .content( + "{\"name\":\"java\",\"description\":\"d3\",\"icon\":\"i3\",\"smallIcon\":\"s3\"}" + ) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(3)) + .andExpect(jsonPath("$.name").value("java")) + .andExpect(jsonPath("$.description").value("d3")) + .andExpect(jsonPath("$.icon").value("i3")) + .andExpect(jsonPath("$.smallIcon").value("s3")); + } } diff --git a/backend/src/test/java/com/openisle/controller/UserControllerTest.java b/backend/src/test/java/com/openisle/controller/UserControllerTest.java index 22d48bd2c..6c47232d5 100644 --- a/backend/src/test/java/com/openisle/controller/UserControllerTest.java +++ b/backend/src/test/java/com/openisle/controller/UserControllerTest.java @@ -1,12 +1,18 @@ package com.openisle.controller; -import com.openisle.model.User; -import com.openisle.service.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import com.openisle.dto.CommentInfoDto; +import com.openisle.dto.PostMetaDto; +import com.openisle.dto.UserDto; import com.openisle.mapper.TagMapper; import com.openisle.mapper.UserMapper; -import com.openisle.dto.UserDto; -import com.openisle.dto.PostMetaDto; -import com.openisle.dto.CommentInfoDto; +import com.openisle.model.User; +import com.openisle.service.*; +import java.util.Optional; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; @@ -18,217 +24,254 @@ import org.springframework.mock.web.MockMultipartFile; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.test.web.servlet.MockMvc; -import java.util.Optional; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - @WebMvcTest(UserController.class) @AutoConfigureMockMvc(addFilters = false) class UserControllerTest { - @Autowired - private MockMvc mockMvc; - @MockBean - private ReactionService reactionService; - @MockBean - private SubscriptionService subscriptionService; - @MockBean - private UserService userService; - @MockBean - private ImageUploader imageUploader; - @MockBean - private PostService postService; - @MockBean - private CommentService commentService; - @MockBean - private LevelService levelService; - @MockBean - private TagService tagService; - @MockBean - private JwtService jwtService; - @MockBean - private UserMapper userMapper; - @MockBean - private TagMapper tagMapper; + @Autowired + private MockMvc mockMvc; - @Test - void getCurrentUser() throws Exception { - User u = new User(); - u.setId(1L); - u.setUsername("alice"); - u.setEmail("a@b.com"); - u.setAvatar("http://x/avatar.png"); - Mockito.when(userService.findByUsername("alice")).thenReturn(Optional.of(u)); - UserDto dto = new UserDto(); - dto.setId(1L); - dto.setUsername("alice"); - dto.setAvatar("http://x/avatar.png"); - Mockito.when(userMapper.toDto(eq(u), any())).thenReturn(dto); + @MockBean + private ReactionService reactionService; - mockMvc.perform(get("/api/users/me").principal(new UsernamePasswordAuthenticationToken("alice","p"))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.avatar").value("http://x/avatar.png")); - } + @MockBean + private SubscriptionService subscriptionService; - @Test - void uploadAvatar() throws Exception { - MockMultipartFile file = new MockMultipartFile("file", "a.png", MediaType.IMAGE_PNG_VALUE, "img".getBytes()); - Mockito.when(imageUploader.upload(any(), eq("a.png"))).thenReturn(java.util.concurrent.CompletableFuture.completedFuture("http://img/a.png")); + @MockBean + private UserService userService; - mockMvc.perform(multipart("/api/users/me/avatar").file(file).principal(new UsernamePasswordAuthenticationToken("alice","p"))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.url").value("http://img/a.png")); + @MockBean + private ImageUploader imageUploader; - Mockito.verify(userService).updateAvatar("alice", "http://img/a.png"); - } + @MockBean + private PostService postService; - @Test - void uploadAvatarRejectsNonImage() throws Exception { - MockMultipartFile file = new MockMultipartFile("file", "a.txt", MediaType.TEXT_PLAIN_VALUE, "text".getBytes()); + @MockBean + private CommentService commentService; - mockMvc.perform(multipart("/api/users/me/avatar").file(file).principal(new UsernamePasswordAuthenticationToken("alice","p"))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("File is not an image")); + @MockBean + private LevelService levelService; - Mockito.verify(imageUploader, Mockito.never()).upload(any(), any()); - } + @MockBean + private TagService tagService; - @Test - void getUserByName() throws Exception { - User u = new User(); - u.setId(2L); - u.setUsername("bob"); - Mockito.when(userService.findByIdentifier("bob")).thenReturn(Optional.of(u)); - UserDto dto = new UserDto(); - dto.setId(2L); - dto.setUsername("bob"); - Mockito.when(userMapper.toDto(eq(u), any())).thenReturn(dto); + @MockBean + private JwtService jwtService; - mockMvc.perform(get("/api/users/bob")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(2)); - } + @MockBean + private UserMapper userMapper; - @Test - void listUserPosts() throws Exception { - User user = new User(); - user.setUsername("bob"); - com.openisle.model.Category cat = new com.openisle.model.Category(); - cat.setName("tech"); - com.openisle.model.Post post = new com.openisle.model.Post(); - post.setId(3L); - post.setTitle("hello"); - post.setCreatedAt(java.time.LocalDateTime.now()); - post.setCategory(cat); - post.setAuthor(user); - Mockito.when(userService.findByIdentifier("bob")).thenReturn(Optional.of(user)); - Mockito.when(postService.getRecentPostsByUser("bob", 10)).thenReturn(java.util.List.of(post)); - PostMetaDto meta = new PostMetaDto(); - meta.setId(3L); - meta.setTitle("hello"); - Mockito.when(userMapper.toMetaDto(post)).thenReturn(meta); + @MockBean + private TagMapper tagMapper; - mockMvc.perform(get("/api/users/bob/posts")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].title").value("hello")); - } + @Test + void getCurrentUser() throws Exception { + User u = new User(); + u.setId(1L); + u.setUsername("alice"); + u.setEmail("a@b.com"); + u.setAvatar("http://x/avatar.png"); + Mockito.when(userService.findByUsername("alice")).thenReturn(Optional.of(u)); + UserDto dto = new UserDto(); + dto.setId(1L); + dto.setUsername("alice"); + dto.setAvatar("http://x/avatar.png"); + Mockito.when(userMapper.toDto(eq(u), any())).thenReturn(dto); - @Test - void listSubscribedPosts() throws Exception { - User user = new User(); - user.setUsername("bob"); - com.openisle.model.Category cat = new com.openisle.model.Category(); - cat.setName("tech"); - com.openisle.model.Post post = new com.openisle.model.Post(); - post.setId(6L); - post.setTitle("fav"); - post.setCreatedAt(java.time.LocalDateTime.now()); - post.setCategory(cat); - post.setAuthor(user); - Mockito.when(userService.findByIdentifier("bob")).thenReturn(Optional.of(user)); - Mockito.when(subscriptionService.getSubscribedPosts("bob")).thenReturn(java.util.List.of(post)); - PostMetaDto meta = new PostMetaDto(); - meta.setId(6L); - meta.setTitle("fav"); - Mockito.when(userMapper.toMetaDto(post)).thenReturn(meta); + mockMvc + .perform( + get("/api/users/me").principal(new UsernamePasswordAuthenticationToken("alice", "p")) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.avatar").value("http://x/avatar.png")); + } - mockMvc.perform(get("/api/users/bob/subscribed-posts")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].title").value("fav")); - } + @Test + void uploadAvatar() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", + "a.png", + MediaType.IMAGE_PNG_VALUE, + "img".getBytes() + ); + Mockito.when(imageUploader.upload(any(), eq("a.png"))).thenReturn( + java.util.concurrent.CompletableFuture.completedFuture("http://img/a.png") + ); - @Test - void listUserReplies() throws Exception { - User user = new User(); - user.setUsername("bob"); - com.openisle.model.Post post = new com.openisle.model.Post(); - post.setId(5L); - com.openisle.model.Category cat = new com.openisle.model.Category(); - cat.setName("tech"); - post.setCategory(cat); - com.openisle.model.Comment comment = new com.openisle.model.Comment(); - comment.setId(4L); - comment.setContent("hi"); - comment.setCreatedAt(java.time.LocalDateTime.now()); - comment.setAuthor(user); - comment.setPost(post); - Mockito.when(userService.findByIdentifier("bob")).thenReturn(Optional.of(user)); - Mockito.when(commentService.getRecentCommentsByUser("bob", 50)).thenReturn(java.util.List.of(comment)); - CommentInfoDto info = new CommentInfoDto(); - info.setId(4L); - info.setContent("hi"); - Mockito.when(userMapper.toCommentInfoDto(comment)).thenReturn(info); + mockMvc + .perform( + multipart("/api/users/me/avatar") + .file(file) + .principal(new UsernamePasswordAuthenticationToken("alice", "p")) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.url").value("http://img/a.png")); - mockMvc.perform(get("/api/users/bob/replies")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].id").value(4)); - } + Mockito.verify(userService).updateAvatar("alice", "http://img/a.png"); + } - @Test - void aggregateUserData() throws Exception { - User user = new User(); - user.setId(2L); - user.setUsername("bob"); - user.setEmail("b@e.com"); - com.openisle.model.Category cat = new com.openisle.model.Category(); - cat.setName("tech"); - com.openisle.model.Post post = new com.openisle.model.Post(); - post.setId(3L); - post.setTitle("hello"); - post.setCreatedAt(java.time.LocalDateTime.now()); - post.setCategory(cat); - post.setAuthor(user); - com.openisle.model.Comment comment = new com.openisle.model.Comment(); - comment.setId(4L); - comment.setContent("hi"); - comment.setCreatedAt(java.time.LocalDateTime.now()); - comment.setAuthor(user); - comment.setPost(post); + @Test + void uploadAvatarRejectsNonImage() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", + "a.txt", + MediaType.TEXT_PLAIN_VALUE, + "text".getBytes() + ); - Mockito.when(userService.findByIdentifier("bob")).thenReturn(Optional.of(user)); - Mockito.when(postService.getRecentPostsByUser("bob", 10)).thenReturn(java.util.List.of(post)); - Mockito.when(commentService.getRecentCommentsByUser("bob", 50)).thenReturn(java.util.List.of(comment)); - UserDto dtoAgg = new UserDto(); - dtoAgg.setId(2L); - dtoAgg.setUsername("bob"); - Mockito.when(userMapper.toDto(eq(user), any())).thenReturn(dtoAgg); - PostMetaDto metaAgg = new PostMetaDto(); - metaAgg.setId(3L); - metaAgg.setTitle("hello"); - Mockito.when(userMapper.toMetaDto(post)).thenReturn(metaAgg); - CommentInfoDto infoAgg = new CommentInfoDto(); - infoAgg.setId(4L); - infoAgg.setContent("hi"); - Mockito.when(userMapper.toCommentInfoDto(comment)).thenReturn(infoAgg); + mockMvc + .perform( + multipart("/api/users/me/avatar") + .file(file) + .principal(new UsernamePasswordAuthenticationToken("alice", "p")) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("File is not an image")); - mockMvc.perform(get("/api/users/bob/all")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.user.id").value(2)) - .andExpect(jsonPath("$.posts[0].id").value(3)) - .andExpect(jsonPath("$.replies[0].id").value(4)); - } + Mockito.verify(imageUploader, Mockito.never()).upload(any(), any()); + } + @Test + void getUserByName() throws Exception { + User u = new User(); + u.setId(2L); + u.setUsername("bob"); + Mockito.when(userService.findByIdentifier("bob")).thenReturn(Optional.of(u)); + UserDto dto = new UserDto(); + dto.setId(2L); + dto.setUsername("bob"); + Mockito.when(userMapper.toDto(eq(u), any())).thenReturn(dto); + + mockMvc + .perform(get("/api/users/bob")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(2)); + } + + @Test + void listUserPosts() throws Exception { + User user = new User(); + user.setUsername("bob"); + com.openisle.model.Category cat = new com.openisle.model.Category(); + cat.setName("tech"); + com.openisle.model.Post post = new com.openisle.model.Post(); + post.setId(3L); + post.setTitle("hello"); + post.setCreatedAt(java.time.LocalDateTime.now()); + post.setCategory(cat); + post.setAuthor(user); + Mockito.when(userService.findByIdentifier("bob")).thenReturn(Optional.of(user)); + Mockito.when(postService.getRecentPostsByUser("bob", 10)).thenReturn(java.util.List.of(post)); + PostMetaDto meta = new PostMetaDto(); + meta.setId(3L); + meta.setTitle("hello"); + Mockito.when(userMapper.toMetaDto(post)).thenReturn(meta); + + mockMvc + .perform(get("/api/users/bob/posts")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].title").value("hello")); + } + + @Test + void listSubscribedPosts() throws Exception { + User user = new User(); + user.setUsername("bob"); + com.openisle.model.Category cat = new com.openisle.model.Category(); + cat.setName("tech"); + com.openisle.model.Post post = new com.openisle.model.Post(); + post.setId(6L); + post.setTitle("fav"); + post.setCreatedAt(java.time.LocalDateTime.now()); + post.setCategory(cat); + post.setAuthor(user); + Mockito.when(userService.findByIdentifier("bob")).thenReturn(Optional.of(user)); + Mockito.when(subscriptionService.getSubscribedPosts("bob")).thenReturn(java.util.List.of(post)); + PostMetaDto meta = new PostMetaDto(); + meta.setId(6L); + meta.setTitle("fav"); + Mockito.when(userMapper.toMetaDto(post)).thenReturn(meta); + + mockMvc + .perform(get("/api/users/bob/subscribed-posts")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].title").value("fav")); + } + + @Test + void listUserReplies() throws Exception { + User user = new User(); + user.setUsername("bob"); + com.openisle.model.Post post = new com.openisle.model.Post(); + post.setId(5L); + com.openisle.model.Category cat = new com.openisle.model.Category(); + cat.setName("tech"); + post.setCategory(cat); + com.openisle.model.Comment comment = new com.openisle.model.Comment(); + comment.setId(4L); + comment.setContent("hi"); + comment.setCreatedAt(java.time.LocalDateTime.now()); + comment.setAuthor(user); + comment.setPost(post); + Mockito.when(userService.findByIdentifier("bob")).thenReturn(Optional.of(user)); + Mockito.when(commentService.getRecentCommentsByUser("bob", 50)).thenReturn( + java.util.List.of(comment) + ); + CommentInfoDto info = new CommentInfoDto(); + info.setId(4L); + info.setContent("hi"); + Mockito.when(userMapper.toCommentInfoDto(comment)).thenReturn(info); + + mockMvc + .perform(get("/api/users/bob/replies")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(4)); + } + + @Test + void aggregateUserData() throws Exception { + User user = new User(); + user.setId(2L); + user.setUsername("bob"); + user.setEmail("b@e.com"); + com.openisle.model.Category cat = new com.openisle.model.Category(); + cat.setName("tech"); + com.openisle.model.Post post = new com.openisle.model.Post(); + post.setId(3L); + post.setTitle("hello"); + post.setCreatedAt(java.time.LocalDateTime.now()); + post.setCategory(cat); + post.setAuthor(user); + com.openisle.model.Comment comment = new com.openisle.model.Comment(); + comment.setId(4L); + comment.setContent("hi"); + comment.setCreatedAt(java.time.LocalDateTime.now()); + comment.setAuthor(user); + comment.setPost(post); + + Mockito.when(userService.findByIdentifier("bob")).thenReturn(Optional.of(user)); + Mockito.when(postService.getRecentPostsByUser("bob", 10)).thenReturn(java.util.List.of(post)); + Mockito.when(commentService.getRecentCommentsByUser("bob", 50)).thenReturn( + java.util.List.of(comment) + ); + UserDto dtoAgg = new UserDto(); + dtoAgg.setId(2L); + dtoAgg.setUsername("bob"); + Mockito.when(userMapper.toDto(eq(user), any())).thenReturn(dtoAgg); + PostMetaDto metaAgg = new PostMetaDto(); + metaAgg.setId(3L); + metaAgg.setTitle("hello"); + Mockito.when(userMapper.toMetaDto(post)).thenReturn(metaAgg); + CommentInfoDto infoAgg = new CommentInfoDto(); + infoAgg.setId(4L); + infoAgg.setContent("hi"); + Mockito.when(userMapper.toCommentInfoDto(comment)).thenReturn(infoAgg); + + mockMvc + .perform(get("/api/users/bob/all")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.user.id").value(2)) + .andExpect(jsonPath("$.posts[0].id").value(3)) + .andExpect(jsonPath("$.replies[0].id").value(4)); + } } diff --git a/backend/src/test/java/com/openisle/integration/ComplexFlowIntegrationTest.java b/backend/src/test/java/com/openisle/integration/ComplexFlowIntegrationTest.java index 600ce136c..488fdd57a 100644 --- a/backend/src/test/java/com/openisle/integration/ComplexFlowIntegrationTest.java +++ b/backend/src/test/java/com/openisle/integration/ComplexFlowIntegrationTest.java @@ -1,11 +1,16 @@ package com.openisle.integration; +import static org.junit.jupiter.api.Assertions.*; + import com.openisle.controller.ActivityController; -import com.openisle.model.User; import com.openisle.model.Role; +import com.openisle.model.User; import com.openisle.repository.UserRepository; import com.openisle.service.AvatarGenerator; import com.openisle.service.EmailSender; +import java.lang.reflect.Array; +import java.util.List; +import java.util.Map; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -13,151 +18,193 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.http.*; -import java.lang.reflect.Array; -import java.util.List; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.*; - -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, - properties = "app.register.mode=DIRECT") +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "app.register.mode=DIRECT" +) class ComplexFlowIntegrationTest { - @Autowired - private TestRestTemplate rest; + @Autowired + private TestRestTemplate rest; - @Autowired - private UserRepository users; + @Autowired + private UserRepository users; - @MockBean - private EmailSender emailService; + @MockBean + private EmailSender emailService; - private String registerAndLogin(String username, String email) { - HttpHeaders h = new HttpHeaders(); - h.setContentType(MediaType.APPLICATION_JSON); - rest.postForEntity("/api/auth/register", new HttpEntity<>( - Map.of("username", username, "email", email, "password", "pass123", "reason", "integration test reason more than twenty"), h), Map.class); - User u = users.findByUsername(username).orElseThrow(); - if (u.getVerificationCode() != null) { - rest.postForEntity("/api/auth/verify", new HttpEntity<>( - Map.of("username", username, "code", u.getVerificationCode()), h), Map.class); + private String registerAndLogin(String username, String email) { + HttpHeaders h = new HttpHeaders(); + h.setContentType(MediaType.APPLICATION_JSON); + rest.postForEntity( + "/api/auth/register", + new HttpEntity<>( + Map.of( + "username", + username, + "email", + email, + "password", + "pass123", + "reason", + "integration test reason more than twenty" + ), + h + ), + Map.class + ); + User u = users.findByUsername(username).orElseThrow(); + if (u.getVerificationCode() != null) { + rest.postForEntity( + "/api/auth/verify", + new HttpEntity<>(Map.of("username", username, "code", u.getVerificationCode()), h), + Map.class + ); + } + ResponseEntity resp = rest.postForEntity( + "/api/auth/login", + new HttpEntity<>(Map.of("username", username, "password", "pass123"), h), + Map.class + ); + return (String) resp.getBody().get("token"); + } + + private String registerAndLoginAsAdmin(String username, String email) { + String token = registerAndLogin(username, email); + User u = users.findByUsername(username).orElseThrow(); + u.setRole(Role.ADMIN); + users.save(u); + return token; + } + + private ResponseEntity postJson(String url, Map body, String token) { + HttpHeaders h = new HttpHeaders(); + h.setContentType(MediaType.APPLICATION_JSON); + if (token != null) h.setBearerAuth(token); + return rest.exchange(url, HttpMethod.POST, new HttpEntity<>(body, h), Map.class); + } + + @Test + void nestedCommentsVisibleInPost() { + String t1 = registerAndLogin("alice1", "a@example.com"); + String t2 = registerAndLogin("bob123", "b@example.com"); + + String adminToken = registerAndLoginAsAdmin("admin1", "admin@example.com"); + ResponseEntity catResp = postJson( + "/api/categories", + Map.of("name", "general", "description", "d", "icon", "i"), + adminToken + ); + Long catId = ((Number) catResp.getBody().get("id")).longValue(); + + ResponseEntity tagResp = postJson( + "/api/tags", + Map.of("name", "java", "description", "d", "icon", "i"), + adminToken + ); + Long tagId = ((Number) tagResp.getBody().get("id")).longValue(); + + ResponseEntity postResp = postJson( + "/api/posts", + Map.of("title", "Hello", "content", "World", "categoryId", catId, "tagIds", List.of(tagId)), + t1 + ); + Long postId = ((Number) postResp.getBody().get("id")).longValue(); + + ResponseEntity c1Resp = postJson( + "/api/posts/" + postId + "/comments", + Map.of("content", "first"), + t2 + ); + Long c1 = ((Number) c1Resp.getBody().get("id")).longValue(); + + ResponseEntity r1Resp = postJson( + "/api/comments/" + c1 + "/replies", + Map.of("content", "reply1"), + t1 + ); + Long r1 = ((Number) r1Resp.getBody().get("id")).longValue(); + + postJson("/api/comments/" + r1 + "/replies", Map.of("content", "reply2"), t2); + + Map post = rest.getForObject("/api/posts/" + postId, Map.class); + assertEquals("Hello", post.get("title")); + List comments = (List) post.get("comments"); + assertEquals(1, comments.size()); + Map cMap = (Map) comments.get(0); + assertEquals("first", cMap.get("content")); + List replies1 = (List) cMap.get("replies"); + assertEquals(1, replies1.size()); + Map rMap = (Map) replies1.get(0); + assertEquals("reply1", rMap.get("content")); + List replies2 = (List) rMap.get("replies"); + assertEquals(1, replies2.size()); + assertEquals("reply2", ((Map) replies2.get(0)).get("content")); + } + + @Test + void reactionsReturnedForPostAndComment() { + String t1 = registerAndLogin("carol1", "c@example.com"); + String t2 = registerAndLogin("dave01", "d@example.com"); + + String adminToken = registerAndLoginAsAdmin("admin2", "admin2@example.com"); + List> categories = (List>) rest.getForObject( + "/api/categories", + List.class + ); + Long catId = null; + if (categories != null) { + for (Map cat : categories) { + if ("general".equals(cat.get("name"))) { + catId = ((Number) cat.get("id")).longValue(); + break; } - ResponseEntity resp = rest.postForEntity("/api/auth/login", new HttpEntity<>( - Map.of("username", username, "password", "pass123"), h), Map.class); - return (String) resp.getBody().get("token"); + } } - private String registerAndLoginAsAdmin(String username, String email) { - String token = registerAndLogin(username, email); - User u = users.findByUsername(username).orElseThrow(); - u.setRole(Role.ADMIN); - users.save(u); - return token; + if (catId == null) { + ResponseEntity catResp = postJson( + "/api/categories", + Map.of("name", "general", "description", "d", "icon", "i"), + adminToken + ); + catId = ((Number) catResp.getBody().get("id")).longValue(); } - private ResponseEntity postJson(String url, Map body, String token) { - HttpHeaders h = new HttpHeaders(); - h.setContentType(MediaType.APPLICATION_JSON); - if (token != null) h.setBearerAuth(token); - return rest.exchange(url, HttpMethod.POST, new HttpEntity<>(body, h), Map.class); - } + ResponseEntity tagResp = postJson( + "/api/tags", + Map.of("name", "spring", "description", "d", "icon", "i"), + adminToken + ); + Long tagId = ((Number) tagResp.getBody().get("id")).longValue(); - @Test - void nestedCommentsVisibleInPost() { - String t1 = registerAndLogin("alice1", "a@example.com"); - String t2 = registerAndLogin("bob123", "b@example.com"); + ResponseEntity postResp = postJson( + "/api/posts", + Map.of("title", "React", "content", "Test", "categoryId", catId, "tagIds", List.of(tagId)), + t1 + ); + Long postId = ((Number) postResp.getBody().get("id")).longValue(); - String adminToken = registerAndLoginAsAdmin("admin1", "admin@example.com"); - ResponseEntity catResp = postJson("/api/categories", - Map.of("name", "general", "description", "d", "icon", "i"), adminToken); - Long catId = ((Number)catResp.getBody().get("id")).longValue(); + postJson("/api/posts/" + postId + "/reactions", Map.of("type", "LIKE"), t2); - ResponseEntity tagResp = postJson("/api/tags", - Map.of("name", "java", "description", "d", "icon", "i"), adminToken); - Long tagId = ((Number)tagResp.getBody().get("id")).longValue(); + ResponseEntity cResp = postJson( + "/api/posts/" + postId + "/comments", + Map.of("content", "hi"), + t1 + ); + Long commentId = ((Number) cResp.getBody().get("id")).longValue(); - ResponseEntity postResp = postJson("/api/posts", - Map.of("title", "Hello", "content", "World", "categoryId", catId, - "tagIds", List.of(tagId)), t1); - Long postId = ((Number)postResp.getBody().get("id")).longValue(); + postJson("/api/comments/" + commentId + "/reactions", Map.of("type", "DISLIKE"), t2); - ResponseEntity c1Resp = postJson("/api/posts/" + postId + "/comments", - Map.of("content", "first"), t2); - Long c1 = ((Number)c1Resp.getBody().get("id")).longValue(); + Map post = rest.getForObject("/api/posts/" + postId, Map.class); + List reactions = (List) post.get("reactions"); + assertEquals(1, reactions.size()); + assertEquals("LIKE", ((Map) reactions.get(0)).get("type")); - ResponseEntity r1Resp = postJson("/api/comments/" + c1 + "/replies", - Map.of("content", "reply1"), t1); - Long r1 = ((Number)r1Resp.getBody().get("id")).longValue(); - - postJson("/api/comments/" + r1 + "/replies", - Map.of("content", "reply2"), t2); - - Map post = rest.getForObject("/api/posts/" + postId, Map.class); - assertEquals("Hello", post.get("title")); - List comments = (List) post.get("comments"); - assertEquals(1, comments.size()); - Map cMap = (Map) comments.get(0); - assertEquals("first", cMap.get("content")); - List replies1 = (List) cMap.get("replies"); - assertEquals(1, replies1.size()); - Map rMap = (Map) replies1.get(0); - assertEquals("reply1", rMap.get("content")); - List replies2 = (List) rMap.get("replies"); - assertEquals(1, replies2.size()); - assertEquals("reply2", ((Map)replies2.get(0)).get("content")); - } - - @Test - void reactionsReturnedForPostAndComment() { - String t1 = registerAndLogin("carol1", "c@example.com"); - String t2 = registerAndLogin("dave01", "d@example.com"); - - String adminToken = registerAndLoginAsAdmin("admin2", "admin2@example.com"); - List> categories = (List>) rest.getForObject("/api/categories", List.class); - Long catId = null; - if (categories != null) { - for (Map cat : categories) { - if ("general".equals(cat.get("name"))) { - catId = ((Number)cat.get("id")).longValue(); - break; - } - } - } - - if (catId == null) { - ResponseEntity catResp = postJson("/api/categories", - Map.of("name", "general", "description", "d", "icon", "i"), adminToken); - catId = ((Number)catResp.getBody().get("id")).longValue(); - } - - ResponseEntity tagResp = postJson("/api/tags", - Map.of("name", "spring", "description", "d", "icon", "i"), adminToken); - Long tagId = ((Number)tagResp.getBody().get("id")).longValue(); - - ResponseEntity postResp = postJson("/api/posts", - Map.of("title", "React", "content", "Test", "categoryId", catId, - "tagIds", List.of(tagId)), t1); - Long postId = ((Number)postResp.getBody().get("id")).longValue(); - - postJson("/api/posts/" + postId + "/reactions", - Map.of("type", "LIKE"), t2); - - ResponseEntity cResp = postJson("/api/posts/" + postId + "/comments", - Map.of("content", "hi"), t1); - Long commentId = ((Number)cResp.getBody().get("id")).longValue(); - - postJson("/api/comments/" + commentId + "/reactions", - Map.of("type", "DISLIKE"), t2); - - Map post = rest.getForObject("/api/posts/" + postId, Map.class); - List reactions = (List) post.get("reactions"); - assertEquals(1, reactions.size()); - assertEquals("LIKE", ((Map)reactions.get(0)).get("type")); - - List comments = (List) post.get("comments"); - Map comment = (Map) comments.get(0); - List creactions = (List) comment.get("reactions"); - assertEquals(1, creactions.size()); - assertEquals("DISLIKE", ((Map)creactions.get(0)).get("type")); - } + List comments = (List) post.get("comments"); + Map comment = (Map) comments.get(0); + List creactions = (List) comment.get("reactions"); + assertEquals(1, creactions.size()); + assertEquals("DISLIKE", ((Map) creactions.get(0)).get("type")); + } } diff --git a/backend/src/test/java/com/openisle/integration/PublishModeIntegrationTest.java b/backend/src/test/java/com/openisle/integration/PublishModeIntegrationTest.java index 6773aee1a..90d3c7244 100644 --- a/backend/src/test/java/com/openisle/integration/PublishModeIntegrationTest.java +++ b/backend/src/test/java/com/openisle/integration/PublishModeIntegrationTest.java @@ -1,10 +1,14 @@ package com.openisle.integration; +import static org.junit.jupiter.api.Assertions.*; + import com.openisle.model.Role; import com.openisle.model.User; import com.openisle.repository.UserRepository; import com.openisle.service.EmailSender; import com.openisle.service.PushNotificationService; +import java.util.List; +import java.util.Map; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -12,89 +16,119 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.http.*; -import java.util.List; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.*; - /** Integration tests for review publish mode. */ -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, - properties = {"app.post.publish-mode=REVIEW","app.register.mode=DIRECT"}) +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { "app.post.publish-mode=REVIEW", "app.register.mode=DIRECT" } +) class PublishModeIntegrationTest { - @Autowired - private TestRestTemplate rest; + @Autowired + private TestRestTemplate rest; - @Autowired - private UserRepository users; + @Autowired + private UserRepository users; - @MockBean - private EmailSender emailService; + @MockBean + private EmailSender emailService; - private String registerAndLogin(String username, String email) { - HttpHeaders h = new HttpHeaders(); - h.setContentType(MediaType.APPLICATION_JSON); - rest.postForEntity("/api/auth/register", new HttpEntity<>( - Map.of("username", username, "email", email, "password", "pass123", "reason", "integration test reason more than twenty"), h), Map.class); - User u = users.findByUsername(username).orElseThrow(); - if (u.getVerificationCode() != null) { - rest.postForEntity("/api/auth/verify", new HttpEntity<>( - Map.of("username", username, "code", u.getVerificationCode()), h), Map.class); - } - ResponseEntity resp = rest.postForEntity("/api/auth/login", new HttpEntity<>( - Map.of("username", username, "password", "pass123"), h), Map.class); - return (String) resp.getBody().get("token"); + private String registerAndLogin(String username, String email) { + HttpHeaders h = new HttpHeaders(); + h.setContentType(MediaType.APPLICATION_JSON); + rest.postForEntity( + "/api/auth/register", + new HttpEntity<>( + Map.of( + "username", + username, + "email", + email, + "password", + "pass123", + "reason", + "integration test reason more than twenty" + ), + h + ), + Map.class + ); + User u = users.findByUsername(username).orElseThrow(); + if (u.getVerificationCode() != null) { + rest.postForEntity( + "/api/auth/verify", + new HttpEntity<>(Map.of("username", username, "code", u.getVerificationCode()), h), + Map.class + ); } + ResponseEntity resp = rest.postForEntity( + "/api/auth/login", + new HttpEntity<>(Map.of("username", username, "password", "pass123"), h), + Map.class + ); + return (String) resp.getBody().get("token"); + } - private String registerAndLoginAsAdmin(String username, String email) { - String token = registerAndLogin(username, email); - User u = users.findByUsername(username).orElseThrow(); - u.setRole(Role.ADMIN); - users.save(u); - return token; - } + private String registerAndLoginAsAdmin(String username, String email) { + String token = registerAndLogin(username, email); + User u = users.findByUsername(username).orElseThrow(); + u.setRole(Role.ADMIN); + users.save(u); + return token; + } - private ResponseEntity postJson(String url, Map body, String token) { - HttpHeaders h = new HttpHeaders(); - h.setContentType(MediaType.APPLICATION_JSON); - if (token != null) h.setBearerAuth(token); - return rest.exchange(url, HttpMethod.POST, new HttpEntity<>(body, h), Map.class); - } + private ResponseEntity postJson(String url, Map body, String token) { + HttpHeaders h = new HttpHeaders(); + h.setContentType(MediaType.APPLICATION_JSON); + if (token != null) h.setBearerAuth(token); + return rest.exchange(url, HttpMethod.POST, new HttpEntity<>(body, h), Map.class); + } - private ResponseEntity get(String url, Class type, String token) { - HttpHeaders h = new HttpHeaders(); - if (token != null) h.setBearerAuth(token); - return rest.exchange(url, HttpMethod.GET, new HttpEntity<>(h), type); - } + private ResponseEntity get(String url, Class type, String token) { + HttpHeaders h = new HttpHeaders(); + if (token != null) h.setBearerAuth(token); + return rest.exchange(url, HttpMethod.GET, new HttpEntity<>(h), type); + } - @Test - void postRequiresApproval() { - String userToken = registerAndLogin("eve123", "e@example.com"); - String adminToken = registerAndLoginAsAdmin("admin1", "admin@example.com"); + @Test + void postRequiresApproval() { + String userToken = registerAndLogin("eve123", "e@example.com"); + String adminToken = registerAndLoginAsAdmin("admin1", "admin@example.com"); - ResponseEntity catResp = postJson("/api/categories", - Map.of("name", "review", "description", "d", "icon", "i"), adminToken); - Long catId = ((Number)catResp.getBody().get("id")).longValue(); + ResponseEntity catResp = postJson( + "/api/categories", + Map.of("name", "review", "description", "d", "icon", "i"), + adminToken + ); + Long catId = ((Number) catResp.getBody().get("id")).longValue(); - ResponseEntity tagResp = postJson("/api/tags", - Map.of("name", "t1", "description", "d", "icon", "i"), adminToken); - Long tagId = ((Number)tagResp.getBody().get("id")).longValue(); + ResponseEntity tagResp = postJson( + "/api/tags", + Map.of("name", "t1", "description", "d", "icon", "i"), + adminToken + ); + Long tagId = ((Number) tagResp.getBody().get("id")).longValue(); - ResponseEntity postResp = postJson("/api/posts", - Map.of("title", "Need", "content", "Review", "categoryId", catId, - "tagIds", List.of(tagId)), userToken); - Long postId = ((Number)postResp.getBody().get("id")).longValue(); + ResponseEntity postResp = postJson( + "/api/posts", + Map.of("title", "Need", "content", "Review", "categoryId", catId, "tagIds", List.of(tagId)), + userToken + ); + Long postId = ((Number) postResp.getBody().get("id")).longValue(); - List list = rest.getForObject("/api/posts", List.class); - assertTrue(list.isEmpty(), "Post should not be listed before approval"); + List list = rest.getForObject("/api/posts", List.class); + assertTrue(list.isEmpty(), "Post should not be listed before approval"); - List> pending = get("/api/admin/posts/pending", List.class, adminToken).getBody(); - assertEquals(1, pending.size()); - assertEquals(postId.intValue(), ((Number)pending.get(0).get("id")).intValue()); + List> pending = get( + "/api/admin/posts/pending", + List.class, + adminToken + ).getBody(); + assertEquals(1, pending.size()); + assertEquals(postId.intValue(), ((Number) pending.get(0).get("id")).intValue()); - postJson("/api/admin/posts/" + postId + "/approve", Map.of(), adminToken); + postJson("/api/admin/posts/" + postId + "/approve", Map.of(), adminToken); - List listAfter = rest.getForObject("/api/posts", List.class); - assertEquals(1, listAfter.size(), "Post should appear after approval"); - } + List listAfter = rest.getForObject("/api/posts", List.class); + assertEquals(1, listAfter.size(), "Post should appear after approval"); + } } diff --git a/backend/src/test/java/com/openisle/integration/SearchIntegrationTest.java b/backend/src/test/java/com/openisle/integration/SearchIntegrationTest.java index 728ee9601..c4fa88446 100644 --- a/backend/src/test/java/com/openisle/integration/SearchIntegrationTest.java +++ b/backend/src/test/java/com/openisle/integration/SearchIntegrationTest.java @@ -1,9 +1,13 @@ package com.openisle.integration; +import static org.junit.jupiter.api.Assertions.*; + import com.openisle.model.Role; import com.openisle.model.User; import com.openisle.repository.UserRepository; import com.openisle.service.EmailSender; +import java.util.List; +import java.util.Map; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -11,76 +15,118 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.http.*; -import java.util.List; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.*; - -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, - properties = "app.register.mode=DIRECT") +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "app.register.mode=DIRECT" +) class SearchIntegrationTest { - @Autowired - private TestRestTemplate rest; - @Autowired - private UserRepository users; - @MockBean - private EmailSender emailService; - private String registerAndLogin(String username, String email) { - HttpHeaders h = new HttpHeaders(); - h.setContentType(MediaType.APPLICATION_JSON); - rest.postForEntity("/api/auth/register", new HttpEntity<>( - Map.of("username", username, "email", email, "password", "pass123", "reason", "integration test reason more than twenty"), h), Map.class); - User u = users.findByUsername(username).orElseThrow(); - if (u.getVerificationCode() != null) { - rest.postForEntity("/api/auth/verify", new HttpEntity<>( - Map.of("username", username, "code", u.getVerificationCode()), h), Map.class); - } - ResponseEntity resp = rest.postForEntity("/api/auth/login", new HttpEntity<>( - Map.of("username", username, "password", "pass123"), h), Map.class); - return (String) resp.getBody().get("token"); + @Autowired + private TestRestTemplate rest; + + @Autowired + private UserRepository users; + + @MockBean + private EmailSender emailService; + + private String registerAndLogin(String username, String email) { + HttpHeaders h = new HttpHeaders(); + h.setContentType(MediaType.APPLICATION_JSON); + rest.postForEntity( + "/api/auth/register", + new HttpEntity<>( + Map.of( + "username", + username, + "email", + email, + "password", + "pass123", + "reason", + "integration test reason more than twenty" + ), + h + ), + Map.class + ); + User u = users.findByUsername(username).orElseThrow(); + if (u.getVerificationCode() != null) { + rest.postForEntity( + "/api/auth/verify", + new HttpEntity<>(Map.of("username", username, "code", u.getVerificationCode()), h), + Map.class + ); } + ResponseEntity resp = rest.postForEntity( + "/api/auth/login", + new HttpEntity<>(Map.of("username", username, "password", "pass123"), h), + Map.class + ); + return (String) resp.getBody().get("token"); + } - private String registerAndLoginAsAdmin(String username, String email) { - String token = registerAndLogin(username, email); - User u = users.findByUsername(username).orElseThrow(); - u.setRole(Role.ADMIN); - users.save(u); - return token; - } + private String registerAndLoginAsAdmin(String username, String email) { + String token = registerAndLogin(username, email); + User u = users.findByUsername(username).orElseThrow(); + u.setRole(Role.ADMIN); + users.save(u); + return token; + } - private ResponseEntity postJson(String url, Map body, String token) { - HttpHeaders h = new HttpHeaders(); - h.setContentType(MediaType.APPLICATION_JSON); - if (token != null) h.setBearerAuth(token); - return rest.exchange(url, HttpMethod.POST, new HttpEntity<>(body, h), Map.class); - } + private ResponseEntity postJson(String url, Map body, String token) { + HttpHeaders h = new HttpHeaders(); + h.setContentType(MediaType.APPLICATION_JSON); + if (token != null) h.setBearerAuth(token); + return rest.exchange(url, HttpMethod.POST, new HttpEntity<>(body, h), Map.class); + } - @Test - void globalSearchReturnsMixedResults() { - String admin = registerAndLoginAsAdmin("admin1", "a@a.com"); - String user = registerAndLogin("bob_nice", "b@b.com"); + @Test + void globalSearchReturnsMixedResults() { + String admin = registerAndLoginAsAdmin("admin1", "a@a.com"); + String user = registerAndLogin("bob_nice", "b@b.com"); - ResponseEntity catResp = postJson("/api/categories", Map.of("name", "niccat", "description", "d", "icon", "i"), admin); - Long catId = ((Number)catResp.getBody().get("id")).longValue(); + ResponseEntity catResp = postJson( + "/api/categories", + Map.of("name", "niccat", "description", "d", "icon", "i"), + admin + ); + Long catId = ((Number) catResp.getBody().get("id")).longValue(); - ResponseEntity tagResp = postJson("/api/tags", Map.of("name", "nictag", "description", "d", "icon", "i"), admin); - Long tagId = ((Number)tagResp.getBody().get("id")).longValue(); + ResponseEntity tagResp = postJson( + "/api/tags", + Map.of("name", "nictag", "description", "d", "icon", "i"), + admin + ); + Long tagId = ((Number) tagResp.getBody().get("id")).longValue(); - ResponseEntity postResp = postJson("/api/posts", - Map.of("title", "Hello World Nice", "content", "Some content", "categoryId", catId, - "tagIds", List.of(tagId)), user); - Long postId = ((Number)postResp.getBody().get("id")).longValue(); + ResponseEntity postResp = postJson( + "/api/posts", + Map.of( + "title", + "Hello World Nice", + "content", + "Some content", + "categoryId", + catId, + "tagIds", + List.of(tagId) + ), + user + ); + Long postId = ((Number) postResp.getBody().get("id")).longValue(); - postJson("/api/posts/" + postId + "/comments", - Map.of("content", "Nice article"), admin); + postJson("/api/posts/" + postId + "/comments", Map.of("content", "Nice article"), admin); - List> results = rest.getForObject("/api/search/global?keyword=nic", List.class); - assertEquals(5, results.size()); - assertTrue(results.stream().anyMatch(m -> "user".equals(m.get("type")))); - assertTrue(results.stream().anyMatch(m -> "post".equals(m.get("type")))); - assertTrue(results.stream().anyMatch(m -> "comment".equals(m.get("type")))); - assertTrue(results.stream().anyMatch(m -> "category".equals(m.get("type")))); - assertTrue(results.stream().anyMatch(m -> "tag".equals(m.get("type")))); - } + List> results = rest.getForObject( + "/api/search/global?keyword=nic", + List.class + ); + assertEquals(5, results.size()); + assertTrue(results.stream().anyMatch(m -> "user".equals(m.get("type")))); + assertTrue(results.stream().anyMatch(m -> "post".equals(m.get("type")))); + assertTrue(results.stream().anyMatch(m -> "comment".equals(m.get("type")))); + assertTrue(results.stream().anyMatch(m -> "category".equals(m.get("type")))); + assertTrue(results.stream().anyMatch(m -> "tag".equals(m.get("type")))); + } } diff --git a/backend/src/test/java/com/openisle/service/CommentServiceTest.java b/backend/src/test/java/com/openisle/service/CommentServiceTest.java index 0e05c052c..e52bdb7be 100644 --- a/backend/src/test/java/com/openisle/service/CommentServiceTest.java +++ b/backend/src/test/java/com/openisle/service/CommentServiceTest.java @@ -1,41 +1,51 @@ package com.openisle.service; -import com.openisle.repository.CommentRepository; -import com.openisle.repository.PostRepository; -import com.openisle.repository.UserRepository; -import com.openisle.repository.ReactionRepository; -import com.openisle.repository.CommentSubscriptionRepository; -import com.openisle.repository.NotificationRepository; -import com.openisle.repository.PointHistoryRepository; -import com.openisle.service.PointService; -import com.openisle.exception.RateLimitException; -import org.junit.jupiter.api.Test; - - import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; +import com.openisle.exception.RateLimitException; +import com.openisle.repository.CommentRepository; +import com.openisle.repository.CommentSubscriptionRepository; +import com.openisle.repository.NotificationRepository; +import com.openisle.repository.PointHistoryRepository; +import com.openisle.repository.PostRepository; +import com.openisle.repository.ReactionRepository; +import com.openisle.repository.UserRepository; +import com.openisle.service.PointService; +import org.junit.jupiter.api.Test; + class CommentServiceTest { - @Test - void addCommentRespectsRateLimit() { - CommentRepository commentRepo = mock(CommentRepository.class); - PostRepository postRepo = mock(PostRepository.class); - UserRepository userRepo = mock(UserRepository.class); - NotificationService notifService = mock(NotificationService.class); - SubscriptionService subService = mock(SubscriptionService.class); - ReactionRepository reactionRepo = mock(ReactionRepository.class); - CommentSubscriptionRepository subRepo = mock(CommentSubscriptionRepository.class); - NotificationRepository nRepo = mock(NotificationRepository.class); - PointHistoryRepository pointHistoryRepo = mock(PointHistoryRepository.class); - PointService pointService = mock(PointService.class); - ImageUploader imageUploader = mock(ImageUploader.class); - CommentService service = new CommentService(commentRepo, postRepo, userRepo, - notifService, subService, reactionRepo, subRepo, nRepo, pointHistoryRepo, pointService, imageUploader); + @Test + void addCommentRespectsRateLimit() { + CommentRepository commentRepo = mock(CommentRepository.class); + PostRepository postRepo = mock(PostRepository.class); + UserRepository userRepo = mock(UserRepository.class); + NotificationService notifService = mock(NotificationService.class); + SubscriptionService subService = mock(SubscriptionService.class); + ReactionRepository reactionRepo = mock(ReactionRepository.class); + CommentSubscriptionRepository subRepo = mock(CommentSubscriptionRepository.class); + NotificationRepository nRepo = mock(NotificationRepository.class); + PointHistoryRepository pointHistoryRepo = mock(PointHistoryRepository.class); + PointService pointService = mock(PointService.class); + ImageUploader imageUploader = mock(ImageUploader.class); - when(commentRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(3L); + CommentService service = new CommentService( + commentRepo, + postRepo, + userRepo, + notifService, + subService, + reactionRepo, + subRepo, + nRepo, + pointHistoryRepo, + pointService, + imageUploader + ); - assertThrows(RateLimitException.class, - () -> service.addComment("alice", 1L, "hi")); - } + when(commentRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(3L); + + assertThrows(RateLimitException.class, () -> service.addComment("alice", 1L, "hi")); + } } diff --git a/backend/src/test/java/com/openisle/service/CosImageUploaderTest.java b/backend/src/test/java/com/openisle/service/CosImageUploaderTest.java index 30bd92ed9..218cd5142 100644 --- a/backend/src/test/java/com/openisle/service/CosImageUploaderTest.java +++ b/backend/src/test/java/com/openisle/service/CosImageUploaderTest.java @@ -1,23 +1,29 @@ package com.openisle.service; -import com.qcloud.cos.COSClient; -import com.qcloud.cos.model.PutObjectRequest; -import com.openisle.repository.ImageRepository; -import org.junit.jupiter.api.Test; - import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; +import com.openisle.repository.ImageRepository; +import com.qcloud.cos.COSClient; +import com.qcloud.cos.model.PutObjectRequest; +import org.junit.jupiter.api.Test; + class CosImageUploaderTest { - @Test - void uploadReturnsUrl() { - COSClient client = mock(COSClient.class); - ImageRepository repo = mock(ImageRepository.class); - CosImageUploader uploader = new CosImageUploader(client, repo, "bucket", "http://cos.example.com"); - String url = uploader.upload("data".getBytes(), "img.png").join(); + @Test + void uploadReturnsUrl() { + COSClient client = mock(COSClient.class); + ImageRepository repo = mock(ImageRepository.class); + CosImageUploader uploader = new CosImageUploader( + client, + repo, + "bucket", + "http://cos.example.com" + ); - verify(client).putObject(any(PutObjectRequest.class)); - assertTrue(url.matches("http://cos.example.com/dynamic_assert/[a-f0-9]{32}\\.png")); - } + String url = uploader.upload("data".getBytes(), "img.png").join(); + + verify(client).putObject(any(PutObjectRequest.class)); + assertTrue(url.matches("http://cos.example.com/dynamic_assert/[a-f0-9]{32}\\.png")); + } } diff --git a/backend/src/test/java/com/openisle/service/MedalServiceTest.java b/backend/src/test/java/com/openisle/service/MedalServiceTest.java index a873ea9c6..d8cf4c08e 100644 --- a/backend/src/test/java/com/openisle/service/MedalServiceTest.java +++ b/backend/src/test/java/com/openisle/service/MedalServiceTest.java @@ -1,107 +1,165 @@ package com.openisle.service; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + import com.openisle.dto.MedalDto; import com.openisle.model.MedalType; import com.openisle.model.User; import com.openisle.repository.CommentRepository; import com.openisle.repository.PostRepository; import com.openisle.repository.UserRepository; -import org.junit.jupiter.api.Test; - import java.time.LocalDateTime; import java.util.List; import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; +import org.junit.jupiter.api.Test; class MedalServiceTest { - @Test - void getMedalsWithoutUser() { - CommentRepository commentRepo = mock(CommentRepository.class); - PostRepository postRepo = mock(PostRepository.class); - UserRepository userRepo = mock(UserRepository.class); - ContributorService contributorService = mock(ContributorService.class); - MedalService service = new MedalService(commentRepo, postRepo, userRepo, contributorService); + @Test + void getMedalsWithoutUser() { + CommentRepository commentRepo = mock(CommentRepository.class); + PostRepository postRepo = mock(PostRepository.class); + UserRepository userRepo = mock(UserRepository.class); + ContributorService contributorService = mock(ContributorService.class); - List medals = service.getMedals(null); - medals.forEach(m -> assertFalse(m.isCompleted())); - assertEquals(6, medals.size()); - } + MedalService service = new MedalService(commentRepo, postRepo, userRepo, contributorService); - @Test - void getMedalsWithUser() { - CommentRepository commentRepo = mock(CommentRepository.class); - PostRepository postRepo = mock(PostRepository.class); - UserRepository userRepo = mock(UserRepository.class); - ContributorService contributorService = mock(ContributorService.class); + List medals = service.getMedals(null); + medals.forEach(m -> assertFalse(m.isCompleted())); + assertEquals(6, medals.size()); + } - when(commentRepo.countByAuthor_Id(1L)).thenReturn(120L); - when(postRepo.countByAuthor_Id(1L)).thenReturn(80L); - when(contributorService.getContributionLines(anyString())).thenReturn(0L); - when(userRepo.countByCreatedAtBefore(any())).thenReturn(50L); - User user = new User(); - user.setId(1L); - user.setCreatedAt(LocalDateTime.of(2025, 9, 15, 0, 0)); - when(userRepo.findById(1L)).thenReturn(Optional.of(user)); - when(userRepo.findByUsername("user")).thenReturn(Optional.of(user)); + @Test + void getMedalsWithUser() { + CommentRepository commentRepo = mock(CommentRepository.class); + PostRepository postRepo = mock(PostRepository.class); + UserRepository userRepo = mock(UserRepository.class); + ContributorService contributorService = mock(ContributorService.class); - MedalService service = new MedalService(commentRepo, postRepo, userRepo, contributorService); - List medals = service.getMedals(1L); + when(commentRepo.countByAuthor_Id(1L)).thenReturn(120L); + when(postRepo.countByAuthor_Id(1L)).thenReturn(80L); + when(contributorService.getContributionLines(anyString())).thenReturn(0L); + when(userRepo.countByCreatedAtBefore(any())).thenReturn(50L); + User user = new User(); + user.setId(1L); + user.setCreatedAt(LocalDateTime.of(2025, 9, 15, 0, 0)); + when(userRepo.findById(1L)).thenReturn(Optional.of(user)); + when(userRepo.findByUsername("user")).thenReturn(Optional.of(user)); - assertEquals(MedalType.COMMENT, user.getDisplayMedal()); - assertTrue(medals.stream().filter(m -> m.getType() == MedalType.COMMENT).findFirst().orElseThrow().isCompleted()); - assertTrue(medals.stream().filter(m -> m.getType() == MedalType.COMMENT).findFirst().orElseThrow().isSelected()); - assertFalse(medals.stream().filter(m -> m.getType() == MedalType.POST).findFirst().orElseThrow().isCompleted()); - assertFalse(medals.stream().filter(m -> m.getType() == MedalType.POST).findFirst().orElseThrow().isSelected()); - assertTrue(medals.stream().filter(m -> m.getType() == MedalType.SEED).findFirst().orElseThrow().isCompleted()); - assertFalse(medals.stream().filter(m -> m.getType() == MedalType.SEED).findFirst().orElseThrow().isSelected()); - assertTrue(medals.stream().filter(m -> m.getType() == MedalType.PIONEER).findFirst().orElseThrow().isCompleted()); - assertFalse(medals.stream().filter(m -> m.getType() == MedalType.PIONEER).findFirst().orElseThrow().isSelected()); - verify(userRepo).save(user); - } + MedalService service = new MedalService(commentRepo, postRepo, userRepo, contributorService); + List medals = service.getMedals(1L); - @Test - void selectMedal() { - CommentRepository commentRepo = mock(CommentRepository.class); - PostRepository postRepo = mock(PostRepository.class); - UserRepository userRepo = mock(UserRepository.class); - ContributorService contributorService = mock(ContributorService.class); + assertEquals(MedalType.COMMENT, user.getDisplayMedal()); + assertTrue( + medals + .stream() + .filter(m -> m.getType() == MedalType.COMMENT) + .findFirst() + .orElseThrow() + .isCompleted() + ); + assertTrue( + medals + .stream() + .filter(m -> m.getType() == MedalType.COMMENT) + .findFirst() + .orElseThrow() + .isSelected() + ); + assertFalse( + medals + .stream() + .filter(m -> m.getType() == MedalType.POST) + .findFirst() + .orElseThrow() + .isCompleted() + ); + assertFalse( + medals + .stream() + .filter(m -> m.getType() == MedalType.POST) + .findFirst() + .orElseThrow() + .isSelected() + ); + assertTrue( + medals + .stream() + .filter(m -> m.getType() == MedalType.SEED) + .findFirst() + .orElseThrow() + .isCompleted() + ); + assertFalse( + medals + .stream() + .filter(m -> m.getType() == MedalType.SEED) + .findFirst() + .orElseThrow() + .isSelected() + ); + assertTrue( + medals + .stream() + .filter(m -> m.getType() == MedalType.PIONEER) + .findFirst() + .orElseThrow() + .isCompleted() + ); + assertFalse( + medals + .stream() + .filter(m -> m.getType() == MedalType.PIONEER) + .findFirst() + .orElseThrow() + .isSelected() + ); + verify(userRepo).save(user); + } - when(commentRepo.countByAuthor_Id(1L)).thenReturn(120L); - when(postRepo.countByAuthor_Id(1L)).thenReturn(0L); - when(contributorService.getContributionLines(anyString())).thenReturn(0L); - when(userRepo.countByCreatedAtBefore(any())).thenReturn(0L); - User user = new User(); - user.setId(1L); - user.setCreatedAt(LocalDateTime.of(2025, 9, 15, 0, 0)); - when(userRepo.findByUsername("user")).thenReturn(Optional.of(user)); - when(userRepo.findById(1L)).thenReturn(Optional.of(user)); + @Test + void selectMedal() { + CommentRepository commentRepo = mock(CommentRepository.class); + PostRepository postRepo = mock(PostRepository.class); + UserRepository userRepo = mock(UserRepository.class); + ContributorService contributorService = mock(ContributorService.class); - MedalService service = new MedalService(commentRepo, postRepo, userRepo, contributorService); - service.selectMedal("user", MedalType.COMMENT); - assertEquals(MedalType.COMMENT, user.getDisplayMedal()); - } + when(commentRepo.countByAuthor_Id(1L)).thenReturn(120L); + when(postRepo.countByAuthor_Id(1L)).thenReturn(0L); + when(contributorService.getContributionLines(anyString())).thenReturn(0L); + when(userRepo.countByCreatedAtBefore(any())).thenReturn(0L); + User user = new User(); + user.setId(1L); + user.setCreatedAt(LocalDateTime.of(2025, 9, 15, 0, 0)); + when(userRepo.findByUsername("user")).thenReturn(Optional.of(user)); + when(userRepo.findById(1L)).thenReturn(Optional.of(user)); - @Test - void selectMedalNotCompleted() { - CommentRepository commentRepo = mock(CommentRepository.class); - PostRepository postRepo = mock(PostRepository.class); - UserRepository userRepo = mock(UserRepository.class); - ContributorService contributorService = mock(ContributorService.class); + MedalService service = new MedalService(commentRepo, postRepo, userRepo, contributorService); + service.selectMedal("user", MedalType.COMMENT); + assertEquals(MedalType.COMMENT, user.getDisplayMedal()); + } - when(commentRepo.countByAuthor_Id(1L)).thenReturn(10L); - when(postRepo.countByAuthor_Id(1L)).thenReturn(0L); - when(contributorService.getContributionLines(anyString())).thenReturn(0L); - when(userRepo.countByCreatedAtBefore(any())).thenReturn(0L); - User user = new User(); - user.setId(1L); - user.setCreatedAt(LocalDateTime.of(2025, 9, 15, 0, 0)); - when(userRepo.findByUsername("user")).thenReturn(Optional.of(user)); - when(userRepo.findById(1L)).thenReturn(Optional.of(user)); + @Test + void selectMedalNotCompleted() { + CommentRepository commentRepo = mock(CommentRepository.class); + PostRepository postRepo = mock(PostRepository.class); + UserRepository userRepo = mock(UserRepository.class); + ContributorService contributorService = mock(ContributorService.class); - MedalService service = new MedalService(commentRepo, postRepo, userRepo, contributorService); - assertThrows(IllegalArgumentException.class, () -> service.selectMedal("user", MedalType.COMMENT)); - } + when(commentRepo.countByAuthor_Id(1L)).thenReturn(10L); + when(postRepo.countByAuthor_Id(1L)).thenReturn(0L); + when(contributorService.getContributionLines(anyString())).thenReturn(0L); + when(userRepo.countByCreatedAtBefore(any())).thenReturn(0L); + User user = new User(); + user.setId(1L); + user.setCreatedAt(LocalDateTime.of(2025, 9, 15, 0, 0)); + when(userRepo.findByUsername("user")).thenReturn(Optional.of(user)); + when(userRepo.findById(1L)).thenReturn(Optional.of(user)); + + MedalService service = new MedalService(commentRepo, postRepo, userRepo, contributorService); + assertThrows(IllegalArgumentException.class, () -> + service.selectMedal("user", MedalType.COMMENT) + ); + } } diff --git a/backend/src/test/java/com/openisle/service/NotificationServiceTest.java b/backend/src/test/java/com/openisle/service/NotificationServiceTest.java index e6be00fea..aab0e27b9 100644 --- a/backend/src/test/java/com/openisle/service/NotificationServiceTest.java +++ b/backend/src/test/java/com/openisle/service/NotificationServiceTest.java @@ -1,274 +1,420 @@ package com.openisle.service; -import com.openisle.model.*; -import com.openisle.repository.NotificationRepository; -import com.openisle.repository.UserRepository; -import com.openisle.repository.ReactionRepository; -import com.openisle.service.PushNotificationService; -import java.util.concurrent.Executor; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.util.List; -import java.util.Optional; -import java.util.HashSet; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.Pageable; - import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; +import com.openisle.model.*; +import com.openisle.repository.NotificationRepository; +import com.openisle.repository.ReactionRepository; +import com.openisle.repository.UserRepository; +import com.openisle.service.PushNotificationService; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.Executor; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + class NotificationServiceTest { - @Test - void markReadUpdatesOnlyOwnedNotifications() { - NotificationRepository nRepo = mock(NotificationRepository.class); - UserRepository uRepo = mock(UserRepository.class); - ReactionRepository rRepo = mock(ReactionRepository.class); - EmailSender email = mock(EmailSender.class); - PushNotificationService push = mock(PushNotificationService.class); - Executor executor = Runnable::run; - NotificationService service = new NotificationService(nRepo, uRepo, email, push, rRepo, executor); - org.springframework.test.util.ReflectionTestUtils.setField(service, "websiteUrl", "https://ex.com"); + @Test + void markReadUpdatesOnlyOwnedNotifications() { + NotificationRepository nRepo = mock(NotificationRepository.class); + UserRepository uRepo = mock(UserRepository.class); + ReactionRepository rRepo = mock(ReactionRepository.class); + EmailSender email = mock(EmailSender.class); + PushNotificationService push = mock(PushNotificationService.class); + Executor executor = Runnable::run; + NotificationService service = new NotificationService( + nRepo, + uRepo, + email, + push, + rRepo, + executor + ); + org.springframework.test.util.ReflectionTestUtils.setField( + service, + "websiteUrl", + "https://ex.com" + ); - User user = new User(); - user.setId(1L); - user.setUsername("alice"); - when(uRepo.findByUsername("alice")).thenReturn(Optional.of(user)); + User user = new User(); + user.setId(1L); + user.setUsername("alice"); + when(uRepo.findByUsername("alice")).thenReturn(Optional.of(user)); - Notification n1 = new Notification(); - n1.setId(10L); - n1.setUser(user); - Notification n2 = new Notification(); - n2.setId(11L); - n2.setUser(user); - when(nRepo.findAllById(List.of(10L, 11L))).thenReturn(List.of(n1, n2)); + Notification n1 = new Notification(); + n1.setId(10L); + n1.setUser(user); + Notification n2 = new Notification(); + n2.setId(11L); + n2.setUser(user); + when(nRepo.findAllById(List.of(10L, 11L))).thenReturn(List.of(n1, n2)); - service.markRead("alice", List.of(10L, 11L)); + service.markRead("alice", List.of(10L, 11L)); - assertTrue(n1.isRead()); - assertTrue(n2.isRead()); - verify(nRepo).saveAll(List.of(n1, n2)); - } + assertTrue(n1.isRead()); + assertTrue(n2.isRead()); + verify(nRepo).saveAll(List.of(n1, n2)); + } - @Test - void listNotificationsWithoutFilter() { - NotificationRepository nRepo = mock(NotificationRepository.class); - UserRepository uRepo = mock(UserRepository.class); - ReactionRepository rRepo = mock(ReactionRepository.class); - EmailSender email = mock(EmailSender.class); - PushNotificationService push = mock(PushNotificationService.class); - Executor executor = Runnable::run; - NotificationService service = new NotificationService(nRepo, uRepo, email, push, rRepo, executor); - org.springframework.test.util.ReflectionTestUtils.setField(service, "websiteUrl", "https://ex.com"); + @Test + void listNotificationsWithoutFilter() { + NotificationRepository nRepo = mock(NotificationRepository.class); + UserRepository uRepo = mock(UserRepository.class); + ReactionRepository rRepo = mock(ReactionRepository.class); + EmailSender email = mock(EmailSender.class); + PushNotificationService push = mock(PushNotificationService.class); + Executor executor = Runnable::run; + NotificationService service = new NotificationService( + nRepo, + uRepo, + email, + push, + rRepo, + executor + ); + org.springframework.test.util.ReflectionTestUtils.setField( + service, + "websiteUrl", + "https://ex.com" + ); - User user = new User(); - user.setId(2L); - user.setUsername("bob"); - user.setDisabledNotificationTypes(new HashSet<>()); - when(uRepo.findByUsername("bob")).thenReturn(Optional.of(user)); + User user = new User(); + user.setId(2L); + user.setUsername("bob"); + user.setDisabledNotificationTypes(new HashSet<>()); + when(uRepo.findByUsername("bob")).thenReturn(Optional.of(user)); - Notification n = new Notification(); - when(nRepo.findByUserOrderByCreatedAtDesc(eq(user), any(Pageable.class))) - .thenReturn(new PageImpl<>(List.of(n))); + Notification n = new Notification(); + when(nRepo.findByUserOrderByCreatedAtDesc(eq(user), any(Pageable.class))).thenReturn( + new PageImpl<>(List.of(n)) + ); - List list = service.listNotifications("bob", null, 0, 10); + List list = service.listNotifications("bob", null, 0, 10); - assertEquals(1, list.size()); - verify(nRepo).findByUserOrderByCreatedAtDesc(eq(user), any(Pageable.class)); - } + assertEquals(1, list.size()); + verify(nRepo).findByUserOrderByCreatedAtDesc(eq(user), any(Pageable.class)); + } - @Test - void countUnreadReturnsRepositoryValue() { - NotificationRepository nRepo = mock(NotificationRepository.class); - UserRepository uRepo = mock(UserRepository.class); - ReactionRepository rRepo = mock(ReactionRepository.class); - EmailSender email = mock(EmailSender.class); - PushNotificationService push = mock(PushNotificationService.class); - Executor executor = Runnable::run; - NotificationService service = new NotificationService(nRepo, uRepo, email, push, rRepo, executor); - org.springframework.test.util.ReflectionTestUtils.setField(service, "websiteUrl", "https://ex.com"); + @Test + void countUnreadReturnsRepositoryValue() { + NotificationRepository nRepo = mock(NotificationRepository.class); + UserRepository uRepo = mock(UserRepository.class); + ReactionRepository rRepo = mock(ReactionRepository.class); + EmailSender email = mock(EmailSender.class); + PushNotificationService push = mock(PushNotificationService.class); + Executor executor = Runnable::run; + NotificationService service = new NotificationService( + nRepo, + uRepo, + email, + push, + rRepo, + executor + ); + org.springframework.test.util.ReflectionTestUtils.setField( + service, + "websiteUrl", + "https://ex.com" + ); - User user = new User(); - user.setId(3L); - user.setUsername("carl"); - user.setDisabledNotificationTypes(new HashSet<>()); - when(uRepo.findByUsername("carl")).thenReturn(Optional.of(user)); - when(nRepo.countByUserAndRead(user, false)).thenReturn(5L); + User user = new User(); + user.setId(3L); + user.setUsername("carl"); + user.setDisabledNotificationTypes(new HashSet<>()); + when(uRepo.findByUsername("carl")).thenReturn(Optional.of(user)); + when(nRepo.countByUserAndRead(user, false)).thenReturn(5L); - long count = service.countUnread("carl"); + long count = service.countUnread("carl"); - assertEquals(5L, count); - verify(nRepo).countByUserAndRead(user, false); - } + assertEquals(5L, count); + verify(nRepo).countByUserAndRead(user, false); + } - @Test - void listNotificationsFiltersDisabledTypes() { - NotificationRepository nRepo = mock(NotificationRepository.class); - UserRepository uRepo = mock(UserRepository.class); - ReactionRepository rRepo = mock(ReactionRepository.class); - EmailSender email = mock(EmailSender.class); - PushNotificationService push = mock(PushNotificationService.class); - Executor executor = Runnable::run; - NotificationService service = new NotificationService(nRepo, uRepo, email, push, rRepo, executor); - org.springframework.test.util.ReflectionTestUtils.setField(service, "websiteUrl", "https://ex.com"); + @Test + void listNotificationsFiltersDisabledTypes() { + NotificationRepository nRepo = mock(NotificationRepository.class); + UserRepository uRepo = mock(UserRepository.class); + ReactionRepository rRepo = mock(ReactionRepository.class); + EmailSender email = mock(EmailSender.class); + PushNotificationService push = mock(PushNotificationService.class); + Executor executor = Runnable::run; + NotificationService service = new NotificationService( + nRepo, + uRepo, + email, + push, + rRepo, + executor + ); + org.springframework.test.util.ReflectionTestUtils.setField( + service, + "websiteUrl", + "https://ex.com" + ); - User user = new User(); - user.setId(4L); - user.setUsername("dana"); - when(uRepo.findByUsername("dana")).thenReturn(Optional.of(user)); + User user = new User(); + user.setId(4L); + user.setUsername("dana"); + when(uRepo.findByUsername("dana")).thenReturn(Optional.of(user)); - Notification n = new Notification(); - when(nRepo.findByUserAndTypeNotInOrderByCreatedAtDesc(eq(user), eq(user.getDisabledNotificationTypes()), any(Pageable.class))) - .thenReturn(new PageImpl<>(List.of(n))); + Notification n = new Notification(); + when( + nRepo.findByUserAndTypeNotInOrderByCreatedAtDesc( + eq(user), + eq(user.getDisabledNotificationTypes()), + any(Pageable.class) + ) + ).thenReturn(new PageImpl<>(List.of(n))); - List list = service.listNotifications("dana", null, 0, 10); + List list = service.listNotifications("dana", null, 0, 10); - assertEquals(1, list.size()); - verify(nRepo).findByUserAndTypeNotInOrderByCreatedAtDesc(eq(user), eq(user.getDisabledNotificationTypes()), any(Pageable.class)); - } + assertEquals(1, list.size()); + verify(nRepo).findByUserAndTypeNotInOrderByCreatedAtDesc( + eq(user), + eq(user.getDisabledNotificationTypes()), + any(Pageable.class) + ); + } - @Test - void countUnreadFiltersDisabledTypes() { - NotificationRepository nRepo = mock(NotificationRepository.class); - UserRepository uRepo = mock(UserRepository.class); - ReactionRepository rRepo = mock(ReactionRepository.class); - EmailSender email = mock(EmailSender.class); - PushNotificationService push = mock(PushNotificationService.class); - Executor executor = Runnable::run; - NotificationService service = new NotificationService(nRepo, uRepo, email, push, rRepo, executor); - org.springframework.test.util.ReflectionTestUtils.setField(service, "websiteUrl", "https://ex.com"); + @Test + void countUnreadFiltersDisabledTypes() { + NotificationRepository nRepo = mock(NotificationRepository.class); + UserRepository uRepo = mock(UserRepository.class); + ReactionRepository rRepo = mock(ReactionRepository.class); + EmailSender email = mock(EmailSender.class); + PushNotificationService push = mock(PushNotificationService.class); + Executor executor = Runnable::run; + NotificationService service = new NotificationService( + nRepo, + uRepo, + email, + push, + rRepo, + executor + ); + org.springframework.test.util.ReflectionTestUtils.setField( + service, + "websiteUrl", + "https://ex.com" + ); - User user = new User(); - user.setId(5L); - user.setUsername("erin"); - when(uRepo.findByUsername("erin")).thenReturn(Optional.of(user)); - when(nRepo.countByUserAndReadAndTypeNotIn(eq(user), eq(false), eq(user.getDisabledNotificationTypes()))) - .thenReturn(2L); + User user = new User(); + user.setId(5L); + user.setUsername("erin"); + when(uRepo.findByUsername("erin")).thenReturn(Optional.of(user)); + when( + nRepo.countByUserAndReadAndTypeNotIn( + eq(user), + eq(false), + eq(user.getDisabledNotificationTypes()) + ) + ).thenReturn(2L); - long count = service.countUnread("erin"); + long count = service.countUnread("erin"); - assertEquals(2L, count); - verify(nRepo).countByUserAndReadAndTypeNotIn(eq(user), eq(false), eq(user.getDisabledNotificationTypes())); - } + assertEquals(2L, count); + verify(nRepo).countByUserAndReadAndTypeNotIn( + eq(user), + eq(false), + eq(user.getDisabledNotificationTypes()) + ); + } - @Test - void createRegisterRequestNotificationsDeletesOldOnes() { - NotificationRepository nRepo = mock(NotificationRepository.class); - UserRepository uRepo = mock(UserRepository.class); - ReactionRepository rRepo = mock(ReactionRepository.class); - EmailSender email = mock(EmailSender.class); - PushNotificationService push = mock(PushNotificationService.class); - Executor executor = Runnable::run; - NotificationService service = new NotificationService(nRepo, uRepo, email, push, rRepo, executor); - org.springframework.test.util.ReflectionTestUtils.setField(service, "websiteUrl", "https://ex.com"); + @Test + void createRegisterRequestNotificationsDeletesOldOnes() { + NotificationRepository nRepo = mock(NotificationRepository.class); + UserRepository uRepo = mock(UserRepository.class); + ReactionRepository rRepo = mock(ReactionRepository.class); + EmailSender email = mock(EmailSender.class); + PushNotificationService push = mock(PushNotificationService.class); + Executor executor = Runnable::run; + NotificationService service = new NotificationService( + nRepo, + uRepo, + email, + push, + rRepo, + executor + ); + org.springframework.test.util.ReflectionTestUtils.setField( + service, + "websiteUrl", + "https://ex.com" + ); - User admin = new User(); - admin.setId(10L); - User applicant = new User(); - applicant.setId(20L); + User admin = new User(); + admin.setId(10L); + User applicant = new User(); + applicant.setId(20L); - when(uRepo.findByRole(Role.ADMIN)).thenReturn(List.of(admin)); + when(uRepo.findByRole(Role.ADMIN)).thenReturn(List.of(admin)); - service.createRegisterRequestNotifications(applicant, "reason"); + service.createRegisterRequestNotifications(applicant, "reason"); - verify(nRepo).deleteByTypeAndFromUser(NotificationType.REGISTER_REQUEST, applicant); - verify(nRepo).save(any(Notification.class)); - } + verify(nRepo).deleteByTypeAndFromUser(NotificationType.REGISTER_REQUEST, applicant); + verify(nRepo).save(any(Notification.class)); + } - @Test - void createActivityRedeemNotificationsDeletesOldOnes() { - NotificationRepository nRepo = mock(NotificationRepository.class); - UserRepository uRepo = mock(UserRepository.class); - ReactionRepository rRepo = mock(ReactionRepository.class); - EmailSender email = mock(EmailSender.class); - PushNotificationService push = mock(PushNotificationService.class); - Executor executor = Runnable::run; - NotificationService service = new NotificationService(nRepo, uRepo, email, push, rRepo, executor); - org.springframework.test.util.ReflectionTestUtils.setField(service, "websiteUrl", "https://ex.com"); + @Test + void createActivityRedeemNotificationsDeletesOldOnes() { + NotificationRepository nRepo = mock(NotificationRepository.class); + UserRepository uRepo = mock(UserRepository.class); + ReactionRepository rRepo = mock(ReactionRepository.class); + EmailSender email = mock(EmailSender.class); + PushNotificationService push = mock(PushNotificationService.class); + Executor executor = Runnable::run; + NotificationService service = new NotificationService( + nRepo, + uRepo, + email, + push, + rRepo, + executor + ); + org.springframework.test.util.ReflectionTestUtils.setField( + service, + "websiteUrl", + "https://ex.com" + ); - User admin = new User(); - admin.setId(10L); - User user = new User(); - user.setId(20L); + User admin = new User(); + admin.setId(10L); + User user = new User(); + user.setId(20L); - when(uRepo.findByRole(Role.ADMIN)).thenReturn(List.of(admin)); + when(uRepo.findByRole(Role.ADMIN)).thenReturn(List.of(admin)); - service.createActivityRedeemNotifications(user, "contact"); + service.createActivityRedeemNotifications(user, "contact"); - verify(nRepo).deleteByTypeAndFromUser(NotificationType.ACTIVITY_REDEEM, user); - verify(nRepo).save(any(Notification.class)); - } + verify(nRepo).deleteByTypeAndFromUser(NotificationType.ACTIVITY_REDEEM, user); + verify(nRepo).save(any(Notification.class)); + } - @Test - void createPointRedeemNotificationsDeletesOldOnes() { - NotificationRepository nRepo = mock(NotificationRepository.class); - UserRepository uRepo = mock(UserRepository.class); - ReactionRepository rRepo = mock(ReactionRepository.class); - EmailSender email = mock(EmailSender.class); - PushNotificationService push = mock(PushNotificationService.class); - Executor executor = Runnable::run; - NotificationService service = new NotificationService(nRepo, uRepo, email, push, rRepo, executor); - org.springframework.test.util.ReflectionTestUtils.setField(service, "websiteUrl", "https://ex.com"); + @Test + void createPointRedeemNotificationsDeletesOldOnes() { + NotificationRepository nRepo = mock(NotificationRepository.class); + UserRepository uRepo = mock(UserRepository.class); + ReactionRepository rRepo = mock(ReactionRepository.class); + EmailSender email = mock(EmailSender.class); + PushNotificationService push = mock(PushNotificationService.class); + Executor executor = Runnable::run; + NotificationService service = new NotificationService( + nRepo, + uRepo, + email, + push, + rRepo, + executor + ); + org.springframework.test.util.ReflectionTestUtils.setField( + service, + "websiteUrl", + "https://ex.com" + ); - User admin = new User(); - admin.setId(10L); - User user = new User(); - user.setId(20L); + User admin = new User(); + admin.setId(10L); + User user = new User(); + user.setId(20L); - when(uRepo.findByRole(Role.ADMIN)).thenReturn(List.of(admin)); + when(uRepo.findByRole(Role.ADMIN)).thenReturn(List.of(admin)); - service.createPointRedeemNotifications(user, "contact"); + service.createPointRedeemNotifications(user, "contact"); - verify(nRepo).deleteByTypeAndFromUser(NotificationType.POINT_REDEEM, user); - verify(nRepo).save(any(Notification.class)); - } + verify(nRepo).deleteByTypeAndFromUser(NotificationType.POINT_REDEEM, user); + verify(nRepo).save(any(Notification.class)); + } - @Test - void createNotificationSendsEmailForCommentReply() { - NotificationRepository nRepo = mock(NotificationRepository.class); - UserRepository uRepo = mock(UserRepository.class); - ReactionRepository rRepo = mock(ReactionRepository.class); - EmailSender email = mock(EmailSender.class); - PushNotificationService push = mock(PushNotificationService.class); - Executor executor = Runnable::run; - NotificationService service = new NotificationService(nRepo, uRepo, email, push, rRepo, executor); - org.springframework.test.util.ReflectionTestUtils.setField(service, "websiteUrl", "https://ex.com"); + @Test + void createNotificationSendsEmailForCommentReply() { + NotificationRepository nRepo = mock(NotificationRepository.class); + UserRepository uRepo = mock(UserRepository.class); + ReactionRepository rRepo = mock(ReactionRepository.class); + EmailSender email = mock(EmailSender.class); + PushNotificationService push = mock(PushNotificationService.class); + Executor executor = Runnable::run; + NotificationService service = new NotificationService( + nRepo, + uRepo, + email, + push, + rRepo, + executor + ); + org.springframework.test.util.ReflectionTestUtils.setField( + service, + "websiteUrl", + "https://ex.com" + ); - User user = new User(); - user.setEmail("a@a.com"); - Post post = new Post(); - post.setId(1L); - Comment comment = new Comment(); - comment.setId(2L); - when(nRepo.save(any(Notification.class))).thenAnswer(i -> i.getArgument(0)); + User user = new User(); + user.setEmail("a@a.com"); + Post post = new Post(); + post.setId(1L); + Comment comment = new Comment(); + comment.setId(2L); + when(nRepo.save(any(Notification.class))).thenAnswer(i -> i.getArgument(0)); - service.createNotification(user, NotificationType.COMMENT_REPLY, post, comment, null, null, null, null); + service.createNotification( + user, + NotificationType.COMMENT_REPLY, + post, + comment, + null, + null, + null, + null + ); - verify(email).sendEmail("a@a.com", "有人回复了你", "https://ex.com/posts/1#comment-2"); - verify(push).sendNotification(eq(user), contains("/posts/1#comment-2")); - } + verify(email).sendEmail("a@a.com", "有人回复了你", "https://ex.com/posts/1#comment-2"); + verify(push).sendNotification(eq(user), contains("/posts/1#comment-2")); + } - @Test - void postViewedNotificationDeletesOldOnes() { - NotificationRepository nRepo = mock(NotificationRepository.class); - UserRepository uRepo = mock(UserRepository.class); - ReactionRepository rRepo = mock(ReactionRepository.class); - EmailSender email = mock(EmailSender.class); - PushNotificationService push = mock(PushNotificationService.class); - Executor executor = Runnable::run; - NotificationService service = new NotificationService(nRepo, uRepo, email, push, rRepo, executor); - org.springframework.test.util.ReflectionTestUtils.setField(service, "websiteUrl", "https://ex.com"); + @Test + void postViewedNotificationDeletesOldOnes() { + NotificationRepository nRepo = mock(NotificationRepository.class); + UserRepository uRepo = mock(UserRepository.class); + ReactionRepository rRepo = mock(ReactionRepository.class); + EmailSender email = mock(EmailSender.class); + PushNotificationService push = mock(PushNotificationService.class); + Executor executor = Runnable::run; + NotificationService service = new NotificationService( + nRepo, + uRepo, + email, + push, + rRepo, + executor + ); + org.springframework.test.util.ReflectionTestUtils.setField( + service, + "websiteUrl", + "https://ex.com" + ); - User owner = new User(); - User viewer = new User(); - Post post = new Post(); + User owner = new User(); + User viewer = new User(); + Post post = new Post(); - when(nRepo.save(any(Notification.class))).thenAnswer(i -> i.getArgument(0)); + when(nRepo.save(any(Notification.class))).thenAnswer(i -> i.getArgument(0)); - service.createNotification(owner, NotificationType.POST_VIEWED, post, null, null, viewer, null, null); + service.createNotification( + owner, + NotificationType.POST_VIEWED, + post, + null, + null, + viewer, + null, + null + ); - verify(nRepo).deleteByTypeAndFromUserAndPost(NotificationType.POST_VIEWED, viewer, post); - verify(nRepo).save(any(Notification.class)); - } + verify(nRepo).deleteByTypeAndFromUserAndPost(NotificationType.POST_VIEWED, viewer, post); + verify(nRepo).save(any(Notification.class)); + } } diff --git a/backend/src/test/java/com/openisle/service/PasswordValidatorTest.java b/backend/src/test/java/com/openisle/service/PasswordValidatorTest.java index 5b8a6d911..1998b42b9 100644 --- a/backend/src/test/java/com/openisle/service/PasswordValidatorTest.java +++ b/backend/src/test/java/com/openisle/service/PasswordValidatorTest.java @@ -1,40 +1,40 @@ package com.openisle.service; -import com.openisle.model.PasswordStrength; -import com.openisle.exception.FieldException; -import org.junit.jupiter.api.Test; - import static org.junit.jupiter.api.Assertions.*; +import com.openisle.exception.FieldException; +import com.openisle.model.PasswordStrength; +import org.junit.jupiter.api.Test; + class PasswordValidatorTest { - @Test - void lowStrengthRequiresSixChars() { - PasswordValidator validator = new PasswordValidator(PasswordStrength.LOW); + @Test + void lowStrengthRequiresSixChars() { + PasswordValidator validator = new PasswordValidator(PasswordStrength.LOW); - assertThrows(FieldException.class, () -> validator.validate("12345")); - assertDoesNotThrow(() -> validator.validate("123456")); - } + assertThrows(FieldException.class, () -> validator.validate("12345")); + assertDoesNotThrow(() -> validator.validate("123456")); + } - @Test - void mediumStrengthRules() { - PasswordValidator validator = new PasswordValidator(PasswordStrength.MEDIUM); + @Test + void mediumStrengthRules() { + PasswordValidator validator = new PasswordValidator(PasswordStrength.MEDIUM); - assertThrows(FieldException.class, () -> validator.validate("abc123")); - assertThrows(FieldException.class, () -> validator.validate("abcdefgh")); - assertThrows(FieldException.class, () -> validator.validate("12345678")); - assertDoesNotThrow(() -> validator.validate("abcd1234")); - } + assertThrows(FieldException.class, () -> validator.validate("abc123")); + assertThrows(FieldException.class, () -> validator.validate("abcdefgh")); + assertThrows(FieldException.class, () -> validator.validate("12345678")); + assertDoesNotThrow(() -> validator.validate("abcd1234")); + } - @Test - void highStrengthRules() { - PasswordValidator validator = new PasswordValidator(PasswordStrength.HIGH); + @Test + void highStrengthRules() { + PasswordValidator validator = new PasswordValidator(PasswordStrength.HIGH); - assertThrows(FieldException.class, () -> validator.validate("Abc123$")); - assertThrows(FieldException.class, () -> validator.validate("abcd1234$xyz")); - assertThrows(FieldException.class, () -> validator.validate("ABCD1234$XYZ")); - assertThrows(FieldException.class, () -> validator.validate("AbcdABCDabcd")); - assertThrows(FieldException.class, () -> validator.validate("Abcd1234abcd")); - assertDoesNotThrow(() -> validator.validate("Abcd1234$xyz")); - } + assertThrows(FieldException.class, () -> validator.validate("Abc123$")); + assertThrows(FieldException.class, () -> validator.validate("abcd1234$xyz")); + assertThrows(FieldException.class, () -> validator.validate("ABCD1234$XYZ")); + assertThrows(FieldException.class, () -> validator.validate("AbcdABCDabcd")); + assertThrows(FieldException.class, () -> validator.validate("Abcd1234abcd")); + assertDoesNotThrow(() -> validator.validate("Abcd1234$xyz")); + } } diff --git a/backend/src/test/java/com/openisle/service/PointServiceRecalculateUserPointsTest.java b/backend/src/test/java/com/openisle/service/PointServiceRecalculateUserPointsTest.java index a06d4501e..9e6f0c540 100644 --- a/backend/src/test/java/com/openisle/service/PointServiceRecalculateUserPointsTest.java +++ b/backend/src/test/java/com/openisle/service/PointServiceRecalculateUserPointsTest.java @@ -1,67 +1,66 @@ package com.openisle.service; +import static org.junit.jupiter.api.Assertions.assertEquals; + import com.openisle.model.PointHistory; import com.openisle.model.PointHistoryType; import com.openisle.model.Role; import com.openisle.model.User; import com.openisle.repository.PointHistoryRepository; import com.openisle.repository.UserRepository; +import java.time.LocalDateTime; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.context.annotation.Import; -import java.time.LocalDateTime; - -import static org.junit.jupiter.api.Assertions.assertEquals; - @DataJpaTest @Import(PointService.class) class PointServiceRecalculateUserPointsTest { - @Autowired - private PointService pointService; + @Autowired + private PointService pointService; - @Autowired - private UserRepository userRepository; + @Autowired + private UserRepository userRepository; - @Autowired - private PointHistoryRepository pointHistoryRepository; + @Autowired + private PointHistoryRepository pointHistoryRepository; - @Test - void recalculatesBalanceAfterDeletion() { - User user = new User(); - user.setUsername("u"); - user.setEmail("u@example.com"); - user.setPassword("p"); - user.setRole(Role.USER); - userRepository.save(user); + @Test + void recalculatesBalanceAfterDeletion() { + User user = new User(); + user.setUsername("u"); + user.setEmail("u@example.com"); + user.setPassword("p"); + user.setRole(Role.USER); + userRepository.save(user); - PointHistory h1 = new PointHistory(); - h1.setUser(user); - h1.setType(PointHistoryType.POST); - h1.setAmount(30); - h1.setBalance(30); - h1.setCreatedAt(LocalDateTime.now().minusMinutes(2)); - pointHistoryRepository.save(h1); + PointHistory h1 = new PointHistory(); + h1.setUser(user); + h1.setType(PointHistoryType.POST); + h1.setAmount(30); + h1.setBalance(30); + h1.setCreatedAt(LocalDateTime.now().minusMinutes(2)); + pointHistoryRepository.save(h1); - PointHistory h2 = new PointHistory(); - h2.setUser(user); - h2.setType(PointHistoryType.COMMENT); - h2.setAmount(10); - h2.setBalance(40); - h2.setCreatedAt(LocalDateTime.now().minusMinutes(1)); - pointHistoryRepository.save(h2); + PointHistory h2 = new PointHistory(); + h2.setUser(user); + h2.setType(PointHistoryType.COMMENT); + h2.setAmount(10); + h2.setBalance(40); + h2.setCreatedAt(LocalDateTime.now().minusMinutes(1)); + pointHistoryRepository.save(h2); - user.setPoint(40); - userRepository.save(user); + user.setPoint(40); + userRepository.save(user); - pointHistoryRepository.delete(h1); + pointHistoryRepository.delete(h1); - int total = pointService.recalculateUserPoints(user); + int total = pointService.recalculateUserPoints(user); - assertEquals(10, total); - assertEquals(10, userRepository.findById(user.getId()).orElseThrow().getPoint()); - assertEquals(10, pointHistoryRepository.findById(h2.getId()).orElseThrow().getBalance()); - } + assertEquals(10, total); + assertEquals(10, userRepository.findById(user.getId()).orElseThrow().getPoint()); + assertEquals(10, pointHistoryRepository.findById(h2.getId()).orElseThrow().getBalance()); + } } diff --git a/backend/src/test/java/com/openisle/service/PostCommentStatsTest.java b/backend/src/test/java/com/openisle/service/PostCommentStatsTest.java index 124c0d280..cd45485f5 100644 --- a/backend/src/test/java/com/openisle/service/PostCommentStatsTest.java +++ b/backend/src/test/java/com/openisle/service/PostCommentStatsTest.java @@ -1,5 +1,7 @@ package com.openisle.service; +import static org.junit.jupiter.api.Assertions.*; + import com.openisle.model.*; import com.openisle.repository.*; import org.junit.jupiter.api.Test; @@ -8,83 +10,81 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.TestPropertySource; import org.springframework.transaction.annotation.Transactional; -import static org.junit.jupiter.api.Assertions.*; - @SpringBootTest @TestPropertySource(locations = "classpath:application.properties") @Transactional public class PostCommentStatsTest { - @Autowired - private PostRepository postRepository; + @Autowired + private PostRepository postRepository; - @Autowired - private UserRepository userRepository; + @Autowired + private UserRepository userRepository; - @Autowired - private CategoryRepository categoryRepository; + @Autowired + private CategoryRepository categoryRepository; - @Autowired - private TagRepository tagRepository; + @Autowired + private TagRepository tagRepository; - @Autowired - private CommentService commentService; + @Autowired + private CommentService commentService; - @Test - public void testPostCommentStatsUpdate() { - // Create test user - User user = new User(); - user.setUsername("testuser"); - user.setEmail("test@example.com"); - user.setPassword("hash"); - user = userRepository.save(user); + @Test + public void testPostCommentStatsUpdate() { + // Create test user + User user = new User(); + user.setUsername("testuser"); + user.setEmail("test@example.com"); + user.setPassword("hash"); + user = userRepository.save(user); - // Create test category - Category category = new Category(); - category.setName("Test Category"); - category.setDescription("Test Category Description"); - category.setIcon("test-icon"); - category = categoryRepository.save(category); + // Create test category + Category category = new Category(); + category.setName("Test Category"); + category.setDescription("Test Category Description"); + category.setIcon("test-icon"); + category = categoryRepository.save(category); - // Create test tag - Tag tag = new Tag(); - tag.setName("Test Tag"); - tag.setDescription("Test Tag Description"); - tag.setIcon("test-tag-icon"); - tag = tagRepository.save(tag); + // Create test tag + Tag tag = new Tag(); + tag.setName("Test Tag"); + tag.setDescription("Test Tag Description"); + tag.setIcon("test-tag-icon"); + tag = tagRepository.save(tag); - // Create test post - Post post = new Post(); - post.setTitle("Test Post"); - post.setContent("Test content"); - post.setAuthor(user); - post.setCategory(category); - post.getTags().add(tag); - post.setStatus(PostStatus.PUBLISHED); - post.setCommentCount(0L); - post = postRepository.save(post); + // Create test post + Post post = new Post(); + post.setTitle("Test Post"); + post.setContent("Test content"); + post.setAuthor(user); + post.setCategory(category); + post.getTags().add(tag); + post.setStatus(PostStatus.PUBLISHED); + post.setCommentCount(0L); + post = postRepository.save(post); - // Verify initial state - assertEquals(0L, post.getCommentCount()); - assertNull(post.getLastReplyAt()); + // Verify initial state + assertEquals(0L, post.getCommentCount()); + assertNull(post.getLastReplyAt()); - // Add a comment - commentService.addComment("testuser", post.getId(), "Test comment"); + // Add a comment + commentService.addComment("testuser", post.getId(), "Test comment"); - // Refresh post from database - post = postRepository.findById(post.getId()).orElseThrow(); + // Refresh post from database + post = postRepository.findById(post.getId()).orElseThrow(); - // Verify comment count and last reply time are updated - assertEquals(1L, post.getCommentCount()); - assertNotNull(post.getLastReplyAt()); + // Verify comment count and last reply time are updated + assertEquals(1L, post.getCommentCount()); + assertNotNull(post.getLastReplyAt()); - // Add another comment - commentService.addComment("testuser", post.getId(), "Another comment"); + // Add another comment + commentService.addComment("testuser", post.getId(), "Another comment"); - // Refresh post again - post = postRepository.findById(post.getId()).orElseThrow(); + // Refresh post again + post = postRepository.findById(post.getId()).orElseThrow(); - // Verify comment count is updated - assertEquals(2L, post.getCommentCount()); - } + // Verify comment count is updated + assertEquals(2L, post.getCommentCount()); + } } diff --git a/backend/src/test/java/com/openisle/service/PostServiceTest.java b/backend/src/test/java/com/openisle/service/PostServiceTest.java index 196d3042a..6d9a220b6 100644 --- a/backend/src/test/java/com/openisle/service/PostServiceTest.java +++ b/backend/src/test/java/com/openisle/service/PostServiceTest.java @@ -1,300 +1,444 @@ package com.openisle.service; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.openisle.exception.RateLimitException; import com.openisle.model.*; import com.openisle.repository.*; -import com.openisle.exception.RateLimitException; -import org.junit.jupiter.api.Test; -import org.springframework.scheduling.TaskScheduler; -import org.springframework.context.ApplicationContext; -import org.springframework.data.redis.core.RedisTemplate; - -import static org.junit.jupiter.api.Assertions.*; - import java.time.LocalDateTime; import java.util.List; import java.util.Optional; - +import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; - -import static org.mockito.Mockito.*; +import org.springframework.context.ApplicationContext; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.scheduling.TaskScheduler; class PostServiceTest { - @Test - void deletePostRemovesReads() { - PostRepository postRepo = mock(PostRepository.class); - UserRepository userRepo = mock(UserRepository.class); - CategoryRepository catRepo = mock(CategoryRepository.class); - TagRepository tagRepo = mock(TagRepository.class); - LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class); - PollPostRepository pollPostRepo = mock(PollPostRepository.class); - PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class); - NotificationService notifService = mock(NotificationService.class); - SubscriptionService subService = mock(SubscriptionService.class); - CommentService commentService = mock(CommentService.class); - CommentRepository commentRepo = mock(CommentRepository.class); - ReactionRepository reactionRepo = mock(ReactionRepository.class); - PostSubscriptionRepository subRepo = mock(PostSubscriptionRepository.class); - NotificationRepository notificationRepo = mock(NotificationRepository.class); - PostReadService postReadService = mock(PostReadService.class); - ImageUploader imageUploader = mock(ImageUploader.class); - TaskScheduler taskScheduler = mock(TaskScheduler.class); - EmailSender emailSender = mock(EmailSender.class); - ApplicationContext context = mock(ApplicationContext.class); - PointService pointService = mock(PointService.class); - PostChangeLogService postChangeLogService = mock(PostChangeLogService.class); - PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class); - RedisTemplate redisTemplate = mock(RedisTemplate.class); - PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo, - pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo, - reactionRepo, subRepo, notificationRepo, postReadService, - imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, - pointHistoryRepository, PublishMode.DIRECT, redisTemplate); - when(context.getBean(PostService.class)).thenReturn(service); + @Test + void deletePostRemovesReads() { + PostRepository postRepo = mock(PostRepository.class); + UserRepository userRepo = mock(UserRepository.class); + CategoryRepository catRepo = mock(CategoryRepository.class); + TagRepository tagRepo = mock(TagRepository.class); + LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class); + PollPostRepository pollPostRepo = mock(PollPostRepository.class); + PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class); + NotificationService notifService = mock(NotificationService.class); + SubscriptionService subService = mock(SubscriptionService.class); + CommentService commentService = mock(CommentService.class); + CommentRepository commentRepo = mock(CommentRepository.class); + ReactionRepository reactionRepo = mock(ReactionRepository.class); + PostSubscriptionRepository subRepo = mock(PostSubscriptionRepository.class); + NotificationRepository notificationRepo = mock(NotificationRepository.class); + PostReadService postReadService = mock(PostReadService.class); + ImageUploader imageUploader = mock(ImageUploader.class); + TaskScheduler taskScheduler = mock(TaskScheduler.class); + EmailSender emailSender = mock(EmailSender.class); + ApplicationContext context = mock(ApplicationContext.class); + PointService pointService = mock(PointService.class); + PostChangeLogService postChangeLogService = mock(PostChangeLogService.class); + PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class); + RedisTemplate redisTemplate = mock(RedisTemplate.class); - Post post = new Post(); - post.setId(1L); - User author = new User(); - author.setId(1L); - author.setRole(Role.USER); - post.setAuthor(author); + PostService service = new PostService( + postRepo, + userRepo, + catRepo, + tagRepo, + lotteryRepo, + pollPostRepo, + pollVoteRepo, + notifService, + subService, + commentService, + commentRepo, + reactionRepo, + subRepo, + notificationRepo, + postReadService, + imageUploader, + taskScheduler, + emailSender, + context, + pointService, + postChangeLogService, + pointHistoryRepository, + PublishMode.DIRECT, + redisTemplate + ); + when(context.getBean(PostService.class)).thenReturn(service); - when(postRepo.findById(1L)).thenReturn(Optional.of(post)); - when(userRepo.findByUsername("alice")).thenReturn(Optional.of(author)); - when(commentRepo.findByPostAndParentIsNullOrderByCreatedAtAsc(post)).thenReturn(List.of()); - when(reactionRepo.findByPost(post)).thenReturn(List.of()); - when(subRepo.findByPost(post)).thenReturn(List.of()); - when(notificationRepo.findByPost(post)).thenReturn(List.of()); - when(pointHistoryRepository.findByPost(post)).thenReturn(List.of()); + Post post = new Post(); + post.setId(1L); + User author = new User(); + author.setId(1L); + author.setRole(Role.USER); + post.setAuthor(author); - service.deletePost(1L, "alice"); + when(postRepo.findById(1L)).thenReturn(Optional.of(post)); + when(userRepo.findByUsername("alice")).thenReturn(Optional.of(author)); + when(commentRepo.findByPostAndParentIsNullOrderByCreatedAtAsc(post)).thenReturn(List.of()); + when(reactionRepo.findByPost(post)).thenReturn(List.of()); + when(subRepo.findByPost(post)).thenReturn(List.of()); + when(notificationRepo.findByPost(post)).thenReturn(List.of()); + when(pointHistoryRepository.findByPost(post)).thenReturn(List.of()); - verify(postReadService).deleteByPost(post); - verify(postRepo).delete(post); - verify(postChangeLogService).deleteLogsForPost(post); - } + service.deletePost(1L, "alice"); - @Test - void deletePostByAdminNotifiesAuthor() { - PostRepository postRepo = mock(PostRepository.class); - UserRepository userRepo = mock(UserRepository.class); - CategoryRepository catRepo = mock(CategoryRepository.class); - TagRepository tagRepo = mock(TagRepository.class); - LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class); - PollPostRepository pollPostRepo = mock(PollPostRepository.class); - PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class); - NotificationService notifService = mock(NotificationService.class); - SubscriptionService subService = mock(SubscriptionService.class); - CommentService commentService = mock(CommentService.class); - CommentRepository commentRepo = mock(CommentRepository.class); - ReactionRepository reactionRepo = mock(ReactionRepository.class); - PostSubscriptionRepository subRepo = mock(PostSubscriptionRepository.class); - NotificationRepository notificationRepo = mock(NotificationRepository.class); - PostReadService postReadService = mock(PostReadService.class); - ImageUploader imageUploader = mock(ImageUploader.class); - TaskScheduler taskScheduler = mock(TaskScheduler.class); - EmailSender emailSender = mock(EmailSender.class); - ApplicationContext context = mock(ApplicationContext.class); - PointService pointService = mock(PointService.class); - PostChangeLogService postChangeLogService = mock(PostChangeLogService.class); - PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class); - RedisTemplate redisTemplate = mock(RedisTemplate.class); + verify(postReadService).deleteByPost(post); + verify(postRepo).delete(post); + verify(postChangeLogService).deleteLogsForPost(post); + } - PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo, - pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo, - reactionRepo, subRepo, notificationRepo, postReadService, - imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, - pointHistoryRepository, PublishMode.DIRECT, redisTemplate); - when(context.getBean(PostService.class)).thenReturn(service); + @Test + void deletePostByAdminNotifiesAuthor() { + PostRepository postRepo = mock(PostRepository.class); + UserRepository userRepo = mock(UserRepository.class); + CategoryRepository catRepo = mock(CategoryRepository.class); + TagRepository tagRepo = mock(TagRepository.class); + LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class); + PollPostRepository pollPostRepo = mock(PollPostRepository.class); + PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class); + NotificationService notifService = mock(NotificationService.class); + SubscriptionService subService = mock(SubscriptionService.class); + CommentService commentService = mock(CommentService.class); + CommentRepository commentRepo = mock(CommentRepository.class); + ReactionRepository reactionRepo = mock(ReactionRepository.class); + PostSubscriptionRepository subRepo = mock(PostSubscriptionRepository.class); + NotificationRepository notificationRepo = mock(NotificationRepository.class); + PostReadService postReadService = mock(PostReadService.class); + ImageUploader imageUploader = mock(ImageUploader.class); + TaskScheduler taskScheduler = mock(TaskScheduler.class); + EmailSender emailSender = mock(EmailSender.class); + ApplicationContext context = mock(ApplicationContext.class); + PointService pointService = mock(PointService.class); + PostChangeLogService postChangeLogService = mock(PostChangeLogService.class); + PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class); + RedisTemplate redisTemplate = mock(RedisTemplate.class); - Post post = new Post(); - post.setId(1L); - post.setTitle("T"); - post.setContent(""); - User author = new User(); - author.setId(2L); - author.setRole(Role.USER); - post.setAuthor(author); + PostService service = new PostService( + postRepo, + userRepo, + catRepo, + tagRepo, + lotteryRepo, + pollPostRepo, + pollVoteRepo, + notifService, + subService, + commentService, + commentRepo, + reactionRepo, + subRepo, + notificationRepo, + postReadService, + imageUploader, + taskScheduler, + emailSender, + context, + pointService, + postChangeLogService, + pointHistoryRepository, + PublishMode.DIRECT, + redisTemplate + ); + when(context.getBean(PostService.class)).thenReturn(service); - User admin = new User(); - admin.setId(1L); - admin.setRole(Role.ADMIN); + Post post = new Post(); + post.setId(1L); + post.setTitle("T"); + post.setContent(""); + User author = new User(); + author.setId(2L); + author.setRole(Role.USER); + post.setAuthor(author); - when(postRepo.findById(1L)).thenReturn(Optional.of(post)); - when(userRepo.findByUsername("admin")).thenReturn(Optional.of(admin)); - when(commentRepo.findByPostAndParentIsNullOrderByCreatedAtAsc(post)).thenReturn(List.of()); - when(reactionRepo.findByPost(post)).thenReturn(List.of()); - when(subRepo.findByPost(post)).thenReturn(List.of()); - when(notificationRepo.findByPost(post)).thenReturn(List.of()); - when(pointHistoryRepository.findByPost(post)).thenReturn(List.of()); + User admin = new User(); + admin.setId(1L); + admin.setRole(Role.ADMIN); - service.deletePost(1L, "admin"); + when(postRepo.findById(1L)).thenReturn(Optional.of(post)); + when(userRepo.findByUsername("admin")).thenReturn(Optional.of(admin)); + when(commentRepo.findByPostAndParentIsNullOrderByCreatedAtAsc(post)).thenReturn(List.of()); + when(reactionRepo.findByPost(post)).thenReturn(List.of()); + when(subRepo.findByPost(post)).thenReturn(List.of()); + when(notificationRepo.findByPost(post)).thenReturn(List.of()); + when(pointHistoryRepository.findByPost(post)).thenReturn(List.of()); - verify(notifService).createNotification(eq(author), eq(NotificationType.POST_DELETED), isNull(), - isNull(), isNull(), eq(admin), isNull(), eq("T")); - } + service.deletePost(1L, "admin"); - @Test - void createPostRespectsRateLimit() { - PostRepository postRepo = mock(PostRepository.class); - UserRepository userRepo = mock(UserRepository.class); - CategoryRepository catRepo = mock(CategoryRepository.class); - TagRepository tagRepo = mock(TagRepository.class); - LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class); - PollPostRepository pollPostRepo = mock(PollPostRepository.class); - PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class); - NotificationService notifService = mock(NotificationService.class); - SubscriptionService subService = mock(SubscriptionService.class); - CommentService commentService = mock(CommentService.class); - CommentRepository commentRepo = mock(CommentRepository.class); - ReactionRepository reactionRepo = mock(ReactionRepository.class); - PostSubscriptionRepository subRepo = mock(PostSubscriptionRepository.class); - NotificationRepository notificationRepo = mock(NotificationRepository.class); - PostReadService postReadService = mock(PostReadService.class); - ImageUploader imageUploader = mock(ImageUploader.class); - TaskScheduler taskScheduler = mock(TaskScheduler.class); - EmailSender emailSender = mock(EmailSender.class); - ApplicationContext context = mock(ApplicationContext.class); - PointService pointService = mock(PointService.class); - PostChangeLogService postChangeLogService = mock(PostChangeLogService.class); - PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class); - RedisTemplate redisTemplate = mock(RedisTemplate.class); + verify(notifService).createNotification( + eq(author), + eq(NotificationType.POST_DELETED), + isNull(), + isNull(), + isNull(), + eq(admin), + isNull(), + eq("T") + ); + } - PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo, - pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo, - reactionRepo, subRepo, notificationRepo, postReadService, - imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, - pointHistoryRepository, PublishMode.DIRECT, redisTemplate); - when(context.getBean(PostService.class)).thenReturn(service); + @Test + void createPostRespectsRateLimit() { + PostRepository postRepo = mock(PostRepository.class); + UserRepository userRepo = mock(UserRepository.class); + CategoryRepository catRepo = mock(CategoryRepository.class); + TagRepository tagRepo = mock(TagRepository.class); + LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class); + PollPostRepository pollPostRepo = mock(PollPostRepository.class); + PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class); + NotificationService notifService = mock(NotificationService.class); + SubscriptionService subService = mock(SubscriptionService.class); + CommentService commentService = mock(CommentService.class); + CommentRepository commentRepo = mock(CommentRepository.class); + ReactionRepository reactionRepo = mock(ReactionRepository.class); + PostSubscriptionRepository subRepo = mock(PostSubscriptionRepository.class); + NotificationRepository notificationRepo = mock(NotificationRepository.class); + PostReadService postReadService = mock(PostReadService.class); + ImageUploader imageUploader = mock(ImageUploader.class); + TaskScheduler taskScheduler = mock(TaskScheduler.class); + EmailSender emailSender = mock(EmailSender.class); + ApplicationContext context = mock(ApplicationContext.class); + PointService pointService = mock(PointService.class); + PostChangeLogService postChangeLogService = mock(PostChangeLogService.class); + PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class); + RedisTemplate redisTemplate = mock(RedisTemplate.class); - when(postRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(1L); + PostService service = new PostService( + postRepo, + userRepo, + catRepo, + tagRepo, + lotteryRepo, + pollPostRepo, + pollVoteRepo, + notifService, + subService, + commentService, + commentRepo, + reactionRepo, + subRepo, + notificationRepo, + postReadService, + imageUploader, + taskScheduler, + emailSender, + context, + pointService, + postChangeLogService, + pointHistoryRepository, + PublishMode.DIRECT, + redisTemplate + ); + when(context.getBean(PostService.class)).thenReturn(service); - assertThrows(RateLimitException.class, - () -> service.createPost("alice", 1L, "t", "c", List.of(1L), - null, null, null, null, null, null, null, null, null)); - } + when(postRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(1L); - @Test - void deletePostRemovesPointHistoriesAndRecalculatesPoints() { - PostRepository postRepo = mock(PostRepository.class); - UserRepository userRepo = mock(UserRepository.class); - CategoryRepository catRepo = mock(CategoryRepository.class); - TagRepository tagRepo = mock(TagRepository.class); - LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class); - PollPostRepository pollPostRepo = mock(PollPostRepository.class); - PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class); - NotificationService notifService = mock(NotificationService.class); - SubscriptionService subService = mock(SubscriptionService.class); - CommentService commentService = mock(CommentService.class); - CommentRepository commentRepo = mock(CommentRepository.class); - ReactionRepository reactionRepo = mock(ReactionRepository.class); - PostSubscriptionRepository subRepo = mock(PostSubscriptionRepository.class); - NotificationRepository notificationRepo = mock(NotificationRepository.class); - PostReadService postReadService = mock(PostReadService.class); - ImageUploader imageUploader = mock(ImageUploader.class); - TaskScheduler taskScheduler = mock(TaskScheduler.class); - EmailSender emailSender = mock(EmailSender.class); - ApplicationContext context = mock(ApplicationContext.class); - PointService pointService = mock(PointService.class); - PostChangeLogService postChangeLogService = mock(PostChangeLogService.class); - PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class); - RedisTemplate redisTemplate = mock(RedisTemplate.class); + assertThrows(RateLimitException.class, () -> + service.createPost( + "alice", + 1L, + "t", + "c", + List.of(1L), + null, + null, + null, + null, + null, + null, + null, + null, + null + ) + ); + } - PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo, - pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo, - reactionRepo, subRepo, notificationRepo, postReadService, - imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, - pointHistoryRepository, PublishMode.DIRECT, redisTemplate); - when(context.getBean(PostService.class)).thenReturn(service); + @Test + void deletePostRemovesPointHistoriesAndRecalculatesPoints() { + PostRepository postRepo = mock(PostRepository.class); + UserRepository userRepo = mock(UserRepository.class); + CategoryRepository catRepo = mock(CategoryRepository.class); + TagRepository tagRepo = mock(TagRepository.class); + LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class); + PollPostRepository pollPostRepo = mock(PollPostRepository.class); + PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class); + NotificationService notifService = mock(NotificationService.class); + SubscriptionService subService = mock(SubscriptionService.class); + CommentService commentService = mock(CommentService.class); + CommentRepository commentRepo = mock(CommentRepository.class); + ReactionRepository reactionRepo = mock(ReactionRepository.class); + PostSubscriptionRepository subRepo = mock(PostSubscriptionRepository.class); + NotificationRepository notificationRepo = mock(NotificationRepository.class); + PostReadService postReadService = mock(PostReadService.class); + ImageUploader imageUploader = mock(ImageUploader.class); + TaskScheduler taskScheduler = mock(TaskScheduler.class); + EmailSender emailSender = mock(EmailSender.class); + ApplicationContext context = mock(ApplicationContext.class); + PointService pointService = mock(PointService.class); + PostChangeLogService postChangeLogService = mock(PostChangeLogService.class); + PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class); + RedisTemplate redisTemplate = mock(RedisTemplate.class); - Post post = new Post(); - post.setId(10L); - User author = new User(); - author.setId(20L); - author.setRole(Role.USER); - post.setAuthor(author); + PostService service = new PostService( + postRepo, + userRepo, + catRepo, + tagRepo, + lotteryRepo, + pollPostRepo, + pollVoteRepo, + notifService, + subService, + commentService, + commentRepo, + reactionRepo, + subRepo, + notificationRepo, + postReadService, + imageUploader, + taskScheduler, + emailSender, + context, + pointService, + postChangeLogService, + pointHistoryRepository, + PublishMode.DIRECT, + redisTemplate + ); + when(context.getBean(PostService.class)).thenReturn(service); - User historyUser = new User(); - historyUser.setId(30L); + Post post = new Post(); + post.setId(10L); + User author = new User(); + author.setId(20L); + author.setRole(Role.USER); + post.setAuthor(author); - PointHistory history = new PointHistory(); - history.setUser(historyUser); - history.setPost(post); + User historyUser = new User(); + historyUser.setId(30L); - when(postRepo.findById(10L)).thenReturn(Optional.of(post)); - when(userRepo.findByUsername("author")).thenReturn(Optional.of(author)); - when(commentRepo.findByPostAndParentIsNullOrderByCreatedAtAsc(post)).thenReturn(List.of()); - when(reactionRepo.findByPost(post)).thenReturn(List.of()); - when(subRepo.findByPost(post)).thenReturn(List.of()); - when(notificationRepo.findByPost(post)).thenReturn(List.of()); - when(pointHistoryRepository.findByPost(post)).thenReturn(List.of(history)); - when(pointService.recalculateUserPoints(historyUser)).thenReturn(0); + PointHistory history = new PointHistory(); + history.setUser(historyUser); + history.setPost(post); - service.deletePost(10L, "author"); + when(postRepo.findById(10L)).thenReturn(Optional.of(post)); + when(userRepo.findByUsername("author")).thenReturn(Optional.of(author)); + when(commentRepo.findByPostAndParentIsNullOrderByCreatedAtAsc(post)).thenReturn(List.of()); + when(reactionRepo.findByPost(post)).thenReturn(List.of()); + when(subRepo.findByPost(post)).thenReturn(List.of()); + when(notificationRepo.findByPost(post)).thenReturn(List.of()); + when(pointHistoryRepository.findByPost(post)).thenReturn(List.of(history)); + when(pointService.recalculateUserPoints(historyUser)).thenReturn(0); - ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); - verify(pointHistoryRepository).saveAll(captor.capture()); - List savedHistories = captor.getValue(); - assertEquals(1, savedHistories.size()); - PointHistory savedHistory = savedHistories.get(0); - assertNull(savedHistory.getPost()); - assertNotNull(savedHistory.getDeletedAt()); - assertTrue(savedHistory.getDeletedAt().isBefore(LocalDateTime.now().plusSeconds(1))); + service.deletePost(10L, "author"); - verify(pointService).recalculateUserPoints(historyUser); - verify(userRepo).saveAll(any()); - } + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(pointHistoryRepository).saveAll(captor.capture()); + List savedHistories = captor.getValue(); + assertEquals(1, savedHistories.size()); + PointHistory savedHistory = savedHistories.get(0); + assertNull(savedHistory.getPost()); + assertNotNull(savedHistory.getDeletedAt()); + assertTrue(savedHistory.getDeletedAt().isBefore(LocalDateTime.now().plusSeconds(1))); - @Test - void finalizeLotteryNotifiesAuthor() { - PostRepository postRepo = mock(PostRepository.class); - UserRepository userRepo = mock(UserRepository.class); - CategoryRepository catRepo = mock(CategoryRepository.class); - TagRepository tagRepo = mock(TagRepository.class); - LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class); - PollPostRepository pollPostRepo = mock(PollPostRepository.class); - PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class); - NotificationService notifService = mock(NotificationService.class); - SubscriptionService subService = mock(SubscriptionService.class); - CommentService commentService = mock(CommentService.class); - CommentRepository commentRepo = mock(CommentRepository.class); - ReactionRepository reactionRepo = mock(ReactionRepository.class); - PostSubscriptionRepository subRepo = mock(PostSubscriptionRepository.class); - NotificationRepository notificationRepo = mock(NotificationRepository.class); - PostReadService postReadService = mock(PostReadService.class); - ImageUploader imageUploader = mock(ImageUploader.class); - TaskScheduler taskScheduler = mock(TaskScheduler.class); - EmailSender emailSender = mock(EmailSender.class); - ApplicationContext context = mock(ApplicationContext.class); - PointService pointService = mock(PointService.class); - PostChangeLogService postChangeLogService = mock(PostChangeLogService.class); - PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class); - RedisTemplate redisTemplate = mock(RedisTemplate.class); + verify(pointService).recalculateUserPoints(historyUser); + verify(userRepo).saveAll(any()); + } - PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo, - pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo, - reactionRepo, subRepo, notificationRepo, postReadService, - imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, - pointHistoryRepository, PublishMode.DIRECT, redisTemplate); - when(context.getBean(PostService.class)).thenReturn(service); + @Test + void finalizeLotteryNotifiesAuthor() { + PostRepository postRepo = mock(PostRepository.class); + UserRepository userRepo = mock(UserRepository.class); + CategoryRepository catRepo = mock(CategoryRepository.class); + TagRepository tagRepo = mock(TagRepository.class); + LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class); + PollPostRepository pollPostRepo = mock(PollPostRepository.class); + PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class); + NotificationService notifService = mock(NotificationService.class); + SubscriptionService subService = mock(SubscriptionService.class); + CommentService commentService = mock(CommentService.class); + CommentRepository commentRepo = mock(CommentRepository.class); + ReactionRepository reactionRepo = mock(ReactionRepository.class); + PostSubscriptionRepository subRepo = mock(PostSubscriptionRepository.class); + NotificationRepository notificationRepo = mock(NotificationRepository.class); + PostReadService postReadService = mock(PostReadService.class); + ImageUploader imageUploader = mock(ImageUploader.class); + TaskScheduler taskScheduler = mock(TaskScheduler.class); + EmailSender emailSender = mock(EmailSender.class); + ApplicationContext context = mock(ApplicationContext.class); + PointService pointService = mock(PointService.class); + PostChangeLogService postChangeLogService = mock(PostChangeLogService.class); + PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class); + RedisTemplate redisTemplate = mock(RedisTemplate.class); - User author = new User(); - author.setId(1L); - User winner = new User(); - winner.setId(2L); + PostService service = new PostService( + postRepo, + userRepo, + catRepo, + tagRepo, + lotteryRepo, + pollPostRepo, + pollVoteRepo, + notifService, + subService, + commentService, + commentRepo, + reactionRepo, + subRepo, + notificationRepo, + postReadService, + imageUploader, + taskScheduler, + emailSender, + context, + pointService, + postChangeLogService, + pointHistoryRepository, + PublishMode.DIRECT, + redisTemplate + ); + when(context.getBean(PostService.class)).thenReturn(service); - LotteryPost lp = new LotteryPost(); - lp.setId(1L); - lp.setAuthor(author); - lp.setTitle("L"); - lp.setPrizeCount(1); - lp.getParticipants().add(winner); + User author = new User(); + author.setId(1L); + User winner = new User(); + winner.setId(2L); - when(lotteryRepo.findById(1L)).thenReturn(Optional.of(lp)); + LotteryPost lp = new LotteryPost(); + lp.setId(1L); + lp.setAuthor(author); + lp.setTitle("L"); + lp.setPrizeCount(1); + lp.getParticipants().add(winner); - service.finalizeLottery(1L); + when(lotteryRepo.findById(1L)).thenReturn(Optional.of(lp)); - verify(notifService).createNotification(eq(winner), eq(NotificationType.LOTTERY_WIN), eq(lp), isNull(), isNull(), eq(author), isNull(), isNull()); - verify(notifService).createNotification(eq(author), eq(NotificationType.LOTTERY_DRAW), eq(lp), isNull(), isNull(), isNull(), isNull(), isNull()); - } + service.finalizeLottery(1L); + + verify(notifService).createNotification( + eq(winner), + eq(NotificationType.LOTTERY_WIN), + eq(lp), + isNull(), + isNull(), + eq(author), + isNull(), + isNull() + ); + verify(notifService).createNotification( + eq(author), + eq(NotificationType.LOTTERY_DRAW), + eq(lp), + isNull(), + isNull(), + isNull(), + isNull(), + isNull() + ); + } } diff --git a/backend/src/test/java/com/openisle/service/ReactionServiceTest.java b/backend/src/test/java/com/openisle/service/ReactionServiceTest.java index 0c74a8f36..6adb53500 100644 --- a/backend/src/test/java/com/openisle/service/ReactionServiceTest.java +++ b/backend/src/test/java/com/openisle/service/ReactionServiceTest.java @@ -1,45 +1,59 @@ package com.openisle.service; -import com.openisle.model.*; -import com.openisle.repository.*; -import org.junit.jupiter.api.Test; - -import java.util.Optional; - import static org.mockito.Mockito.*; +import com.openisle.model.*; +import com.openisle.repository.*; +import java.util.Optional; +import org.junit.jupiter.api.Test; + class ReactionServiceTest { - @Test - void reactToPostSendsEmailEveryFive() { - ReactionRepository reactionRepo = mock(ReactionRepository.class); - UserRepository userRepo = mock(UserRepository.class); - PostRepository postRepo = mock(PostRepository.class); - CommentRepository commentRepo = mock(CommentRepository.class); - MessageRepository messageRepo = mock(MessageRepository.class); - NotificationService notif = mock(NotificationService.class); - EmailSender email = mock(EmailSender.class); - ReactionService service = new ReactionService(reactionRepo, userRepo, postRepo, commentRepo, messageRepo, notif, email); - org.springframework.test.util.ReflectionTestUtils.setField(service, "websiteUrl", "https://ex.com"); - User user = new User(); - user.setId(1L); - user.setUsername("bob"); - User author = new User(); - author.setId(2L); - author.setEmail("a@a.com"); - Post post = new Post(); - post.setId(3L); - post.setAuthor(author); + @Test + void reactToPostSendsEmailEveryFive() { + ReactionRepository reactionRepo = mock(ReactionRepository.class); + UserRepository userRepo = mock(UserRepository.class); + PostRepository postRepo = mock(PostRepository.class); + CommentRepository commentRepo = mock(CommentRepository.class); + MessageRepository messageRepo = mock(MessageRepository.class); + NotificationService notif = mock(NotificationService.class); + EmailSender email = mock(EmailSender.class); + ReactionService service = new ReactionService( + reactionRepo, + userRepo, + postRepo, + commentRepo, + messageRepo, + notif, + email + ); + org.springframework.test.util.ReflectionTestUtils.setField( + service, + "websiteUrl", + "https://ex.com" + ); - when(userRepo.findByUsername("bob")).thenReturn(Optional.of(user)); - when(postRepo.findById(3L)).thenReturn(Optional.of(post)); - when(reactionRepo.findByUserAndPostAndType(user, post, ReactionType.LIKE)).thenReturn(Optional.empty()); - when(reactionRepo.save(any(Reaction.class))).thenAnswer(i -> i.getArgument(0)); - when(reactionRepo.countReceived(author.getUsername())).thenReturn(5L); + User user = new User(); + user.setId(1L); + user.setUsername("bob"); + User author = new User(); + author.setId(2L); + author.setEmail("a@a.com"); + Post post = new Post(); + post.setId(3L); + post.setAuthor(author); - service.reactToPost("bob", 3L, ReactionType.LIKE); + when(userRepo.findByUsername("bob")).thenReturn(Optional.of(user)); + when(postRepo.findById(3L)).thenReturn(Optional.of(post)); + when(reactionRepo.findByUserAndPostAndType(user, post, ReactionType.LIKE)).thenReturn( + Optional.empty() + ); + when(reactionRepo.save(any(Reaction.class))).thenAnswer(i -> i.getArgument(0)); + when(reactionRepo.countReceived(author.getUsername())).thenReturn(5L); - verify(email).sendEmail("a@a.com", "你有新的互动", "https://ex.com/messages"); - verify(notif).sendCustomPush(author, "你有新的互动", "https://ex.com/messages"); - } + service.reactToPost("bob", 3L, ReactionType.LIKE); + + verify(email).sendEmail("a@a.com", "你有新的互动", "https://ex.com/messages"); + verify(notif).sendCustomPush(author, "你有新的互动", "https://ex.com/messages"); + } } diff --git a/backend/src/test/java/com/openisle/service/SearchServiceTest.java b/backend/src/test/java/com/openisle/service/SearchServiceTest.java index b4997fe5c..59027b64d 100644 --- a/backend/src/test/java/com/openisle/service/SearchServiceTest.java +++ b/backend/src/test/java/com/openisle/service/SearchServiceTest.java @@ -1,51 +1,66 @@ package com.openisle.service; -import com.openisle.model.Post; -import com.openisle.model.PostStatus; -import com.openisle.repository.CommentRepository; -import com.openisle.repository.PostRepository; -import com.openisle.repository.UserRepository; -import com.openisle.repository.CategoryRepository; -import com.openisle.repository.TagRepository; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.util.List; - import static org.junit.jupiter.api.Assertions.*; +import com.openisle.model.Post; +import com.openisle.model.PostStatus; +import com.openisle.repository.CategoryRepository; +import com.openisle.repository.CommentRepository; +import com.openisle.repository.PostRepository; +import com.openisle.repository.TagRepository; +import com.openisle.repository.UserRepository; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + class SearchServiceTest { - @Test - void globalSearchDeduplicatesPosts() { - UserRepository userRepo = Mockito.mock(UserRepository.class); - PostRepository postRepo = Mockito.mock(PostRepository.class); - CommentRepository commentRepo = Mockito.mock(CommentRepository.class); - CategoryRepository categoryRepo = Mockito.mock(CategoryRepository.class); - TagRepository tagRepo = Mockito.mock(TagRepository.class); - SearchService service = new SearchService(userRepo, postRepo, commentRepo, categoryRepo, tagRepo); + @Test + void globalSearchDeduplicatesPosts() { + UserRepository userRepo = Mockito.mock(UserRepository.class); + PostRepository postRepo = Mockito.mock(PostRepository.class); + CommentRepository commentRepo = Mockito.mock(CommentRepository.class); + CategoryRepository categoryRepo = Mockito.mock(CategoryRepository.class); + TagRepository tagRepo = Mockito.mock(TagRepository.class); + SearchService service = new SearchService( + userRepo, + postRepo, + commentRepo, + categoryRepo, + tagRepo + ); - Post post1 = new Post(); - post1.setId(1L); - post1.setTitle("hello"); - Post post2 = new Post(); - post2.setId(2L); - post2.setTitle("world"); + Post post1 = new Post(); + post1.setId(1L); + post1.setTitle("hello"); + Post post2 = new Post(); + post2.setId(2L); + post2.setTitle("world"); - Mockito.when(postRepo.findByTitleContainingIgnoreCaseOrContentContainingIgnoreCaseAndStatus( - Mockito.anyString(), Mockito.anyString(), Mockito.eq(PostStatus.PUBLISHED))) - .thenReturn(List.of(post1)); - Mockito.when(postRepo.findByTitleContainingIgnoreCaseAndStatus(Mockito.anyString(), Mockito.eq(PostStatus.PUBLISHED))) - .thenReturn(List.of(post1, post2)); - Mockito.when(commentRepo.findByContentContainingIgnoreCase(Mockito.anyString())) - .thenReturn(List.of()); - Mockito.when(userRepo.findByUsernameContainingIgnoreCase(Mockito.anyString())) - .thenReturn(List.of()); + Mockito.when( + postRepo.findByTitleContainingIgnoreCaseOrContentContainingIgnoreCaseAndStatus( + Mockito.anyString(), + Mockito.anyString(), + Mockito.eq(PostStatus.PUBLISHED) + ) + ).thenReturn(List.of(post1)); + Mockito.when( + postRepo.findByTitleContainingIgnoreCaseAndStatus( + Mockito.anyString(), + Mockito.eq(PostStatus.PUBLISHED) + ) + ).thenReturn(List.of(post1, post2)); + Mockito.when(commentRepo.findByContentContainingIgnoreCase(Mockito.anyString())).thenReturn( + List.of() + ); + Mockito.when(userRepo.findByUsernameContainingIgnoreCase(Mockito.anyString())).thenReturn( + List.of() + ); - List results = service.globalSearch("h"); + List results = service.globalSearch("h"); - assertEquals(2, results.size()); - assertEquals(1L, results.get(0).id()); - assertEquals(2L, results.get(1).id()); - } + assertEquals(2, results.size()); + assertEquals(1L, results.get(0).id()); + assertEquals(2L, results.get(1).id()); + } } diff --git a/backend/src/test/java/com/openisle/service/UsernameValidatorTest.java b/backend/src/test/java/com/openisle/service/UsernameValidatorTest.java index 55300ad56..8706c2c40 100644 --- a/backend/src/test/java/com/openisle/service/UsernameValidatorTest.java +++ b/backend/src/test/java/com/openisle/service/UsernameValidatorTest.java @@ -1,22 +1,22 @@ package com.openisle.service; +import static org.junit.jupiter.api.Assertions.*; + import com.openisle.exception.FieldException; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.*; - class UsernameValidatorTest { - @Test - void rejectsEmptyUsername() { - UsernameValidator validator = new UsernameValidator(); - assertThrows(FieldException.class, () -> validator.validate("")); - assertThrows(FieldException.class, () -> validator.validate(null)); - } + @Test + void rejectsEmptyUsername() { + UsernameValidator validator = new UsernameValidator(); + assertThrows(FieldException.class, () -> validator.validate("")); + assertThrows(FieldException.class, () -> validator.validate(null)); + } - @Test - void allowsShortUsername() { - UsernameValidator validator = new UsernameValidator(); - assertDoesNotThrow(() -> validator.validate("a")); - } + @Test + void allowsShortUsername() { + UsernameValidator validator = new UsernameValidator(); + assertDoesNotThrow(() -> validator.validate("a")); + } } diff --git a/docs/README.md b/docs/README.md index b6b4b3ea3..ddb160ffe 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,4 +1,5 @@ # OpenIsle Documentation + ## 要求 使用 [bun](https://bun.com/) 作为工具链的运行时,版本 1.2+。 diff --git a/docs/app/(home)/[[...slug]]/page.tsx b/docs/app/(home)/[[...slug]]/page.tsx index 2a24c0f1b..a4b4886e4 100644 --- a/docs/app/(home)/[[...slug]]/page.tsx +++ b/docs/app/(home)/[[...slug]]/page.tsx @@ -1,11 +1,16 @@ -import { source } from '@/lib/source'; -import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/page'; -import type { Metadata } from 'next'; -import { notFound } from 'next/navigation'; -import { createRelativeLink } from 'fumadocs-ui/mdx'; -import { getMDXComponents } from '@/mdx-components'; -import { Card, Cards } from 'fumadocs-ui/components/card'; -import { getPageTreePeers } from 'fumadocs-core/server'; +import { source } from "@/lib/source"; +import { + DocsBody, + DocsDescription, + DocsPage, + DocsTitle, +} from "fumadocs-ui/page"; +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { createRelativeLink } from "fumadocs-ui/mdx"; +import { getMDXComponents } from "@/mdx-components"; +import { Card, Cards } from "fumadocs-ui/components/card"; +import { getPageTreePeers } from "fumadocs-core/server"; function DocsCategory({ url }: { url: string }) { return ( @@ -19,7 +24,7 @@ function DocsCategory({ url }: { url: string }) { ); } -export default async function Page(props: PageProps<'/[[...slug]]'>) { +export default async function Page(props: PageProps<"/[[...slug]]">) { const params = await props.params; const page = source.getPage(params.slug); if (!page) notFound(); @@ -48,7 +53,7 @@ export async function generateStaticParams() { } export async function generateMetadata( - props: PageProps<'/[[...slug]]'> + props: PageProps<"/[[...slug]]">, ): Promise { const params = await props.params; const page = source.getPage(params.slug); diff --git a/docs/app/(home)/layout.tsx b/docs/app/(home)/layout.tsx index 5380d48a8..2af9aa31e 100644 --- a/docs/app/(home)/layout.tsx +++ b/docs/app/(home)/layout.tsx @@ -1,10 +1,10 @@ -import { DocsLayout } from 'fumadocs-ui/layouts/docs'; -import { baseOptions } from '@/lib/layout.shared'; -import { source } from '@/lib/source'; -import { CodeXmlIcon, CompassIcon, ServerIcon } from 'lucide-react'; +import { DocsLayout } from "fumadocs-ui/layouts/docs"; +import { baseOptions } from "@/lib/layout.shared"; +import { source } from "@/lib/source"; +import { CodeXmlIcon, CompassIcon, ServerIcon } from "lucide-react"; function TabIcon({ - color = 'var(--color-fd-foreground)', + color = "var(--color-fd-foreground)", children, }: { color?: string; @@ -15,7 +15,7 @@ function TabIcon({ className="[&_svg]:size-full rounded-lg size-full text-(--tab-color) max-md:bg-(--tab-color)/10 max-md:border max-md:p-1.5" style={ { - '--tab-color': color, + "--tab-color": color, } as React.CSSProperties } > @@ -28,7 +28,7 @@ function TabTitle({ children }: { children: React.ReactNode }) { return {children}; } -export default function Layout({ children }: LayoutProps<'/'>) { +export default function Layout({ children }: LayoutProps<"/">) { return ( // @ts-ignore ) { prefetch: true, tabs: [ { - title: 'OpenIsle 前端', + title: "OpenIsle 前端", description: 前端开发文档, - url: '/frontend', + url: "/frontend", icon: ( @@ -48,9 +48,9 @@ export default function Layout({ children }: LayoutProps<'/'>) { ), }, { - title: 'OpenIsle 后端', + title: "OpenIsle 后端", description: 后端开发文档, - url: '/backend', + url: "/backend", icon: ( @@ -58,9 +58,9 @@ export default function Layout({ children }: LayoutProps<'/'>) { ), }, { - title: 'OpenIsle API', + title: "OpenIsle API", description: 后端 API 文档, - url: '/openapi', + url: "/openapi", icon: ( diff --git a/docs/app/global.css b/docs/app/global.css index 25072e9d0..f8100af47 100644 --- a/docs/app/global.css +++ b/docs/app/global.css @@ -1,4 +1,4 @@ -@import 'tailwindcss'; -@import 'fumadocs-ui/css/neutral.css'; -@import 'fumadocs-ui/css/preset.css'; -@import 'fumadocs-openapi/css/preset.css'; +@import "tailwindcss"; +@import "fumadocs-ui/css/neutral.css"; +@import "fumadocs-ui/css/preset.css"; +@import "fumadocs-openapi/css/preset.css"; diff --git a/docs/app/layout.tsx b/docs/app/layout.tsx index 3aaa91604..c3e2bea97 100644 --- a/docs/app/layout.tsx +++ b/docs/app/layout.tsx @@ -1,12 +1,12 @@ -import '@/app/global.css'; -import { Provider } from '@/app/provider'; -import { Inter } from 'next/font/google'; +import "@/app/global.css"; +import { Provider } from "@/app/provider"; +import { Inter } from "next/font/google"; const inter = Inter({ - subsets: ['latin'], + subsets: ["latin"], }); -export default function Layout({ children }: LayoutProps<'/'>) { +export default function Layout({ children }: LayoutProps<"/">) { return ( diff --git a/docs/app/provider.tsx b/docs/app/provider.tsx index c6fe65ef6..a6f26d3fc 100644 --- a/docs/app/provider.tsx +++ b/docs/app/provider.tsx @@ -1,7 +1,7 @@ -'use client'; +"use client"; -import { RootProvider } from 'fumadocs-ui/provider'; -import type { ReactNode } from 'react'; +import { RootProvider } from "fumadocs-ui/provider"; +import type { ReactNode } from "react"; export function Provider({ children }: { children: ReactNode }) { return {children}; diff --git a/docs/lib/layout.shared.tsx b/docs/lib/layout.shared.tsx index 1e7f9faf5..ae2ae80ea 100644 --- a/docs/lib/layout.shared.tsx +++ b/docs/lib/layout.shared.tsx @@ -1,14 +1,14 @@ -import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared'; +import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared"; /** * Shared layout configurations */ export function baseOptions(): BaseLayoutProps { return { - githubUrl: 'https://github.com/nagisa77/OpenIsle', + githubUrl: "https://github.com/nagisa77/OpenIsle", nav: { - title: 'OpenIsle Docs', - url: '/', + title: "OpenIsle Docs", + url: "/", }, searchToggle: { enabled: false, diff --git a/docs/lib/media-adapter.client.ts b/docs/lib/media-adapter.client.ts index 9b9bd9af8..514199446 100644 --- a/docs/lib/media-adapter.client.ts +++ b/docs/lib/media-adapter.client.ts @@ -1,4 +1,4 @@ -'use client'; +"use client"; // forward them so that Fumadocs can also use your media adapter in a client component -export { OpenIsleMediaAdapter } from './media-adapter'; +export { OpenIsleMediaAdapter } from "./media-adapter"; diff --git a/docs/lib/media-adapter.ts b/docs/lib/media-adapter.ts index 18a3b52ba..f8caafe2b 100644 --- a/docs/lib/media-adapter.ts +++ b/docs/lib/media-adapter.ts @@ -1,4 +1,4 @@ -import type { MediaAdapter } from 'fumadocs-openapi'; +import type { MediaAdapter } from "fumadocs-openapi"; export const OpenIsleMediaAdapter: MediaAdapter = { encode(data) { @@ -6,16 +6,16 @@ export const OpenIsleMediaAdapter: MediaAdapter = { }, // returns code that inits a `body` variable, used for request body generateExample(data, ctx) { - if (ctx.lang === 'js') { + if (ctx.lang === "js") { return `const body = "hello world"`; } - if (ctx.lang === 'python') { + if (ctx.lang === "python") { return `body = "hello world"`; } - if (ctx.lang === 'go' && 'addImport' in ctx) { - ctx.addImport('strings'); + if (ctx.lang === "go" && "addImport" in ctx) { + ctx.addImport("strings"); return `body := strings.NewReader("hello world")`; } diff --git a/docs/lib/openapi.ts b/docs/lib/openapi.ts index 7d6bf4859..4121629ac 100644 --- a/docs/lib/openapi.ts +++ b/docs/lib/openapi.ts @@ -1,5 +1,5 @@ -import { createOpenAPI } from 'fumadocs-openapi/server'; +import { createOpenAPI } from "fumadocs-openapi/server"; export const openapi = createOpenAPI({ - input: ['https://staging.open-isle.com/api/v3/api-docs'], + input: ["https://staging.open-isle.com/api/v3/api-docs"], }); diff --git a/docs/lib/source.ts b/docs/lib/source.ts index 5fe372178..a5cf95794 100644 --- a/docs/lib/source.ts +++ b/docs/lib/source.ts @@ -1,16 +1,16 @@ -import { createElement } from 'react'; -import { icons } from 'lucide-react'; -import { loader } from 'fumadocs-core/source'; -import { transformerOpenAPI } from 'fumadocs-openapi/server'; -import { createOpenAPI } from 'fumadocs-openapi/server'; -import { docs } from '@/.source'; -import * as Adapters from './media-adapter'; -import * as ClientAdapters from './media-adapter.client'; +import { createElement } from "react"; +import { icons } from "lucide-react"; +import { loader } from "fumadocs-core/source"; +import { transformerOpenAPI } from "fumadocs-openapi/server"; +import { createOpenAPI } from "fumadocs-openapi/server"; +import { docs } from "@/.source"; +import * as Adapters from "./media-adapter"; +import * as ClientAdapters from "./media-adapter.client"; // See https://fumadocs.vercel.app/docs/headless/source-api for more info export const source = loader({ // it assigns a URL to your pages - baseUrl: '/', + baseUrl: "/", source: docs.toFumadocsSource(), pageTree: { transformers: [transformerOpenAPI()], @@ -26,10 +26,10 @@ export const source = loader({ }); export const openapi = createOpenAPI({ - proxyUrl: '/api/proxy', + proxyUrl: "/api/proxy", mediaAdapters: { // override the default adapter of `application/json` - 'application/json': { + "application/json": { ...Adapters.OpenIsleMediaAdapter, client: ClientAdapters.OpenIsleMediaAdapter, }, diff --git a/docs/mdx-components.tsx b/docs/mdx-components.tsx index 8b00ea0a7..e8ff1075c 100644 --- a/docs/mdx-components.tsx +++ b/docs/mdx-components.tsx @@ -1,7 +1,7 @@ -import defaultMdxComponents from 'fumadocs-ui/mdx'; -import type { MDXComponents } from 'mdx/types'; -import { APIPage } from 'fumadocs-openapi/ui'; -import { openapi } from '@/lib/openapi'; +import defaultMdxComponents from "fumadocs-ui/mdx"; +import type { MDXComponents } from "mdx/types"; +import { APIPage } from "fumadocs-openapi/ui"; +import { openapi } from "@/lib/openapi"; // use this function to get MDX components, you will need it for rendering MDX export function getMDXComponents(components?: MDXComponents): MDXComponents { diff --git a/docs/next.config.mjs b/docs/next.config.mjs index 219f24dee..52f509d72 100644 --- a/docs/next.config.mjs +++ b/docs/next.config.mjs @@ -1,10 +1,10 @@ -import { createMDX } from 'fumadocs-mdx/next'; +import { createMDX } from "fumadocs-mdx/next"; const withMDX = createMDX(); /** @type {import('next').NextConfig} */ const config = { - output: 'export', + output: "export", reactStrictMode: true, }; diff --git a/docs/package.json b/docs/package.json index db1b60c10..67c26a722 100644 --- a/docs/package.json +++ b/docs/package.json @@ -30,4 +30,4 @@ "tailwindcss": "^4.1.12", "typescript": "^5.9.2" } -} \ No newline at end of file +} diff --git a/docs/postcss.config.mjs b/docs/postcss.config.mjs index a34a3d560..c2ddf7482 100644 --- a/docs/postcss.config.mjs +++ b/docs/postcss.config.mjs @@ -1,5 +1,5 @@ export default { plugins: { - '@tailwindcss/postcss': {}, + "@tailwindcss/postcss": {}, }, }; diff --git a/docs/scripts/generate-docs.ts b/docs/scripts/generate-docs.ts index 29b2a330b..449e28d15 100644 --- a/docs/scripts/generate-docs.ts +++ b/docs/scripts/generate-docs.ts @@ -1,9 +1,9 @@ -import { generateFiles } from 'fumadocs-openapi'; -import { openapi } from '@/lib/openapi'; +import { generateFiles } from "fumadocs-openapi"; +import { openapi } from "@/lib/openapi"; void generateFiles({ input: openapi, - output: './content/docs/openapi/(generated)', + output: "./content/docs/openapi/(generated)", // we recommend to enable it // make sure your endpoint description doesn't break MDX syntax. includeDescription: true, diff --git a/docs/source.config.ts b/docs/source.config.ts index e989c751a..c5bd5cacf 100644 --- a/docs/source.config.ts +++ b/docs/source.config.ts @@ -3,7 +3,7 @@ import { defineDocs, frontmatterSchema, metaSchema, -} from 'fumadocs-mdx/config'; +} from "fumadocs-mdx/config"; // You can customise Zod schemas for frontmatter and `meta.json` here // see https://fumadocs.dev/docs/mdx/collections#define-docs diff --git a/frontend_nuxt/assets/fonts.css b/frontend_nuxt/assets/fonts.css index 9141e95aa..b6753e3de 100644 --- a/frontend_nuxt/assets/fonts.css +++ b/frontend_nuxt/assets/fonts.css @@ -1,7 +1,7 @@ /* Maple Mono - Thin 100 */ @font-face { - font-family: "Maple Mono"; - src: url("/fonts/maple-mono-100-normal.woff2") format("woff2"); + font-family: 'Maple Mono'; + src: url('/fonts/maple-mono-100-normal.woff2') format('woff2'); font-weight: 100; font-style: normal; font-display: swap; @@ -9,8 +9,8 @@ /* Maple Mono - Thin Italic 100 */ @font-face { - font-family: "Maple Mono"; - src: url("/fonts/maple-mono-100-italic.woff2") format("woff2"); + font-family: 'Maple Mono'; + src: url('/fonts/maple-mono-100-italic.woff2') format('woff2'); font-weight: 100; font-style: italic; font-display: swap; @@ -18,8 +18,8 @@ /* Maple Mono - ExtraLight 200 */ @font-face { - font-family: "Maple Mono"; - src: url("/fonts/maple-mono-200-normal.woff2") format("woff2"); + font-family: 'Maple Mono'; + src: url('/fonts/maple-mono-200-normal.woff2') format('woff2'); font-weight: 200; font-style: normal; font-display: swap; @@ -27,8 +27,8 @@ /* Maple Mono - ExtraLight Italic 200 */ @font-face { - font-family: "Maple Mono"; - src: url("/fonts/maple-mono-200-italic.woff2") format("woff2"); + font-family: 'Maple Mono'; + src: url('/fonts/maple-mono-200-italic.woff2') format('woff2'); font-weight: 200; font-style: italic; font-display: swap; @@ -36,8 +36,8 @@ /* Maple Mono - Light 300 */ @font-face { - font-family: "Maple Mono"; - src: url("/fonts/maple-mono-300-normal.woff2") format("woff2"); + font-family: 'Maple Mono'; + src: url('/fonts/maple-mono-300-normal.woff2') format('woff2'); font-weight: 300; font-style: normal; font-display: swap; @@ -45,8 +45,8 @@ /* Maple Mono - Light Italic 300 */ @font-face { - font-family: "Maple Mono"; - src: url("/fonts/maple-mono-300-italic.woff2") format("woff2"); + font-family: 'Maple Mono'; + src: url('/fonts/maple-mono-300-italic.woff2') format('woff2'); font-weight: 300; font-style: italic; font-display: swap; @@ -54,8 +54,8 @@ /* Maple Mono - Regular 400 */ @font-face { - font-family: "Maple Mono"; - src: url("/fonts/maple-mono-400-normal.woff2") format("woff2"); + font-family: 'Maple Mono'; + src: url('/fonts/maple-mono-400-normal.woff2') format('woff2'); font-weight: 400; font-style: normal; font-display: swap; @@ -63,8 +63,8 @@ /* Maple Mono - Italic 400 */ @font-face { - font-family: "Maple Mono"; - src: url("/fonts/maple-mono-400-italic.woff2") format("woff2"); + font-family: 'Maple Mono'; + src: url('/fonts/maple-mono-400-italic.woff2') format('woff2'); font-weight: 400; font-style: italic; font-display: swap; @@ -72,8 +72,8 @@ /* Maple Mono - Medium 500 */ @font-face { - font-family: "Maple Mono"; - src: url("/fonts/maple-mono-500-normal.woff2") format("woff2"); + font-family: 'Maple Mono'; + src: url('/fonts/maple-mono-500-normal.woff2') format('woff2'); font-weight: 500; font-style: normal; font-display: swap; @@ -81,8 +81,8 @@ /* Maple Mono - Medium Italic 500 */ @font-face { - font-family: "Maple Mono"; - src: url("/fonts/maple-mono-500-italic.woff2") format("woff2"); + font-family: 'Maple Mono'; + src: url('/fonts/maple-mono-500-italic.woff2') format('woff2'); font-weight: 500; font-style: italic; font-display: swap; @@ -90,8 +90,8 @@ /* Maple Mono - SemiBold 600 */ @font-face { - font-family: "Maple Mono"; - src: url("/fonts/maple-mono-600-normal.woff2") format("woff2"); + font-family: 'Maple Mono'; + src: url('/fonts/maple-mono-600-normal.woff2') format('woff2'); font-weight: 600; font-style: normal; font-display: swap; @@ -99,8 +99,8 @@ /* Maple Mono - SemiBold Italic 600 */ @font-face { - font-family: "Maple Mono"; - src: url("/fonts/maple-mono-600-italic.woff2") format("woff2"); + font-family: 'Maple Mono'; + src: url('/fonts/maple-mono-600-italic.woff2') format('woff2'); font-weight: 600; font-style: italic; font-display: swap; @@ -108,8 +108,8 @@ /* Maple Mono - Bold 700 */ @font-face { - font-family: "Maple Mono"; - src: url("/fonts/maple-mono-700-normal.woff2") format("woff2"); + font-family: 'Maple Mono'; + src: url('/fonts/maple-mono-700-normal.woff2') format('woff2'); font-weight: 700; font-style: normal; font-display: swap; @@ -117,8 +117,8 @@ /* Maple Mono - Bold Italic 700 */ @font-face { - font-family: "Maple Mono"; - src: url("/fonts/maple-mono-700-italic.woff2") format("woff2"); + font-family: 'Maple Mono'; + src: url('/fonts/maple-mono-700-italic.woff2') format('woff2'); font-weight: 700; font-style: italic; font-display: swap; @@ -126,8 +126,8 @@ /* Maple Mono - ExtraBold 800 */ @font-face { - font-family: "Maple Mono"; - src: url("/fonts/maple-mono-800-normal.woff2") format("woff2"); + font-family: 'Maple Mono'; + src: url('/fonts/maple-mono-800-normal.woff2') format('woff2'); font-weight: 800; font-style: normal; font-display: swap; @@ -135,8 +135,8 @@ /* Maple Mono - ExtraBold Italic 800 */ @font-face { - font-family: "Maple Mono"; - src: url("/fonts/maple-mono-800-italic.woff2") format("woff2"); + font-family: 'Maple Mono'; + src: url('/fonts/maple-mono-800-italic.woff2') format('woff2'); font-weight: 800; font-style: italic; font-display: swap; diff --git a/frontend_nuxt/components/CommentEditor.vue b/frontend_nuxt/components/CommentEditor.vue index 3a8408f85..40d602c03 100644 --- a/frontend_nuxt/components/CommentEditor.vue +++ b/frontend_nuxt/components/CommentEditor.vue @@ -8,13 +8,9 @@
- +
@@ -115,7 +111,7 @@ export default { }, }) // 不是手机的情况下不添加快捷键 - if(!isMobile.value){ + if (!isMobile.value) { // 添加快捷键监听 (Ctrl+Enter 或 Cmd+Enter) const handleKeydown = (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { @@ -172,7 +168,7 @@ export default { }, ) - return { submit, isDisabled, editorId, isMac, isMobile} + return { submit, isDisabled, editorId, isMac, isMobile } }, } diff --git a/frontend_nuxt/composables/useChannelsUnreadCount.js b/frontend_nuxt/composables/useChannelsUnreadCount.js index e684a0c53..5c792b499 100644 --- a/frontend_nuxt/composables/useChannelsUnreadCount.js +++ b/frontend_nuxt/composables/useChannelsUnreadCount.js @@ -3,73 +3,73 @@ import { useWebSocket } from './useWebSocket' import { getToken } from '~/utils/auth' const count = ref(0) -let isInitialized = false; +let isInitialized = false export function useChannelsUnreadCount() { - const config = useRuntimeConfig(); - const API_BASE_URL = config.public.apiBaseUrl; - const { subscribe, isConnected, connect } = useWebSocket(); + const config = useRuntimeConfig() + const API_BASE_URL = config.public.apiBaseUrl + const { subscribe, isConnected, connect } = useWebSocket() const fetchChannelUnread = async () => { - const token = getToken(); + const token = getToken() if (!token) { - count.value = 0; - return; + count.value = 0 + return } try { const response = await fetch(`${API_BASE_URL}/api/channels/unread-count`, { headers: { Authorization: `Bearer ${token}` }, - }); + }) if (response.ok) { - const data = await response.json(); - count.value = data; + const data = await response.json() + count.value = data } } catch (e) { - console.error('Failed to fetch channel unread count:', e); + console.error('Failed to fetch channel unread count:', e) } - }; - + } + const setupWebSocketListener = () => { - const destination = '/user/queue/channel-unread'; + const destination = '/user/queue/channel-unread' subscribe(destination, (message) => { - const unread = parseInt(message.body, 10); + const unread = parseInt(message.body, 10) if (!isNaN(unread)) { - count.value = unread; + count.value = unread } - }).then(subscription => { + }).then((subscription) => { if (subscription) { - console.log('频道未读消息订阅成功'); + console.log('频道未读消息订阅成功') } - }); - }; + }) + } const initialize = () => { - const token = getToken(); + const token = getToken() if (!token) { - count.value = 0; - return; + count.value = 0 + return } if (!isConnected.value) { - connect(token); + connect(token) } - fetchChannelUnread(); - setupWebSocketListener(); - }; + fetchChannelUnread() + setupWebSocketListener() + } const setFromList = (channels) => { - count.value = Array.isArray(channels) ? channels.filter((c) => c.unreadCount > 0).length : 0; - }; + count.value = Array.isArray(channels) ? channels.filter((c) => c.unreadCount > 0).length : 0 + } - const hasUnread = computed(() => count.value > 0); + const hasUnread = computed(() => count.value > 0) if (!isInitialized) { - const token = getToken(); + const token = getToken() if (token) { - isInitialized = true; - initialize(); + isInitialized = true + initialize() } } @@ -79,5 +79,5 @@ export function useChannelsUnreadCount() { fetchChannelUnread, initialize, setFromList, - }; + } } diff --git a/frontend_nuxt/composables/useReactionTypes.js b/frontend_nuxt/composables/useReactionTypes.js index 0d9350c9c..aa2477c52 100644 --- a/frontend_nuxt/composables/useReactionTypes.js +++ b/frontend_nuxt/composables/useReactionTypes.js @@ -13,7 +13,7 @@ export function useReactionTypes() { reactionTypes.value = [...(window.reactionTypes || [])] return reactionTypes.value } - + isLoading = true try { const token = getToken() @@ -47,6 +47,6 @@ export function useReactionTypes() { reactionTypes: readonly(reactionTypes), fetchReactionTypes, initialize, - isInitialized: readonly(isInitialized) + isInitialized: readonly(isInitialized), } -} \ No newline at end of file +} diff --git a/frontend_nuxt/composables/useUnreadCount.js b/frontend_nuxt/composables/useUnreadCount.js index 677083aea..77233cbcb 100644 --- a/frontend_nuxt/composables/useUnreadCount.js +++ b/frontend_nuxt/composables/useUnreadCount.js @@ -1,76 +1,76 @@ -import { ref, watch, onMounted } from 'vue'; -import { useWebSocket } from './useWebSocket'; -import { getToken } from '~/utils/auth'; +import { ref, watch, onMounted } from 'vue' +import { useWebSocket } from './useWebSocket' +import { getToken } from '~/utils/auth' -const count = ref(0); -let isInitialized = false; +const count = ref(0) +let isInitialized = false export function useUnreadCount() { - const config = useRuntimeConfig(); - const API_BASE_URL = config.public.apiBaseUrl; - const { subscribe, isConnected, connect } = useWebSocket(); + const config = useRuntimeConfig() + const API_BASE_URL = config.public.apiBaseUrl + const { subscribe, isConnected, connect } = useWebSocket() const fetchUnreadCount = async () => { - const token = getToken(); + const token = getToken() if (!token) { - count.value = 0; - return; + count.value = 0 + return } try { const response = await fetch(`${API_BASE_URL}/api/messages/unread-count`, { headers: { Authorization: `Bearer ${token}` }, - }); + }) if (response.ok) { - const data = await response.json(); - count.value = data; + const data = await response.json() + count.value = data } } catch (error) { - console.error('Failed to fetch unread count:', error); + console.error('Failed to fetch unread count:', error) } - }; + } const setupWebSocketListener = () => { - console.log('设置未读消息订阅...'); - const destination = '/user/queue/unread-count'; - + console.log('设置未读消息订阅...') + const destination = '/user/queue/unread-count' + subscribe(destination, (message) => { - const unreadCount = parseInt(message.body, 10); + const unreadCount = parseInt(message.body, 10) if (!isNaN(unreadCount)) { - count.value = unreadCount; + count.value = unreadCount } - }).then(subscription => { + }).then((subscription) => { if (subscription) { - console.log('未读消息订阅成功'); + console.log('未读消息订阅成功') } - }); - }; + }) + } const initialize = () => { - const token = getToken(); + const token = getToken() if (!token) { - count.value = 0; - return; + count.value = 0 + return } if (!isConnected.value) { - connect(token); + connect(token) } - - fetchUnreadCount(); - setupWebSocketListener(); - }; + + fetchUnreadCount() + setupWebSocketListener() + } if (!isInitialized) { - const token = getToken(); + const token = getToken() if (token) { - isInitialized = true; - initialize(); + isInitialized = true + initialize() } } return { count, fetchUnreadCount, - initialize, - }; -} \ No newline at end of file + initialize, + } +} diff --git a/frontend_nuxt/composables/useWebSocket.js b/frontend_nuxt/composables/useWebSocket.js index 18a95b15f..3ff611936 100644 --- a/frontend_nuxt/composables/useWebSocket.js +++ b/frontend_nuxt/composables/useWebSocket.js @@ -12,7 +12,7 @@ const resubscribeCallbacks = new Map() // Helper for unified subscription logging const logSubscriptionActivity = (action, destination, subscriptionId = 'N/A') => { console.log( - `[SUB_MAN] ${action} | Dest: ${destination} | SubID: ${subscriptionId} | Active: ${activeSubscriptions.value.size}` + `[SUB_MAN] ${action} | Dest: ${destination} | SubID: ${subscriptionId} | Active: ${activeSubscriptions.value.size}`, ) } @@ -21,7 +21,6 @@ const connect = (token) => { return } - const config = useRuntimeConfig() const WEBSOCKET_URL = config.public.websocketUrl const socketUrl = `${WEBSOCKET_URL}/api/sockjs` @@ -31,9 +30,7 @@ const connect = (token) => { connectHeaders: { Authorization: `Bearer ${token}`, }, - debug: function (str) { - - }, + debug: function (str) {}, reconnectDelay: 10000, heartbeatIncoming: 4000, heartbeatOutgoing: 4000, @@ -42,7 +39,7 @@ const connect = (token) => { stompClient.onConnect = (frame) => { isConnected.value = true resubscribeCallbacks.forEach((callback, destination) => { - doSubscribe(destination, callback) + doSubscribe(destination, callback) }) } @@ -50,16 +47,14 @@ const connect = (token) => { console.error('Full frame:', frame) } - stompClient.onWebSocketError = (event) => { - - } + stompClient.onWebSocketError = (event) => {} stompClient.onWebSocketClose = (event) => { - isConnected.value = false; - activeSubscriptions.value.clear(); - logSubscriptionActivity('Cleared all subscriptions due to WebSocket close', 'N/A'); - }; - + isConnected.value = false + activeSubscriptions.value.clear() + logSubscriptionActivity('Cleared all subscriptions due to WebSocket close', 'N/A') + } + stompClient.onDisconnect = (frame) => { isConnected.value = false } @@ -92,7 +87,7 @@ const unsubscribe = (destination) => { const unsubscribeAll = () => { logSubscriptionActivity('Unsubscribing from ALL', `Total: ${activeSubscriptions.value.size}`) const destinations = [...activeSubscriptions.value.keys()] - destinations.forEach(dest => { + destinations.forEach((dest) => { unsubscribe(dest) }) } @@ -148,16 +143,20 @@ const subscribe = (destination, callback) => { const sub = doSubscribe(destination, callback) resolve(sub) } else { - const unwatch = watch(isConnected, (newVal) => { - if (newVal) { - setTimeout(() => { - const sub = doSubscribe(destination, callback) - unwatch() - resolve(sub) - }, 100) - } - }, { immediate: false }) - + const unwatch = watch( + isConnected, + (newVal) => { + if (newVal) { + setTimeout(() => { + const sub = doSubscribe(destination, callback) + unwatch() + resolve(sub) + }, 100) + } + }, + { immediate: false }, + ) + setTimeout(() => { unwatch() if (!isConnected.value) { diff --git a/package.json b/package.json index c0e6339d9..c0be706a5 100644 --- a/package.json +++ b/package.json @@ -27,4 +27,4 @@ "frontend_nuxt/**/*": "prettier --write --cache --ignore-unknown", "backend/src/**/*.java": "prettier --write --cache --ignore-unknown" } -} \ No newline at end of file +}