From d18f306f97b43639b302ec6047ddee892d150bca Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 31 Oct 2026 22:03:38 +0800 Subject: [PATCH 1/4] =?UTF-8?q?chore(demo):=20=E5=BC=95=E5=85=A5=20dim=5Fs?= =?UTF-8?q?tack(=E6=AC=A1=E5=85=83=E6=A0=88)=E4=BD=9C=E4=B8=BA=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E5=8F=82=E8=80=83=E9=A1=B9=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除其内部 .git,作为普通参考代码纳入主仓库 - Spring Boot 4 + React 19 个人博客,供前端优化参考 Co-Authored-By: Claude --- demo/dim_stack/.gitattributes | 3 + demo/dim_stack/.github/workflows/build.yml | 88 + demo/dim_stack/.gitignore | 39 + demo/dim_stack/Dockerfile | 24 + demo/dim_stack/LICENSE | 21 + demo/dim_stack/README.md | 119 + .../backend/check_role_permission.sql | 28 + demo/dim_stack/backend/dimstack-app/pom.xml | 201 ++ .../xyz/lingview/dimstack/StartServer.java | 113 + .../dimstack/annotation/RateLimit.java | 40 + .../annotation/RequiresPermission.java | 14 + .../lingview/dimstack/common/ApiResponse.java | 46 + .../dimstack/common/RateLimitException.java | 24 + .../config/CacheAutoConfiguration.java | 125 + .../lingview/dimstack/config/ConfigInfo.java | 18 + .../dimstack/config/DatabaseCreator.java | 45 + .../dimstack/config/DatabaseInitializer.java | 55 + .../config/DynamicResourceResolver.java | 67 + .../dimstack/config/FlywayConfig.java | 41 + .../config/HotArticleCacheInitializer.java | 21 + .../config/InitSiteConfigToRedis.java | 40 + .../lingview/dimstack/config/RedisConfig.java | 34 + .../dimstack/config/SessionConfig.java | 86 + .../dimstack/config/Swagger3Config.java | 20 + .../dimstack/config/ThemeProperties.java | 56 + .../lingview/dimstack/config/WebConfig.java | 94 + .../controller/AnnouncementController.java | 48 + .../dimstack/controller/ApiKeyController.java | 83 + .../ArticleGenerationController.java | 58 + .../controller/ArticlePageController.java | 74 + .../controller/ArticleReviewController.java | 61 + .../controller/ArticleSearchController.java | 46 + .../AttachmentManagementController.java | 395 +++ .../controller/BackentCommentController.java | 144 + .../controller/CacheManagementController.java | 272 ++ .../controller/CaptchaController.java | 64 + .../controller/CommentController.java | 65 + .../controller/CommentReviewController.java | 78 + .../controller/CustomErrorController.java | 30 + .../controller/CustomPageController.java | 133 + .../controller/DashboardController.java | 37 + .../controller/EditArticleController.java | 220 ++ .../controller/ExpansionController.java | 446 +++ .../controller/FaviconController.java | 105 + .../controller/FileAccessController.java | 62 + .../controller/ForgotPasswordController.java | 82 + .../controller/FriendLinksController.java | 215 ++ .../dimstack/controller/HomeController.java | 34 + .../controller/HomePageController.java | 79 + .../controller/HotArticleController.java | 24 + .../controller/LlmConfigController.java | 134 + .../controller/LlmPromptConfigController.java | 58 + .../dimstack/controller/LoginController.java | 60 + .../dimstack/controller/MenusController.java | 72 + .../dimstack/controller/MusicController.java | 149 + .../controller/PermissionTestController.java | 116 + .../dimstack/controller/PluginController.java | 158 + .../dimstack/controller/ProxyController.java | 94 + .../controller/RandomArticleController.java | 34 + .../controller/ReadArticleController.java | 102 + .../controller/RegisterController.java | 60 + .../dimstack/controller/RobotsController.java | 33 + .../dimstack/controller/RoleController.java | 91 + .../dimstack/controller/RouterController.java | 24 + .../controller/SiteConfigController.java | 435 +++ .../controller/SitemapController.java | 172 ++ .../controller/StatisticsController.java | 38 + .../controller/StorageMethodController.java | 106 + .../SystematicNotificationController.java | 123 + .../controller/TagAndCategoryController.java | 95 + .../controller/TagCategoryController.java | 246 ++ .../dimstack/controller/ThemeController.java | 188 ++ .../dimstack/controller/UpdateController.java | 219 ++ .../dimstack/controller/UploadController.java | 142 + .../dimstack/controller/UserController.java | 210 ++ .../controller/UserRoleController.java | 50 + .../dimstack/domain/Announcement.java | 13 + .../xyz/lingview/dimstack/domain/ApiKey.java | 16 + .../xyz/lingview/dimstack/domain/Article.java | 25 + .../dimstack/domain/ArticleCategory.java | 19 + .../domain/ArticleCategoryAndCount.java | 15 + .../lingview/dimstack/domain/ArticleLike.java | 13 + .../lingview/dimstack/domain/ArticleTag.java | 14 + .../dimstack/domain/AttachmentManagement.java | 25 + .../xyz/lingview/dimstack/domain/Comment.java | 19 + .../lingview/dimstack/domain/CommentLike.java | 13 + .../lingview/dimstack/domain/CustomPage.java | 22 + .../dimstack/domain/DashboardMenu.java | 18 + .../lingview/dimstack/domain/FriendLinks.java | 25 + .../dimstack/domain/FriendLinksConfig.java | 17 + .../lingview/dimstack/domain/HomeArticle.java | 24 + .../lingview/dimstack/domain/LlmConfig.java | 10 + .../dimstack/domain/LlmPromptConfig.java | 13 + .../xyz/lingview/dimstack/domain/Login.java | 9 + .../lingview/dimstack/domain/MailConfig.java | 17 + .../xyz/lingview/dimstack/domain/Menus.java | 19 + .../xyz/lingview/dimstack/domain/Music.java | 20 + .../lingview/dimstack/domain/Permission.java | 12 + .../dimstack/domain/PluginConfig.java | 20 + .../lingview/dimstack/domain/PluginInfo.java | 40 + .../dimstack/domain/PluginSqlAudit.java | 21 + .../lingview/dimstack/domain/ReadArticle.java | 27 + .../lingview/dimstack/domain/Register.java | 15 + .../xyz/lingview/dimstack/domain/Role.java | 12 + .../lingview/dimstack/domain/SiteConfig.java | 48 + .../dimstack/domain/StorageMethod.java | 23 + .../domain/StorageMigrationFailedItem.java | 15 + .../dimstack/domain/StorageMigrationLog.java | 20 + .../domain/SystematicNotification.java | 25 + .../lingview/dimstack/domain/UpdateInfo.java | 22 + .../dimstack/domain/UploadArticle.java | 23 + .../dimstack/domain/UploadAttachment.java | 15 + .../dimstack/domain/UserInformation.java | 20 + .../dto/request/AddCommentRequestDTO.java | 10 + .../dto/request/ApiKeyCreateRequestDTO.java | 10 + .../dto/request/ArticleCategoryDTO.java | 23 + .../dimstack/dto/request/ArticleDTO.java | 20 + .../dto/request/ArticleDetailDTO.java | 20 + .../dto/request/ArticleReviewDTO.java | 18 + .../ArticleStatusUpdateRequestDTO.java | 9 + .../dimstack/dto/request/ArticleTagDTO.java | 15 + .../dimstack/dto/request/CommentDTO.java | 26 + .../dto/request/CustomPageRequest.java | 17 + .../dto/request/DashboardMenuDTO.java | 18 + .../dimstack/dto/request/EditArticleDTO.java | 24 + .../dto/request/ForgotPasswordDTO.java | 19 + .../dto/request/FriendLinksRequestDTO.java | 32 + .../dimstack/dto/request/HeroDTO.java | 11 + .../dimstack/dto/request/HotArticleDTO.java | 18 + .../dimstack/dto/request/LoginDTO.java | 11 + .../dimstack/dto/request/MenusDTO.java | 14 + .../dimstack/dto/request/PageRequest.java | 10 + .../dimstack/dto/request/PageResult.java | 13 + .../dto/request/PerformUpdateRequestDTO.java | 14 + .../dimstack/dto/request/RegisterDTO.java | 33 + .../dimstack/dto/request/SearchResultDTO.java | 18 + .../dto/request/TestSmtpRequestDTO.java | 14 + .../dto/request/ThemeSlugRequestDTO.java | 8 + .../dto/request/UpdateArticleDTO.java | 20 + .../dimstack/dto/request/UserDTO.java | 22 + .../dimstack/dto/request/UserUpdateDTO.java | 18 + .../response/ApiKeyCreatedResponseDTO.java | 12 + .../dto/response/ApiKeyResponseDTO.java | 14 + .../dto/response/ArticleOperationResult.java | 16 + .../ArticleReviewDetailResponseDTO.java | 9 + .../ArticleReviewListResponseDTO.java | 15 + .../ArticleReviewStatusResponseDTO.java | 9 + .../response/ArticleSearchResponseDTO.java | 10 + .../dto/response/ArticleSearchResultDTO.java | 11 + .../dto/response/CustomPageResponse.java | 22 + .../response/ForgotPasswordResponseDTO.java | 15 + .../dto/response/LoginResponseDTO.java | 10 + .../dto/response/LogoutResponseDTO.java | 9 + .../dto/response/RegisterResponseDTO.java | 10 + .../dto/response/ThemeDetailResponseDTO.java | 10 + .../dto/response/ThemeListResponseDTO.java | 9 + .../dto/response/UserStatusResponseDTO.java | 11 + .../dimstack/enums/AiReviewResult.java | 12 + .../dimstack/init/InitApplication.java | 63 + .../dimstack/init/InitController.java | 414 +++ .../interceptor/GlobalExceptionHandler.java | 44 + .../dimstack/interceptor/RateLimitAspect.java | 146 + .../interceptor/ThemeResourceFilter.java | 171 ++ .../interceptor/UserAgentSessionFilter.java | 49 + .../UserPermissionInterceptor.java | 60 + .../dimstack/mapper/AnnouncementMapper.java | 15 + .../dimstack/mapper/ApiKeyMapper.java | 29 + .../mapper/ArticleCategoryMapper.java | 45 + .../dimstack/mapper/ArticleLikeMapper.java | 13 + .../dimstack/mapper/ArticleMapper.java | 27 + .../dimstack/mapper/ArticleReviewMapper.java | 28 + .../dimstack/mapper/ArticleSearchMapper.java | 17 + .../dimstack/mapper/ArticleTagMapper.java | 27 + .../mapper/AttachmentManagementMapper.java | 102 + .../dimstack/mapper/BackendCommentMapper.java | 49 + .../dimstack/mapper/CommentLikeMapper.java | 13 + .../dimstack/mapper/CommentMapper.java | 20 + .../dimstack/mapper/CustomPageMapper.java | 35 + .../dimstack/mapper/DashboardMenuMapper.java | 32 + .../dimstack/mapper/EditArticleMapper.java | 57 + .../mapper/FriendLinksConfigMapper.java | 13 + .../dimstack/mapper/FriendLinksMapper.java | 41 + .../dimstack/mapper/HotArticleMapper.java | 10 + .../dimstack/mapper/LlmConfigMapper.java | 15 + .../mapper/LlmPromptConfigMapper.java | 12 + .../lingview/dimstack/mapper/LoginMapper.java | 11 + .../lingview/dimstack/mapper/MenusMapper.java | 15 + .../lingview/dimstack/mapper/MusicMapper.java | 35 + .../dimstack/mapper/PermissionMapper.java | 16 + .../dimstack/mapper/PluginConfigMapper.java | 22 + .../dimstack/mapper/PluginMapper.java | 29 + .../dimstack/mapper/PluginSqlAuditMapper.java | 25 + .../dimstack/mapper/ReadArticleMapper.java | 28 + .../dimstack/mapper/RegisterMapper.java | 15 + .../lingview/dimstack/mapper/RoleMapper.java | 50 + .../dimstack/mapper/SiteConfigMapper.java | 42 + .../dimstack/mapper/StatisticsMapper.java | 11 + .../dimstack/mapper/StorageMethodMapper.java | 29 + .../mapper/StorageMigrationLogMapper.java | 24 + .../mapper/SystematicNotificationMapper.java | 57 + .../dimstack/mapper/UploadMapper.java | 20 + .../mapper/UserInformationMapper.java | 69 + .../dimstack/mapper/UserPermissionMapper.java | 11 + .../dimstack/mapper/UserRoleMapper.java | 24 + ...efaultPluginApplicationContextFactory.java | 234 ++ .../dimstack/plugin/DefaultSpringPlugin.java | 76 + .../plugin/DimStackPluginManager.java | 124 + .../dimstack/plugin/ExtensionGetterImpl.java | 58 + .../PluginApplicationContextFactory.java | 17 + .../plugin/PluginAssetController.java | 98 + .../dimstack/plugin/PluginAuditLogger.java | 31 + .../plugin/PluginAutoConfiguration.java | 96 + .../dimstack/plugin/PluginDbImpl.java | 69 + .../dimstack/plugin/PluginDbRegistry.java | 58 + .../plugin/PluginExtensionLoader.java | 116 + .../dimstack/plugin/PluginLifecycleGuard.java | 48 + .../dimstack/plugin/PluginManifest.java | 43 + .../dimstack/plugin/PluginPermission.java | 17 + .../plugin/PluginPermissionInterceptor.java | 57 + .../plugin/PluginPermissionRegistrar.java | 74 + .../PluginRequestMappingHandlerMapping.java | 106 + .../dimstack/plugin/PluginSqlAuditProxy.java | 144 + .../dimstack/plugin/PluginSqlAuditor.java | 111 + .../dimstack/plugin/PluginYamlLoader.java | 34 + .../dimstack/plugin/SettingFetcherImpl.java | 46 + .../dimstack/plugin/SpringPluginFactory.java | 29 + .../plugin/UiPluginBundleService.java | 14 + .../plugin/UiPluginBundleServiceImpl.java | 123 + .../dimstack/plugin/UiPluginController.java | 35 + .../plugin/UiPluginProviderDescriptor.java | 34 + .../plugin/YamlPluginDescriptorFinder.java | 242 ++ .../lingview/dimstack/security/AuthType.java | 8 + .../dimstack/security/AuthenticatedUser.java | 30 + .../RepeatableReadRequestWrapper.java | 54 + .../dimstack/security/SecurityFilter.java | 132 + .../dimstack/security/SessionAuthFilter.java | 222 ++ .../dimstack/security/TokenAuthResolver.java | 52 + .../dimstack/security/UserContextHolder.java | 22 + .../dimstack/service/AnnouncementService.java | 10 + .../dimstack/service/ApiKeyService.java | 25 + .../service/ArticleCategoryService.java | 27 + .../service/ArticleReviewService.java | 18 + .../service/ArticleSearchService.java | 10 + .../dimstack/service/ArticleService.java | 12 + .../dimstack/service/ArticleTagService.java | 22 + .../service/AttachmentManagementService.java | 60 + .../service/BackendCommentService.java | 40 + .../dimstack/service/CacheService.java | 29 + .../dimstack/service/CommentService.java | 13 + .../dimstack/service/CurrentUserService.java | 23 + .../dimstack/service/CustomPageService.java | 27 + .../service/DashboardMenuService.java | 7 + .../dimstack/service/EditArticleService.java | 35 + .../dimstack/service/FileAccessService.java | 25 + .../dimstack/service/FileStorage.java | 31 + .../service/ForgotPasswordService.java | 42 + .../service/FriendLinksConfigService.java | 8 + .../dimstack/service/FriendLinksService.java | 25 + .../dimstack/service/HotArticleService.java | 9 + .../service/ImageCompressionService.java | 5 + .../lingview/dimstack/service/LLMService.java | 21 + .../dimstack/service/LlmConfigService.java | 11 + .../service/LlmPromptConfigService.java | 10 + .../dimstack/service/LoginService.java | 11 + .../dimstack/service/MailService.java | 57 + .../dimstack/service/MenusService.java | 14 + .../dimstack/service/MusicService.java | 20 + .../dimstack/service/NotificationService.java | 26 + .../service/PageViewCounterService.java | 12 + .../dimstack/service/PluginService.java | 44 + .../dimstack/service/ReadArticleService.java | 24 + .../dimstack/service/RegisterService.java | 8 + .../dimstack/service/RoleService.java | 39 + .../dimstack/service/SiteConfigService.java | 49 + .../dimstack/service/StatisticsService.java | 8 + .../service/StorageFacadeService.java | 16 + .../service/StorageMethodService.java | 30 + .../dimstack/service/UpdateService.java | 36 + .../dimstack/service/UploadService.java | 26 + .../service/UserBlacklistService.java | 16 + .../service/UserPermissionCheckService.java | 10 + .../dimstack/service/UserRoleService.java | 20 + .../dimstack/service/UserService.java | 66 + .../service/impl/AnnouncementServiceImpl.java | 26 + .../service/impl/ApiKeyServiceImpl.java | 95 + .../impl/ArticleCategoryServiceImpl.java | 263 ++ .../impl/ArticleReviewServiceImpl.java | 183 ++ .../impl/ArticleSearchServiceImpl.java | 35 + .../service/impl/ArticleServiceImpl.java | 90 + .../service/impl/ArticleTagServiceImpl.java | 160 + .../impl/AttachmentManagementServiceImpl.java | 619 ++++ .../impl/BackendCommentServiceImpl.java | 215 ++ .../service/impl/CommentServiceImpl.java | 420 +++ .../service/impl/CurrentUserServiceImpl.java | 56 + .../service/impl/CustomPageServiceImpl.java | 156 + .../impl/DashboardMenuServiceImpl.java | 65 + .../service/impl/EditArticleServiceImpl.java | 962 ++++++ .../service/impl/FileAccessServiceImpl.java | 196 ++ .../impl/ForgotPasswordServiceImpl.java | 651 ++++ .../impl/FriendLinksConfigServiceImpl.java | 43 + .../service/impl/FriendLinksServiceImpl.java | 221 ++ .../service/impl/HotArticleServiceImpl.java | 42 + .../impl/ImageCompressionServiceImpl.java | 248 ++ .../dimstack/service/impl/LLMServiceImpl.java | 207 ++ .../service/impl/LlmConfigServiceImpl.java | 56 + .../impl/LlmPromptConfigServiceImpl.java | 37 + .../service/impl/LocalFileStorageImpl.java | 94 + .../service/impl/LoginServiceImpl.java | 186 ++ .../service/impl/MailServiceImpl.java | 186 ++ .../service/impl/MemoryCacheServiceImpl.java | 147 + .../service/impl/MenusServiceImpl.java | 86 + .../service/impl/MusicServiceImpl.java | 88 + .../service/impl/NotificationServiceImpl.java | 132 + .../impl/PageViewCounterServiceImpl.java | 125 + .../service/impl/PluginServiceImpl.java | 741 +++++ .../service/impl/ReadArticleServiceImpl.java | 270 ++ .../service/impl/RedisCacheServiceImpl.java | 192 ++ .../service/impl/RegisterServiceImpl.java | 212 ++ .../service/impl/RoleServiceImpl.java | 169 + .../service/impl/S3FileStorageImpl.java | 177 ++ .../service/impl/SiteConfigServiceImpl.java | 263 ++ .../service/impl/StatisticsServiceImpl.java | 37 + .../impl/StorageFacadeServiceImpl.java | 160 + .../impl/StorageMethodServiceImpl.java | 216 ++ .../service/impl/UpdateServiceImpl.java | 433 +++ .../service/impl/UploadServiceImpl.java | 1257 ++++++++ .../impl/UserBlacklistServiceImpl.java | 43 + .../impl/UserPermissionCheckServiceImpl.java | 46 + .../service/impl/UserRoleServiceImpl.java | 71 + .../service/impl/UserServiceImpl.java | 347 +++ .../service/impl/WebDavFileStorageImpl.java | 236 ++ .../task/ExpiredAttachmentCleanupTask.java | 35 + .../task/HotArticleScheduledTask.java | 22 + .../task/TempUploadCleanupService.java | 104 + .../lingview/dimstack/util/ApiKeyUtil.java | 35 + .../xyz/lingview/dimstack/util/BotUtil.java | 20 + .../lingview/dimstack/util/CaptchaUtil.java | 79 + .../dimstack/util/CategoryPathUtil.java | 47 + .../lingview/dimstack/util/DomainUtil.java | 33 + .../lingview/dimstack/util/FileSuffix.java | 23 + .../dimstack/util/ImageCompressorUtil.java | 96 + .../util/LargeLanguageModelsUtil.java | 118 + .../lingview/dimstack/util/MimeTypeUtil.java | 52 + .../lingview/dimstack/util/PasswordUtil.java | 16 + .../lingview/dimstack/util/RandomUtil.java | 123 + .../dimstack/util/SiteConfigUtil.java | 278 ++ .../xyz/lingview/dimstack/util/UrlUtil.java | 26 + .../src/main/resources/application-init.yml | 18 + .../src/main/resources/banner.txt | 7 + .../V10__add_image_compression_threads.sql | 3 + .../V11__add_proxy_resource_download.sql | 2 + .../V12__add_friend_links_config.sql | 12 + .../db/migration/V13__update_llm_prompt.sql | 5 + .../V14__clear_custom_page_status_delete.sql | 2 + .../db/migration/V15__add_comment_toggle.sql | 2 + .../db/migration/V16__add_api_key.sql | 13 + .../db/migration/V17__add_announcement.sql | 16 + .../db/migration/V18__add_comment_review.sql | 14 + ...9__allow_null_tag_and_category_explain.sql | 2 + .../db/migration/V1__init_tables.sql | 7 + .../db/migration/V20__add_storage_support.sql | 36 + .../migration/V21__storage_migration_log.sql | 27 + .../V22__add_attachment_content_type.sql | 3 + .../db/migration/V23__add_plugin_tables.sql | 38 + .../migration/V24__add_plugin_sql_audit.sql | 10 + .../db/migration/V25__menu_auto_increment.sql | 3 + .../migration/V26__widen_permission_code.sql | 4 + .../db/migration/V2__admin_post_no_review.sql | 2 + .../migration/V3__multi_level_categories.sql | 71 + .../migration/V4__attachment_management.sql | 18 + .../migration/V5__add_original_filename.sql | 3 + .../migration/V6__rbac_to_complete_rbac.sql | 301 ++ .../db/migration/V7__add_llm_model.sql | 25 + .../db/migration/V8__add_code_Injection.sql | 11 + .../migration/V9__add_image_compression.sql | 3 + .../db/migration/database_version.txt | 1 + .../dimstack-app/src/main/resources/init.sql | 920 ++++++ .../src/main/resources/logback-spring.xml | 41 + .../resources/mapper/AnnouncementMapper.xml | 16 + .../main/resources/mapper/ApiKeyMapper.xml | 61 + .../mapper/ArticleCategoryMapper.xml | 319 ++ .../resources/mapper/ArticleLikeMapper.xml | 21 + .../main/resources/mapper/ArticleMapper.xml | 108 + .../resources/mapper/ArticleReviewMapper.xml | 107 + .../resources/mapper/ArticleSearchMapper.xml | 52 + .../resources/mapper/ArticleTagMapper.xml | 144 + .../mapper/AttachmentManagementMapper.xml | 233 ++ .../resources/mapper/BackendCommentMapper.xml | 137 + .../resources/mapper/CommentLikeMapper.xml | 21 + .../main/resources/mapper/CommentMapper.xml | 43 + .../resources/mapper/CustomPageMapper.xml | 119 + .../resources/mapper/DashboardMenuMapper.xml | 58 + .../resources/mapper/EditArticleMapper.xml | 241 ++ .../mapper/FriendLinksConfigMapper.xml | 52 + .../resources/mapper/FriendLinksMapper.xml | 118 + .../resources/mapper/HotArticleMapper.xml | 32 + .../main/resources/mapper/LlmConfigMapper.xml | 27 + .../mapper/LlmPromptConfigMapper.xml | 17 + .../src/main/resources/mapper/LoginMapper.xml | 35 + .../src/main/resources/mapper/MenusMapper.xml | 48 + .../src/main/resources/mapper/MusicMapper.xml | 62 + .../resources/mapper/PermissionMapper.xml | 24 + .../resources/mapper/PluginConfigMapper.xml | 23 + .../main/resources/mapper/PluginMapper.xml | 44 + .../resources/mapper/PluginSqlAuditMapper.xml | 24 + .../resources/mapper/ReadArticleMapper.xml | 190 ++ .../main/resources/mapper/RegisterMapper.xml | 28 + .../src/main/resources/mapper/RoleMapper.xml | 98 + .../resources/mapper/SiteConfigMapper.xml | 163 + .../resources/mapper/StatisticsMapper.xml | 21 + .../resources/mapper/StorageMethodMapper.xml | 59 + .../mapper/StorageMigrationLogMapper.xml | 65 + .../mapper/SystematicNotificationMapper.xml | 70 + .../resources/mapper/UploadArticleMapper.xml | 68 + .../mapper/UploadAttachmentMapper.xml | 26 + .../mapper/UserInformationMapper.xml | 344 +++ .../resources/mapper/UserPermissionMapper.xml | 18 + .../main/resources/mapper/UserRoleMapper.xml | 41 + .../src/main/resources/mybatis-config.xml | 15 + .../src/main/resources/system_version.txt | 1 + .../main/resources/templates/init/result.html | 151 + .../main/resources/templates/init/setup.html | 413 +++ .../src/main/resources/templates/seo_404.html | 8 + .../main/resources/templates/seo_article.html | 35 + .../main/resources/templates/seo_home.html | 42 + .../test/ArticleTagAndCategoryTest.java | 72 + .../PermissionBasedTestControllerTest.java | 144 + .../test/PermissionMapperIntegrationTest.java | 27 + .../backend/dimstack-plugin-api/pom.xml | 24 + .../dimstack/plugin/api/BasePlugin.java | 23 + .../dimstack/plugin/api/ExtensionGetter.java | 14 + .../dimstack/plugin/api/ExtensionPoint.java | 10 + .../dimstack/plugin/api/PluginContext.java | 55 + .../dimstack/plugin/api/PluginDb.java | 16 + .../plugin/api/RequiresPermission.java | 20 + .../dimstack/plugin/api/SettingFetcher.java | 12 + demo/dim_stack/backend/pom.xml | 46 + demo/dim_stack/cli/src/config/__init__.py | 0 demo/dim_stack/cli/src/config/cli_config.py | 67 + demo/dim_stack/cli/src/config/config_read.py | 13 + demo/dim_stack/cli/src/core/__init__.py | 0 demo/dim_stack/cli/src/core/command_router.py | 65 + demo/dim_stack/cli/src/core/server_control.py | 270 ++ demo/dim_stack/cli/src/main.py | 91 + demo/dim_stack/cli/src/model/__init__.py | 0 demo/dim_stack/cli/src/model/config_model.py | 169 + demo/dim_stack/cli/src/view/__init__.py | 0 demo/dim_stack/cli/src/view/cli_menu.py | 122 + .../dim_stack/docs/architecture/cache-mode.md | 5 + .../docs/architecture/startup-flow.md | 5 + demo/dim_stack/docs/deployment.md | 211 ++ .../dim_stack/docs/features/code-injection.md | 17 + demo/dim_stack/docs/features/comments.md | 81 + .../docs/features/external-resources.md | 52 + .../docs/features/image-compression.md | 43 + demo/dim_stack/docs/features/key.md | 19 + .../features/llm_generation_and_review.md | 119 + .../features/script_usage_instructions.md | 90 + demo/dim_stack/docs/features/seo.md | 26 + demo/dim_stack/docs/features/storage.md | 100 + demo/dim_stack/docs/legacy.md | 19 + .../docs/plugins/plugin-development-guide.md | 682 +++++ .../themes/themes-frontend-routing-guide.md | 2728 +++++++++++++++++ demo/dim_stack/frontend/.gitignore | 24 + demo/dim_stack/frontend/eslint.config.js | 40 + demo/dim_stack/frontend/index.html | 25 + demo/dim_stack/frontend/package.json | 59 + .../dim_stack/frontend/public/image_error.svg | 6 + .../vendor-entries/react-dom-client.js | 6 + .../scripts/vendor-entries/react-dom.js | 14 + .../vendor-entries/react-jsx-runtime.js | 6 + .../vendor-entries/react-router-dom.js | 4 + .../frontend/scripts/vendor-entries/react.js | 42 + demo/dim_stack/frontend/src/Api.jsx | 92 + demo/dim_stack/frontend/src/App.jsx | 112 + .../src/components/AnnouncementModal.jsx | 81 + .../frontend/src/components/ArticleCard.jsx | 187 ++ .../frontend/src/components/ArticleList.jsx | 237 ++ .../src/components/ArticlePreview.jsx | 959 ++++++ .../frontend/src/components/ArticleReader.jsx | 409 +++ .../src/components/CategorySidebar.jsx | 132 + .../src/components/CommentSection.jsx | 465 +++ .../src/components/CustomHtmlPage.jsx | 273 ++ .../frontend/src/components/ExtensionSlot.jsx | 26 + .../src/components/ForgotPassword.jsx | 486 +++ .../frontend/src/components/FriendLinks.jsx | 562 ++++ .../frontend/src/components/Header.jsx | 519 ++++ .../frontend/src/components/Hero.jsx | 120 + .../frontend/src/components/ImageCropper.jsx | 271 ++ .../frontend/src/components/ImageLightbox.jsx | 218 ++ .../frontend/src/components/Login.jsx | 343 +++ .../frontend/src/components/MusicPlayer.jsx | 414 +++ .../frontend/src/components/PageNotFound.jsx | 56 + .../src/components/RecommendedArticles.jsx | 183 ++ .../frontend/src/components/Register.jsx | 516 ++++ .../src/components/ScriptAwareHtml.jsx | 31 + .../frontend/src/components/Search.jsx | 182 ++ .../src/components/TableOfContents.jsx | 323 ++ .../frontend/src/components/TagSidebar.jsx | 128 + .../frontend/src/components/ThemeToggle.jsx | 106 + .../dashboard/AnnouncementManager.jsx | 195 ++ .../components/dashboard/ApiKeyManager.jsx | 257 ++ .../components/dashboard/ArticleInfoForm.jsx | 698 +++++ .../components/dashboard/ArticlesReview.jsx | 1017 ++++++ .../src/components/dashboard/ArticlesView.jsx | 555 ++++ .../src/components/dashboard/AvatarUpload.jsx | 133 + .../components/dashboard/CategoriesView.jsx | 412 +++ .../components/dashboard/CommentsReview.jsx | 155 + .../src/components/dashboard/CommentsView.jsx | 417 +++ .../dashboard/CustomPageManager.jsx | 305 ++ .../src/components/dashboard/Dashboard.jsx | 579 ++++ .../components/dashboard/DashboardHeader.jsx | 113 + .../components/dashboard/DashboardView.jsx | 307 ++ .../src/components/dashboard/DataTable.jsx | 149 + .../dashboard/DocumentPreviewModal.jsx | 638 ++++ .../dashboard/FriendLinksManager.jsx | 1002 ++++++ .../dashboard/GlobalAttachmentsManager.jsx | 1210 ++++++++ .../components/dashboard/MarkdownEditor.jsx | 1389 +++++++++ .../components/dashboard/MarkdownPreview.jsx | 324 ++ .../components/dashboard/MarkdownTextarea.jsx | 30 + .../components/dashboard/MarkdownToolbar.jsx | 74 + .../src/components/dashboard/MenusView.jsx | 385 +++ .../src/components/dashboard/Pagination.jsx | 161 + .../components/dashboard/PluginManager.jsx | 460 +++ .../src/components/dashboard/ProfileView.jsx | 508 +++ .../src/components/dashboard/RoleManager.jsx | 468 +++ .../src/components/dashboard/Sidebar.jsx | 238 ++ .../components/dashboard/SiteSettingsView.jsx | 2332 ++++++++++++++ .../dashboard/StorageConfigSection.jsx | 571 ++++ .../src/components/dashboard/TagsView.jsx | 267 ++ .../dashboard/TextSelectionToolbar.jsx | 188 ++ .../components/dashboard/ThemesStoreView.jsx | 477 +++ .../components/dashboard/UpdateManager.jsx | 702 +++++ .../dashboard/UserAttachmentsManager.jsx | 597 ++++ .../src/components/dashboard/UsersView.jsx | 1070 +++++++ .../frontend/src/hooks/useFileUpload.jsx | 163 + demo/dim_stack/frontend/src/index.css | 1466 +++++++++ demo/dim_stack/frontend/src/main.jsx | 23 + demo/dim_stack/frontend/src/pages/Home.jsx | 167 + .../src/plugin/ExtensionPointRegistry.js | 52 + .../frontend/src/plugin/pluginRuntime.js | 215 ++ .../frontend/src/utils/IconUtils.jsx | 109 + demo/dim_stack/frontend/src/utils/axios.jsx | 50 + demo/dim_stack/frontend/src/utils/config.jsx | 40 + .../src/utils/formatDetectionUtils.jsx | 121 + .../frontend/src/utils/markdownUtils.jsx | 128 + .../src/utils/remark-disable-lonely-ol.jsx | 45 + .../frontend/src/utils/toastManager.jsx | 115 + .../src/utils/uploadProgressManager.jsx | 180 ++ demo/dim_stack/frontend/vite.config.js | 56 + demo/dim_stack/frontend/vite.vendor.config.js | 32 + .../article_generation_default_prompt.png | Bin 0 -> 39744 bytes .../images/article_generation_demo_1.png | Bin 0 -> 23486 bytes .../images/article_generation_demo_2.png | Bin 0 -> 336729 bytes .../images/article_generation_demo_3.png | Bin 0 -> 934487 bytes ...article_reader_download_original_image.png | Bin 0 -> 2686575 bytes .../images/article_review_default_prompt.png | Bin 0 -> 325469 bytes .../check_dimstack_service_status_command.png | Bin 0 -> 97338 bytes .../images/comment_review_default_prompt.png | Bin 0 -> 57820 bytes .../images/comment_section_management.png | Bin 0 -> 187863 bytes .../images/comment_section_review.png | Bin 0 -> 189549 bytes .../images/comment_section_toggle_switch.png | Bin 0 -> 105102 bytes .../images/comment_section_toggles_switch.png | Bin 0 -> 311594 bytes .../images/custom_code_injection_config.png | Bin 0 -> 555757 bytes .../enable_dimstack_autostart_command.png | Bin 0 -> 15131 bytes .../images/homepage_loaded_successfully.png | Bin 0 -> 2876821 bytes .../images/initialization_setup_page.png | Bin 0 -> 4551 bytes .../images/initialization_success_page.png | Bin 0 -> 29276 bytes .../images/initialization_wizard_form.png | Bin 0 -> 69308 bytes demo/dim_stack/images/key_1.png | Bin 0 -> 170752 bytes demo/dim_stack/images/key_2.png | Bin 0 -> 24301 bytes .../large_model_configuration_interface.png | Bin 0 -> 116026 bytes .../llm_article_content_reading_support.png | Bin 0 -> 171071 bytes .../localization_of_external_resources_1.png | Bin 0 -> 294482 bytes .../localization_of_external_resources_2.png | Bin 0 -> 854070 bytes .../localization_of_external_resources_3.png | Bin 0 -> 592559 bytes .../localization_of_external_resources_4.png | Bin 0 -> 846760 bytes demo/dim_stack/images/memory_mode.svg | 1 + demo/dim_stack/images/scripts.png | Bin 0 -> 12840 bytes .../images/seo_optimization_support.png | Bin 0 -> 984008 bytes .../images/smart_image_compression_config.png | Bin 0 -> 950735 bytes .../images/start_dimstack_service_command.png | Bin 0 -> 2522 bytes demo/dim_stack/images/start_up_flowchart.svg | 1 + demo/dim_stack/images/storage_1.png | Bin 0 -> 119650 bytes demo/dim_stack/images/storage_2.png | Bin 0 -> 38747 bytes demo/dim_stack/images/storage_3.png | Bin 0 -> 26771 bytes demo/dim_stack/images/storge_3.png | Bin 0 -> 27907 bytes .../images/systemd_daemon_reload_command.png | Bin 0 -> 8056 bytes .../images/systemd_service_file_config.png | Bin 0 -> 78214 bytes .../v54tov55_mysql5.sql" | 16 + .../v54tov55_mysql8.sql" | 16 + .../v64tov65_mysql5.sql" | 15 + .../v64tov65_mysql8.sql" | 15 + .../v77tov82_mysql5.sql" | 13 + .../v77tov82_mysql8.sql" | 13 + .../\350\257\264\346\230\216.txt" | 1 + demo/dim_stack/plugin_example/hello/pom.xml | 55 + .../xyz/example/hello/HelloController.java | 24 + .../xyz/example/hello/NoteController.java | 30 + .../src/main/resources/extensions/menu.yaml | 5 + .../hello/src/main/resources/plugin.yaml | 13 + .../hello/src/main/resources/ui/entry.js | 57 + .../hello/src/main/resources/ui/style.css | 35 + .../src/main/resources/ui/ui-plugin.json | 1 + demo/dim_stack/scripts/build_docker.sh | 61 + demo/dim_stack/scripts/build_release.ps1 | 160 + demo/dim_stack/scripts/build_release.sh | 129 + demo/dim_stack/scripts/cli_build.sh | 5 + .../scripts/generate_update_log_yaml.py | 174 ++ demo/dim_stack/scripts/git_history.bat | 4 + demo/dim_stack/scripts/git_history.sh | 2 + 611 files changed, 67784 insertions(+) create mode 100644 demo/dim_stack/.gitattributes create mode 100644 demo/dim_stack/.github/workflows/build.yml create mode 100644 demo/dim_stack/.gitignore create mode 100644 demo/dim_stack/Dockerfile create mode 100644 demo/dim_stack/LICENSE create mode 100644 demo/dim_stack/README.md create mode 100644 demo/dim_stack/backend/check_role_permission.sql create mode 100644 demo/dim_stack/backend/dimstack-app/pom.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/StartServer.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/annotation/RateLimit.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/annotation/RequiresPermission.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/common/ApiResponse.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/common/RateLimitException.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/CacheAutoConfiguration.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/ConfigInfo.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/DatabaseCreator.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/DatabaseInitializer.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/DynamicResourceResolver.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/FlywayConfig.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/HotArticleCacheInitializer.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/InitSiteConfigToRedis.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/RedisConfig.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/SessionConfig.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/Swagger3Config.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/ThemeProperties.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/WebConfig.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/AnnouncementController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ApiKeyController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ArticleGenerationController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ArticlePageController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ArticleReviewController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ArticleSearchController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/AttachmentManagementController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/BackentCommentController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/CacheManagementController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/CaptchaController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/CommentController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/CommentReviewController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/CustomErrorController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/CustomPageController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/DashboardController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/EditArticleController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ExpansionController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/FaviconController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/FileAccessController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ForgotPasswordController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/FriendLinksController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/HomeController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/HomePageController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/HotArticleController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/LlmConfigController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/LlmPromptConfigController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/LoginController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/MenusController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/MusicController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/PermissionTestController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/PluginController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ProxyController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/RandomArticleController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ReadArticleController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/RegisterController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/RobotsController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/RoleController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/RouterController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/SiteConfigController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/SitemapController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/StatisticsController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/StorageMethodController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/SystematicNotificationController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/TagAndCategoryController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/TagCategoryController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ThemeController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/UpdateController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/UploadController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/UserController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/UserRoleController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/Announcement.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/ApiKey.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/Article.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/ArticleCategory.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/ArticleCategoryAndCount.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/ArticleLike.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/ArticleTag.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/AttachmentManagement.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/Comment.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/CommentLike.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/CustomPage.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/DashboardMenu.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/FriendLinks.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/FriendLinksConfig.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/HomeArticle.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/LlmConfig.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/LlmPromptConfig.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/Login.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/MailConfig.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/Menus.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/Music.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/Permission.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/PluginConfig.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/PluginInfo.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/PluginSqlAudit.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/ReadArticle.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/Register.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/Role.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/SiteConfig.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/StorageMethod.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/StorageMigrationFailedItem.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/StorageMigrationLog.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/SystematicNotification.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/UpdateInfo.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/UploadArticle.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/UploadAttachment.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/UserInformation.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/AddCommentRequestDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/ApiKeyCreateRequestDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/ArticleCategoryDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/ArticleDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/ArticleDetailDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/ArticleReviewDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/ArticleStatusUpdateRequestDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/ArticleTagDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/CommentDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/CustomPageRequest.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/DashboardMenuDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/EditArticleDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/ForgotPasswordDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/FriendLinksRequestDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/HeroDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/HotArticleDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/LoginDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/MenusDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/PageRequest.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/PageResult.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/PerformUpdateRequestDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/RegisterDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/SearchResultDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/TestSmtpRequestDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/ThemeSlugRequestDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/UpdateArticleDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/UserDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/UserUpdateDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ApiKeyCreatedResponseDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ApiKeyResponseDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ArticleOperationResult.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ArticleReviewDetailResponseDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ArticleReviewListResponseDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ArticleReviewStatusResponseDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ArticleSearchResponseDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ArticleSearchResultDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/CustomPageResponse.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ForgotPasswordResponseDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/LoginResponseDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/LogoutResponseDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/RegisterResponseDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ThemeDetailResponseDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ThemeListResponseDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/UserStatusResponseDTO.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/enums/AiReviewResult.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/init/InitApplication.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/init/InitController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/interceptor/GlobalExceptionHandler.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/interceptor/RateLimitAspect.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/interceptor/ThemeResourceFilter.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/interceptor/UserAgentSessionFilter.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/interceptor/UserPermissionInterceptor.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/AnnouncementMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/ApiKeyMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/ArticleCategoryMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/ArticleLikeMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/ArticleMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/ArticleReviewMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/ArticleSearchMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/ArticleTagMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/AttachmentManagementMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/BackendCommentMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/CommentLikeMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/CommentMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/CustomPageMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/DashboardMenuMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/EditArticleMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/FriendLinksConfigMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/FriendLinksMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/HotArticleMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/LlmConfigMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/LlmPromptConfigMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/LoginMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/MenusMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/MusicMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/PermissionMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/PluginConfigMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/PluginMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/PluginSqlAuditMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/ReadArticleMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/RegisterMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/RoleMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/SiteConfigMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/StatisticsMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/StorageMethodMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/StorageMigrationLogMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/SystematicNotificationMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/UploadMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/UserInformationMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/UserPermissionMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/UserRoleMapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/DefaultPluginApplicationContextFactory.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/DefaultSpringPlugin.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/DimStackPluginManager.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/ExtensionGetterImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginApplicationContextFactory.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginAssetController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginAuditLogger.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginAutoConfiguration.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginDbImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginDbRegistry.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginExtensionLoader.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginLifecycleGuard.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginManifest.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginPermission.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginPermissionInterceptor.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginPermissionRegistrar.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginRequestMappingHandlerMapping.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginSqlAuditProxy.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginSqlAuditor.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginYamlLoader.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/SettingFetcherImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/SpringPluginFactory.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/UiPluginBundleService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/UiPluginBundleServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/UiPluginController.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/UiPluginProviderDescriptor.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/YamlPluginDescriptorFinder.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/security/AuthType.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/security/AuthenticatedUser.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/security/RepeatableReadRequestWrapper.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/security/SecurityFilter.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/security/SessionAuthFilter.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/security/TokenAuthResolver.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/security/UserContextHolder.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/AnnouncementService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/ApiKeyService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/ArticleCategoryService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/ArticleReviewService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/ArticleSearchService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/ArticleService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/ArticleTagService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/AttachmentManagementService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/BackendCommentService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/CacheService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/CommentService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/CurrentUserService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/CustomPageService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/DashboardMenuService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/EditArticleService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/FileAccessService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/FileStorage.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/ForgotPasswordService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/FriendLinksConfigService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/FriendLinksService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/HotArticleService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/ImageCompressionService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/LLMService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/LlmConfigService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/LlmPromptConfigService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/LoginService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/MailService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/MenusService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/MusicService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/NotificationService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/PageViewCounterService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/PluginService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/ReadArticleService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/RegisterService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/RoleService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/SiteConfigService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/StatisticsService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/StorageFacadeService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/StorageMethodService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/UpdateService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/UploadService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/UserBlacklistService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/UserPermissionCheckService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/UserRoleService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/UserService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/AnnouncementServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/ApiKeyServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/ArticleCategoryServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/ArticleReviewServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/ArticleSearchServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/ArticleServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/ArticleTagServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/AttachmentManagementServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/BackendCommentServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/CommentServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/CurrentUserServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/CustomPageServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/DashboardMenuServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/EditArticleServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/FileAccessServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/ForgotPasswordServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/FriendLinksConfigServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/FriendLinksServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/HotArticleServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/ImageCompressionServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/LLMServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/LlmConfigServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/LlmPromptConfigServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/LocalFileStorageImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/LoginServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/MailServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/MemoryCacheServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/MenusServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/MusicServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/NotificationServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/PageViewCounterServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/PluginServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/ReadArticleServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/RedisCacheServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/RegisterServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/RoleServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/S3FileStorageImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/SiteConfigServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/StatisticsServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/StorageFacadeServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/StorageMethodServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/UpdateServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/UploadServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/UserBlacklistServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/UserPermissionCheckServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/UserRoleServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/UserServiceImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/WebDavFileStorageImpl.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/task/ExpiredAttachmentCleanupTask.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/task/HotArticleScheduledTask.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/task/TempUploadCleanupService.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/ApiKeyUtil.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/BotUtil.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/CaptchaUtil.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/CategoryPathUtil.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/DomainUtil.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/FileSuffix.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/ImageCompressorUtil.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/LargeLanguageModelsUtil.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/MimeTypeUtil.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/PasswordUtil.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/RandomUtil.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/SiteConfigUtil.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/UrlUtil.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/application-init.yml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/banner.txt create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V10__add_image_compression_threads.sql create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V11__add_proxy_resource_download.sql create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V12__add_friend_links_config.sql create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V13__update_llm_prompt.sql create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V14__clear_custom_page_status_delete.sql create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V15__add_comment_toggle.sql create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V16__add_api_key.sql create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V17__add_announcement.sql create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V18__add_comment_review.sql create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V19__allow_null_tag_and_category_explain.sql create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V1__init_tables.sql create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V20__add_storage_support.sql create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V21__storage_migration_log.sql create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V22__add_attachment_content_type.sql create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V23__add_plugin_tables.sql create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V24__add_plugin_sql_audit.sql create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V25__menu_auto_increment.sql create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V26__widen_permission_code.sql create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V2__admin_post_no_review.sql create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V3__multi_level_categories.sql create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V4__attachment_management.sql create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V5__add_original_filename.sql create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V6__rbac_to_complete_rbac.sql create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V7__add_llm_model.sql create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V8__add_code_Injection.sql create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V9__add_image_compression.sql create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/database_version.txt create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/init.sql create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/logback-spring.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/AnnouncementMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/ApiKeyMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/ArticleCategoryMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/ArticleLikeMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/ArticleMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/ArticleReviewMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/ArticleSearchMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/ArticleTagMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/AttachmentManagementMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/BackendCommentMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/CommentLikeMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/CommentMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/CustomPageMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/DashboardMenuMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/EditArticleMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/FriendLinksConfigMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/FriendLinksMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/HotArticleMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/LlmConfigMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/LlmPromptConfigMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/LoginMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/MenusMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/MusicMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/PermissionMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/PluginConfigMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/PluginMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/PluginSqlAuditMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/ReadArticleMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/RegisterMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/RoleMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/SiteConfigMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/StatisticsMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/StorageMethodMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/StorageMigrationLogMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/SystematicNotificationMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/UploadArticleMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/UploadAttachmentMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/UserInformationMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/UserPermissionMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/UserRoleMapper.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/mybatis-config.xml create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/system_version.txt create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/templates/init/result.html create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/templates/init/setup.html create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/templates/seo_404.html create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/templates/seo_article.html create mode 100644 demo/dim_stack/backend/dimstack-app/src/main/resources/templates/seo_home.html create mode 100644 demo/dim_stack/backend/dimstack-app/src/test/java/xyz/lingview/dimstack/test/ArticleTagAndCategoryTest.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/test/java/xyz/lingview/dimstack/test/PermissionBasedTestControllerTest.java create mode 100644 demo/dim_stack/backend/dimstack-app/src/test/java/xyz/lingview/dimstack/test/PermissionMapperIntegrationTest.java create mode 100644 demo/dim_stack/backend/dimstack-plugin-api/pom.xml create mode 100644 demo/dim_stack/backend/dimstack-plugin-api/src/main/java/xyz/lingview/dimstack/plugin/api/BasePlugin.java create mode 100644 demo/dim_stack/backend/dimstack-plugin-api/src/main/java/xyz/lingview/dimstack/plugin/api/ExtensionGetter.java create mode 100644 demo/dim_stack/backend/dimstack-plugin-api/src/main/java/xyz/lingview/dimstack/plugin/api/ExtensionPoint.java create mode 100644 demo/dim_stack/backend/dimstack-plugin-api/src/main/java/xyz/lingview/dimstack/plugin/api/PluginContext.java create mode 100644 demo/dim_stack/backend/dimstack-plugin-api/src/main/java/xyz/lingview/dimstack/plugin/api/PluginDb.java create mode 100644 demo/dim_stack/backend/dimstack-plugin-api/src/main/java/xyz/lingview/dimstack/plugin/api/RequiresPermission.java create mode 100644 demo/dim_stack/backend/dimstack-plugin-api/src/main/java/xyz/lingview/dimstack/plugin/api/SettingFetcher.java create mode 100644 demo/dim_stack/backend/pom.xml create mode 100644 demo/dim_stack/cli/src/config/__init__.py create mode 100644 demo/dim_stack/cli/src/config/cli_config.py create mode 100644 demo/dim_stack/cli/src/config/config_read.py create mode 100644 demo/dim_stack/cli/src/core/__init__.py create mode 100644 demo/dim_stack/cli/src/core/command_router.py create mode 100644 demo/dim_stack/cli/src/core/server_control.py create mode 100644 demo/dim_stack/cli/src/main.py create mode 100644 demo/dim_stack/cli/src/model/__init__.py create mode 100644 demo/dim_stack/cli/src/model/config_model.py create mode 100644 demo/dim_stack/cli/src/view/__init__.py create mode 100644 demo/dim_stack/cli/src/view/cli_menu.py create mode 100644 demo/dim_stack/docs/architecture/cache-mode.md create mode 100644 demo/dim_stack/docs/architecture/startup-flow.md create mode 100644 demo/dim_stack/docs/deployment.md create mode 100644 demo/dim_stack/docs/features/code-injection.md create mode 100644 demo/dim_stack/docs/features/comments.md create mode 100644 demo/dim_stack/docs/features/external-resources.md create mode 100644 demo/dim_stack/docs/features/image-compression.md create mode 100644 demo/dim_stack/docs/features/key.md create mode 100644 demo/dim_stack/docs/features/llm_generation_and_review.md create mode 100644 demo/dim_stack/docs/features/script_usage_instructions.md create mode 100644 demo/dim_stack/docs/features/seo.md create mode 100644 demo/dim_stack/docs/features/storage.md create mode 100644 demo/dim_stack/docs/legacy.md create mode 100644 demo/dim_stack/docs/plugins/plugin-development-guide.md create mode 100644 demo/dim_stack/docs/themes/themes-frontend-routing-guide.md create mode 100644 demo/dim_stack/frontend/.gitignore create mode 100644 demo/dim_stack/frontend/eslint.config.js create mode 100644 demo/dim_stack/frontend/index.html create mode 100644 demo/dim_stack/frontend/package.json create mode 100644 demo/dim_stack/frontend/public/image_error.svg create mode 100644 demo/dim_stack/frontend/scripts/vendor-entries/react-dom-client.js create mode 100644 demo/dim_stack/frontend/scripts/vendor-entries/react-dom.js create mode 100644 demo/dim_stack/frontend/scripts/vendor-entries/react-jsx-runtime.js create mode 100644 demo/dim_stack/frontend/scripts/vendor-entries/react-router-dom.js create mode 100644 demo/dim_stack/frontend/scripts/vendor-entries/react.js create mode 100644 demo/dim_stack/frontend/src/Api.jsx create mode 100644 demo/dim_stack/frontend/src/App.jsx create mode 100644 demo/dim_stack/frontend/src/components/AnnouncementModal.jsx create mode 100644 demo/dim_stack/frontend/src/components/ArticleCard.jsx create mode 100644 demo/dim_stack/frontend/src/components/ArticleList.jsx create mode 100644 demo/dim_stack/frontend/src/components/ArticlePreview.jsx create mode 100644 demo/dim_stack/frontend/src/components/ArticleReader.jsx create mode 100644 demo/dim_stack/frontend/src/components/CategorySidebar.jsx create mode 100644 demo/dim_stack/frontend/src/components/CommentSection.jsx create mode 100644 demo/dim_stack/frontend/src/components/CustomHtmlPage.jsx create mode 100644 demo/dim_stack/frontend/src/components/ExtensionSlot.jsx create mode 100644 demo/dim_stack/frontend/src/components/ForgotPassword.jsx create mode 100644 demo/dim_stack/frontend/src/components/FriendLinks.jsx create mode 100644 demo/dim_stack/frontend/src/components/Header.jsx create mode 100644 demo/dim_stack/frontend/src/components/Hero.jsx create mode 100644 demo/dim_stack/frontend/src/components/ImageCropper.jsx create mode 100644 demo/dim_stack/frontend/src/components/ImageLightbox.jsx create mode 100644 demo/dim_stack/frontend/src/components/Login.jsx create mode 100644 demo/dim_stack/frontend/src/components/MusicPlayer.jsx create mode 100644 demo/dim_stack/frontend/src/components/PageNotFound.jsx create mode 100644 demo/dim_stack/frontend/src/components/RecommendedArticles.jsx create mode 100644 demo/dim_stack/frontend/src/components/Register.jsx create mode 100644 demo/dim_stack/frontend/src/components/ScriptAwareHtml.jsx create mode 100644 demo/dim_stack/frontend/src/components/Search.jsx create mode 100644 demo/dim_stack/frontend/src/components/TableOfContents.jsx create mode 100644 demo/dim_stack/frontend/src/components/TagSidebar.jsx create mode 100644 demo/dim_stack/frontend/src/components/ThemeToggle.jsx create mode 100644 demo/dim_stack/frontend/src/components/dashboard/AnnouncementManager.jsx create mode 100644 demo/dim_stack/frontend/src/components/dashboard/ApiKeyManager.jsx create mode 100644 demo/dim_stack/frontend/src/components/dashboard/ArticleInfoForm.jsx create mode 100644 demo/dim_stack/frontend/src/components/dashboard/ArticlesReview.jsx create mode 100644 demo/dim_stack/frontend/src/components/dashboard/ArticlesView.jsx create mode 100644 demo/dim_stack/frontend/src/components/dashboard/AvatarUpload.jsx create mode 100644 demo/dim_stack/frontend/src/components/dashboard/CategoriesView.jsx create mode 100644 demo/dim_stack/frontend/src/components/dashboard/CommentsReview.jsx create mode 100644 demo/dim_stack/frontend/src/components/dashboard/CommentsView.jsx create mode 100644 demo/dim_stack/frontend/src/components/dashboard/CustomPageManager.jsx create mode 100644 demo/dim_stack/frontend/src/components/dashboard/Dashboard.jsx create mode 100644 demo/dim_stack/frontend/src/components/dashboard/DashboardHeader.jsx create mode 100644 demo/dim_stack/frontend/src/components/dashboard/DashboardView.jsx create mode 100644 demo/dim_stack/frontend/src/components/dashboard/DataTable.jsx create mode 100644 demo/dim_stack/frontend/src/components/dashboard/DocumentPreviewModal.jsx create mode 100644 demo/dim_stack/frontend/src/components/dashboard/FriendLinksManager.jsx create mode 100644 demo/dim_stack/frontend/src/components/dashboard/GlobalAttachmentsManager.jsx create mode 100644 demo/dim_stack/frontend/src/components/dashboard/MarkdownEditor.jsx create mode 100644 demo/dim_stack/frontend/src/components/dashboard/MarkdownPreview.jsx create mode 100644 demo/dim_stack/frontend/src/components/dashboard/MarkdownTextarea.jsx create mode 100644 demo/dim_stack/frontend/src/components/dashboard/MarkdownToolbar.jsx create mode 100644 demo/dim_stack/frontend/src/components/dashboard/MenusView.jsx create mode 100644 demo/dim_stack/frontend/src/components/dashboard/Pagination.jsx create mode 100644 demo/dim_stack/frontend/src/components/dashboard/PluginManager.jsx create mode 100644 demo/dim_stack/frontend/src/components/dashboard/ProfileView.jsx create mode 100644 demo/dim_stack/frontend/src/components/dashboard/RoleManager.jsx create mode 100644 demo/dim_stack/frontend/src/components/dashboard/Sidebar.jsx create mode 100644 demo/dim_stack/frontend/src/components/dashboard/SiteSettingsView.jsx create mode 100644 demo/dim_stack/frontend/src/components/dashboard/StorageConfigSection.jsx create mode 100644 demo/dim_stack/frontend/src/components/dashboard/TagsView.jsx create mode 100644 demo/dim_stack/frontend/src/components/dashboard/TextSelectionToolbar.jsx create mode 100644 demo/dim_stack/frontend/src/components/dashboard/ThemesStoreView.jsx create mode 100644 demo/dim_stack/frontend/src/components/dashboard/UpdateManager.jsx create mode 100644 demo/dim_stack/frontend/src/components/dashboard/UserAttachmentsManager.jsx create mode 100644 demo/dim_stack/frontend/src/components/dashboard/UsersView.jsx create mode 100644 demo/dim_stack/frontend/src/hooks/useFileUpload.jsx create mode 100644 demo/dim_stack/frontend/src/index.css create mode 100644 demo/dim_stack/frontend/src/main.jsx create mode 100644 demo/dim_stack/frontend/src/pages/Home.jsx create mode 100644 demo/dim_stack/frontend/src/plugin/ExtensionPointRegistry.js create mode 100644 demo/dim_stack/frontend/src/plugin/pluginRuntime.js create mode 100644 demo/dim_stack/frontend/src/utils/IconUtils.jsx create mode 100644 demo/dim_stack/frontend/src/utils/axios.jsx create mode 100644 demo/dim_stack/frontend/src/utils/config.jsx create mode 100644 demo/dim_stack/frontend/src/utils/formatDetectionUtils.jsx create mode 100644 demo/dim_stack/frontend/src/utils/markdownUtils.jsx create mode 100644 demo/dim_stack/frontend/src/utils/remark-disable-lonely-ol.jsx create mode 100644 demo/dim_stack/frontend/src/utils/toastManager.jsx create mode 100644 demo/dim_stack/frontend/src/utils/uploadProgressManager.jsx create mode 100644 demo/dim_stack/frontend/vite.config.js create mode 100644 demo/dim_stack/frontend/vite.vendor.config.js create mode 100644 demo/dim_stack/images/article_generation_default_prompt.png create mode 100644 demo/dim_stack/images/article_generation_demo_1.png create mode 100644 demo/dim_stack/images/article_generation_demo_2.png create mode 100644 demo/dim_stack/images/article_generation_demo_3.png create mode 100644 demo/dim_stack/images/article_reader_download_original_image.png create mode 100644 demo/dim_stack/images/article_review_default_prompt.png create mode 100644 demo/dim_stack/images/check_dimstack_service_status_command.png create mode 100644 demo/dim_stack/images/comment_review_default_prompt.png create mode 100644 demo/dim_stack/images/comment_section_management.png create mode 100644 demo/dim_stack/images/comment_section_review.png create mode 100644 demo/dim_stack/images/comment_section_toggle_switch.png create mode 100644 demo/dim_stack/images/comment_section_toggles_switch.png create mode 100644 demo/dim_stack/images/custom_code_injection_config.png create mode 100644 demo/dim_stack/images/enable_dimstack_autostart_command.png create mode 100644 demo/dim_stack/images/homepage_loaded_successfully.png create mode 100644 demo/dim_stack/images/initialization_setup_page.png create mode 100644 demo/dim_stack/images/initialization_success_page.png create mode 100644 demo/dim_stack/images/initialization_wizard_form.png create mode 100644 demo/dim_stack/images/key_1.png create mode 100644 demo/dim_stack/images/key_2.png create mode 100644 demo/dim_stack/images/large_model_configuration_interface.png create mode 100644 demo/dim_stack/images/llm_article_content_reading_support.png create mode 100644 demo/dim_stack/images/localization_of_external_resources_1.png create mode 100644 demo/dim_stack/images/localization_of_external_resources_2.png create mode 100644 demo/dim_stack/images/localization_of_external_resources_3.png create mode 100644 demo/dim_stack/images/localization_of_external_resources_4.png create mode 100644 demo/dim_stack/images/memory_mode.svg create mode 100644 demo/dim_stack/images/scripts.png create mode 100644 demo/dim_stack/images/seo_optimization_support.png create mode 100644 demo/dim_stack/images/smart_image_compression_config.png create mode 100644 demo/dim_stack/images/start_dimstack_service_command.png create mode 100644 demo/dim_stack/images/start_up_flowchart.svg create mode 100644 demo/dim_stack/images/storage_1.png create mode 100644 demo/dim_stack/images/storage_2.png create mode 100644 demo/dim_stack/images/storage_3.png create mode 100644 demo/dim_stack/images/storge_3.png create mode 100644 demo/dim_stack/images/systemd_daemon_reload_command.png create mode 100644 demo/dim_stack/images/systemd_service_file_config.png create mode 100644 "demo/dim_stack/legacy/\346\225\260\346\215\256\345\272\223\346\233\264\346\226\260\350\204\232\346\234\254/v54tov55_mysql5.sql" create mode 100644 "demo/dim_stack/legacy/\346\225\260\346\215\256\345\272\223\346\233\264\346\226\260\350\204\232\346\234\254/v54tov55_mysql8.sql" create mode 100644 "demo/dim_stack/legacy/\346\225\260\346\215\256\345\272\223\346\233\264\346\226\260\350\204\232\346\234\254/v64tov65_mysql5.sql" create mode 100644 "demo/dim_stack/legacy/\346\225\260\346\215\256\345\272\223\346\233\264\346\226\260\350\204\232\346\234\254/v64tov65_mysql8.sql" create mode 100644 "demo/dim_stack/legacy/\346\225\260\346\215\256\345\272\223\346\233\264\346\226\260\350\204\232\346\234\254/v77tov82_mysql5.sql" create mode 100644 "demo/dim_stack/legacy/\346\225\260\346\215\256\345\272\223\346\233\264\346\226\260\350\204\232\346\234\254/v77tov82_mysql8.sql" create mode 100644 "demo/dim_stack/legacy/\346\225\260\346\215\256\345\272\223\346\233\264\346\226\260\350\204\232\346\234\254/\350\257\264\346\230\216.txt" create mode 100644 demo/dim_stack/plugin_example/hello/pom.xml create mode 100644 demo/dim_stack/plugin_example/hello/src/main/java/xyz/example/hello/HelloController.java create mode 100644 demo/dim_stack/plugin_example/hello/src/main/java/xyz/example/hello/NoteController.java create mode 100644 demo/dim_stack/plugin_example/hello/src/main/resources/extensions/menu.yaml create mode 100644 demo/dim_stack/plugin_example/hello/src/main/resources/plugin.yaml create mode 100644 demo/dim_stack/plugin_example/hello/src/main/resources/ui/entry.js create mode 100644 demo/dim_stack/plugin_example/hello/src/main/resources/ui/style.css create mode 100644 demo/dim_stack/plugin_example/hello/src/main/resources/ui/ui-plugin.json create mode 100644 demo/dim_stack/scripts/build_docker.sh create mode 100644 demo/dim_stack/scripts/build_release.ps1 create mode 100644 demo/dim_stack/scripts/build_release.sh create mode 100644 demo/dim_stack/scripts/cli_build.sh create mode 100644 demo/dim_stack/scripts/generate_update_log_yaml.py create mode 100644 demo/dim_stack/scripts/git_history.bat create mode 100644 demo/dim_stack/scripts/git_history.sh diff --git a/demo/dim_stack/.gitattributes b/demo/dim_stack/.gitattributes new file mode 100644 index 0000000..c13936d --- /dev/null +++ b/demo/dim_stack/.gitattributes @@ -0,0 +1,3 @@ +*.xml linguist-language=Java +*.sql linguist-language=Java +*.yml linguist-language=Java \ No newline at end of file diff --git a/demo/dim_stack/.github/workflows/build.yml b/demo/dim_stack/.github/workflows/build.yml new file mode 100644 index 0000000..d22672c --- /dev/null +++ b/demo/dim_stack/.github/workflows/build.yml @@ -0,0 +1,88 @@ +name: Build and Release +on: + workflow_dispatch: + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Verify migration baseline (init.sql vs database_version.txt) + run: | + MIG_DIR=backend/dimstack-app/src/main/resources/db/migration + latest=$(ls "$MIG_DIR"/V*.sql | sed -E 's#.*/V([0-9]+)__.*#\1#' | sort -n | tail -1) + baseline=$(tr -d '[:space:]' < "$MIG_DIR/database_version.txt") + echo "latest migration = V$latest, database_version.txt = $baseline" + if [ "$latest" != "$baseline" ]; then + echo "::error::database_version.txt($baseline) 与最新迁移脚本(V$latest) 不一致, 新装库会漏掉迁移内容" + exit 1 + fi + + - uses: actions/cache@v4 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: ${{ runner.os }}-maven- + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: 'temurin' + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install frontend dependencies + run: | + cd frontend + npm install + + - name: Build frontend + run: | + cd frontend + npm run build + + - name: Clean backend static directory + run: | + mkdir -p backend/dimstack-app/src/main/resources/static + rm -rf backend/dimstack-app/src/main/resources/static/* + + - name: Copy frontend build to backend static + run: | + mkdir -p backend/dimstack-app/src/main/resources/static + cp -r frontend/dist/* backend/dimstack-app/src/main/resources/static/ + + - name: Build backend with Maven + run: | + cd backend + mvn clean package -DskipTests + + - name: Create release package + run: | + cd backend + mkdir release + cp dimstack-app/target/*.jar release/ + cd release + zip -r dim_stack-release.zip * + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: dimstack-release + path: backend/release/dim_stack-release.zip + + - name: Create Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "v${{ github.run_number }}" \ + --title "Release v${{ github.run_number }}" \ + --notes "Automated release" \ + backend/release/dim_stack-release.zip diff --git a/demo/dim_stack/.gitignore b/demo/dim_stack/.gitignore new file mode 100644 index 0000000..9b53700 --- /dev/null +++ b/demo/dim_stack/.gitignore @@ -0,0 +1,39 @@ +backend/dimstack-app/target/ +backend/dimstack-app/docs/ +backend/dimstack-app/src/main/resources/static/ +backend/dimstack-plugin-api/target/ +dimstack_expansion_server/target/ +dimstack_expansion_server/docs/ +frontend/node_modules/ +frontend/dist/ +.idea/ +upload/ +logs/ +systemconfig/target/ +.vscode/ +application-setup.properties +/systemconfig/dependency-reduced-pom.xml +qodana.yaml +cli/.vscode +cli/src/o +cli/src/nuitka-crash-report.xml +firefly-theme/ +examples/plugins/**/target/ +plugin_example/**/target/ +/plugins/ +plugin-startup.log + +# 初始化生成的配置(含数据库密码等敏感信息)与运行期数据目录 +/config/ +/data/ +# 根目录游离的构建产物与 IDE 文件 +/BOOT-INF/ +/*.jar +*.iml +# 前端共享依赖 vendor 产物(npm prebuild/predev 自动生成) +frontend/public/vendor/ + +# 开发测试用的主题与示例插件(不入库) +/themes/ +/enterprise-theme/ +/examples/ diff --git a/demo/dim_stack/Dockerfile b/demo/dim_stack/Dockerfile new file mode 100644 index 0000000..aff0a9e --- /dev/null +++ b/demo/dim_stack/Dockerfile @@ -0,0 +1,24 @@ +FROM alpine:latest + +LABEL maintainer="lingview " + +RUN sed -i 's|https\?://dl-cdn.alpinelinux.org/alpine|https://mirrors.tuna.tsinghua.edu.cn/alpine|g' /etc/apk/repositories + +RUN apk update +RUN apk add --no-cache openjdk21-jre wget + +RUN addgroup -g 1001 -S dimstack && \ + adduser -u 1001 -S dimstack -G dimstack + +RUN mkdir -p /dim_stack && \ + chown -R dimstack:dimstack /dim_stack + +WORKDIR /dim_stack + +RUN wget -O dimstack-1.0-SNAPSHOT.jar https://update.apilinks.cn/dimstack/dimstack-1.0-SNAPSHOT.jar && \ + chown dimstack:dimstack dimstack-1.0-SNAPSHOT.jar + +USER dimstack + +ENTRYPOINT ["java", "-XX:+UseContainerSupport", "-XX:MaxRAMPercentage=75.0", "-jar", "dimstack-1.0-SNAPSHOT.jar"] +CMD ["--server.port=2222"] \ No newline at end of file diff --git a/demo/dim_stack/LICENSE b/demo/dim_stack/LICENSE new file mode 100644 index 0000000..7e7859f --- /dev/null +++ b/demo/dim_stack/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025-2026 lingview and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/demo/dim_stack/README.md b/demo/dim_stack/README.md new file mode 100644 index 0000000..461dcc0 --- /dev/null +++ b/demo/dim_stack/README.md @@ -0,0 +1,119 @@ +# 次元栈 · Dim Stack + +> 一个简单易用的个人博客系统 + +![Spring Boot](https://img.shields.io/badge/Spring_Boot-4.1.0-green.svg) + +![React](https://img.shields.io/badge/React-19.1.1-%2361DAFB.svg) + +![Node.js](https://img.shields.io/badge/Node.js-22.16.0-43853D.svg) + +![Database](https://img.shields.io/badge/Database-MySQL-4479A1.svg) + +![Database](https://img.shields.io/badge/Database-Redis-DC382D.svg) + +--- + +## 🌟 项目简介 + +**次元栈** 是基于 Spring Boot 4 全新构建的个人博客系统。 + +**官方 QQ 群**:577040366(群内可体验预览版) + +### 核心功能 + +- 📝 内容管理(CMS) +- 💬 用户互动:评论、点赞 +- 🔖 标签分类:支持跨圈层内容组织 +- 👥 用户系统:个人主页、文章发布管理、完整 RBAC 权限系统 +- 📱 响应式前端,支持移动端浏览 +- 📦 支持首页、文章页服务端渲染(SSR) +- 🤖 大模型内容审核&生成 +- 🖼️ 智能图片压缩 +- 🔍 SEO 优化:动态 Sitemap、Robots、搜索引擎友好 + +### Star History + + + + + Star History Chart + + + +--- + +## 🛠 技术栈 + +| 层级 | 技术选型 | +| --- | --- | +| **后端** | Java 17+,Spring Boot 4,MySQL 5.7+,Redis | +| **前端** | React 19,Vite,Tailwind CSS v4 | +| **构建** | Maven(后端),npm(前端) | +| **部署** | Docker,Linux,Windows | + +--- + +## 快速开始 + +### 运行要求 + +- JDK 17+(推荐 21+) +- MySQL 5.7+ +- Redis 5+(可选) + +### 启动步骤 + +1. 运行 jar 包: + ```bash + java -jar dimstack-1.0-SNAPSHOT.jar + ``` +2. 浏览器访问终端输出的地址,进入初始化向导 +3. 填写配置完成后重启服务 + +详细部署教程请查看:[部署文档](docs/deployment.md) + +--- + +## 文档导航 + +### 系统部署&构建 + +- [部署&构建](docs/deployment.md) + +### 功能说明 + +- [大模型审核与生成](docs/features/llm_generation_and_review.md) +- [智能图片压缩](docs/features/image-compression.md) +- [外部资源本地化](docs/features/external-resources.md) +- [外置存储](docs/features/storage.md) +- [代码注入(自定义 Head/Footer)](docs/features/code-injection.md) +- [SEO 优化与大模型读取](docs/features/seo.md) +- [评论区功能](docs/features/comments.md) +- [访问控制Key&Agent调用说明](docs/features/key.md) + +### 架构说明 + +- [系统启动流程](docs/architecture/startup-flow.md) +- [缓存模式](docs/architecture/cache-mode.md) + +### 主题 +- [面向主题开发者接口文档](docs/themes/themes-frontend-routing-guide.md) + +### 插件 +- [面向插件开发者接口文档](docs/plugins/plugin-development-guide.md) + +### 项目脚本 +- [项目脚本使用说明(包括构建维护等脚本)](docs/features/script_usage_instructions.md) + +### 老版本迁移 +- [老版本升级说明](docs/legacy.md) + +--- + +### 💡 特别感谢 + +感谢所有为本项目贡献代码、提出问题和提供反馈的开发者。 +感谢开源社区持续的支持与贡献。 + +如果你在本项目中做出了贡献,请提交 PR 将你的名字加入到致谢名单中! diff --git a/demo/dim_stack/backend/check_role_permission.sql b/demo/dim_stack/backend/check_role_permission.sql new file mode 100644 index 0000000..3519f67 --- /dev/null +++ b/demo/dim_stack/backend/check_role_permission.sql @@ -0,0 +1,28 @@ +-- 查看作者(READER)权限 +SELECT r.name AS role_name, p.code, p.name AS permission_name +FROM role_permission rp + JOIN role r ON rp.role_id = r.id + JOIN permission p ON rp.permission_id = p.id +WHERE r.code = 'READER'; + +-- 查看作者(AUTHOR)权限 +SELECT r.name AS role_name, p.code, p.name AS permission_name +FROM role_permission rp + JOIN role r ON rp.role_id = r.id + JOIN permission p ON rp.permission_id = p.id +WHERE r.code = 'AUTHOR'; + + +-- 查看作者(POST_MANAGER)权限 +SELECT r.name AS role_name, p.code, p.name AS permission_name +FROM role_permission rp + JOIN role r ON rp.role_id = r.id + JOIN permission p ON rp.permission_id = p.id +WHERE r.code = 'POST_MANAGER'; + +-- 查看作者(ADMIN)权限 +SELECT r.name AS role_name, p.code, p.name AS permission_name +FROM role_permission rp + JOIN role r ON rp.role_id = r.id + JOIN permission p ON rp.permission_id = p.id +WHERE r.code = 'ADMIN'; \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/pom.xml b/demo/dim_stack/backend/dimstack-app/pom.xml new file mode 100644 index 0000000..ca25c69 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/pom.xml @@ -0,0 +1,201 @@ + + + 4.0.0 + + + xyz.lingview.dimstack + dimstack-parent + 1.0-SNAPSHOT + + + dimstack + Dim Stack Application + Dim Stack host application module + + + + xyz.lingview.dimstack + dimstack-plugin-api + + + + org.springframework.boot + spring-boot-starter + + + + org.springframework.boot + spring-boot-starter-webmvc + + + + org.springframework + spring-context-support + + + + org.springframework + spring-web + + + + + org.mybatis.spring.boot + mybatis-spring-boot-starter + 4.0.1 + compile + + + + com.mysql + mysql-connector-j + + + + + org.projectlombok + lombok + provided + + + + + org.springframework.boot + spring-boot-starter-mail + + + org.springframework.boot + spring-boot-starter-restclient + + + + org.springframework.session + spring-session-data-redis + + + org.springframework.boot + spring-boot-starter-data-redis + + + + + org.mindrot + jbcrypt + 0.4 + + + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.boot + spring-boot-starter-validation + + + + net.coobird + thumbnailator + 0.4.21 + compile + + + + org.springframework.boot + spring-boot-starter-flyway + compile + + + + org.flywaydb + flyway-mysql + 11.20.2 + compile + + + + + com.github.ben-manes.caffeine + caffeine + 3.2.3 + compile + + + + org.aspectj + aspectjweaver + + + + + software.amazon.awssdk + s3 + 2.29.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + xyz.lingview.dimstack.StartServer + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + + + + + + + + diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/StartServer.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/StartServer.java new file mode 100644 index 0000000..e35b0e6 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/StartServer.java @@ -0,0 +1,113 @@ +package xyz.lingview.dimstack; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.ApplicationListener; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.core.env.Environment; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession; +import org.springframework.stereotype.Component; +import xyz.lingview.dimstack.config.ConfigInfo; +import xyz.lingview.dimstack.service.CacheService; + +import java.net.Inet4Address; +import java.net.UnknownHostException; + +@Slf4j +@SpringBootApplication +@EnableAsync +@EnableScheduling +// 内存模式下无法使用该注解() +//@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 604800, redisNamespace = "dimstack:session") +public class StartServer { + public static void main(String[] args) { + if (needsInitialization()) { + startInitMode(args); + } else { + SpringApplication.run(StartServer.class, args); + } + } + + private static boolean needsInitialization() { + return !ConfigInfo.isConfigComplete(); + } + + private static void startInitMode(String[] args) { + log.info("系统初始化模块加载成功"); + log.info("检测到首次运行或配置不完整,启动初始化模式..."); + log.info("配置文件目录: {}", ConfigInfo.CONFIG_DIR.toAbsolutePath()); + + SpringApplication initApp = new SpringApplication(xyz.lingview.dimstack.init.InitApplication.class); + + String[] initArgs = combineArguments(args, new String[]{ + "--spring.config.location=classpath:/", + "--spring.config.name=application-init" + }); + + initApp.run(initArgs); + } + + + private static String[] combineArguments(String[] originalArgs, String[] additionalArgs) { + String[] combined = new String[originalArgs.length + additionalArgs.length]; + System.arraycopy(originalArgs, 0, combined, 0, originalArgs.length); + System.arraycopy(additionalArgs, 0, combined, originalArgs.length, additionalArgs.length); + return combined; + } + + + @Component + public static class ServerInfoPrinter implements ApplicationListener { + + private final Environment environment; + + private final CacheService cacheService; + + public ServerInfoPrinter(Environment environment, CacheService cacheService) { + this.environment = environment; + this.cacheService = cacheService; + } + + public String getPort() { + return environment.getProperty("local.server.port"); + } + + private boolean printed = false; + + @Override + public void onApplicationEvent(ContextRefreshedEvent event) { + if (!printed && event.getApplicationContext().getParent() == null) { + printed = true; + + // 检查是否是初始化模式 + String appMode = event.getApplicationContext().getEnvironment().getProperty("app.mode"); + boolean isInitMode = "init".equals(appMode); + + if (!isInitMode) { + // 清除站点配置缓存,每次重启都从数据库读取 + try { + cacheService.delete("dimstack:site_config"); + log.info("已清除站点配置缓存,将从数据库重新加载"); + } catch (Exception e) { + log.warn("清除站点配置缓存失败", e); + } + + String hostAddress = null; + try { + hostAddress = Inet4Address.getLocalHost().getHostAddress(); + } catch (UnknownHostException e) { + log.warn("无法获取本机IP地址", e); + hostAddress = "localhost"; + } + + String localHost = hostAddress + ":" + getPort(); + String url = "http://" + localHost; + log.info("当前服务器地址为:" + url); + } + } + } + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/annotation/RateLimit.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/annotation/RateLimit.java new file mode 100644 index 0000000..3571ef4 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/annotation/RateLimit.java @@ -0,0 +1,40 @@ +package xyz.lingview.dimstack.annotation; + +import java.lang.annotation.*; + +/** + * @Author: lingview + * @Date: 2026/03/14 22:38:55 + * @Description: 请求限流 + * @Version: 1.0 + */ +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface RateLimit { + + long window() default 60; + + TimeUnit timeUnit() default TimeUnit.SECONDS; + + int maxRequests() default 10; + + String message() default "请求过于频繁,请稍后再试"; + + enum TimeUnit { + MILLISECONDS(java.util.concurrent.TimeUnit.MILLISECONDS), + SECONDS(java.util.concurrent.TimeUnit.SECONDS), + MINUTES(java.util.concurrent.TimeUnit.MINUTES), + HOURS(java.util.concurrent.TimeUnit.HOURS); + + private final java.util.concurrent.TimeUnit timeUnit; + + TimeUnit(java.util.concurrent.TimeUnit timeUnit) { + this.timeUnit = timeUnit; + } + + public java.util.concurrent.TimeUnit toTimeUnit() { + return timeUnit; + } + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/annotation/RequiresPermission.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/annotation/RequiresPermission.java new file mode 100644 index 0000000..96f5876 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/annotation/RequiresPermission.java @@ -0,0 +1,14 @@ +package xyz.lingview.dimstack.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface RequiresPermission { + + String[] value(); + boolean all() default false; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/common/ApiResponse.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/common/ApiResponse.java new file mode 100644 index 0000000..c2a9768 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/common/ApiResponse.java @@ -0,0 +1,46 @@ +package xyz.lingview.dimstack.common; + +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 通用 API 响应结构 + * @param 响应数据类型 + */ +@Data +@NoArgsConstructor +public class ApiResponse { + private int code; + private String message; + private T data; + + private ApiResponse(int code, String message, T data) { + this.code = code; + this.message = message; + this.data = data; + } + + public static ApiResponse success(T data) { + return new ApiResponse<>(200, "success", data); + } + + public static ApiResponse success(String message, T data) { + return new ApiResponse<>(200, message, data); + } + + public static ApiResponse success(String message) { + return new ApiResponse<>(200, message, null); + } + + public static ApiResponse error(int code, String message) { + return new ApiResponse<>(code, message, null); + } + + public static ApiResponse error(int code, String message, T data) { + return new ApiResponse<>(code, message, data); + } + + public static ApiResponse rateLimit(String message) { + return new ApiResponse<>(429, message, null); + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/common/RateLimitException.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/common/RateLimitException.java new file mode 100644 index 0000000..b2f5dc6 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/common/RateLimitException.java @@ -0,0 +1,24 @@ +package xyz.lingview.dimstack.common; + +/** + * @Author: lingview + * @Date: 2026/03/14 22:40:25 + * @Description: 请求限流异常 + * @Version: 1.0 + */ +public class RateLimitException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public RateLimitException() { + super("请求过于频繁,请稍后再试"); + } + + public RateLimitException(String message) { + super(message); + } + + public RateLimitException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/CacheAutoConfiguration.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/CacheAutoConfiguration.java new file mode 100644 index 0000000..3bf5e01 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/CacheAutoConfiguration.java @@ -0,0 +1,125 @@ +package xyz.lingview.dimstack.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.RedisSerializer; +import xyz.lingview.dimstack.service.CacheService; +import xyz.lingview.dimstack.service.impl.MemoryCacheServiceImpl; +import xyz.lingview.dimstack.service.impl.RedisCacheServiceImpl; + +import jakarta.annotation.PostConstruct; + +@Configuration +@Slf4j +public class CacheAutoConfiguration { + + @Value("${app.redis.enabled:#{null}}") + private Boolean appRedisEnabled; + + @Value("${spring.data.redis.host:}") + private String redisHost; + + @Value("${spring.data.redis.port:0}") + private int redisPort; + + @PostConstruct + public void initialize() { + RedisMode mode = determineRedisMode(); + log.info("缓存模式: {}", mode); + } + + @Bean + @Primary + public CacheService cacheService(RedisConnectionFactory connectionFactory) { + RedisMode mode = determineRedisMode(); + + switch (mode) { + case REDIS_ENABLED: + case REDIS_COMPATIBILITY: + return createRedisCacheService(connectionFactory); + case MEMORY_ONLY: + return createMemoryCacheService(); + default: + throw new IllegalStateException("未知的Redis模式"); + } + } + + + private RedisMode determineRedisMode() { + if (appRedisEnabled != null && appRedisEnabled) { + log.info("检测到 app.redis.enabled=true,使用Redis模式"); + return RedisMode.REDIS_ENABLED; + } + + if (appRedisEnabled != null && !appRedisEnabled) { + log.info("检测到 app.redis.enabled=false,使用内存模式"); + return RedisMode.MEMORY_ONLY; + } + + if (hasRedisConfiguration()) { + log.info("未配置 app.redis.enabled,但检测到Redis连接配置,启用兼容模式,请尽快重新生成配置文件,后续版本可能放弃该兼容"); + return RedisMode.REDIS_COMPATIBILITY; + } + + log.info("未检测到任何Redis配置,使用内存模式"); + return RedisMode.MEMORY_ONLY; + } + + private CacheService createRedisCacheService(RedisConnectionFactory connectionFactory) { + try { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(connectionFactory); + redisTemplate.setKeySerializer(RedisSerializer.string()); + redisTemplate.setValueSerializer(RedisSerializer.json()); + redisTemplate.setHashKeySerializer(RedisSerializer.string()); + redisTemplate.setHashValueSerializer(RedisSerializer.json()); + redisTemplate.afterPropertiesSet(); + + RedisCacheServiceImpl redisImpl = new RedisCacheServiceImpl(redisTemplate); + if (redisImpl.isRedisAvailable()) { + log.info("Redis 连接成功,使用 Redis 缓存"); + return redisImpl; + } else { + String errorMsg = "Redis连接失败:无法连接到Redis服务器,请检查以下配置:\n" + + "1. Redis服务是否已启动\n" + + "2. spring.data.redis.host 和 spring.data.redis.port 是否正确\n" + + "3. 网络连接是否正常"; + log.error(errorMsg); + throw new RuntimeException(errorMsg); + } + } catch (Exception e) { + String errorMsg = String.format( + "Redis初始化失败: %s\n请检查:\n" + + "1. Redis配置是否正确 (host: %s, port: %d)\n" + + "2. Redis服务是否正常运行\n" + + "3. 如果不需要Redis,请设置 app.redis.enabled=false", + e.getMessage(), redisHost, redisPort + ); + log.error(errorMsg); + throw new RuntimeException(errorMsg, e); + } + } + + private CacheService createMemoryCacheService() { + log.info("使用内存缓存"); + return new MemoryCacheServiceImpl(); + } + + + private boolean hasRedisConfiguration() { + return redisHost != null && !redisHost.isEmpty() && + redisPort > 0 && redisPort <= 65535; + } + + + private enum RedisMode { + REDIS_ENABLED, + REDIS_COMPATIBILITY, + MEMORY_ONLY + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/ConfigInfo.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/ConfigInfo.java new file mode 100644 index 0000000..e05537a --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/ConfigInfo.java @@ -0,0 +1,18 @@ +package xyz.lingview.dimstack.config; + +import java.nio.file.Path; + +/** + * @Author: lingview + * @Date: 2026/01/10 18:54:11 + * @Description: 配置文件检查 + * @Version: 1.0 + */ +public class ConfigInfo { + public static final Path CONFIG_DIR = Path.of("config"); + public static final Path MAIN_CONFIG_FILE = CONFIG_DIR.resolve("application.yml"); + + public static boolean isConfigComplete() { + return MAIN_CONFIG_FILE.toFile().exists(); + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/DatabaseCreator.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/DatabaseCreator.java new file mode 100644 index 0000000..606fd43 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/DatabaseCreator.java @@ -0,0 +1,45 @@ +package xyz.lingview.dimstack.config; + +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.Statement; + +@Component +@Slf4j +public class DatabaseCreator { + + @Value("${spring.datasource.url}") + private String dbUrl; + + @Value("${spring.datasource.username}") + private String username; + + @Value("${spring.datasource.password}") + private String password; + + @PostConstruct + public void ensureDatabaseExists() { + try { + String dbName = dbUrl.substring(dbUrl.lastIndexOf("/") + 1).split("\\?")[0]; + String baseUrl = dbUrl.substring(0, dbUrl.lastIndexOf("/")); + + try (Connection conn = DriverManager.getConnection(baseUrl, username, password); + Statement stmt = conn.createStatement()) { + + stmt.executeUpdate("CREATE DATABASE IF NOT EXISTS `" + dbName + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); +// System.out.println("已确保数据库存在:" + dbName); + log.info("已确保数据库存在:{}", dbName); + + } + } catch (SQLException e) { +// System.err.println("数据库创建失败:" + e.getMessage()); + log.error("数据库创建失败", e); + } + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/DatabaseInitializer.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/DatabaseInitializer.java new file mode 100644 index 0000000..7bc4064 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/DatabaseInitializer.java @@ -0,0 +1,55 @@ +// 已弃用全部交给init模块处理 + +//package xyz.lingview.dimstack.config; +// +//import lombok.extern.slf4j.Slf4j; +//import org.springframework.boot.CommandLineRunner; +//import org.springframework.boot.sql.init.dependency.DependsOnDatabaseInitialization; +//import org.springframework.core.io.ClassPathResource; +//import org.springframework.jdbc.datasource.init.ScriptUtils; +//import org.springframework.stereotype.Component; +// +//import javax.sql.DataSource; +//import java.sql.Connection; +//import java.sql.DatabaseMetaData; +//import java.sql.ResultSet; +// +//@Component +//@DependsOnDatabaseInitialization +//@Slf4j +//public class DatabaseInitializer implements CommandLineRunner { +// +// private final DataSource dataSource; +// +// public DatabaseInitializer(DataSource dataSource) { +// this.dataSource = dataSource; +// } +// +// @Override +// public void run(String... args) throws Exception { +// try (Connection conn = dataSource.getConnection()) { +// if (isDatabaseEmpty(conn)) { +//// System.out.println("检测到数据库为空,正在执行初始化 SQL..."); +// log.info("检测到数据库为空,正在执行初始化 SQL..."); +// ScriptUtils.executeSqlScript(conn, new ClassPathResource("init.sql")); +//// System.out.println("数据库初始化完成!"); +// log.info("数据库初始化完成!"); +// } else { +//// System.out.println("数据库已有数据,跳过初始化。"); +// log.info("数据库已有数据,跳过初始化。"); +// } +// } catch (Exception e) { +//// System.err.println("数据库初始化失败: " + e.getMessage()); +//// e.printStackTrace(); +// log.error("数据库初始化失败", e); +// +// } +// } +// +// private boolean isDatabaseEmpty(Connection conn) throws Exception { +// DatabaseMetaData metaData = conn.getMetaData(); +// try (ResultSet rs = metaData.getTables(null, null, "%", new String[]{"TABLE"})) { +// return !rs.next(); +// } +// } +//} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/DynamicResourceResolver.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/DynamicResourceResolver.java new file mode 100644 index 0000000..d191771 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/DynamicResourceResolver.java @@ -0,0 +1,67 @@ +// 已弃用 相关功能已在ThemeResourceFilter中实现 + +//package xyz.lingview.dimstack.config; +// +//import jakarta.servlet.http.HttpServletRequest; +//import org.springframework.beans.factory.annotation.Autowired; +//import org.springframework.core.io.Resource; +//import org.springframework.core.io.ResourceLoader; +//import org.springframework.stereotype.Component; +//import org.springframework.util.StringUtils; +// +//import java.io.File; +//import java.nio.file.Files; +//import java.nio.file.Path; +// +//@Component +//public class DynamicResourceResolver { +// +// @Autowired +// private ThemeProperties themeProperties; +// +// @Autowired +// private ResourceLoader resourceLoader; +// +// public Resource resolveResource(HttpServletRequest request) { +// try { +// String requestURI = request.getRequestURI(); +// String contextPath = request.getContextPath(); +// +// String resourcePath = requestURI.substring(contextPath.length()); +// if (!resourcePath.startsWith("/")) { +// resourcePath = "/" + resourcePath; +// } +// +// String cleanPath = resourcePath.substring(1); +// +// String themesPath = themeProperties.getThemesPath(); +// String activeTheme = themeProperties.getActiveTheme(); +// +// if (StringUtils.hasText(themesPath) && StringUtils.hasText(activeTheme)) { +// Path themeResourcePath = Path.of(themesPath, activeTheme).resolve(cleanPath); +// File themeResourceFile = themeResourcePath.toFile(); +// +// if (themeResourceFile.exists() && themeResourceFile.isFile()) { +// return resourceLoader.getResource(themeResourceFile.toURI().toString()); +// } +// } +// +// String classpathResourceLocation = "classpath:/static/" + cleanPath; +// Resource classpathResource = resourceLoader.getResource(classpathResourceLocation); +// +// if (classpathResource.exists() && classpathResource.isReadable()) { +// return classpathResource; +// } +// +// Resource rootResource = resourceLoader.getResource("classpath:/" + cleanPath); +// if (rootResource.exists() && rootResource.isReadable()) { +// return rootResource; +// } +// +// return null; +// +// } catch (Exception e) { +// return null; +// } +// } +//} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/FlywayConfig.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/FlywayConfig.java new file mode 100644 index 0000000..985aced --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/FlywayConfig.java @@ -0,0 +1,41 @@ +package xyz.lingview.dimstack.config; + +import lombok.extern.slf4j.Slf4j; +import org.flywaydb.core.Flyway; +import org.springframework.boot.flyway.autoconfigure.FlywayProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import javax.sql.DataSource; + +/** + * @Author: lingview + * @Date: 2026/01/27 11:47:27 + * @Description: Flyway配置 + * @Version: 1.0 + */ +@Configuration +@EnableConfigurationProperties(FlywayProperties.class) +@Slf4j +public class FlywayConfig { + + @Bean + public Flyway flyway(DataSource dataSource, FlywayProperties flywayProperties) { + Flyway flyway = Flyway.configure() + .dataSource(dataSource) + .baselineOnMigrate(true) + .baselineVersion("1") + .locations("classpath:db/migration") + .encoding("UTF-8") + .validateOnMigrate(true) + .outOfOrder(false) + .load(); + + log.info("开始数据库验证及更新..."); + flyway.migrate(); + log.info("数据库验证更新完毕"); + + return flyway; + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/HotArticleCacheInitializer.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/HotArticleCacheInitializer.java new file mode 100644 index 0000000..c94fb0a --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/HotArticleCacheInitializer.java @@ -0,0 +1,21 @@ +package xyz.lingview.dimstack.config; + +import xyz.lingview.dimstack.service.HotArticleService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationListener; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.stereotype.Component; + +@Component +public class HotArticleCacheInitializer implements ApplicationListener { + + @Autowired + private HotArticleService hotArticleService; + + @Override + public void onApplicationEvent(ContextRefreshedEvent event) { + if (event.getApplicationContext().getParent() == null) { + hotArticleService.refreshHotArticlesCache(); + } + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/InitSiteConfigToRedis.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/InitSiteConfigToRedis.java new file mode 100644 index 0000000..8093093 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/InitSiteConfigToRedis.java @@ -0,0 +1,40 @@ +package xyz.lingview.dimstack.config; + +import tools.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; +import xyz.lingview.dimstack.domain.SiteConfig; +import xyz.lingview.dimstack.service.CacheService; +import xyz.lingview.dimstack.service.SiteConfigService; + +@Component +@Slf4j +public class InitSiteConfigToRedis implements CommandLineRunner { + + @Autowired + private SiteConfigService siteConfigService; + + @Autowired + private CacheService cacheService; + + @Autowired + private ObjectMapper objectMapper; + + @Override + public void run(String... args) throws Exception { + log.info("开始将站点配置加载到缓存"); + try { + SiteConfig siteConfig = siteConfigService.getSiteConfig(); + if (siteConfig != null) { + cacheService.set("dimstack:site_config", siteConfig); + log.info("站点配置已成功加载到缓存"); + } else { + log.warn("未找到站点配置信息,跳过缓存加载"); + } + } catch (Exception e) { + log.error("加载站点配置到缓存失败", e); + } + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/RedisConfig.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/RedisConfig.java new file mode 100644 index 0000000..e79f723 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/RedisConfig.java @@ -0,0 +1,34 @@ +package xyz.lingview.dimstack.config; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.serializer.RedisSerializer; + +@Configuration +public class RedisConfig { + + @Bean + @ConditionalOnProperty(name = "app.redis.enabled", havingValue = "true", matchIfMissing = false) + public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) { + return new StringRedisTemplate(factory); + } + + @Bean + @ConditionalOnProperty(name = "app.redis.enabled", havingValue = "true", matchIfMissing = false) + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + template.setKeySerializer(RedisSerializer.string()); + template.setValueSerializer(RedisSerializer.json()); + template.setHashKeySerializer(RedisSerializer.string()); + template.setHashValueSerializer(RedisSerializer.json()); + + template.afterPropertiesSet(); + return template; + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/SessionConfig.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/SessionConfig.java new file mode 100644 index 0000000..7f81bfb --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/SessionConfig.java @@ -0,0 +1,86 @@ +package xyz.lingview.dimstack.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.boot.tomcat.servlet.TomcatServletWebServerFactory; +import org.springframework.boot.web.server.WebServerFactoryCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.session.data.redis.config.ConfigureRedisAction; +import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession; +import org.springframework.session.web.http.CookieSerializer; +import org.springframework.session.web.http.DefaultCookieSerializer; + +@Configuration +@Slf4j +public class SessionConfig { + + @Value("${app.redis.enabled:#{null}}") + private Boolean appRedisEnabled; + + @Value("${spring.data.redis.host:}") + private String redisHost; + + @Value("${spring.data.redis.port:0}") + private int redisPort; + + private boolean shouldUseRedisSession() { + if (appRedisEnabled != null && appRedisEnabled) { + log.debug("Session模式: app.redis.enabled=true"); + return true; + } + + if (appRedisEnabled != null && !appRedisEnabled) { + log.debug("Session模式: app.redis.enabled=false"); + return false; + } + + boolean hasRedisConfig = redisHost != null && !redisHost.isEmpty() && + redisPort > 0 && redisPort <= 65535; + log.debug("Session模式: 兼容模式检测 hasRedisConfig={}", hasRedisConfig); + return hasRedisConfig; + } + + @Configuration + @ConditionalOnExpression( + "#{${app.redis.enabled:null} == true || " + + "(${app.redis.enabled:null} == null && " + + "'${spring.data.redis.host:}'.length() > 0 && " + + "${spring.data.redis.port:0} > 0)}" + ) + @EnableRedisHttpSession( + maxInactiveIntervalInSeconds = 604800, + redisNamespace = "dimstack:session" + ) + public static class RedisSessionConfiguration { + + @Bean + public ConfigureRedisAction configureRedisAction() { + log.info("Redis Session 已启用"); + return ConfigureRedisAction.NO_OP; + } + } + + @Bean + public CookieSerializer cookieSerializer() { + DefaultCookieSerializer serializer = new DefaultCookieSerializer(); + serializer.setCookieName("SESSION"); + serializer.setUseHttpOnlyCookie(true); + serializer.setSameSite("Lax"); + serializer.setCookiePath("/"); + serializer.setCookieMaxAge(604800); + + return serializer; + } + + + @Bean + public WebServerFactoryCustomizer tomcatCustomizer() { + return factory -> factory.addContextCustomizers(context -> { + context.setSessionTimeout(10080); + context.addMimeMapping("mjs", "text/javascript"); + context.addMimeMapping("wasm", "application/wasm"); + }); + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/Swagger3Config.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/Swagger3Config.java new file mode 100644 index 0000000..f8ac13f --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/Swagger3Config.java @@ -0,0 +1,20 @@ +// 最大程度避免生产环境的误配置以及误开启~ + +//package xyz.lingview.dimstack.config; +// +//import io.swagger.v3.oas.models.OpenAPI; +//import io.swagger.v3.oas.models.info.Info; +//import org.springframework.context.annotation.Bean; +//import org.springframework.context.annotation.Configuration; +// +// +//@Configuration +//public class Swagger3Config { +// @Bean +// public OpenAPI springShopOpenAPI() { +// return new OpenAPI() +// .info(new Info().title("次元栈") +// .description("次元栈接口文档") +// .version("v1.0.0")); +// } +//} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/ThemeProperties.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/ThemeProperties.java new file mode 100644 index 0000000..efbe5ba --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/ThemeProperties.java @@ -0,0 +1,56 @@ +package xyz.lingview.dimstack.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.ApplicationListener; +import org.springframework.stereotype.Component; +import xyz.lingview.dimstack.domain.SiteConfig; +import xyz.lingview.dimstack.service.SiteConfigService; + +@Component +@ConfigurationProperties(prefix = "app.theme") +@Slf4j +public class ThemeProperties implements ApplicationListener { + + @Autowired + private SiteConfigService siteConfigService; + + private String activeTheme = "default"; + private String themesPath = "themes"; + + @Override + public void onApplicationEvent(ApplicationReadyEvent event) { + try { + SiteConfig siteConfig = siteConfigService.getSiteConfig(); + if (siteConfig != null && siteConfig.getSite_theme() != null && !siteConfig.getSite_theme().isEmpty()) { + this.activeTheme = siteConfig.getSite_theme(); +// System.out.println("已加载主题:" + activeTheme); + log.info("已加载主题:{}", activeTheme); + } else { +// System.out.println("未找到站点主题配置,使用默认主题:" + activeTheme); + log.warn("未找到站点主题配置,使用默认主题:{}", activeTheme); + } + } catch (Exception e) { +// System.err.println("初始化主题失败:" + e.getMessage()); + log.error("初始化主题失败:{}", e.getMessage()); + } + } + + public String getActiveTheme() { + return activeTheme; + } + + public void setActiveTheme(String activeTheme) { + this.activeTheme = activeTheme; + } + + public String getThemesPath() { + return themesPath; + } + + public void setThemesPath(String themesPath) { + this.themesPath = themesPath; + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/WebConfig.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/WebConfig.java new file mode 100644 index 0000000..0fca27e --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/config/WebConfig.java @@ -0,0 +1,94 @@ +package xyz.lingview.dimstack.config; + +import org.springframework.web.client.RestTemplate; +import xyz.lingview.dimstack.interceptor.ThemeResourceFilter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import xyz.lingview.dimstack.interceptor.UserAgentSessionFilter; +import xyz.lingview.dimstack.interceptor.UserPermissionInterceptor; +import xyz.lingview.dimstack.security.SessionAuthFilter; + +import java.io.IOException; +import org.springframework.core.io.ClassPathResource; +import org.springframework.web.servlet.resource.PathResourceResolver; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Autowired + private ThemeResourceFilter themeResourceFilter; + + @Autowired + private UserAgentSessionFilter userAgentSessionFilter; + + @Bean + public SessionAuthFilter sessionAuthFilter() { + return new SessionAuthFilter(); + } + + // 注册 SessionAuthFilter + @Bean + public FilterRegistrationBean sessionAuthFilterRegistration() { + FilterRegistrationBean registration = new FilterRegistrationBean<>(); + registration.setFilter(sessionAuthFilter()); + registration.addUrlPatterns("/api/*"); + registration.setOrder(2); + return registration; + } + + // 注册主题资源过滤器 + @Bean + public FilterRegistrationBean themeResourceFilterRegistration() { + FilterRegistrationBean registration = new FilterRegistrationBean<>(); + registration.setFilter(themeResourceFilter); + registration.addUrlPatterns("/*"); + registration.setOrder(0); + return registration; + } + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + + registry.addResourceHandler("/**") + .addResourceLocations("classpath:/static/") + .resourceChain(true) + .addResolver(new PathResourceResolver() { + @Override + protected org.springframework.core.io.Resource getResource( + String resourcePath, + org.springframework.core.io.Resource location) throws IOException { + org.springframework.core.io.Resource requestedResource = location.createRelative(resourcePath); + if (requestedResource.exists() && requestedResource.isReadable()) { + return requestedResource; + } + + return new ClassPathResource("/static/index.html"); + } + }); + } + + // 注册权限拦截器 + @Autowired + private UserPermissionInterceptor userPermissionInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(userAgentSessionFilter) + .addPathPatterns("/api/**") + .excludePathPatterns("/api/login", "/api/logout"); + + registry.addInterceptor(userPermissionInterceptor) + .addPathPatterns("/api/**"); + } + + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/AnnouncementController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/AnnouncementController.java new file mode 100644 index 0000000..6de672c --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/AnnouncementController.java @@ -0,0 +1,48 @@ +package xyz.lingview.dimstack.controller; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import xyz.lingview.dimstack.annotation.RequiresPermission; +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.domain.Announcement; +import xyz.lingview.dimstack.service.AnnouncementService; + +import java.util.Map; + +@RestController +@RequestMapping("/api/announcement") +@Slf4j +public class AnnouncementController { + + @Autowired + private AnnouncementService announcementService; + + @GetMapping + public ApiResponse getAnnouncement() { + try { + Announcement announcement = announcementService.getLatest(); + return ApiResponse.success(announcement); + } catch (Exception e) { + log.error("获取公告失败", e); + return ApiResponse.error(500, "获取公告失败: " + e.getMessage()); + } + } + + @PostMapping + @RequiresPermission("system:announcement:management") + public ApiResponse saveAnnouncement(@RequestBody Map payload) { + String content = payload.get("content"); + try { + boolean success = announcementService.save(content != null ? content : ""); + if (success) { + return ApiResponse.success("公告保存成功"); + } else { + return ApiResponse.error(500, "公告保存失败"); + } + } catch (Exception e) { + log.error("保存公告失败", e); + return ApiResponse.error(500, "保存公告失败: " + e.getMessage()); + } + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ApiKeyController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ApiKeyController.java new file mode 100644 index 0000000..67a5347 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ApiKeyController.java @@ -0,0 +1,83 @@ +package xyz.lingview.dimstack.controller; + +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.dto.request.ApiKeyCreateRequestDTO; +import xyz.lingview.dimstack.dto.response.ApiKeyCreatedResponseDTO; +import xyz.lingview.dimstack.dto.response.ApiKeyResponseDTO; +import xyz.lingview.dimstack.service.ApiKeyService; +import xyz.lingview.dimstack.service.CurrentUserService; + +import java.util.List; + +/** + * @Author: lingview + * @Date: 2026/06/09 11:35:19 + * @Description: API Key 管理器(个人中心) + * @Version: 1.0 + */ +@RestController +@RequestMapping("/api/apikey") +@Slf4j +public class ApiKeyController { + + @Autowired + private ApiKeyService apiKeyService; + + @Autowired + private CurrentUserService currentUserService; + + @GetMapping("/list") + public ApiResponse> listApiKeys() { + String userId = currentUserService.getCurrentUserUuid(); + if (userId == null) { + return ApiResponse.error(401, "用户未登录"); + } + return ApiResponse.success(apiKeyService.listApiKeys(userId)); + } + + @PostMapping("/create") + public ApiResponse createApiKey(@RequestBody @Valid ApiKeyCreateRequestDTO requestDTO) { + String userId = currentUserService.getCurrentUserUuid(); + if (userId == null) { + return ApiResponse.error(401, "用户未登录"); + } + try { + ApiKeyCreatedResponseDTO created = apiKeyService.createApiKey(userId, requestDTO.getDescription()); + return ApiResponse.success("API Key 创建成功,请妥善保存,该 Key 仅显示一次", created); + } catch (Exception e) { + log.error("创建 API Key 失败", e); + return ApiResponse.error(500, "创建 API Key 失败"); + } + } + + @PutMapping("/{id}/status") + public ApiResponse updateApiKeyStatus(@PathVariable Integer id, + @RequestParam Integer status) { + String userId = currentUserService.getCurrentUserUuid(); + if (userId == null) { + return ApiResponse.error(401, "用户未登录"); + } + boolean result = apiKeyService.updateApiKeyStatus(userId, id, status); + if (result) { + return ApiResponse.success(status == 1 ? "API Key 已启用" : "API Key 已禁用"); + } + return ApiResponse.error(404, "未找到该 API Key"); + } + + @DeleteMapping("/{id}") + public ApiResponse deleteApiKey(@PathVariable Integer id) { + String userId = currentUserService.getCurrentUserUuid(); + if (userId == null) { + return ApiResponse.error(401, "用户未登录"); + } + boolean result = apiKeyService.deleteApiKey(userId, id); + if (result) { + return ApiResponse.success("API Key 删除成功"); + } + return ApiResponse.error(404, "未找到该 API Key"); + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ArticleGenerationController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ArticleGenerationController.java new file mode 100644 index 0000000..53893cb --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ArticleGenerationController.java @@ -0,0 +1,58 @@ +package xyz.lingview.dimstack.controller; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import xyz.lingview.dimstack.annotation.RateLimit; +import xyz.lingview.dimstack.annotation.RequiresPermission; +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.service.CurrentUserService; +import xyz.lingview.dimstack.service.LLMService; + +import java.util.Map; + +@RestController +@Slf4j +@RequestMapping("/api/llm") +public class ArticleGenerationController { + + @Autowired + private LLMService llmService; + + @Autowired + private CurrentUserService currentUserService; + + @PostMapping("/generate") + @RequiresPermission({"post:add","post:edit"}) + @RateLimit(window = 60, maxRequests = 3) + public ApiResponse generateArticle( + @RequestBody Map request) { + try { + String username = currentUserService.getCurrentUsername(); + if (username == null) { + return ApiResponse.error(401, "用户未登录"); + } + + String description = request.get("description"); + if (description == null || description.trim().isEmpty()) { + return ApiResponse.error(400, "文章描述不能为空"); + } + + log.info("用户 {} 请求生成文章,描述: {}", username, description.substring(0, Math.min(50, description.length()))); + + String generatedContent = llmService.generateArticle(description); + + if (generatedContent != null && !generatedContent.trim().isEmpty()) { + log.info("用户 {} 文章生成成功", username); + return ApiResponse.success("文章生成成功", generatedContent); + } else { + log.warn("用户 {} 文章生成失败,返回内容为空", username); + return ApiResponse.error(500, "文章生成失败,请检查LLM配置或稍后重试"); + } + + } catch (Exception e) { + log.error("文章生成接口异常", e); + return ApiResponse.error(500, "文章生成失败: " + e.getMessage()); + } + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ArticlePageController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ArticlePageController.java new file mode 100644 index 0000000..cb1b574 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ArticlePageController.java @@ -0,0 +1,74 @@ +package xyz.lingview.dimstack.controller; + +import jakarta.servlet.http.HttpServletRequest; +import xyz.lingview.dimstack.domain.ReadArticle; +import xyz.lingview.dimstack.domain.SiteConfig; +import xyz.lingview.dimstack.service.ReadArticleService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.*; + +import org.springframework.web.servlet.ModelAndView; +import xyz.lingview.dimstack.service.SiteConfigService; +import xyz.lingview.dimstack.util.BotUtil; +import xyz.lingview.dimstack.util.DomainUtil; +import xyz.lingview.dimstack.util.UrlUtil; + +/** + * @Author: lingview + * @Date: 2025/11/16 10:37:17 + * @Description: 文章页ssr + * @Version: 1.0 + */ +@Slf4j +@Controller +public class ArticlePageController { + + @Autowired + private ReadArticleService readArticleService; + + @Autowired + private SiteConfigService siteConfigService; + + @GetMapping("/article/{alias}") + public Object articleForSEO( + @PathVariable String alias, + @RequestHeader(value = "User-Agent", required = false) String ua, + HttpServletRequest request + ) { + log.info("访问文章 {} UA={}", alias, ua); + + boolean bot = BotUtil.isBot(ua); + + if (bot) { + try { + ReadArticle article = readArticleService.getArticleByAlias(alias, null); + SiteConfig siteConfig = siteConfigService.getSiteConfig(); + + String domain = DomainUtil.getFullDomain(request); + String url = domain + "/article/" + alias; + + ModelAndView mv = new ModelAndView("seo_article"); + mv.addObject("title", article.getArticle_name()); + mv.addObject("excerpt", article.getExcerpt()); + mv.addObject("content", article.getArticle_content()); + mv.addObject("date", article.getCreate_time()); + mv.addObject("tag", article.getTag()); + + mv.addObject("url", url); + mv.addObject("domain", domain); + mv.addObject("siteIcon", UrlUtil.getFullUrl(domain, siteConfig.getSite_icon())); + + + return mv; + + } catch (Exception e) { + return new ModelAndView("seo_404"); + } + } + + return "forward:/index.html"; + } + +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ArticleReviewController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ArticleReviewController.java new file mode 100644 index 0000000..e37ed88 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ArticleReviewController.java @@ -0,0 +1,61 @@ +package xyz.lingview.dimstack.controller; + +import org.springframework.web.bind.annotation.*; +import xyz.lingview.dimstack.annotation.RequiresPermission; +import xyz.lingview.dimstack.dto.request.ArticleStatusUpdateRequestDTO; +import xyz.lingview.dimstack.dto.response.ArticleReviewListResponseDTO; +import xyz.lingview.dimstack.dto.response.ArticleReviewStatusResponseDTO; +import xyz.lingview.dimstack.service.ArticleReviewService; +import xyz.lingview.dimstack.domain.Article; +import xyz.lingview.dimstack.common.ApiResponse; + +import java.util.Map; + +@RestController +@RequestMapping("/api/articlereview") +public class ArticleReviewController { + + private final ArticleReviewService articleReviewService; + + public ArticleReviewController(ArticleReviewService articleReviewService) { + this.articleReviewService = articleReviewService; + } + + // 获取未审核的文章列表 + @GetMapping("/getarticlelist") + @RequiresPermission("system:post:review") + public ApiResponse getArticleList(@RequestParam(defaultValue = "1") Integer page, + @RequestParam(defaultValue = "10") Integer size) { + return ApiResponse.success(articleReviewService.getUnreviewedArticles(page, size)); + } + + // 根据文章id获取文章内容 + @GetMapping("/getarticlecontent") + @RequiresPermission("system:post:review") + public ApiResponse
getArticleContent(@RequestParam String articleId) { + return ApiResponse.success(articleReviewService.getArticleContent(articleId)); + } + + + // 修改文章状态 + @PostMapping("/articlestatus") + @RequiresPermission("system:post:review") + public ApiResponse updateArticleStatus(@RequestBody ArticleStatusUpdateRequestDTO request) { + return ApiResponse.success(articleReviewService.updateArticleStatus(request.getArticleId(), request.getStatus())); + } + + // 根据文章id删除文章(软删除) + @PostMapping("/articlereviewdeletearticle") + @RequiresPermission("system:post:review") + public ApiResponse articleReviewDeleteArticle(@RequestBody ArticleStatusUpdateRequestDTO request) { + return ApiResponse.success(articleReviewService.deleteArticle(request.getArticleId())); + } + + // 获取所有文章列表(用于审核全部文章) + @GetMapping("/getallarticles") + @RequiresPermission("system:post:review") + public ApiResponse getAllArticles(@RequestParam(defaultValue = "1") Integer page, + @RequestParam(defaultValue = "10") Integer size) { + return ApiResponse.success(articleReviewService.getAllArticles(page, size)); + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ArticleSearchController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ArticleSearchController.java new file mode 100644 index 0000000..78f6b58 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ArticleSearchController.java @@ -0,0 +1,46 @@ +package xyz.lingview.dimstack.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.domain.Article; +import xyz.lingview.dimstack.dto.response.ArticleSearchResponseDTO; +import xyz.lingview.dimstack.dto.response.ArticleSearchResultDTO; +import xyz.lingview.dimstack.service.ArticleSearchService; + +import java.util.List; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/articlesearch") +public class ArticleSearchController { + + @Autowired + private ArticleSearchService articleSearchService; + + @GetMapping("/search") + public ApiResponse searchArticles(@RequestParam(required = false) String keyword) { + try { + List
articles = articleSearchService.searchArticles(keyword); + + List articleInfoList = articles.stream() + .map(article -> { + ArticleSearchResultDTO info = new ArticleSearchResultDTO(); + info.setTitle(article.getArticle_name()); + info.setAlias(article.getAlias()); + info.setId(article.getId()); + info.setExcerpt(article.getExcerpt()); + return info; + }) + .collect(Collectors.toList()); + + ArticleSearchResponseDTO responseDTO = new ArticleSearchResponseDTO(); + responseDTO.setData(articleInfoList); + responseDTO.setCount(articles.size()); + + return ApiResponse.success(responseDTO); + } catch (Exception e) { + return ApiResponse.error(500, "搜索失败: " + e.getMessage()); + } + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/AttachmentManagementController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/AttachmentManagementController.java new file mode 100644 index 0000000..1318441 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/AttachmentManagementController.java @@ -0,0 +1,395 @@ +package xyz.lingview.dimstack.controller; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import xyz.lingview.dimstack.annotation.RequiresPermission; +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.domain.StorageMigrationLog; +import xyz.lingview.dimstack.service.AttachmentManagementService; +import xyz.lingview.dimstack.service.CurrentUserService; +import xyz.lingview.dimstack.service.UserService; + +import java.util.List; +import java.util.Map; + +/** + * @Author: lingview + * @Date: 2026/02/08 17:40:00 + * @Description: 附件管理控制器 + * @Version: 1.0 + */ +@RestController +@RequestMapping("/api/attachments") +public class AttachmentManagementController { + + @Autowired + private AttachmentManagementService attachmentManagementService; + + @Autowired + private UserService userService; + + @Autowired + private CurrentUserService currentUserService; + + /** + * 分页查询所有附件(管理员接口,仅正常状态) + */ + @GetMapping("/admin/page") + @RequiresPermission({"system:attachment:view", "system:attachment:management"}) + public ApiResponse> getAttachments( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int size) { + Map result = attachmentManagementService.getPage(page, size); + return ApiResponse.success(result); + } + + /** + * 分页查询所有附件(管理员接口,已删除状态,6小时内) + */ + @GetMapping("/admin/page-deleted-only") + @RequiresPermission({"system:attachment:view", "system:attachment:management"}) + public ApiResponse> getDeletedAttachmentsOnly( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int size) { + Map result = attachmentManagementService.getDeletedPageOnly(page, size); + return ApiResponse.success(result); + } + + /** + * 分页查询所有附件(管理员接口,包含最近删除的6小时内) + */ + @GetMapping("/admin/page-with-deleted") + @RequiresPermission({"system:attachment:view", "system:attachment:management"}) + public ApiResponse> getAttachmentsWithDeleted( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int size) { + Map result = attachmentManagementService.getPageWithRecentDeleted(page, size); + return ApiResponse.success(result); + } + + /** + * 根据用户UUID分页查询附件(管理员接口,正常状态) + */ + @GetMapping("/admin/page-by-user") + @RequiresPermission({"system:attachment:view", "system:attachment:management"}) + public ApiResponse> getAttachmentsByUser( + @RequestParam String userUuid, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int size) { + Map result = attachmentManagementService.getPageByUserUuid(userUuid, page, size); + return ApiResponse.success(result); + } + + /** + * 根据用户UUID分页查询附件(管理员接口,包含删除状态) + */ + @GetMapping("/admin/page-by-user-with-deleted") + @RequiresPermission({"system:attachment:view", "system:attachment:management"}) + public ApiResponse> getAttachmentsByUserWithDeleted( + @RequestParam String userUuid, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int size) { + Map result = attachmentManagementService.getPageByUserUuidWithDeleted(userUuid, page, size); + return ApiResponse.success(result); + } + + /** + * 根据用户UUID分页查询已删除附件(管理员接口,仅已删除状态,6小时内) + */ + @GetMapping("/admin/page-deleted-only-by-user") + @RequiresPermission({"system:attachment:view", "system:attachment:management"}) + public ApiResponse> getDeletedAttachmentsByUserOnly( + @RequestParam String userUuid, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int size) { + Map result = attachmentManagementService.getDeletedPageByUserUuidOnly(userUuid, page, size); + return ApiResponse.success(result); + } + + /** + * 基于UUID分页查询附件(用户接口,仅正常状态) + */ + @GetMapping("/page") + public ApiResponse> getAttachmentsByUuid( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int size, + HttpServletRequest request) { + String username = currentUserService.getCurrentUsername(); + String uuid = userService.getUserUUID(username); + + Map result = attachmentManagementService.getPageByUuid(uuid, page, size); + return ApiResponse.success(result); + } + + /** + * 基于UUID分页查询附件(用户接口,仅已删除状态,6小时内) + */ + @GetMapping("/page-deleted-only") + @RequiresPermission({"attachment:view", "attachment:edit"}) + public ApiResponse> getDeletedAttachmentsByUuidOnly( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int size, + HttpServletRequest request) { + String username = currentUserService.getCurrentUsername(); + String uuid = userService.getUserUUID(username); + + Map result = attachmentManagementService.getDeletedPageByUuidOnly(uuid, page, size); + return ApiResponse.success(result); + } + + /** + * 基于UUID分页查询附件(用户接口,包含最近删除的6小时内) + */ + @GetMapping("/page-with-deleted") + @RequiresPermission({"attachment:view", "attachment:edit"}) + public ApiResponse> getAttachmentsByUuidWithDeleted( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int size, + HttpServletRequest request) { + String username = currentUserService.getCurrentUsername(); + String uuid = userService.getUserUUID(username); + + Map result = attachmentManagementService.getPageByUuidWithRecentDeleted(uuid, page, size); + return ApiResponse.success(result); + } + + /** + * 删除附件(管理员接口) + */ + @DeleteMapping("/admin/{attachmentId}") + @RequiresPermission({"system:attachment:delete", "system:attachment:management"}) + public ApiResponse deleteAttachmentAdmin( + @PathVariable String attachmentId) { + + boolean success = attachmentManagementService.deleteAttachment(attachmentId); + + if (success) { + return ApiResponse.success("删除成功"); + } else { + return ApiResponse.error(400, "删除失败"); + } + } + + // 管理员批量删除附件 + @PostMapping("/admin/batch-delete") + @RequiresPermission({"system:attachment:delete", "system:attachment:management"}) + public ApiResponse batchDeleteAttachmentAdmin(@RequestBody List attachmentIds) { + if (attachmentIds == null || attachmentIds.isEmpty()) { + return ApiResponse.error(400, "请选择要删除的附件"); + } + + int count = attachmentManagementService.batchDeleteAttachments(attachmentIds); + return ApiResponse.success("成功删除 " + count + " 个附件", count); + } + + // 管理员批量撤销删除附件 + @PostMapping("/admin/batch-restore") + @RequiresPermission({"system:attachment:undelete", "system:attachment:management"}) + public ApiResponse batchRestoreAttachmentAdmin(@RequestBody List attachmentIds) { + if (attachmentIds == null || attachmentIds.isEmpty()) { + return ApiResponse.error(400, "请选择要撤销的附件"); + } + + int count = attachmentManagementService.batchRestoreAttachments(attachmentIds); + return ApiResponse.success("成功撤销 " + count + " 个附件", count); + } + + // 管理员批量彻底删除附件 + @PostMapping("/admin/batch-physically-delete") + @RequiresPermission({"system:attachment:delete", "system:attachment:management"}) + public ApiResponse batchPhysicallyDeleteAttachmentAdmin(@RequestBody List attachmentIds) { + if (attachmentIds == null || attachmentIds.isEmpty()) { + return ApiResponse.error(400, "请选择要彻底删除的附件"); + } + + int count = attachmentManagementService.batchPhysicallyDeleteAttachments(attachmentIds); + return ApiResponse.success("成功彻底删除 " + count + " 个附件", count); + } + + /** + * 撤销删除附件(管理员接口) + */ + @PostMapping("/admin/{attachmentId}/restore") + @RequiresPermission({"system:attachment:undelete", "system:attachment:management"}) + public ApiResponse restoreAttachmentAdmin( + @PathVariable String attachmentId) { + + boolean success = attachmentManagementService.restoreAttachment(attachmentId); + if (success) { + return ApiResponse.success("撤销删除成功"); + } else { + return ApiResponse.error(400, "撤销删除失败"); + } + } + + /** + * 普通用户删除附件接口 + */ + @DeleteMapping("/{attachmentId}") + @RequiresPermission({"attachment:delete", "attachment:edit"}) + public ApiResponse deleteAttachment( + @PathVariable String attachmentId, + HttpServletRequest request) { + + String username = currentUserService.getCurrentUsername(); + String userUuid = userService.getUserUUID(username); + + String attachmentOwnerUuid = attachmentManagementService.getAttachmentOwnerUuid(attachmentId); + if (attachmentOwnerUuid == null) { + return ApiResponse.error(400, "附件不存在"); + } + + if (!attachmentOwnerUuid.equals(userUuid)) { + return ApiResponse.error(403, "无权限删除此附件"); + } + + boolean success = attachmentManagementService.deleteAttachment(attachmentId); + if (success) { + return ApiResponse.success("删除成功"); + } else { + return ApiResponse.error(400, "删除失败"); + } + } + + /** + * 普通用户撤销删除附件接口 + */ + @PostMapping("/{attachmentId}/restore") + @RequiresPermission({"attachment:undelete", "attachment:edit"}) + public ApiResponse restoreAttachment( + @PathVariable String attachmentId, + HttpServletRequest request) { + + String username = currentUserService.getCurrentUsername(); + String userUuid = userService.getUserUUID(username); + + String attachmentOwnerUuid = attachmentManagementService.getAttachmentOwnerUuid(attachmentId); + if (attachmentOwnerUuid == null) { + return ApiResponse.error(400, "附件不存在"); + } + + if (!attachmentOwnerUuid.equals(userUuid)) { + return ApiResponse.error(403, "无权限操作此附件"); + } + boolean success = attachmentManagementService.restoreAttachment(attachmentId); + if (success) { + return ApiResponse.success("撤销删除成功"); + } else { + return ApiResponse.error(400, "撤销删除失败"); + } + } + + @PostMapping("/admin/{attachmentId}/physically-delete") + @RequiresPermission({"system:attachment:delete", "system:attachment:management"}) + public ApiResponse physicallyDeleteAttachmentAdmin( + @PathVariable String attachmentId) { + boolean success = attachmentManagementService.physicallyDeleteAttachment(attachmentId); + if (success) { + return ApiResponse.success("彻底删除成功"); + } else { + return ApiResponse.error(400, "彻底删除失败"); + } + } + + @PostMapping("/{attachmentId}/physically-delete") + @RequiresPermission({"attachment:delete", "attachment:edit"}) + public ApiResponse physicallyDeleteAttachment( + @PathVariable String attachmentId, + HttpServletRequest request) { + + String username = currentUserService.getCurrentUsername(); + String userUuid = userService.getUserUUID(username); + + String attachmentOwnerUuid = attachmentManagementService.getAttachmentOwnerUuid(attachmentId); + if (attachmentOwnerUuid == null) { + return ApiResponse.error(400, "附件不存在"); + } + + if (!attachmentOwnerUuid.equals(userUuid)) { + return ApiResponse.error(403, "无权限操作此附件"); + } + + boolean success = attachmentManagementService.physicallyDeleteAttachment(attachmentId); + if (success) { + return ApiResponse.success("彻底删除成功"); + } else { + return ApiResponse.error(400, "彻底删除失败"); + } + } + + @PostMapping("/admin/migrate-storage") + @RequiresPermission({"system:attachment:management"}) + public ApiResponse> migrateStorage(@RequestBody Map payload) { + String sourceStorageId = payload.get("sourceStorageId"); + String targetStorageId = payload.get("targetStorageId"); + + if (sourceStorageId == null || targetStorageId == null) { + return ApiResponse.error(400, "源存储和目标存储不能为空"); + } + if (sourceStorageId.equals(targetStorageId)) { + return ApiResponse.error(400, "源存储和目标存储不能相同"); + } + + Map result = attachmentManagementService.migrateStorage(sourceStorageId, targetStorageId); + if (result.containsKey("error")) { + return ApiResponse.error(400, (String) result.get("error")); + } + return ApiResponse.success(result); + } + + @PostMapping("/admin/switch-storage") + @RequiresPermission({"system:attachment:management"}) + public ApiResponse> switchAttachmentStorage(@RequestBody Map payload) { + String attachmentId = payload.get("attachmentId"); + String targetStorageId = payload.get("targetStorageId"); + + if (attachmentId == null || targetStorageId == null) { + return ApiResponse.error(400, "参数不完整"); + } + + Map result = attachmentManagementService.switchAttachmentStorage(attachmentId, targetStorageId); + if (result.containsKey("error")) { + return ApiResponse.error(400, (String) result.get("error")); + } + return ApiResponse.success(result); + } + + @PostMapping("/admin/migrate-storage/retry") + @RequiresPermission({"system:attachment:management"}) + public ApiResponse> retryMigrateStorage(@RequestBody Map payload) { + String sourceStorageId = (String) payload.get("sourceStorageId"); + String targetStorageId = (String) payload.get("targetStorageId"); + @SuppressWarnings("unchecked") + List attachmentIds = (List) payload.get("attachmentIds"); + + if (sourceStorageId == null || targetStorageId == null || attachmentIds == null || attachmentIds.isEmpty()) { + return ApiResponse.error(400, "参数不完整"); + } + if (sourceStorageId.equals(targetStorageId)) { + return ApiResponse.error(400, "源存储和目标存储不能相同"); + } + + Map result = attachmentManagementService.retryMigrateStorage(sourceStorageId, targetStorageId, attachmentIds); + if (result.containsKey("error")) { + return ApiResponse.error(400, (String) result.get("error")); + } + return ApiResponse.success(result); + } + + @GetMapping("/admin/migrate-logs") + @RequiresPermission({"system:attachment:management"}) + public ApiResponse> getMigrateLogs() { + return ApiResponse.success(attachmentManagementService.getMigrateLogs()); + } + + @GetMapping("/admin/migrate-logs/{id}") + @RequiresPermission({"system:attachment:management"}) + public ApiResponse getMigrateLogDetail(@PathVariable int id) { + StorageMigrationLog log = attachmentManagementService.getMigrateLogDetail(id); + if (log == null) { + return ApiResponse.error(400, "记录不存在"); + } + return ApiResponse.success(log); + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/BackentCommentController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/BackentCommentController.java new file mode 100644 index 0000000..f61f253 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/BackentCommentController.java @@ -0,0 +1,144 @@ +package xyz.lingview.dimstack.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import xyz.lingview.dimstack.annotation.RequiresPermission; +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.domain.Comment; +import xyz.lingview.dimstack.dto.request.CommentDTO; +import xyz.lingview.dimstack.service.BackendCommentService; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/backentcomments") +public class BackentCommentController { + + @Autowired + private BackendCommentService backendCommentService; + + // 获取指定文章的所有评论 + @GetMapping("/article/{article_id}") + @RequiresPermission({"system:comments:view", "system:comments:management"}) + public ApiResponse> getCommentsByArticleId(@PathVariable String article_id) { + List comments = backendCommentService.getCommentsByArticleId(article_id); + String articleTitle = backendCommentService.getArticleTitle(article_id); + + Map result = new HashMap<>(); + result.put("comments", comments); + result.put("articleTitle", articleTitle); + result.put("article_id", article_id); + + return ApiResponse.success(result); + } + + // 获取评论详情 + @GetMapping("/{comment_id}") + @RequiresPermission({"system:comments:view", "system:comments:management"}) + public ApiResponse getCommentDetail(@PathVariable String comment_id) { + Comment comment = backendCommentService.getCommentDetail(comment_id); + if (comment == null) { + return ApiResponse.error(404, "评论不存在"); + } + return ApiResponse.success(comment); + } + + // 修改评论内容 + @PutMapping("/{comment_id}") + @RequiresPermission({"system:comments:edit", "system:comments:management"}) + public ApiResponse updateComment(@PathVariable String comment_id, + @RequestBody Map payload) { + String content = payload.get("content"); + if (content == null || content.trim().isEmpty()) { + return ApiResponse.error(400, "评论内容不能为空"); + } + + boolean success = backendCommentService.updateCommentContent(comment_id, content); + if (success) { + return ApiResponse.success("评论更新成功"); + } else { + return ApiResponse.error(400, "评论更新失败"); + } + } + + // 修改评论时间 + @PutMapping("/{comment_id}/time") + @RequiresPermission({"system:comments:edit", "system:comments:management"}) + public ApiResponse updateCommentTime(@PathVariable String comment_id, + @RequestBody Map payload) { + String create_time = payload.get("create_time"); + if (create_time == null || create_time.trim().isEmpty()) { + return ApiResponse.error(400, "评论时间不能为空"); + } + + boolean success = backendCommentService.updateCommentTime(comment_id, create_time); + if (success) { + return ApiResponse.success("评论时间更新成功"); + } else { + return ApiResponse.error(400, "评论时间更新失败"); + } + } + + // 修改评论用户 + @PutMapping("/{comment_id}/user") + @RequiresPermission({"system:comments:edit", "system:comments:management"}) + public ApiResponse updateCommentUser(@PathVariable String comment_id, + @RequestBody Map payload) { + String username = payload.get("username"); + if (username == null || username.trim().isEmpty()) { + return ApiResponse.error(400, "用户名不能为空"); + } + + boolean success = backendCommentService.updateCommentUser(comment_id, username); + if (success) { + return ApiResponse.success("评论用户更新成功"); + } else { + return ApiResponse.error(400, "用户不存在或更新失败"); + } + } + + // 搜索用户 + @GetMapping("/users/search") + @RequiresPermission({"system:comments:edit", "system:comments:management"}) + public ApiResponse>> searchUsers(@RequestParam String q) { + if (q == null || q.trim().isEmpty()) { + return ApiResponse.success(new ArrayList<>()); + } + List> users = backendCommentService.searchUsers(q.trim()); + return ApiResponse.success(users); + } + + // 删除评论 + @DeleteMapping("/{comment_id}") + @RequiresPermission({"system:comments:delete", "system:comments:management"}) + public ApiResponse deleteComment(@PathVariable String comment_id) { + boolean success = backendCommentService.deleteComment(comment_id); + if (success) { + return ApiResponse.success("评论删除成功"); + } else { + return ApiResponse.error(400, "评论删除失败"); + } + } + + + // 分页获取所有评论 + @GetMapping + @RequiresPermission({"system:comments:view", "system:comments:management"}) + public ApiResponse> getAllComments(@RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int size) { + List comments = backendCommentService.getAllCommentsWithPagination(page, size); + int totalComments = backendCommentService.getTotalCommentsCount(); + int totalPages = (int) Math.ceil((double) totalComments / size); + + Map response = new HashMap<>(); + response.put("comments", comments); + response.put("currentPage", page); + response.put("totalPages", totalPages); + response.put("totalComments", totalComments); + response.put("pageSize", size); + + return ApiResponse.success(response); + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/CacheManagementController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/CacheManagementController.java new file mode 100644 index 0000000..a6f937c --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/CacheManagementController.java @@ -0,0 +1,272 @@ +package xyz.lingview.dimstack.controller; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import xyz.lingview.dimstack.annotation.RequiresPermission; +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.service.CacheService; +import xyz.lingview.dimstack.service.ArticleService; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +@RestController +@RequestMapping("/api/cache") +public class CacheManagementController { + + @Autowired + private CacheService cacheService; + + @Autowired + private ArticleService articleService; + + @PostMapping("/clear/article-list") + @RequiresPermission("system:config:management") + public ApiResponse> clearArticleListCache() { + log.info("执行清除文章列表分页缓存操作"); + try { + articleService.clearArticleCache(); + + Map result = new HashMap<>(); + result.put("message", "已清除所有文章列表分页缓存"); + result.put("prefix", "article:home:"); + + return ApiResponse.success(result); + } catch (Exception e) { + log.error("清除文章列表分页缓存失败", e); + return ApiResponse.error(500, "清除文章列表分页缓存失败: " + e.getMessage()); + } + } + + @PostMapping("/clear/all") + @RequiresPermission("system:config:management") + public ApiResponse> clearAllCache() { + log.info("执行清除所有缓存操作"); + try { + String[] cacheKeys = { + "dimstack:site_config", + "dimstack:user:blacklist", + "dimstack:hot_articles", + "dimstack:sitemap:articles", + "dimstack:sitemap:categories", + "dimstack:llm_config", + "dimstack:sitemap:tags" + }; + + cacheService.deleteByPrefix("article:home:"); + log.info("已清除所有文章列表分页缓存"); + + for (String key : cacheKeys) { + cacheService.delete(key); + log.info("已清除缓存: {}", key); + } + + Map result = new HashMap<>(); + result.put("clearedKeys", cacheKeys); + result.put("count", cacheKeys.length); + + return ApiResponse.success(result); + } catch (Exception e) { + log.error("清除所有缓存失败", e); + return ApiResponse.error(500, "清除缓存失败: " + e.getMessage()); + } + } + + @PostMapping("/clear/article") + @RequiresPermission("system:config:management") + public ApiResponse> clearArticleCache(@RequestBody(required = false) Map request) { + log.info("执行清除文章缓存操作"); + try { + if (request != null && request.containsKey("alias")) { + String alias = request.get("alias"); + if (alias == null || alias.trim().isEmpty()) { + return ApiResponse.error(400, "文章别名不能为空"); + } + + String cacheKey = "dimstack:article:" + alias; + cacheService.delete(cacheKey); + log.info("已清除文章缓存: {}, 别名: {}", cacheKey, alias); + + Map result = new HashMap<>(); + result.put("clearedKey", cacheKey); + result.put("alias", alias); + + return ApiResponse.success(result); + } else { + cacheService.deleteByPrefix("dimstack:article:"); + log.info("已清除所有文章缓存"); + + Map result = new HashMap<>(); + result.put("message", "已清除所有文章缓存"); + + return ApiResponse.success(result); + } + } catch (Exception e) { + log.error("清除文章缓存失败", e); + return ApiResponse.error(500, "清除文章缓存失败: " + e.getMessage()); + } + } + + @PostMapping("/clear/site-config") + @RequiresPermission("system:config:management") + public ApiResponse> clearSiteConfigCache() { + log.info("执行清除站点配置缓存操作"); + try { + cacheService.delete("dimstack:site_config"); + log.info("已清除站点配置缓存"); + + Map result = new HashMap<>(); + result.put("clearedKey", "dimstack:site_config"); + + return ApiResponse.success(result); + } catch (Exception e) { + log.error("清除站点配置缓存失败", e); + return ApiResponse.error(500, "清除站点配置缓存失败: " + e.getMessage()); + } + } + + @PostMapping("/clear/user-blacklist") + @RequiresPermission("system:config:management") + public ApiResponse> clearUserBlacklistCache() { + log.info("执行清除用户黑名单缓存操作"); + try { + cacheService.deleteSet("dimstack:user:blacklist"); + log.info("已清除用户黑名单缓存"); + + Map result = new HashMap<>(); + result.put("clearedKey", "dimstack:user:blacklist"); + + return ApiResponse.success(result); + } catch (Exception e) { + log.error("清除用户黑名单缓存失败", e); + return ApiResponse.error(500, "清除用户黑名单缓存失败: " + e.getMessage()); + } + } + + @PostMapping("/clear/hot-articles") + @RequiresPermission("system:config:management") + public ApiResponse> clearHotArticlesCache() { + log.info("执行清除热门文章缓存操作"); + try { + cacheService.delete("dimstack:hot_articles"); + log.info("已清除热门文章缓存"); + + Map result = new HashMap<>(); + result.put("clearedKey", "dimstack:hot_articles"); + + return ApiResponse.success(result); + } catch (Exception e) { + log.error("清除热门文章缓存失败", e); + return ApiResponse.error(500, "清除热门文章缓存失败: " + e.getMessage()); + } + } + + @PostMapping("/clear/sitemap/articles") + @RequiresPermission("system:config:management") + public ApiResponse> clearSitemapArticlesCache() { + log.info("执行清除站点地图文章缓存操作"); + try { + cacheService.delete("dimstack:sitemap:articles"); + log.info("已清除站点地图文章缓存"); + + Map result = new HashMap<>(); + result.put("clearedKey", "dimstack:sitemap:articles"); + + return ApiResponse.success(result); + } catch (Exception e) { + log.error("清除站点地图文章缓存失败", e); + return ApiResponse.error(500, "清除站点地图文章缓存失败: " + e.getMessage()); + } + } + + @PostMapping("/clear/sitemap/categories") + @RequiresPermission("system:config:management") + public ApiResponse> clearSitemapCategoriesCache() { + log.info("执行清除站点地图分类缓存操作"); + try { + cacheService.delete("dimstack:sitemap:categories"); + log.info("已清除站点地图分类缓存"); + + Map result = new HashMap<>(); + result.put("clearedKey", "dimstack:sitemap:categories"); + + return ApiResponse.success(result); + } catch (Exception e) { + log.error("清除站点地图分类缓存失败", e); + return ApiResponse.error(500, "清除站点地图分类缓存失败: " + e.getMessage()); + } + } + + @PostMapping("/clear/llm-config") + @RequiresPermission("system:config:management") + public ApiResponse> clearLlmConfigCache() { + log.info("执行清除LLM配置缓存操作"); + try { + cacheService.delete("dimstack:llm_config"); + log.info("已清除LLM配置缓存"); + + Map result = new HashMap<>(); + result.put("clearedKey", "dimstack:llm_config"); + + return ApiResponse.success(result); + } catch (Exception e) { + log.error("清除LLM配置缓存失败", e); + return ApiResponse.error(500, "清除LLM配置缓存失败: " + e.getMessage()); + } + } + + @PostMapping("/clear/sitemap/tags") + @RequiresPermission("system:config:management") + public ApiResponse> clearSitemapTagsCache() { + log.info("执行清除站点地图标签缓存操作"); + try { + cacheService.delete("dimstack:sitemap:tags"); + log.info("已清除站点地图标签缓存"); + + Map result = new HashMap<>(); + result.put("clearedKey", "dimstack:sitemap:tags"); + + return ApiResponse.success(result); + } catch (Exception e) { + log.error("清除站点地图标签缓存失败", e); + return ApiResponse.error(500, "清除站点地图标签缓存失败: " + e.getMessage()); + } + } + + @PostMapping("/clear/batch") + @RequiresPermission("system:config:management") + public ApiResponse> clearBatchCache(@RequestBody Map> request) { + log.info("执行批量清除缓存操作"); + try { + List cacheKeys = request.get("keys"); + if (cacheKeys == null || cacheKeys.isEmpty()) { + return ApiResponse.error(400, "请指定要清除的缓存键"); + } + + List clearedKeys = new java.util.ArrayList<>(); + for (String key : cacheKeys) { + if (key.endsWith(":")) { + cacheService.deleteByPrefix(key); + clearedKeys.add(key + "*"); + log.info("已清除以 {} 开头的所有缓存", key); + } else { + cacheService.delete(key); + clearedKeys.add(key); + log.info("已清除缓存: {}", key); + } + } + + Map result = new HashMap<>(); + result.put("clearedKeys", clearedKeys); + result.put("count", clearedKeys.size()); + + return ApiResponse.success(result); + } catch (Exception e) { + log.error("批量清除缓存失败", e); + return ApiResponse.error(500, "批量清除缓存失败: " + e.getMessage()); + } + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/CaptchaController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/CaptchaController.java new file mode 100644 index 0000000..0e0abcc --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/CaptchaController.java @@ -0,0 +1,64 @@ +package xyz.lingview.dimstack.controller; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import xyz.lingview.dimstack.service.CacheService; + +import jakarta.servlet.http.HttpSession; +import xyz.lingview.dimstack.util.CaptchaUtil; +import xyz.lingview.dimstack.common.ApiResponse; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +@RestController +@Slf4j +@RequestMapping("/api") +public class CaptchaController { + + @Autowired + private CacheService cacheService; + + @GetMapping("/captcha") + public ApiResponse> getCaptcha(HttpSession session) { + try { + // 生成新的验证码 + String captcha = CaptchaUtil.generateCaptcha(4); + String captchaKey = CaptchaUtil.generateCaptchaKey(); + + // 删除旧的验证码(如果存在) + String oldCaptchaKey = (String) session.getAttribute("captchaKey"); + if (oldCaptchaKey != null) { + cacheService.delete("captcha_" + oldCaptchaKey); + } + + // 保存新验证码到缓存(有效期5分钟) + cacheService.set("captcha_" + captchaKey, captcha.toLowerCase(), 5, TimeUnit.MINUTES); + + // 存储新的验证码key到session + session.setAttribute("captchaKey", captchaKey); + + // 生成验证码图片 + String captchaImage = CaptchaUtil.generateCaptchaImage(captcha); + + Map responseData = new HashMap<>(); + responseData.put("image", "data:image/png;base64," + captchaImage); + responseData.put("key", captchaKey); + + return ApiResponse.success(responseData); + + } catch (Exception e) { + log.error("验证码生成失败", e); + ApiResponse> response = new ApiResponse<>(); + response.setCode(500); + response.setMessage("验证码生成失败"); + response.setData(null); + return response; + } + } + +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/CommentController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/CommentController.java new file mode 100644 index 0000000..0d73ac0 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/CommentController.java @@ -0,0 +1,65 @@ +package xyz.lingview.dimstack.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import xyz.lingview.dimstack.annotation.RateLimit; +import xyz.lingview.dimstack.annotation.RequiresPermission; +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.dto.request.AddCommentRequestDTO; +import xyz.lingview.dimstack.dto.request.CommentDTO; +import xyz.lingview.dimstack.service.CommentService; +import xyz.lingview.dimstack.service.CurrentUserService; + +import java.util.List; + +@RestController +@RequestMapping("/api/comments") +public class CommentController { + + @Autowired + private CommentService commentService; + + @Autowired + private CurrentUserService currentUserService; + + // 获取文章的评论列表 + @GetMapping("/article/{articleAlias}") + public ApiResponse> getCommentsByArticle(@PathVariable String articleAlias) { + String username = currentUserService.getCurrentUsername(); + List comments = commentService.getCommentsByArticleAlias(articleAlias, username); + return ApiResponse.success(comments); + } + + // 添加评论 + @PostMapping + @RateLimit(window = 60, maxRequests = 5) + @RequiresPermission({"comments:add", "comments:edit"}) + public ApiResponse addComment(@RequestBody AddCommentRequestDTO request) { + String username = currentUserService.getCurrentUsername(); + int status = commentService.addComment(username, request); + if (status == 3) { + return ApiResponse.success("您的评论已提交,将在审核通过后展示"); + } + return ApiResponse.success("评论成功"); + } + + // 点赞评论 + @PostMapping("/{commentId}/like") + @RequiresPermission({"comments:like", "comments:edit"}) + @RateLimit(window = 60, maxRequests = 5) + public ApiResponse likeComment(@PathVariable String commentId) { + String username = currentUserService.getCurrentUsername(); + commentService.likeComment(username, commentId); + return ApiResponse.success("点赞成功"); + } + + // 删除评论 + @DeleteMapping("/{commentId}") + @RateLimit(window = 60, maxRequests = 5) + @RequiresPermission({"comments:delete", "comments:edit"}) + public ApiResponse deleteComment(@PathVariable String commentId) { + String username = currentUserService.getCurrentUsername(); + commentService.deleteComment(username, commentId); + return ApiResponse.success("删除成功"); + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/CommentReviewController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/CommentReviewController.java new file mode 100644 index 0000000..ae8df42 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/CommentReviewController.java @@ -0,0 +1,78 @@ +package xyz.lingview.dimstack.controller; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import xyz.lingview.dimstack.annotation.RequiresPermission; +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.dto.request.CommentDTO; +import xyz.lingview.dimstack.service.BackendCommentService; + +import java.util.*; + +@RestController +@RequestMapping("/api/commentreview") +@Slf4j +public class CommentReviewController { + + @Autowired + private BackendCommentService backendCommentService; + + @GetMapping("/pending") + @RequiresPermission("system:comments:review") + public ApiResponse> getPendingComments( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int size) { + try { + List comments = backendCommentService.getCommentsByStatus(3, page, size); + int total = backendCommentService.countByStatus(3); + Map result = new HashMap<>(); + result.put("data", comments); + result.put("total", total); + result.put("page", page); + result.put("size", size); + result.put("total_pages", (int) Math.ceil((double) total / size)); + return ApiResponse.success(result); + } catch (Exception e) { + log.error("获取待审核评论失败", e); + return ApiResponse.error(500, "获取待审核评论失败"); + } + } + + @GetMapping("/all") + @RequiresPermission("system:comments:review") + public ApiResponse> getAllComments( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int size) { + try { + List comments = backendCommentService.getAllReviewComments(page, size); + int total = backendCommentService.countAllReview(); + Map result = new HashMap<>(); + result.put("data", comments); + result.put("total", total); + result.put("page", page); + result.put("size", size); + result.put("total_pages", (int) Math.ceil((double) total / size)); + return ApiResponse.success(result); + } catch (Exception e) { + log.error("获取全部评论失败", e); + return ApiResponse.error(500, "获取全部评论失败"); + } + } + + @PutMapping("/{comment_id}/status") + @RequiresPermission("system:comments:review") + public ApiResponse updateStatus(@PathVariable String comment_id, + @RequestBody Map payload) { + Integer status = payload.get("status"); + if (status == null) { + return ApiResponse.error(400, "状态不能为空"); + } + boolean success = backendCommentService.updateCommentReviewStatus(comment_id, status); + if (success) { + return ApiResponse.success("评论状态更新成功"); + } else { + return ApiResponse.error(400, "评论状态更新失败"); + } + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/CustomErrorController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/CustomErrorController.java new file mode 100644 index 0000000..6408e68 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/CustomErrorController.java @@ -0,0 +1,30 @@ +// 升级SpringBoot4.x所以将该配置改到webconfig因为4.x弃用了ErrorController +// +//package xyz.lingview.dimstack.controller; +// +//import jakarta.servlet.RequestDispatcher; +//import jakarta.servlet.http.HttpServletRequest; +//import org.springframework.boot.web.servlet.error.ErrorController; +//import org.springframework.stereotype.Controller; +//import org.springframework.web.bind.annotation.RequestMapping; +// +///** +// * @Author: lingview +// * @Date: 2025/12/09 23:43:53 +// * @Description: 全局错误处理 +// * @Version: 1.0 +// */ +//@Controller +//public class CustomErrorController implements ErrorController { +// +// @RequestMapping("/error") +// public String handleError(HttpServletRequest request) { +// Integer statusCode = (Integer) request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE); +// +// if (statusCode != null && statusCode == 404) { +// return "forward:/index.html"; +// } +// +// return "forward:/index.html"; +// } +//} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/CustomPageController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/CustomPageController.java new file mode 100644 index 0000000..1918d96 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/CustomPageController.java @@ -0,0 +1,133 @@ +package xyz.lingview.dimstack.controller; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import xyz.lingview.dimstack.annotation.RequiresPermission; +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.dto.request.CustomPageRequest; +import xyz.lingview.dimstack.dto.response.CustomPageResponse; +import xyz.lingview.dimstack.service.CurrentUserService; +import xyz.lingview.dimstack.service.CustomPageService; +import xyz.lingview.dimstack.service.UserService; + +import java.util.List; + +/** + * @Author: lingview + * @Date: 2026/01/17 20:16:05 + * @Description: 自定义页面控制器 + * @Version: 1.0 + */ +@RestController +@RequestMapping("/api/custom-pages") +public class CustomPageController { + + @Autowired + private CustomPageService customPageService; + + @Autowired + private UserService userService; + + @Autowired + private CurrentUserService currentUserService; + + /** + * 创建自定义页面 + */ + @PostMapping + @RequiresPermission({"system:custompage:add","system:custompage:management"}) + public ApiResponse createCustomPage( + @RequestBody CustomPageRequest request, + HttpServletRequest httpRequest) { + + String userUuid = getCurrentUserUuid(httpRequest); + if (userUuid == null) { + return ApiResponse.error(401, "用户未登录"); + } + + return customPageService.createCustomPage(request, userUuid); + } + + /** + * 更新自定义页面 + */ + @PutMapping("/{id}") + @RequiresPermission({"system:custompage:update","system:custompage:management"}) + public ApiResponse updateCustomPage( + @PathVariable Integer id, + @RequestBody CustomPageRequest request, + HttpServletRequest httpRequest) { + + String userUuid = getCurrentUserUuid(httpRequest); + if (userUuid == null) { + return ApiResponse.error(401, "用户未登录"); + } + + return customPageService.updateCustomPage(id, request, userUuid); + } + + /** + * 删除自定义页面 + */ + @DeleteMapping("/{id}") + @RequiresPermission({"system:custompage:delete","system:custompage:management"}) + public ApiResponse deleteCustomPage( + @PathVariable Integer id, + HttpServletRequest httpRequest) { + + String userUuid = getCurrentUserUuid(httpRequest); + if (userUuid == null) { + return ApiResponse.error(401, "用户未登录", false); + } + + return customPageService.deleteCustomPage(id, userUuid); + } + + /** + * 根据别名获取自定义页面 + */ + @GetMapping("/{alias}") + public ApiResponse getCustomPageByAlias(@PathVariable String alias) { + return customPageService.getCustomPageByAlias(alias); + } + + /** + * 获取用户的所有自定义页面 + */ + @GetMapping("/user") + @RequiresPermission({"system:custompage:view","system:custompage:management"}) + public ApiResponse> getCustomPagesByUser(HttpServletRequest httpRequest) { + + String userUuid = getCurrentUserUuid(httpRequest); + if (userUuid == null) { + return ApiResponse.error(401, "用户未登录", null); + } + + return customPageService.getCustomPagesByUser(userUuid); + } + + /** + * 获取所有自定义页面 + */ + @GetMapping + @RequiresPermission({"system:edit","system:custompage:management"}) + public ApiResponse> getAllCustomPages(HttpServletRequest httpRequest) { + String userUuid = getCurrentUserUuid(httpRequest); + if (userUuid == null) { + return ApiResponse.error(401, "用户未登录", null); + } + return customPageService.getAllCustomPages(); + } + + /** + * 从Session获取当前用户UUID + */ + private String getCurrentUserUuid(HttpServletRequest request) { + String username = currentUserService.getCurrentUsername(); + if (username != null) { + return userService.getUserUUID(username); + } + return null; + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/DashboardController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/DashboardController.java new file mode 100644 index 0000000..4d59259 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/DashboardController.java @@ -0,0 +1,37 @@ +package xyz.lingview.dimstack.controller; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.dto.request.DashboardMenuDTO; +import xyz.lingview.dimstack.service.CurrentUserService; +import xyz.lingview.dimstack.service.DashboardMenuService; + +@RestController +@RequestMapping("/api/dashboard") +@Slf4j +public class DashboardController { + + @Autowired + private DashboardMenuService dashboardMenuService; + + @Autowired + private CurrentUserService currentUserService; + + @GetMapping("/menus") + public ApiResponse getDashboardMenus() { + try { + String username = currentUserService.getCurrentUsername(); + + DashboardMenuDTO menus = dashboardMenuService.getDashboardMenus(username); + + return ApiResponse.success(menus); + } catch (Exception e) { + log.error("获取仪表盘菜单失败", e); + return ApiResponse.error(500, "获取仪表盘菜单失败: " + e.getMessage()); + } + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/EditArticleController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/EditArticleController.java new file mode 100644 index 0000000..873d3e3 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/EditArticleController.java @@ -0,0 +1,220 @@ +package xyz.lingview.dimstack.controller; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import xyz.lingview.dimstack.annotation.RateLimit; +import xyz.lingview.dimstack.annotation.RequiresPermission; +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.dto.request.ArticleDetailDTO; +import xyz.lingview.dimstack.dto.request.UpdateArticleDTO; +import xyz.lingview.dimstack.dto.response.ArticleOperationResult; +import xyz.lingview.dimstack.service.CurrentUserService; +import xyz.lingview.dimstack.service.EditArticleService; + +import java.util.Map; + +@RestController +@Slf4j +@RequestMapping("/api") +public class EditArticleController { + + @Autowired + private EditArticleService editArticleService; + + @Autowired + private CurrentUserService currentUserService; + + @GetMapping("/getarticlelist") + @RequiresPermission({"post:view", "post:edit"}) + public ApiResponse> getArticleList( + @RequestParam(defaultValue = "1") Integer page, + @RequestParam(defaultValue = "10") Integer size, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) Integer status, + @RequestParam(required = false) Integer categoryId, + @RequestParam(defaultValue = "true") Boolean includeSubCategories, + @RequestParam(required = false) String tag, + @RequestParam(required = false) String startDate, + @RequestParam(required = false) String endDate) { + try { + String username = currentUserService.getCurrentUsername(); + + if (username == null) { + return ApiResponse.error(401, "用户未登录"); + } + + Map result = editArticleService.getArticleListByUsername( + username, page, size, keyword, status, categoryId, includeSubCategories, tag, startDate, endDate); + + return ApiResponse.success("获取文章列表成功", result); + + } catch (Exception e) { + return ApiResponse.error(500, "获取文章列表失败: " + e.getMessage()); + } + } + + @PostMapping("/updatearticle") + @RequiresPermission({"post:update", "post:edit"}) + public ApiResponse updateArticle( + @RequestBody UpdateArticleDTO updateArticleDTO) { + try { + String username = currentUserService.getCurrentUsername(); + + if (username == null) { + return ApiResponse.error(401, "用户未登录"); + } + + ArticleOperationResult result = editArticleService.updateArticleWithNotification(updateArticleDTO, username); + + return result.isSuccess() + ? ApiResponse.success(result.getMessage()) + : ApiResponse.error(500, result.getMessage()); + + } catch (Exception e) { + log.error("文章更新失败", e); + return ApiResponse.error(500, "文章更新失败: " + e.getMessage()); + } + } + + @GetMapping("/getarticle/{articleId}") + @RequiresPermission({"post:details", "post:edit"}) + public ApiResponse getArticleDetail( + @PathVariable String articleId) { + try { + String username = currentUserService.getCurrentUsername(); + + if (username == null) { + return ApiResponse.error(401, "用户未登录"); + } + + ArticleDetailDTO article = editArticleService.getArticleDetailById(articleId, username); + +// System.out.println("article: " + article); + if (article != null) { + return ApiResponse.success("获取文章详情成功", article); + } else { + return ApiResponse.error(404, "获取文章详情失败:权限不足或文章不存在"); + } + + } catch (Exception e) { + return ApiResponse.error(500, "获取文章详情失败: " + e.getMessage()); + } + } + + @PostMapping("/deletearticle") + @RequiresPermission({"post:delete", "post:edit"}) + public ApiResponse deleteArticle( + @RequestBody Map payload) { + try { + String username = currentUserService.getCurrentUsername(); + + if (username == null) { + return ApiResponse.error(401, "用户未登录"); + } + + String articleId = payload.get("article_id"); + if (articleId == null || articleId.isEmpty()) { + return ApiResponse.error(400, "文章ID不能为空"); + } + + ArticleOperationResult result = editArticleService.deleteArticleWithNotification(articleId, username); + + return result.isSuccess() + ? ApiResponse.success(result.getMessage()) + : ApiResponse.error(500, result.getMessage()); + + } catch (Exception e) { + log.error("文章删除失败", e); + return ApiResponse.error(500, "文章删除失败: " + e.getMessage()); + } + } + + @PostMapping("/unpublisharticle") + @RequiresPermission({"post:unpublish", "post:edit"}) + public ApiResponse unpublishArticle( + @RequestBody Map payload) { + try { + String username = currentUserService.getCurrentUsername(); + + if (username == null) { + return ApiResponse.error(401, "用户未登录"); + } + + String articleId = payload.get("article_id"); + + if (articleId == null || articleId.isEmpty()) { + return ApiResponse.error(400, "文章ID不能为空"); + } + + ArticleOperationResult result = editArticleService.unpublishArticleWithValidation(articleId, username); + + return result.isSuccess() + ? ApiResponse.success(result.getMessage()) + : ApiResponse.error(500, result.getMessage()); + + } catch (Exception e) { + log.error("取消发布失败", e); + return ApiResponse.error(500, "取消发布失败: " + e.getMessage()); + } + } + + + @PostMapping("/publisharticle") + @RateLimit(window = 60, maxRequests = 2) + @RequiresPermission({"post:publish", "post:edit"}) + public ApiResponse publishArticle( + @RequestBody Map payload) { + try { + String username = currentUserService.getCurrentUsername(); + + if (username == null) { + return ApiResponse.error(401, "用户未登录"); + } + + String articleId = payload.get("article_id"); + + if (articleId == null || articleId.isEmpty()) { + return ApiResponse.error(400, "文章ID不能为空"); + } + + ArticleOperationResult result = editArticleService.publishArticleWithReview(articleId, username); + + return result.isSuccess() + ? ApiResponse.success(result.getMessage()) + : ApiResponse.error(500, result.getMessage()); + + } catch (Exception e) { + log.error("发布失败", e); + return ApiResponse.error(500, "发布失败: " + e.getMessage()); + } + } + + @PostMapping("/removearticlepassword") + @RequiresPermission({"post:removepassword", "post:edit"}) + public ApiResponse removeArticlePassword( + @RequestBody Map payload) { + try { + String username = currentUserService.getCurrentUsername(); + + if (username == null) { + return ApiResponse.error(401, "用户未登录"); + } + + String articleId = payload.get("article_id"); + if (articleId == null || articleId.isEmpty()) { + return ApiResponse.error(400, "文章ID不能为空"); + } + + ArticleOperationResult result = editArticleService.removeArticlePasswordWithValidation(articleId, username); + + return result.isSuccess() + ? ApiResponse.success(result.getMessage()) + : ApiResponse.error(500, result.getMessage()); + + } catch (Exception e) { + log.error("移除文章密码失败", e); + return ApiResponse.error(500, "移除文章密码失败: " + e.getMessage()); + } + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ExpansionController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ExpansionController.java new file mode 100644 index 0000000..44a8840 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ExpansionController.java @@ -0,0 +1,446 @@ +package xyz.lingview.dimstack.controller; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; +import org.springframework.beans.factory.annotation.Autowired; +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 org.springframework.web.client.RestTemplate; +import xyz.lingview.dimstack.annotation.RequiresPermission; +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.config.ThemeProperties; +import xyz.lingview.dimstack.dto.request.ThemeSlugRequestDTO; +import xyz.lingview.dimstack.dto.response.ThemeDetailResponseDTO; +import xyz.lingview.dimstack.dto.response.ThemeListResponseDTO; +import xyz.lingview.dimstack.service.SiteConfigService; +import xyz.lingview.dimstack.service.UpdateService; + +import java.io.*; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; +import java.util.zip.ZipInputStream; + +/** + * @Auther: lingview + * @Date: 2025/09/22 18:57:54 + * @Description: 系统扩展控制器 + */ +@RestController +@RequestMapping("/api") +public class ExpansionController { + + @Autowired + private SiteConfigService siteConfigService; + + @Autowired + private RestTemplate restTemplate; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private ThemeProperties themeProperties; + + @Autowired + private UpdateService updateService; + + private static final String DEFAULT_THEME = "default"; + + // 主题名字只允许字母、数字、连字符和下划线 + private static final Pattern THEME_SLUG_PATTERN = Pattern.compile("^[a-zA-Z0-9_-]+$"); + + @PostMapping("/getthemeslist") + @RequiresPermission("system:theme:management") + public ApiResponse getThemesList() { + try { + // 从数据库获取扩展服务器地址 + String expansionServer = siteConfigService.getExpansionServer(); + + if (expansionServer == null || expansionServer.isEmpty()) { + return ApiResponse.error(400, "扩展服务器地址未配置"); + } + + // 从扩展服务器获取主题列表 + String themesJson = restTemplate.getForObject(expansionServer, String.class); + + if (themesJson != null) { + ThemeListResponseDTO responseDTO = new ThemeListResponseDTO(); + JsonNode themes = objectMapper.readTree(themesJson); + + String currentVersion = updateService.getCurrentVersion(); + if (themes.isArray()) { + for (JsonNode theme : themes) { + if (theme instanceof ObjectNode obj) { + String min = optText(theme, "min_core_version"); + String max = optText(theme, "max_core_version"); + obj.put("compatible", updateService.isCoreVersionInRange(currentVersion, min, max)); + obj.put("current_core_version", currentVersion); + } + } + } + + responseDTO.setData(themes); + return ApiResponse.success(responseDTO); + } else { + return ApiResponse.error(500, "无法从扩展服务器获取主题列表"); + } + } catch (Exception e) { + return ApiResponse.error(500, "获取主题列表失败: " + e.getMessage()); + } + } + + @PostMapping("/gettheme") + @RequiresPermission("system:theme:management") + public ApiResponse getTheme(@RequestBody ThemeSlugRequestDTO request) { + ThemeDetailResponseDTO responseDTO = new ThemeDetailResponseDTO(); + try { + String slug = request.getSlug(); + if (slug == null || slug.isEmpty()) { + return ApiResponse.error(400, "主题标识(slug)不能为空"); + } + + // 验证主题标识的合法性 + if (!isValidThemeSlug(slug)) { + return ApiResponse.error(400, "主题标识包含非法字符,只允许字母、数字、连字符和下划线"); + } + + // 从数据库获取扩展服务器地址 + String expansionServer = siteConfigService.getExpansionServer(); + if (expansionServer == null || expansionServer.isEmpty()) { + return ApiResponse.error(400, "扩展服务器地址未配置"); + } + + // 从扩展服务器获取主题列表 + String themesJson = restTemplate.getForObject(expansionServer, String.class); + if (themesJson == null) { + return ApiResponse.error(500, "无法从扩展服务器获取主题列表"); + } + + JsonNode themes = objectMapper.readTree(themesJson); + JsonNode targetTheme = null; + + // 查找匹配的主题 + if (themes.isArray()) { + for (JsonNode theme : themes) { + if (slug.equals(theme.get("slug").asString())) { + targetTheme = theme; + break; + } + } + } + + if (targetTheme == null) { + return ApiResponse.error(404, "未找到标识为 '" + slug + "' 的主题"); + } + + // 校验主题与当前系统核心版本的兼容性 + String currentVersion = updateService.getCurrentVersion(); + String minCore = optText(targetTheme, "min_core_version"); + String maxCore = optText(targetTheme, "max_core_version"); + if (!updateService.isCoreVersionInRange(currentVersion, minCore, maxCore)) { + return ApiResponse.error(400, String.format( + "主题 '%s' 与当前系统核心版本 %s 不兼容(要求核心版本 %s ~ %s)", + slug, currentVersion, + minCore == null ? "不限" : minCore, + maxCore == null ? "不限" : maxCore)); + } + + // 获取下载URL + String downloadUrl = targetTheme.get("download_url").asString(); + if (downloadUrl == null || downloadUrl.isEmpty()) { + return ApiResponse.error(400, "主题下载链接无效"); + } + + // 下载并解压主题 + boolean downloadSuccess = downloadAndExtractTheme(downloadUrl, slug); + if (downloadSuccess) { + responseDTO.setData(targetTheme); + responseDTO.setMessage("主题 '" + slug + "' 下载并安装成功"); + return ApiResponse.success("主题 '" + slug + "' 下载并安装成功", responseDTO); + } else { + return ApiResponse.error(500, "主题 '" + slug + "' 下载或解压失败"); + } + } catch (Exception e) { + return ApiResponse.error(500, "获取主题失败: " + e.getMessage()); + } + } + + @PostMapping("/deletetheme") + @RequiresPermission("system:theme:management") + public ApiResponse deleteTheme(@RequestBody ThemeSlugRequestDTO request) { + try { + String slug = request.getSlug(); + if (slug == null || slug.isEmpty()) { + return ApiResponse.error(400, "主题标识(slug)不能为空"); + } + + // 验证主题标识的合法性 + if (!isValidThemeSlug(slug)) { + return ApiResponse.error(400, "主题标识包含非法字符,只允许字母、数字、连字符和下划线"); + } + + // 是否删除的是当前激活的主题(允许删除,删除后回退到默认主题) + String activeTheme = siteConfigService.getSiteTheme(); + boolean deletingActive = slug.equals(activeTheme); + + // 构建主题路径 + String themesPath = "themes"; + Path themeDir = Path.of(themesPath, slug).normalize(); + + // 验证路径是否在themes目录内,防止路径遍历攻击 + Path themesBasePath = Path.of(themesPath).toAbsolutePath().normalize(); + if (!themeDir.toAbsolutePath().normalize().startsWith(themesBasePath)) { + return ApiResponse.error(400, "非法的主题路径"); + } + + // 检查主题目录是否存在 + if (!Files.exists(themeDir)) { + return ApiResponse.error(404, "主题目录 '" + slug + "' 不存在"); + } + + // 确保这是一个目录而不是文件 + if (!Files.isDirectory(themeDir)) { + return ApiResponse.error(400, "'" + slug + "' 不是一个有效的主题目录"); + } + + // 删除主题目录 + deleteDirectory(themeDir); + + // 若删除的是当前激活主题,回退到默认主题,避免前台找不到主题资源 + if (deletingActive) { + siteConfigService.updateSiteTheme(DEFAULT_THEME); + themeProperties.setActiveTheme(DEFAULT_THEME); + return ApiResponse.success("主题 '" + slug + "' 删除成功,已切换回默认主题"); + } + + return ApiResponse.success("主题 '" + slug + "' 删除成功"); + } catch (Exception e) { + return ApiResponse.error(500, "删除主题失败: " + e.getMessage()); + } + } + + + private String optText(JsonNode node, String field) { + JsonNode value = node.get(field); + if (value == null || value.isNull()) { + return null; + } + String text = value.asString(); + return (text == null || text.isBlank()) ? null : text; + } + + /** + * 验证主题标识是否合法 + * @param slug 主题标识 + * @return 是否合法 + */ + private boolean isValidThemeSlug(String slug) { + if (slug == null || slug.isEmpty()) { + return false; + } + + if (slug.contains("..") || slug.contains("/") || slug.contains("\\") || slug.contains(":")) { + return false; + } + + return THEME_SLUG_PATTERN.matcher(slug).matches(); + } + + /** + * 下载并解压主题 + * @param downloadUrl 下载链接 + * @param themeSlug 主题标识 + * @return 是否成功 + */ + private boolean downloadAndExtractTheme(String downloadUrl, String themeSlug) { + try { + String themesPath = "themes"; + Path themesDir = Path.of(themesPath); + if (!Files.exists(themesDir)) { + Files.createDirectories(themesDir); + } + + Path themeDir = Path.of(themesPath, themeSlug).normalize(); + + Path themesBasePath = Path.of(themesPath).toAbsolutePath().normalize(); + if (!themeDir.toAbsolutePath().normalize().startsWith(themesBasePath)) { + throw new IOException("非法的主题路径"); + } + + if (Files.exists(themeDir)) { + deleteDirectory(themeDir); + } + + Path tempFile = Files.createTempFile("theme_", ".zip"); + try { + HttpClient client = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .connectTimeout(Duration.ofSeconds(30)) + .build(); + HttpRequest httpRequest = HttpRequest.newBuilder(URI.create(downloadUrl)) + .timeout(Duration.ofMinutes(5)) + .GET() + .build(); + HttpResponse response = client.send(httpRequest, + HttpResponse.BodyHandlers.ofFile(tempFile)); + + if (response.statusCode() != 200) { + throw new IOException("下载主题失败,HTTP 状态码: " + response.statusCode()); + } + if (!isZipFile(tempFile)) { + throw new IOException("下载内容不是有效的 ZIP 文件,请检查主题下载地址"); + } + + unzipFile(tempFile, themeDir); + + flattenSingleRootDir(themeDir); + + try (DirectoryStream ds = Files.newDirectoryStream(themeDir)) { + if (!ds.iterator().hasNext()) { + throw new IOException("主题解压后内容为空"); + } + } + } finally { + Files.deleteIfExists(tempFile); + } + + return true; + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + private boolean isZipFile(Path file) { + try (InputStream in = Files.newInputStream(file)) { + byte[] header = new byte[4]; + int read = in.read(header); + if (read < 4) { + return false; + } + return header[0] == 0x50 && header[1] == 0x4B + && (header[2] == 0x03 || header[2] == 0x05 || header[2] == 0x07); + } catch (IOException e) { + return false; + } + } + + private void flattenSingleRootDir(Path themeDir) throws IOException { + if (Files.exists(themeDir.resolve("index.html"))) { + return; + } + + List entries = new ArrayList<>(); + try (DirectoryStream ds = Files.newDirectoryStream(themeDir)) { + for (Path p : ds) { + entries.add(p); + } + } + + if (entries.size() != 1 || !Files.isDirectory(entries.get(0))) { + return; + } + + Path inner = entries.get(0); + List children = new ArrayList<>(); + try (DirectoryStream ds = Files.newDirectoryStream(inner)) { + for (Path child : ds) { + children.add(child); + } + } + + for (Path child : children) { + Files.move(child, themeDir.resolve(child.getFileName().toString()), + StandardCopyOption.REPLACE_EXISTING); + } + Files.delete(inner); + } + + /** + * 解压ZIP文件 + * @param zipFile ZIP文件路径 + * @param destDir 目标目录 + * @throws IOException IO异常 + */ + private void unzipFile(Path zipFile, Path destDir) throws IOException { + try (InputStream in = Files.newInputStream(zipFile); + ZipInputStream zipIn = new ZipInputStream(in)) { + + if (!Files.exists(destDir)) { + Files.createDirectories(destDir); + } + + java.util.zip.ZipEntry entry = zipIn.getNextEntry(); + while (entry != null) { + String entryName = entry.getName(); + if (entryName.contains("..")) { + throw new IOException("ZIP条目包含非法路径: " + entryName); + } + + Path entryPath = destDir.resolve(entryName).normalize(); + + if (!entryPath.startsWith(destDir)) { + throw new IOException("非法路径: " + entry.getName()); + } + + if (entry.isDirectory()) { + if (!Files.exists(entryPath)) { + Files.createDirectories(entryPath); + } + } else { + Path parent = entryPath.getParent(); + if (!Files.exists(parent)) { + Files.createDirectories(parent); + } + + try (OutputStream out = Files.newOutputStream(entryPath)) { + byte[] buffer = new byte[1024]; + int len; + while ((len = zipIn.read(buffer)) > 0) { + out.write(buffer, 0, len); + } + } + } + + zipIn.closeEntry(); + entry = zipIn.getNextEntry(); + } + } + } + + /** + * 递归删除目录 + * @param dir 目录路径 + * @throws IOException IO异常 + */ + private void deleteDirectory(Path dir) throws IOException { + if (Files.exists(dir)) { + Path themesBasePath = Path.of("themes").toAbsolutePath().normalize(); + if (!dir.toAbsolutePath().normalize().startsWith(themesBasePath)) { + throw new IOException("非法的删除路径: " + dir); + } + + Files.walk(dir) + .sorted(java.util.Comparator.reverseOrder()) + .map(Path::toFile) + .forEach(File::delete); + } + } + + +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/FaviconController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/FaviconController.java new file mode 100644 index 0000000..7c490c2 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/FaviconController.java @@ -0,0 +1,105 @@ +package xyz.lingview.dimstack.controller; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.*; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.RestTemplate; +import xyz.lingview.dimstack.service.SiteConfigService; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.URI; + +import net.coobird.thumbnailator.Thumbnails; + +@Slf4j +@RestController +public class FaviconController { + + private final RestTemplate restTemplate = new RestTemplate(); + + @Autowired + private SiteConfigService siteConfigService; + + @GetMapping(value = "/favicon.ico", produces = "image/png") + public ResponseEntity getFavicon(HttpServletRequest request) throws IOException { + String iconUrl = siteConfigService.getSiteIcon(); + if (iconUrl == null || iconUrl.trim().isEmpty()) { + return ResponseEntity.notFound().build(); + } + + String targetUrl; + + if (iconUrl.startsWith("http://") || iconUrl.startsWith("https://")) { + targetUrl = iconUrl; + } else { + String scheme = request.getScheme(); + String serverName = request.getServerName(); + int serverPort = request.getServerPort(); + + StringBuilder baseUrl = new StringBuilder(); + baseUrl.append(scheme).append("://").append(serverName); + if (("http".equals(scheme) && serverPort != 80) || + ("https".equals(scheme) && serverPort != 443)) { + baseUrl.append(":").append(serverPort); + } + targetUrl = baseUrl.toString() + iconUrl; + } + + if (!targetUrl.startsWith("http://") && !targetUrl.startsWith("https://")) { + log.warn("无效的图标 URL 协议: {}", targetUrl); + return ResponseEntity.badRequest().build(); + } + + URI uri; + try { + uri = URI.create(targetUrl); + } catch (IllegalArgumentException e) { + log.error("图标 URL 格式非法,无法构造 URI: {}", targetUrl, e); + return ResponseEntity.badRequest().build(); + } + + try { + HttpHeaders headers = new HttpHeaders(); + headers.set(HttpHeaders.USER_AGENT, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange( + uri, + HttpMethod.GET, + entity, + byte[].class + ); + + if (!response.getStatusCode().is2xxSuccessful()) { + log.warn("获取图标失败,HTTP {}: {}", response.getStatusCode(), targetUrl); + return ResponseEntity.status(response.getStatusCode()).build(); + } + + byte[] imageBytes = response.getBody(); + if (imageBytes == null || imageBytes.length == 0) { + log.warn("图标内容为空: {}", targetUrl); + return ResponseEntity.notFound().build(); + } + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + Thumbnails.of(new java.io.ByteArrayInputStream(imageBytes)) + .size(32, 32) + .outputFormat("png") + .toOutputStream(baos); + + return ResponseEntity.ok() + .contentType(MediaType.IMAGE_PNG) + .header(HttpHeaders.CACHE_CONTROL, "public, max-age=86400") + .body(new ByteArrayResource(baos.toByteArray())); + + } catch (Exception e) { + log.error("站点图标转换失败: {}", targetUrl, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/FileAccessController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/FileAccessController.java new file mode 100644 index 0000000..0d394f3 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/FileAccessController.java @@ -0,0 +1,62 @@ +package xyz.lingview.dimstack.controller; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import xyz.lingview.dimstack.service.FileAccessService; + +import java.net.URI; + +/** + * @Author: lingview + * @Date: 2025/11/12 09:03:20 + * @Description: 文件访问控制器 + * @Version: 1.0 + */ +@Slf4j +@RestController +@RequestMapping("/file") +public class FileAccessController { + + @Autowired + private FileAccessService fileAccessService; + + @GetMapping("/{accessKey}") + public ResponseEntity getFile( + @PathVariable String accessKey, + @RequestParam(required = false, defaultValue = "false") Boolean download) { + try { + FileAccessService.FileAccessResult result = fileAccessService.getFile(accessKey, download); + + if (!result.found()) { + return ResponseEntity.notFound().build(); + } + + // 外部存储:302 重定向 + if (result.redirectUrl() != null) { + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(result.redirectUrl())) + .header(HttpHeaders.CONTENT_DISPOSITION, result.filename()) + .build(); + } + + // 本地存储:直接返回文件内容 + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(result.contentType())) + .header(HttpHeaders.CONTENT_DISPOSITION, result.filename()) + .body(result.resource()); + + } catch (Exception e) { + log.error("获取文件时发生错误,访问键: {}", accessKey, e); + return ResponseEntity.internalServerError().build(); + } + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ForgotPasswordController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ForgotPasswordController.java new file mode 100644 index 0000000..9127dbc --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ForgotPasswordController.java @@ -0,0 +1,82 @@ +package xyz.lingview.dimstack.controller; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import xyz.lingview.dimstack.dto.request.ForgotPasswordDTO; +import xyz.lingview.dimstack.dto.response.ForgotPasswordResponseDTO; +import xyz.lingview.dimstack.service.ForgotPasswordService; +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.util.SiteConfigUtil; + +import java.util.HashMap; +import java.util.Map; + +/** + * @Author: lingview + * @Date: 2026/01/13 18:59:30 + * @Description: 密码找回控制器 + * @Version: 2.2 + */ +@Slf4j +@RestController +@RequestMapping("/api") +public class ForgotPasswordController { + + @Autowired + private ForgotPasswordService forgotPasswordService; + + @Autowired + private SiteConfigUtil siteConfigUtil; + + /** + * 发送忘记密码验证码到邮箱 + */ + @PostMapping("/forgot-password/send-captcha") + public ApiResponse> sendForgotPasswordCaptcha( + @RequestBody ForgotPasswordDTO request, + HttpSession session, + HttpServletRequest httpRequest) { + + try { + return forgotPasswordService.sendForgotPasswordCaptcha(request, session, httpRequest); + } catch (Exception e) { + log.error("发送忘记密码验证码时发生异常", e); + Map response = new HashMap<>(); + response.put("message", "发送验证码失败,请稍后再试"); + return ApiResponse.error(500, "发送验证码失败,请稍后再试"); + } + } + + /** + * 验证验证码 + */ + @PostMapping("/forgot-password/verify") + public ApiResponse verifyForgotPassword( + @RequestBody ForgotPasswordDTO request, + HttpSession session, + HttpServletRequest httpRequest) { + try { + return forgotPasswordService.verifyForgotPassword(request, session, httpRequest); + } catch (Exception e) { + log.error("验证验证码时发生异常", e); + return ApiResponse.error(500, "验证失败,请稍后再试"); + } + } + + // 重置密码 + @PostMapping("/forgot-password/reset") + public ApiResponse resetPassword( + @RequestBody ForgotPasswordDTO request, + HttpSession session, + HttpServletRequest httpRequest) { + try { + return forgotPasswordService.resetPassword(request, session, httpRequest); + } catch (Exception e) { + log.error("重置密码时发生异常", e); + return ApiResponse.error(500, "密码重置失败,请稍后再试"); + } + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/FriendLinksController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/FriendLinksController.java new file mode 100644 index 0000000..9495e9c --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/FriendLinksController.java @@ -0,0 +1,215 @@ +package xyz.lingview.dimstack.controller; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import xyz.lingview.dimstack.annotation.RequiresPermission; +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.domain.FriendLinks; +import xyz.lingview.dimstack.domain.FriendLinksConfig; +import xyz.lingview.dimstack.dto.request.FriendLinksRequestDTO; +import xyz.lingview.dimstack.service.FriendLinksService; +import xyz.lingview.dimstack.service.FriendLinksConfigService; + +import jakarta.validation.Valid; +import java.util.List; +import java.util.Map; +/** + * @Author: lingview + * @Date: 2025/12/04 19:52:09 + * @Description: 友链控制器 + * @Version: 1.0 + */ +@RestController +@RequestMapping("/api/friend-links") +@Slf4j +public class FriendLinksController { + + @Autowired + private FriendLinksService friendLinkService; + + @Autowired + private FriendLinksConfigService friendLinksConfigService; + + + @PostMapping("/apply") + public ApiResponse applyFriendLink(@RequestBody @Valid FriendLinksRequestDTO requestDTO) { + try { + log.info("收到友链申请: {}", requestDTO.getSiteName()); + boolean result = friendLinkService.applyFriendLink(requestDTO); + + if (result) { + return ApiResponse.success("友链申请已提交,等待管理员审核"); + } else { + return ApiResponse.error(500, "友链申请失败"); + } + } catch (Exception e) { + log.error("友链申请失败", e); + return ApiResponse.error(500, "友链申请时发生错误: " + e.getMessage()); + } + } + + +// @GetMapping("/approved") +// public ResponseEntity> getApprovedFriendLinks() { +// try { +// List friendLinks = friendLinkService.getApprovedFriendLinks(); +// return ResponseEntity.ok(Map.of( +// "success", true, +// "data", friendLinks +// )); +// } catch (Exception e) { +// log.error("获取友链列表失败", e); +// return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) +// .body(Map.of( +// "success", false, +// "message", "获取友链列表失败: " + e.getMessage() +// )); +// } +// } + + + @GetMapping("/approved/page") + public ApiResponse> getApprovedFriendLinksByPage( + @RequestParam(defaultValue = "1") Integer page, + @RequestParam(defaultValue = "12") Integer size) { + try { + Map result = friendLinkService.getFriendLinksByPage(page, size, 1); + return ApiResponse.success(result); + } catch (Exception e) { + log.error("获取友链列表失败", e); + return ApiResponse.error(500, "获取友链列表失败: " + e.getMessage()); + } + } + + + + @PostMapping + @RequiresPermission("system:friendlinks:management") + public ApiResponse addFriendLink(@RequestBody @Valid FriendLinksRequestDTO requestDTO) { + try { + boolean result = friendLinkService.addFriendLink(requestDTO); + if (result) { + return ApiResponse.success("友链添加成功"); + } else { + return ApiResponse.error(500, "友链添加失败"); + } + } catch (Exception e) { + log.error("后台添加友链失败", e); + return ApiResponse.error(500, "添加友链时发生错误: " + e.getMessage()); + } + } + + @GetMapping + @RequiresPermission("system:friendlinks:management") + public ApiResponse> getFriendLinks( + @RequestParam(defaultValue = "1") Integer page, + @RequestParam(defaultValue = "10") Integer size, + @RequestParam(required = false) Integer status) { + try { + Map result = friendLinkService.getFriendLinksByPage(page, size, status); + return ApiResponse.success(result); + } catch (Exception e) { + log.error("获取友链列表失败", e); + return ApiResponse.error(500, "获取友链列表失败: " + e.getMessage()); + } + } + + + @PutMapping("/{id}/status") + @RequiresPermission("system:friendlinks:management") + public ApiResponse updateFriendLinkStatus( + @PathVariable Integer id, + @RequestParam Integer status) { + try { + boolean result = friendLinkService.updateFriendLinkStatus(id, status); + if (result) { + return ApiResponse.success("友链状态更新成功"); + } else { + return ApiResponse.error(404, "未找到该友链"); + } + } catch (Exception e) { + log.error("更新友链状态失败", e); + return ApiResponse.error(500, "更新友链状态失败: " + e.getMessage()); + } + } + + + @DeleteMapping("/{id}") + @RequiresPermission("system:friendlinks:management") + public ApiResponse deleteFriendLink(@PathVariable Integer id) { + try { + boolean result = friendLinkService.deleteFriendLink(id); + if (result) { + return ApiResponse.success("友链删除成功"); + } else { + return ApiResponse.error(404, "未找到该友链"); + } + } catch (Exception e) { + log.error("删除友链失败", e); + return ApiResponse.error(500, "删除友链失败: " + e.getMessage()); + } + } + + @DeleteMapping("/{id}/permanent") + @RequiresPermission("system:friendlinks:management") + public ApiResponse deleteFriendLinkPermanent(@PathVariable Integer id) { + try { + boolean result = friendLinkService.deleteFriendLinkPermanent(id); + if (result) { + return ApiResponse.success("友链彻底删除成功"); + } else { + return ApiResponse.error(404, "未找到该友链"); + } + } catch (Exception e) { + log.error("彻底删除友链失败", e); + return ApiResponse.error(500, "彻底删除友链失败: " + e.getMessage()); + } + } + + @GetMapping("/site-info") + public ApiResponse getSiteFriendLinkInfo() { + try { + FriendLinksConfig config = friendLinksConfigService.getActiveConfig(); + return ApiResponse.success(config); + } catch (Exception e) { + log.error("获取本站友链信息失败", e); + return ApiResponse.error(500, "获取本站友链信息失败: " + e.getMessage()); + } + } + + @PutMapping("/site-info") + @RequiresPermission("system:friendlinks:management") + public ApiResponse updateSiteFriendLinkInfo(@RequestBody FriendLinksConfig config) { + try { + boolean result = friendLinksConfigService.updateConfig(config); + if (result) { + return ApiResponse.success("本站友链信息更新成功"); + } else { + return ApiResponse.error(500, "本站友链信息更新失败"); + } + } catch (Exception e) { + log.error("更新本站友链信息失败", e); + return ApiResponse.error(500, "更新本站友链信息失败: " + e.getMessage()); + } + } + + // 友链后台编辑 + @PutMapping("/{id}") + @RequiresPermission("system:friendlinks:management") + public ApiResponse updateFriendLink(@PathVariable Integer id, @RequestBody @Valid FriendLinksRequestDTO requestDTO) { + try { + boolean result = friendLinkService.updateFriendLink(id, requestDTO); + if (result) { + return ApiResponse.success("友链信息更新成功"); + } else { + return ApiResponse.error(404, "未找到该友链"); + } + } catch (Exception e) { + log.error("更新友链失败", e); + return ApiResponse.error(500, "更新友链失败: " + e.getMessage()); + } + } + +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/HomeController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/HomeController.java new file mode 100644 index 0000000..22f2d64 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/HomeController.java @@ -0,0 +1,34 @@ +package xyz.lingview.dimstack.controller; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.service.ArticleService; +import xyz.lingview.dimstack.dto.request.PageRequest; +import xyz.lingview.dimstack.dto.request.PageResult; +import xyz.lingview.dimstack.dto.request.ArticleDTO; + +@Slf4j +@RestController +@RequestMapping("/api") +public class HomeController { + + @Autowired + private ArticleService articleService; + + @GetMapping("/articles") + public ApiResponse> getHomeArticles(PageRequest pageRequest) { + log.info("接收到获取文章列表请求: page={}, size={}, category={}", + pageRequest.getPage(), pageRequest.getSize(), pageRequest.getCategory()); + + try { + PageResult result = articleService.getArticlesForHomePage(pageRequest); + log.info("成功返回文章列表,共{}条记录", result.getTotal()); + return ApiResponse.success(result); + } catch (Exception e) { + log.error("获取文章列表时发生错误", e); + throw e; + } + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/HomePageController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/HomePageController.java new file mode 100644 index 0000000..07fa93d --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/HomePageController.java @@ -0,0 +1,79 @@ +package xyz.lingview.dimstack.controller; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.servlet.ModelAndView; +import xyz.lingview.dimstack.domain.SiteConfig; +import xyz.lingview.dimstack.dto.request.ArticleDTO; +import xyz.lingview.dimstack.dto.request.PageRequest; +import xyz.lingview.dimstack.dto.request.PageResult; +import xyz.lingview.dimstack.service.ArticleService; +import xyz.lingview.dimstack.service.SiteConfigService; +import xyz.lingview.dimstack.util.BotUtil; +import xyz.lingview.dimstack.util.DomainUtil; +import xyz.lingview.dimstack.util.UrlUtil; + +import java.util.List; + +/** + * @Author: lingview + * @Date: 2025/12/09 17:04:05 + * @Description: 首页ssr + * @Version: 1.0 + */ +@Slf4j +@Controller +public class HomePageController { + + @Autowired + private ArticleService articleService; + + @Autowired + private SiteConfigService siteConfigService; + + @GetMapping("/") + public Object homePageForSEO( + @RequestHeader(value = "User-Agent", required = false) String ua, + HttpServletRequest request + ) { + log.info("访问首页 UA={}", ua); + + boolean bot = BotUtil.isBot(ua); + + if (bot) { + try { + PageRequest pageRequest = new PageRequest(); + pageRequest.setPage(1); + pageRequest.setSize(6); + + PageResult articleResult = articleService.getArticlesForHomePage(pageRequest); + List articles = articleResult.getData(); + + SiteConfig siteConfig = siteConfigService.getSiteConfig(); + + String domain = DomainUtil.getFullDomain(request); + String url = domain + "/"; + + ModelAndView mv = new ModelAndView("seo_home"); + mv.addObject("articles", articles); + mv.addObject("siteName", siteConfig.getSite_name()); + mv.addObject("siteDescription", siteConfig.getCopyright()); + mv.addObject("siteIcon", UrlUtil.getFullUrl(domain, siteConfig.getSite_icon())); + mv.addObject("url", url); + mv.addObject("domain", domain); + + return mv; + } catch (Exception e) { + log.error("生成首页SEO页面时出错", e); + return new ModelAndView("seo_404"); + } + } + + return "forward:/index.html"; + } + +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/HotArticleController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/HotArticleController.java new file mode 100644 index 0000000..bebe1c9 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/HotArticleController.java @@ -0,0 +1,24 @@ +package xyz.lingview.dimstack.controller; + +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.dto.request.HotArticleDTO; +import xyz.lingview.dimstack.service.HotArticleService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import java.util.List; + +@RestController +@RequestMapping("/api/hot") +public class HotArticleController { + + @Autowired + private HotArticleService hotArticleService; + + @GetMapping("/articles") + public ApiResponse> getHotArticles() { + List articles = hotArticleService.getHotArticles(); + return ApiResponse.success(articles); + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/LlmConfigController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/LlmConfigController.java new file mode 100644 index 0000000..f66c0c5 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/LlmConfigController.java @@ -0,0 +1,134 @@ +package xyz.lingview.dimstack.controller; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import xyz.lingview.dimstack.annotation.RequiresPermission; +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.domain.LlmConfig; +import xyz.lingview.dimstack.service.LlmConfigService; +import xyz.lingview.dimstack.util.LargeLanguageModelsUtil; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +@RestController +@RequestMapping("/api/llm") +@Slf4j +public class LlmConfigController { + + @Autowired + private LlmConfigService llmConfigService; + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + @GetMapping("/config") + @RequiresPermission("system:config:management") + public ApiResponse getLlmConfig() { + log.info("获取LLM配置信息"); + try { + LlmConfig config = llmConfigService.getLlmConfig(); + if (config != null) { + LlmConfig safeConfig = new LlmConfig(); + safeConfig.setApi_url(config.getApi_url()); + safeConfig.setModel(config.getModel()); + if (config.getApi_key() != null && !config.getApi_key().trim().isEmpty()) { + safeConfig.setApi_key(""); + } else { + safeConfig.setApi_key(""); + } + return ApiResponse.success(safeConfig); + } else { + return ApiResponse.success(new LlmConfig()); + } + } catch (Exception e) { + log.error("获取LLM配置信息失败", e); + return ApiResponse.error(500, "获取LLM配置信息失败"); + } + } + + @PostMapping("/config") + @RequiresPermission("system:config:management") + public ApiResponse updateLlmConfig(@RequestBody LlmConfig llmConfig) { + log.info("更新LLM配置信息"); + try { + if (llmConfig.getApi_url() == null || llmConfig.getApi_url().trim().isEmpty()) { + return ApiResponse.error(400, "API地址不能为空"); + } + + if (llmConfig.getModel() == null || llmConfig.getModel().trim().isEmpty()) { + return ApiResponse.error(400, "模型名称不能为空"); + } + + if (llmConfig.getApi_key() == null || llmConfig.getApi_key().trim().isEmpty()) { + LlmConfig existingConfig = llmConfigService.getLlmConfig(); + if (existingConfig != null && existingConfig.getApi_key() != null) { + llmConfig.setApi_key(existingConfig.getApi_key()); + } else { + return ApiResponse.error(400, "首次配置时API密钥不能为空"); + } + } + + boolean result = llmConfigService.updateLlmConfig(llmConfig); + + if (result) { + return ApiResponse.success("LLM配置更新成功"); + } else { + return ApiResponse.error(500, "更新LLM配置失败"); + } + } catch (Exception e) { + log.error("更新LLM配置信息失败", e); + return ApiResponse.error(500, "更新LLM配置失败"); + } + } + + @GetMapping("/test") + @RequiresPermission("system:config:management") + public ApiResponse testConnection() { + log.info("测试LLM连接"); + try { + LlmConfig llmConfig = llmConfigService.getLlmConfig(); + if (llmConfig == null) { + return ApiResponse.error(400, "请先保存LLM配置"); + } + if (llmConfig.getApi_key() == null || llmConfig.getApi_key().trim().isEmpty()) { + return ApiResponse.error(400, "API密钥未配置"); + } + if (llmConfig.getApi_url() == null || llmConfig.getApi_url().trim().isEmpty()) { + return ApiResponse.error(400, "API地址未配置"); + } + if (llmConfig.getModel() == null || llmConfig.getModel().trim().isEmpty()) { + return ApiResponse.error(400, "模型名称未配置"); + } + + String response = LargeLanguageModelsUtil.callOpenAICompatibleAPI( + llmConfig.getApi_key(), + llmConfig.getApi_url(), + llmConfig.getModel(), + "你是次元栈blog系统的辅助AI", + "你好" + ); + + JsonNode rootNode = objectMapper.readTree(response); + JsonNode choices = rootNode.get("choices"); + if (choices != null && choices.isArray() && choices.size() > 0) { + JsonNode message = choices.get(0).get("message"); + if (message != null && message.get("content") != null) { + String content = message.get("content").asText(); + String preview = content.length() > 100 ? content.substring(0, 100) + "..." : content; + log.info("LLM连接测试成功,响应预览: {}", preview); + return ApiResponse.success("连接成功", "连接成功:"+preview); + } + } + + log.warn("LLM连接测试返回格式异常: {}", response); + return ApiResponse.error(500, "连接成功但响应格式异常"); + } catch (Exception e) { + log.error("LLM连接测试失败", e); + String errorMsg = e.getMessage(); + if (errorMsg != null && errorMsg.length() > 200) { + errorMsg = errorMsg.substring(0, 200); + } + return ApiResponse.error(500, "连接失败: " + errorMsg); + } + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/LlmPromptConfigController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/LlmPromptConfigController.java new file mode 100644 index 0000000..b3351e8 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/LlmPromptConfigController.java @@ -0,0 +1,58 @@ +package xyz.lingview.dimstack.controller; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import xyz.lingview.dimstack.annotation.RequiresPermission; +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.domain.LlmPromptConfig; +import xyz.lingview.dimstack.service.LlmPromptConfigService; + +@RestController +@RequestMapping("/api/llm/prompt") +@Slf4j +public class LlmPromptConfigController { + + @Autowired + private LlmPromptConfigService llmPromptConfigService; + + @GetMapping("/{promptName}") + @RequiresPermission("system:config:management") + public ApiResponse getPrompt(@PathVariable String promptName) { + log.info("获取提示词配置: {}", promptName); + try { + LlmPromptConfig config = llmPromptConfigService.getPromptByName(promptName); + if (config != null) { + return ApiResponse.success(config); + } else { + return ApiResponse.error(404, "提示词配置不存在"); + } + } catch (Exception e) { + log.error("获取提示词配置失败", e); + return ApiResponse.error(500, "获取提示词配置失败"); + } + } + + @PutMapping("/{promptName}") + @RequiresPermission("system:config:management") + public ApiResponse updatePrompt(@PathVariable String promptName, @RequestBody LlmPromptConfig config) { + log.info("更新提示词配置: {}", promptName); + try { + if (config.getPrompt_content() == null || config.getPrompt_content().trim().isEmpty()) { + return ApiResponse.error(400, "提示词内容不能为空"); + } + + config.setPrompt_name(promptName); + boolean result = llmPromptConfigService.updatePrompt(config); + + if (result) { + return ApiResponse.success("提示词更新成功"); + } else { + return ApiResponse.error(500, "提示词更新失败"); + } + } catch (Exception e) { + log.error("更新提示词配置失败", e); + return ApiResponse.error(500, "更新提示词配置失败"); + } + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/LoginController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/LoginController.java new file mode 100644 index 0000000..22fa730 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/LoginController.java @@ -0,0 +1,60 @@ +package xyz.lingview.dimstack.controller; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import xyz.lingview.dimstack.dto.request.LoginDTO; +import xyz.lingview.dimstack.dto.response.LoginResponseDTO; +import xyz.lingview.dimstack.dto.response.LogoutResponseDTO; +import xyz.lingview.dimstack.service.LoginService; +import xyz.lingview.dimstack.common.ApiResponse; + +/** + * @Auther: lingview + * @Date: 2025/08/26 22:57:54 + * @Description: 用户登录控制器 + */ +@Slf4j +@RestController +@RequestMapping("/api") +public class LoginController { + + @Autowired + private LoginService loginService; + + /** + * 用户登录接口 + */ + @PostMapping("/login") + public ApiResponse login( + @RequestBody LoginDTO loginDTO, + HttpServletRequest request) { + + try { + LoginResponseDTO result = loginService.login(loginDTO, request); + if (result.isSuccess()) { + return ApiResponse.success(result); + } else { + return ApiResponse.error(result.getCode(), result.getMessage()); + } + } catch (Exception e) { + log.error("登录过程发生未知错误", e); + return ApiResponse.error(500, "登录失败,请稍后再试"); + } + } + + /** + * 用户登出接口 + */ + @PostMapping("/logout") + public ApiResponse logout(HttpSession session) { + loginService.logout(session); + + var data = new LogoutResponseDTO(); + data.setSuccess(true); + data.setMessage("登出成功"); + return ApiResponse.success(data); + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/MenusController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/MenusController.java new file mode 100644 index 0000000..21a26f7 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/MenusController.java @@ -0,0 +1,72 @@ +package xyz.lingview.dimstack.controller; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import xyz.lingview.dimstack.annotation.RequiresPermission; +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.domain.Menus; +import xyz.lingview.dimstack.dto.request.MenusDTO; +import xyz.lingview.dimstack.service.CurrentUserService; +import xyz.lingview.dimstack.service.MenusService; + +import java.util.List; + +@Slf4j +@RestController +@RequestMapping("/api") +public class MenusController { + + @Autowired + private MenusService menusService; + + @Autowired + private CurrentUserService currentUserService; + + @GetMapping("/frontendgetmenus") + public ApiResponse> frontendGetMenus() { + return ApiResponse.success(menusService.getAllMenus()); + } + + @GetMapping("/getmenus") + @RequiresPermission("system:menus:management") + public ApiResponse> getMenus() { + return ApiResponse.success(menusService.getAllMenus()); + } + + @PostMapping("/addmenus") + @RequiresPermission("system:menus:management") + public ApiResponse addMenus(@RequestBody Menus menus) { + String username = currentUserService.getCurrentUsername(); + if (username == null) { + return ApiResponse.error(401, "用户未登录"); + } + menusService.addMenus(menus, username); + return ApiResponse.success("", "添加菜单成功"); + } + + @PostMapping("/editmenus") + @RequiresPermission("system:menus:management") + public ApiResponse editMenus(@RequestBody Menus menus) { + String username = currentUserService.getCurrentUsername(); + if (username == null) { + return ApiResponse.error(401, "用户未登录"); + } + menusService.updateMenus(menus, username); + return ApiResponse.success("", "编辑菜单成功"); + } + + @PostMapping("/deletemenus") + @RequiresPermission("system:menus:management") + public ApiResponse deleteMenus(@RequestParam String menus_id) { + menusService.deleteMenus(menus_id); + return ApiResponse.success("", "删除菜单成功"); + } + + @PostMapping("/updatesortorder") + @RequiresPermission("system:menus:management") + public ApiResponse updateSortOrder(@RequestBody List menusList) { + menusService.updateSortOrder(menusList); + return ApiResponse.success("", "更新排序成功"); + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/MusicController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/MusicController.java new file mode 100644 index 0000000..effea01 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/MusicController.java @@ -0,0 +1,149 @@ +package xyz.lingview.dimstack.controller; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import xyz.lingview.dimstack.annotation.RequiresPermission; +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.domain.Music; +import xyz.lingview.dimstack.service.CurrentUserService; +import xyz.lingview.dimstack.service.MusicService; +import xyz.lingview.dimstack.service.UserService; +import xyz.lingview.dimstack.util.SiteConfigUtil; + +/** + * @Author: lingview + * @Date: 2026/01/24 00:42:28 + * @Description: 站点音乐控制器 + * @Version: 1.0 + */ + +@RestController +@RequestMapping("/api/music") +@Slf4j +public class MusicController { + + @Autowired + private MusicService musicService; + + @Autowired + private UserService userService; + + @Autowired + private SiteConfigUtil siteConfigUtil; + + @Autowired + private CurrentUserService currentUserService; + + // 获取所有启用的音乐 + @GetMapping("/enabled") + public ApiResponse getEnabledMusics() { + + try { + if (!siteConfigUtil.isMusicEnabled()) { + return ApiResponse.success(java.util.Collections.emptyList()); + } + return ApiResponse.success(musicService.getAllEnabledMusics()); + } catch (Exception e) { + log.error("获取启用的音乐列表失败", e); + return ApiResponse.error(500, "获取音乐列表失败: " + e.getMessage()); + } + } + +// 因为某些情况下redis的数据是没有启用悬浮播放器,所以为后台提供不依赖redis的接口 + @GetMapping("/admin/enabled") + @RequiresPermission({"system:music:management","system:config:management"}) + public ApiResponse getAdminEnabledMusics() { + + try { + return ApiResponse.success(musicService.getAllEnabledMusics()); + } catch (Exception e) { + log.error("获取启用的音乐列表失败", e); + return ApiResponse.error(500, "获取音乐列表失败: " + e.getMessage()); + } + } + + + // 添加音乐 + @PostMapping("/add") + @RequiresPermission({"system:music:management","system:config:management"}) + public ApiResponse addMusic(HttpServletRequest request ,@RequestBody Music music) { + try { + String username = currentUserService.getCurrentUsername(); + String currentUser = userService.getUserUUID(username); + + music.setUuid(currentUser); + + boolean result = musicService.addMusic(music); + if (result) { + return ApiResponse.success("音乐添加成功"); + } else { + return ApiResponse.error(500, "音乐添加失败"); + } + } catch (Exception e) { + log.error("添加音乐失败", e); + return ApiResponse.error(500, "添加音乐时发生错误: " + e.getMessage()); + } + } + + // 更新音乐 + @PutMapping("/update/{id}") + @RequiresPermission({"system:music:management","system:config:management"}) + public ApiResponse updateMusic(@PathVariable Integer id, @RequestBody Music music) { + try { + music.setId(id); + + // 验证必要字段 + if (music.getMusicName() == null || music.getMusicName().trim().isEmpty()) { + return ApiResponse.error(400, "曲名不能为空"); + } + if (music.getMusicAuthor() == null || music.getMusicAuthor().trim().isEmpty()) { + return ApiResponse.error(400, "歌手名不能为空"); + } + if (music.getMusicUrl() == null || music.getMusicUrl().trim().isEmpty()) { + return ApiResponse.error(400, "音乐地址不能为空"); + } + + boolean result = musicService.updateMusic(music); + if (result) { + return ApiResponse.success("音乐更新成功"); + } else { + return ApiResponse.error(500, "音乐更新失败"); + } + } catch (Exception e) { + log.error("更新音乐失败", e); + return ApiResponse.error(500, "更新音乐时发生错误: " + e.getMessage()); + } + } + + // 删除音乐(软删除) + @DeleteMapping("/delete/{id}") + @RequiresPermission({"system:music:management","system:config:management"}) + public ApiResponse deleteMusic(@PathVariable Integer id) { + try { + boolean result = musicService.deleteMusic(id); + if (result) { + return ApiResponse.success("音乐删除成功"); + } else { + return ApiResponse.error(500, "音乐删除失败"); + } + } catch (Exception e) { + log.error("删除音乐失败", e); + return ApiResponse.error(500, "删除音乐时发生错误: " + e.getMessage()); + } + } + + // 获取所有音乐(包括已删除的) + @GetMapping("/all") + @RequiresPermission({"system:music:management", "system:config:management"}) + public ApiResponse getAllMusics() { + try { + return ApiResponse.success(musicService.getAllMusics()); + } catch (Exception e) { + log.error("获取所有音乐列表失败", e); + return ApiResponse.error(500, "获取音乐列表失败: " + e.getMessage()); + } + } + +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/PermissionTestController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/PermissionTestController.java new file mode 100644 index 0000000..5b2f021 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/PermissionTestController.java @@ -0,0 +1,116 @@ +//package xyz.lingview.dimstack.controller; +// +//import xyz.lingview.dimstack.annotation.RequiresPermission; +//import org.springframework.web.bind.annotation.*; +//import jakarta.servlet.http.HttpSession; +//import java.util.HashMap; +//import java.util.Map; +// +//// 此接口文件为测试文件 +//@RestController +//@RequestMapping("/api/test/permission") +//public class PermissionTestController { +// +// // 测试查看文章权限 +// @GetMapping("/view") +// @RequiresPermission("post:view") +// public Map testViewPermission(HttpSession session) { +// return createResponse(session, "post:view", "查看文章权限测试"); +// } +// +// // 测试创建文章权限 +// @GetMapping("/create") +// @RequiresPermission("post:create") +// public Map testCreatePermission(HttpSession session) { +// return createResponse(session, "post:create", "创建文章权限测试"); +// } +// +// // 测试编辑自己文章权限 +// @GetMapping("/edit/own") +// @RequiresPermission("post:edit:own") +// public Map testEditOwnPermission(HttpSession session) { +// return createResponse(session, "post:edit:own", "编辑自己文章权限测试"); +// } +// +// // 测试编辑任何文章权限 +// @GetMapping("/edit/any") +// @RequiresPermission("post:edit:any") +// public Map testEditAnyPermission(HttpSession session) { +// return createResponse(session, "post:edit:any", "编辑任何文章权限测试"); +// } +// +// // 测试删除自己文章权限 +// @GetMapping("/delete/own") +// @RequiresPermission("post:delete:own") +// public Map testDeleteOwnPermission(HttpSession session) { +// return createResponse(session, "post:delete:own", "删除自己文章权限测试"); +// } +// +// // 测试删除任何文章权限 +// @GetMapping("/delete/any") +// @RequiresPermission("post:delete:any") +// public Map testDeleteAnyPermission(HttpSession session) { +// return createResponse(session, "post:delete:any", "删除任何文章权限测试"); +// } +// +// // 测试提交文章发布权限 +// @GetMapping("/submit") +// @RequiresPermission("post:submit") +// public Map testSubmitPermission(HttpSession session) { +// return createResponse(session, "post:submit", "提交文章发布权限测试"); +// } +// +// // 测试发布文章权限 +// @GetMapping("/publish") +// @RequiresPermission("post:publish") +// public Map testPublishPermission(HttpSession session) { +// return createResponse(session, "post:publish", "发布文章权限测试"); +// } +// +// // 测试审核文章权限 +// @GetMapping("/review") +// @RequiresPermission("post:review") +// public Map testReviewPermission(HttpSession session) { +// return createResponse(session, "post:review", "审核文章权限测试"); +// } +// +// // 测试需要多个权限(AND模式) +// @GetMapping("/multi-and") +// @RequiresPermission(value = {"post:view", "post:create"}, all = true) +// public Map testMultiAndPermission(HttpSession session) { +// return createResponse(session, "post:view & post:create", "多权限AND模式测试"); +// } +// +// // 测试需要多个权限(OR模式) +// @GetMapping("/multi-or") +// @RequiresPermission(value = {"post:edit:own", "post:edit:any"}, all = false) +// public Map testMultiOrPermission(HttpSession session) { +// return createResponse(session, "post:edit:own | post:edit:any", "多权限OR模式测试"); +// } +// +// // 测试无需特殊权限的接口 +// @GetMapping("/public") +// public Map testPublicAccess(HttpSession session) { +// return createResponse(session, "public", "公共访问接口测试"); +// } +// +// +// private Map createResponse(HttpSession session, String permission, String description) { +// Map response = new HashMap<>(); +// response.put("success", true); +// response.put("message", "权限验证通过"); +// response.put("permission", permission); +// response.put("description", description); +// response.put("username", session.getAttribute("username")); +// response.put("timestamp", System.currentTimeMillis()); +// +// System.out.println("=== 权限测试 ==="); +// System.out.println("用户: " + session.getAttribute("username")); +// System.out.println("访问接口: " + description); +// System.out.println("所需权限: " + permission); +// System.out.println("时间: " + new java.util.Date()); +// System.out.println("==============="); +// +// return response; +// } +//} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/PluginController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/PluginController.java new file mode 100644 index 0000000..8dbeace --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/PluginController.java @@ -0,0 +1,158 @@ +package xyz.lingview.dimstack.controller; + +import lombok.extern.slf4j.Slf4j; +import org.pf4j.PluginRuntimeException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import xyz.lingview.dimstack.annotation.RequiresPermission; +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.domain.PluginInfo; +import xyz.lingview.dimstack.domain.PluginSqlAudit; +import xyz.lingview.dimstack.dto.request.PageResult; +import xyz.lingview.dimstack.service.PluginService; + +import java.util.List; +import java.util.Map; + +/** + * @Author: lingview + * @Date: 2026/08/03 20:23:41 + * @Description: 插件管理接口 + * @Version: 1.0 + */ +@RestController +@RequestMapping("/api/plugins") +@Slf4j +public class PluginController { + + @Autowired + private PluginService pluginService; + + private ApiResponse fail(String action, Exception e) { + if (e instanceof PluginRuntimeException) { + log.warn("{} 失败: {}", action, e.getMessage()); + return ApiResponse.error(500, e.getMessage()); + } + String traceId = Long.toHexString(System.nanoTime()); + log.error("{} 失败, 错误编号={}", action, traceId, e); + return ApiResponse.error(500, action + "失败, 请稍后重试(错误编号 " + traceId + ")"); + } + + @GetMapping + @RequiresPermission("plugin:management") + public ApiResponse> list() { + try { + return ApiResponse.success(pluginService.list()); + } catch (Exception e) { + return fail("获取插件列表", e); + } + } + + @PostMapping("/install") + @RequiresPermission("plugin:management") + public ApiResponse install(@RequestParam("file") MultipartFile file) { + try { + return ApiResponse.success(pluginService.install(file)); + } catch (Exception e) { + return fail("安装插件", e); + } + } + + @PostMapping("/{name}/start") + @RequiresPermission("plugin:management") + public ApiResponse start(@PathVariable String name) { + try { + pluginService.start(name); + return ApiResponse.success("插件已启用"); + } catch (Exception e) { + return fail("启用插件", e); + } + } + + @PostMapping("/{name}/stop") + @RequiresPermission("plugin:management") + public ApiResponse stop(@PathVariable String name) { + try { + pluginService.stop(name); + return ApiResponse.success("插件已停用"); + } catch (Exception e) { + return fail("停用插件", e); + } + } + + @DeleteMapping("/{name}") + @RequiresPermission("plugin:management") + public ApiResponse uninstall(@PathVariable String name) { + try { + pluginService.uninstall(name); + return ApiResponse.success("插件已卸载"); + } catch (Exception e) { + return fail("卸载插件", e); + } + } + + @PostMapping("/{name}/upgrade") + @RequiresPermission("plugin:management") + public ApiResponse upgrade(@PathVariable String name, + @RequestParam("file") MultipartFile file) { + try { + return ApiResponse.success(pluginService.upgrade(name, file)); + } catch (Exception e) { + return fail("升级插件", e); + } + } + + @PostMapping("/{name}/reload") + @RequiresPermission("plugin:management") + public ApiResponse reload(@PathVariable String name) { + try { + pluginService.reload(name); + return ApiResponse.success("插件已重载"); + } catch (Exception e) { + return fail("重载插件", e); + } + } + + @GetMapping("/{name}/config") + @RequiresPermission("plugin:management") + public ApiResponse> getConfig(@PathVariable String name) { + try { + return ApiResponse.success(pluginService.getConfig(name)); + } catch (Exception e) { + return fail("读取插件配置", e); + } + } + + @PutMapping("/{name}/config") + @RequiresPermission("plugin:management") + public ApiResponse saveConfig(@PathVariable String name, @RequestBody Map config) { + try { + pluginService.saveConfig(name, config); + return ApiResponse.success("插件配置已保存"); + } catch (Exception e) { + return fail("保存插件配置", e); + } + } + + @GetMapping("/{name}/sql-audit") + @RequiresPermission("plugin:management") + public ApiResponse> sqlAudit(@PathVariable String name, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + try { + int safePage = Math.max(page, 1); + int safeSize = Math.min(Math.max(size, 1), 100); + PageResult result = new PageResult<>(); + result.setData(pluginService.listSqlAudit(name, safePage, safeSize)); + int total = (int) pluginService.countSqlAudit(name); + result.setTotal(total); + result.setPage(safePage); + result.setSize(safeSize); + result.setTotal_pages((total + safeSize - 1) / safeSize); + return ApiResponse.success(result); + } catch (Exception e) { + return fail("查询插件SQL审计", e); + } + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ProxyController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ProxyController.java new file mode 100644 index 0000000..3605c4f --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ProxyController.java @@ -0,0 +1,94 @@ +// 设计问题已弃用,将在后续移除 + +//package xyz.lingview.dimstack.controller; +// +//import lombok.extern.slf4j.Slf4j; +//import org.springframework.http.HttpHeaders; +//import org.springframework.http.HttpStatus; +//import org.springframework.http.MediaType; +//import org.springframework.http.ResponseEntity; +//import org.springframework.util.StringUtils; +//import org.springframework.web.bind.annotation.*; +// +//import java.io.IOException; +//import java.net.URL; +//import java.net.URLConnection; +//import java.util.regex.Pattern; +// +///** +// * @Author: lingview +// * @Date: 2026/01/20 10:36:14 +// * @Description: 代理资源下载 +// * @Version: 1.0 +// */ +//@Slf4j +//@RestController +//@RequestMapping("/api/proxy") +//public class ProxyController { +// +// private static final Pattern URL_PATTERN = Pattern.compile( +// "^https?://[a-zA-Z0-9][a-zA-Z0-9\\-]{1,61}[a-zA-Z0-9](\\.[a-zA-Z0-9][a-zA-Z0-9\\-]{1,61}[a-zA-Z0-9])*(:\\d+)?(/.*)?$" +// ); +// +// @GetMapping("/favicon") +// public ResponseEntity getFavicon(@RequestParam String url) { +// if (!isValidUrl(url)) { +// log.warn("无效的URL: {}", url); +// return ResponseEntity.badRequest().build(); +// } +// +// try { +// if (!isFaviconUrl(url)) { +// log.warn("不是favicon URL: {}", url); +// return ResponseEntity.badRequest().build(); +// } +// +// URL imageUrl = new URL(url); +// URLConnection connection = imageUrl.openConnection(); +// +// connection.setRequestProperty("User-Agent", "Mozilla/5.0 (compatible; favicon-fetcher)"); +// connection.setConnectTimeout(5000); +// connection.setReadTimeout(10000); +// connection.setDoInput(true); +// +// byte[] imageBytes = connection.getInputStream().readAllBytes(); +// String contentType = connection.getContentType(); +// +// if (!contentType.startsWith("image/")) { +// log.warn("URL {} 不返回图像内容: {}", url, contentType); +// return ResponseEntity.unprocessableEntity().build(); +// } +// +// HttpHeaders headers = new HttpHeaders(); +// headers.setContentType(MediaType.valueOf(contentType)); +// headers.setContentLength(imageBytes.length); +// headers.setCacheControl("max-age=3600"); +// +// log.info("成功获取网站图标: {}", url); +// return new ResponseEntity<>(imageBytes, headers, HttpStatus.OK); +// } catch (IOException e) { +// log.error("获取网站图标失败: {}", url, e); +// return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build(); +// } catch (Exception e) { +// log.error("获取网站图标发生未知错误: {}", url, e); +// return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); +// } +// } +// +// private boolean isValidUrl(String url) { +// if (!StringUtils.hasText(url)) { +// return false; +// } +// return URL_PATTERN.matcher(url).matches(); +// } +// +// +// private boolean isFaviconUrl(String url) { +// return url.endsWith("/favicon.ico") || +// url.endsWith("/favicon.png") || +// url.endsWith("/apple-touch-icon.png") || +// url.endsWith("/apple-touch-icon-precomposed.png") || +// url.contains("/favicon") || +// url.contains("touch-icon"); +// } +//} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/RandomArticleController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/RandomArticleController.java new file mode 100644 index 0000000..9c51e4d --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/RandomArticleController.java @@ -0,0 +1,34 @@ +package xyz.lingview.dimstack.controller; + +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.domain.ReadArticle; +import xyz.lingview.dimstack.service.ReadArticleService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequestMapping("/api/random-article") +public class RandomArticleController { + + @Autowired + private ReadArticleService readArticleService; + + @GetMapping + public ApiResponse getRandomArticle() { + try { + ReadArticle article = readArticleService.getRandomArticle(); + if (article == null) { + return ApiResponse.error(404, "暂无已发布的文章"); + } + article.setPassword("******"); + return ApiResponse.success(article); + } catch (Exception e) { + log.error("获取随机文章失败:", e); + return ApiResponse.error(500, "获取随机文章失败"); + } + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ReadArticleController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ReadArticleController.java new file mode 100644 index 0000000..6b10287 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ReadArticleController.java @@ -0,0 +1,102 @@ +package xyz.lingview.dimstack.controller; + +import xyz.lingview.dimstack.annotation.RateLimit; +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.domain.ReadArticle; +import xyz.lingview.dimstack.service.ReadArticleService; +import xyz.lingview.dimstack.service.CurrentUserService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@RestController +@RequestMapping("/api/article") +public class ReadArticleController { + + @Autowired + private ReadArticleService readArticleService; + + @Autowired + private CurrentUserService currentUserService; + + + @GetMapping("/{alias}/check-password") + public ApiResponse> checkArticlePassword(@PathVariable String alias) { + try { + boolean needPassword = readArticleService.isArticleNeedPassword(alias); + Map data = new HashMap<>(); + data.put("needPassword", needPassword); + return ApiResponse.success(data); + } catch (Exception e) { + log.error("检查文章密码失败: ", e); + return ApiResponse.error(400, "检查文章密码失败"); + } + } + + @GetMapping("/{alias}") + public ApiResponse getArticleContent( + @PathVariable String alias, + @RequestParam(required = false) String password) { + try { + ReadArticle article = readArticleService.getArticleByAlias(alias, password); + log.debug("Article data: {}", article); + article.setPassword("******"); + return ApiResponse.success(article); + } catch (Exception e) { + log.error("获取文章内容失败:", e); + return ApiResponse.error(400, e.getMessage()); + } + } + + + /** + * 点赞/取消点赞文章 + * @param alias + * @param session + * @return + */ + @RateLimit(window = 60, maxRequests = 5) + @PostMapping("/{alias}/like") + public ApiResponse> likeArticle(@PathVariable String alias) { + Map response = new HashMap<>(); + try { + String username = currentUserService.getCurrentUsername(); + if (username == null) { + return ApiResponse.error(401, "请先登录后再点赞"); + } + readArticleService.likeArticle(username, alias); + response.put("success", true); + return ApiResponse.success(response); + } catch (Exception e) { + log.error("点赞文章失败:", e); + response.put("success", false); + response.put("message", e.getMessage()); + return ApiResponse.error(500, "点赞文章失败"); + } + } + + // 获取用户是否已点赞文章 + @GetMapping("/{alias}/liked") + public ApiResponse> getLikedStatus(@PathVariable String alias) { + Map response = new HashMap<>(); + try { + String username = currentUserService.getCurrentUsername(); + if (username == null) { + response.put("liked", false); + return ApiResponse.success(response); + } + boolean liked = readArticleService.isUserLikedArticle(username, alias); + response.put("liked", liked); + return ApiResponse.success(response); + } catch (Exception e) { + log.error("获取点赞状态失败:", e); + response.put("liked", false); + return ApiResponse.error(500, "获取点赞状态失败"); + } + } + +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/RegisterController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/RegisterController.java new file mode 100644 index 0000000..9b7aa8a --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/RegisterController.java @@ -0,0 +1,60 @@ +package xyz.lingview.dimstack.controller; + +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.validation.BindingResult; +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 xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.dto.request.RegisterDTO; +import xyz.lingview.dimstack.dto.response.RegisterResponseDTO; +import xyz.lingview.dimstack.service.RegisterService; + +/** + * @Auther: lingview + * @Date: 2025/08/26 22:57:54 + * @Description: 用户注册控制器 + */ +@RestController +@RequestMapping("/api") +@Slf4j +public class RegisterController { + + @Autowired + private RegisterService registerService; + + /** + * 用户注册接口 + * + * @param requestDTO 包含注册所需信息的请求体,必须包含以下字段: + * - username: 用户名(非空) + * - email: 邮箱(可选) + * - phone: 手机号(可选) + * - password: 密码(非空) + * - captcha: 验证码(非空) + * - captchaKey: 验证码标识符(非空) + * @return ApiResponse 注册结果响应 + */ + @PostMapping("/register") + public ApiResponse register(@RequestBody @Valid RegisterDTO requestDTO, BindingResult bindingResult) { + if (bindingResult.hasErrors()) { + String errorMsg = bindingResult.getFieldError().getDefaultMessage(); + return ApiResponse.error(400, errorMsg); + } + + try { + RegisterResponseDTO result = registerService.register(requestDTO); + if (result.isSuccess()) { + return ApiResponse.success(result); + } else { + return ApiResponse.error(result.getCode(), result.getMessage()); + } + } catch (Exception e) { + log.error("注册过程中发生错误", e); + return ApiResponse.error(500, "注册失败,请检查输入或稍后再试"); + } + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/RobotsController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/RobotsController.java new file mode 100644 index 0000000..501d518 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/RobotsController.java @@ -0,0 +1,33 @@ +package xyz.lingview.dimstack.controller; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import xyz.lingview.dimstack.util.DomainUtil; + +@RestController +public class RobotsController { + + @GetMapping(value = "/robots.txt", produces = "text/plain;charset=UTF-8") + public String robots(HttpServletRequest request) { + + String domain = DomainUtil.getFullDomain(request); + + StringBuilder sb = new StringBuilder(); + + sb.append("User-agent: *\n"); + sb.append("Allow: /\n\n"); + + sb.append("Disallow: /dashboard/\n"); + sb.append("Disallow: /login\n"); + sb.append("Disallow: /register\n"); + sb.append("Disallow: /admin/\n"); + + sb.append("\nCrawl-delay: 2\n\n"); + + sb.append("Sitemap: ").append(domain).append("/sitemap-index.xml"); + + return sb.toString(); + } + +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/RoleController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/RoleController.java new file mode 100644 index 0000000..d0c5c4d --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/RoleController.java @@ -0,0 +1,91 @@ +package xyz.lingview.dimstack.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import xyz.lingview.dimstack.annotation.RequiresPermission; +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.domain.Permission; +import xyz.lingview.dimstack.domain.Role; +import xyz.lingview.dimstack.service.RoleService; + +import java.util.List; + +@RestController +@RequestMapping("/api") +public class RoleController { + + @Autowired + private RoleService roleService; + + // 获取所有角色 + @GetMapping("/role/list") + @RequiresPermission("system:role:management") + public ApiResponse> getAllRoles() { + List roles = roleService.getAllRoles(); + return ApiResponse.success(roles); + } + + // 获取所有权限列表 + @GetMapping("/permission/list") + @RequiresPermission("system:role:management") + public ApiResponse> getAllPermissions() { + List permissions = roleService.selectAllPermissions(); + return ApiResponse.success(permissions); + } + + // 根据id获取角色详情 + @GetMapping("/role/{id}") + @RequiresPermission("system:role:management") + public ApiResponse getRoleById(@PathVariable Integer id) { + Role role = roleService.getRoleById(id); + if (role == null) { + return ApiResponse.error(404, "角色不存在"); + } + return ApiResponse.success(role); + } + + // 创建新角色 + @PostMapping("/role/create") + @RequiresPermission("system:role:management") + public ApiResponse createRole(@RequestBody Role role) { + return roleService.createRole(role); + } + + // 更新角色信息 + @PutMapping("/role/update") + @RequiresPermission("system:role:management") + public ApiResponse updateRole(@RequestBody Role role) { + return roleService.updateRole(role); + } + + // 删除角色 + @DeleteMapping("/role/delete/{id}") + @RequiresPermission("system:role:management") + public ApiResponse deleteRole(@PathVariable Integer id) { + return roleService.deleteRole(id); + } + + // 为角色添加权限(通过权限码) + @PostMapping("/role/{roleId}/permission/add") + @RequiresPermission("system:role:management") + public ApiResponse addPermissionToRole(@PathVariable Integer roleId, + @RequestParam String permissionCode) { + return roleService.addPermissionToRole(roleId, permissionCode); + } + + // 从角色移除权限(通过权限码) + @DeleteMapping("/role/{roleId}/permission/remove") + @RequiresPermission("system:role:management") + public ApiResponse removePermissionFromRole(@PathVariable Integer roleId, + @RequestParam String permissionCode) { + return roleService.removePermissionFromRole(roleId, permissionCode); + } + + // 获取角色的所有权限码 + @GetMapping("/role/{roleId}/permissions") + @RequiresPermission("system:role:management") + public ApiResponse> getPermissionCodesByRoleId(@PathVariable Integer roleId) { + List permissionCodes = roleService.getPermissionCodesByRoleId(roleId); + return ApiResponse.success(permissionCodes); + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/RouterController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/RouterController.java new file mode 100644 index 0000000..e8fc38a --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/RouterController.java @@ -0,0 +1,24 @@ +package xyz.lingview.dimstack.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +public class RouterController { + + @GetMapping({ +// "/", + "/login", + "/register", + "/dashboard", + "/dashboard/**", + "/article/**", + "/category/**", + "/tag/**", + "/friend-links", + "/forgot-password", + }) + public String frontendRoutes() { + return "forward:/index.html"; + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/SiteConfigController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/SiteConfigController.java new file mode 100644 index 0000000..9412f08 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/SiteConfigController.java @@ -0,0 +1,435 @@ +package xyz.lingview.dimstack.controller; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; +import xyz.lingview.dimstack.annotation.RequiresPermission; +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.domain.Role; +import xyz.lingview.dimstack.domain.SiteConfig; +import xyz.lingview.dimstack.dto.request.HeroDTO; +import xyz.lingview.dimstack.dto.request.TestSmtpRequestDTO; +import xyz.lingview.dimstack.service.MailService; +import xyz.lingview.dimstack.service.SiteConfigService; +import org.springframework.beans.factory.annotation.Autowired; +import xyz.lingview.dimstack.service.UserService; +import xyz.lingview.dimstack.service.impl.ImageCompressionServiceImpl; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/site") +@Slf4j +public class SiteConfigController { + + @Autowired + private SiteConfigService siteConfigService; + + @Autowired + private MailService mailService; + + @Autowired + private xyz.lingview.dimstack.service.CacheService cacheService; + + @Autowired + private ImageCompressionServiceImpl imageCompressionService; + + @Autowired + private UserService userService; + + @GetMapping("/hero") + public HeroDTO getHeroConfig() { + return siteConfigService.getHeroConfig(); + } + + @GetMapping("/copyright") + public String getCopyright() { + return siteConfigService.getCopyright(); + } + + @GetMapping("/name") + public String getSiteName() { + return siteConfigService.getSiteName(); + } + + @GetMapping("/icon") + public String getSiteIcon() { + return siteConfigService.getSiteIcon(); + } + + @GetMapping("/getsiteconfig") + @RequiresPermission("system:config:management") + public ApiResponse getsiteconfig() { + log.info("获取站点配置信息"); + try { + SiteConfig config = siteConfigService.getSiteConfig(); + if (config != null) { + // 创建副本以避免修改内存缓存中的原始对象 + SiteConfig configCopy = new SiteConfig(); + org.springframework.beans.BeanUtils.copyProperties(config, configCopy); + configCopy.setMail_password(null); + return ApiResponse.success(configCopy); + } else { + return ApiResponse.error(404, "未找到站点配置信息"); + } + } catch (Exception e) { + log.error("获取站点配置信息失败", e); + return ApiResponse.error(500, "获取站点配置信息失败: " + e.getMessage()); + } + } + + @PostMapping("/editsiteconfig") + @RequiresPermission("system:config:management") + public ApiResponse editsiteconfig(@RequestBody SiteConfig siteConfig) { + log.info("更新站点配置信息"); + try { + // 从数据库获取最新配置,避免内存缓存模式下的密码被污染 + SiteConfig dbConfig = siteConfigService.getSiteConfig(); + if (dbConfig == null) { + return ApiResponse.error(500, "无法获取当前站点配置"); + } + + // 保存原始的邮箱密码,防止内存缓存模式下异常覆盖 + String originalMailPassword = dbConfig.getMail_password(); + SiteConfig currentConfig = dbConfig; + + if (siteConfig.getSite_name() != null && !siteConfig.getSite_name().trim().isEmpty()) { + currentConfig.setSite_name(siteConfig.getSite_name()); + } else if (currentConfig.getSite_name() == null || currentConfig.getSite_name().trim().isEmpty()) { + return ApiResponse.error(400, "站点名称不能为空"); + } + + if (siteConfig.getCopyright() != null && !siteConfig.getCopyright().trim().isEmpty()) { + currentConfig.setCopyright(siteConfig.getCopyright()); + } else if (currentConfig.getCopyright() == null || currentConfig.getCopyright().trim().isEmpty()) { + return ApiResponse.error(400, "版权信息不能为空"); + } + + if (siteConfig.getHero_title() != null && !siteConfig.getHero_title().trim().isEmpty()) { + currentConfig.setHero_title(siteConfig.getHero_title()); + } else if (currentConfig.getHero_title() == null || currentConfig.getHero_title().trim().isEmpty()) { + return ApiResponse.error(400, "首页标题不能为空"); + } + + if (siteConfig.getHero_subtitle() != null && !siteConfig.getHero_subtitle().trim().isEmpty()) { + currentConfig.setHero_subtitle(siteConfig.getHero_subtitle()); + } else if (currentConfig.getHero_subtitle() == null || currentConfig.getHero_subtitle().trim().isEmpty()) { + return ApiResponse.error(400, "首页副标题不能为空"); + } + + if (siteConfig.getRegister_user_permission() != null) { + currentConfig.setRegister_user_permission(siteConfig.getRegister_user_permission()); + } + +// if (siteConfig.getArticle_status() != 0) { +// currentConfig.setArticle_status(siteConfig.getArticle_status()); +// } + currentConfig.setArticle_status(siteConfig.getArticle_status()); + + if (siteConfig.getHero_image() != null) { + currentConfig.setHero_image(siteConfig.getHero_image()); + } + + if (siteConfig.getSite_icon() != null) { + currentConfig.setSite_icon(siteConfig.getSite_icon()); + } + + if (siteConfig.getSite_theme() != null) { + currentConfig.setSite_theme(siteConfig.getSite_theme()); + } + + if (siteConfig.getExpansion_server() != null) { + currentConfig.setExpansion_server(siteConfig.getExpansion_server()); + } + + if (siteConfig.getEnable_notification() != null) { + currentConfig.setEnable_notification(siteConfig.getEnable_notification()); + } + + if (siteConfig.getSmtp_host() != null) { + currentConfig.setSmtp_host(siteConfig.getSmtp_host()); + } + + if (siteConfig.getSmtp_port() != null) { + currentConfig.setSmtp_port(siteConfig.getSmtp_port()); + } + + if (siteConfig.getMail_sender_email() != null) { + currentConfig.setMail_sender_email(siteConfig.getMail_sender_email()); + } + + if (siteConfig.getMail_sender_name() != null) { + currentConfig.setMail_sender_name(siteConfig.getMail_sender_name()); + } + + if (siteConfig.getMail_username() != null) { + currentConfig.setMail_username(siteConfig.getMail_username()); + } + + if (siteConfig.getMail_password() != null && !siteConfig.getMail_password().isEmpty()) { + log.debug("前端提供了新密码,将更新密码"); + currentConfig.setMail_password(siteConfig.getMail_password()); + } else { + log.debug("前端未提供密码,保留原始密码: {}", originalMailPassword != null ? "***已设置***" : "NULL"); + currentConfig.setMail_password(originalMailPassword); + } + + log.debug("最终用于更新的密码: {}", currentConfig.getMail_password() != null ? "***已设置***" : "NULL"); + + if (siteConfig.getMail_protocol() != null) { + currentConfig.setMail_protocol(siteConfig.getMail_protocol()); + } + + if (siteConfig.getMail_enable_tls() != null) { + currentConfig.setMail_enable_tls(siteConfig.getMail_enable_tls()); + } + + if (siteConfig.getMail_enable_ssl() != null) { + currentConfig.setMail_enable_ssl(siteConfig.getMail_enable_ssl()); + } + + if (siteConfig.getMail_default_encoding() != null) { + currentConfig.setMail_default_encoding(siteConfig.getMail_default_encoding()); + } + + if (siteConfig.getIcp_record_number() != null) { + currentConfig.setIcp_record_number(siteConfig.getIcp_record_number()); + } + + if (siteConfig.getMps_record_number() != null) { + currentConfig.setMps_record_number(siteConfig.getMps_record_number()); + } + + if (siteConfig.getEnable_register() != null) { + currentConfig.setEnable_register(siteConfig.getEnable_register()); + } + if (siteConfig.getEnable_music() != null) { + currentConfig.setEnable_music(siteConfig.getEnable_music()); + } + if (siteConfig.getAdmin_post_no_review() != null) { + currentConfig.setAdmin_post_no_review(siteConfig.getAdmin_post_no_review()); + } + + if (siteConfig.getEnable_llm() != null) { + currentConfig.setEnable_llm(siteConfig.getEnable_llm()); + } + + if (siteConfig.getEnable_llm_article_review() != null) { + currentConfig.setEnable_llm_article_review(siteConfig.getEnable_llm_article_review()); + } + + if (siteConfig.getEnable_llm_create_article() != null) { + currentConfig.setEnable_llm_create_article(siteConfig.getEnable_llm_create_article()); + } + + if (siteConfig.getGlobal_head_code() != null) { + currentConfig.setGlobal_head_code(siteConfig.getGlobal_head_code()); + } + + if (siteConfig.getContent_head_code() != null) { + currentConfig.setContent_head_code(siteConfig.getContent_head_code()); + } + + if (siteConfig.getFooter_code() != null) { + currentConfig.setFooter_code(siteConfig.getFooter_code()); + } + + if (siteConfig.getEnable_image_compression() != null) { + currentConfig.setEnable_image_compression(siteConfig.getEnable_image_compression()); + } + + if (siteConfig.getImage_compression_threads() != null) { + currentConfig.setImage_compression_threads(siteConfig.getImage_compression_threads()); + } + + if (siteConfig.getProxy_resource_download() != null) { + currentConfig.setProxy_resource_download(siteConfig.getProxy_resource_download()); + } + + if (siteConfig.getEnable_comment() != null) { + currentConfig.setEnable_comment(siteConfig.getEnable_comment()); + } + + if (siteConfig.getComment_status() != null) { + currentConfig.setComment_status(siteConfig.getComment_status()); + } + + if (siteConfig.getEnable_llm_comment_review() != null) { + currentConfig.setEnable_llm_comment_review(siteConfig.getEnable_llm_comment_review()); + } + + if (siteConfig.getAdmin_comment_no_review() != null) { + currentConfig.setAdmin_comment_no_review(siteConfig.getAdmin_comment_no_review()); + } + + boolean result = siteConfigService.updateSiteConfig(currentConfig); + + if (result) { + try { + imageCompressionService.updateWorkerThreads(); + } catch (Exception e) { + log.warn("更新图片压缩线程数失败", e); + } + + return ApiResponse.success("站点配置更新成功"); + } else { + return ApiResponse.error(500, "更新站点配置失败"); + } + } catch (Exception e) { + log.error("更新站点配置信息失败", e); + return ApiResponse.error(500, "更新站点配置时发生错误: " + e.getMessage()); + } + } + + @GetMapping("/roles") + @RequiresPermission("system:config:management") + public ApiResponse> getAllRoles() { + log.info("获取所有角色列表"); + try { + List roles = userService.getAllRoles(); + return ApiResponse.success(roles); + } catch (Exception e) { + log.error("获取角色列表失败", e); + return ApiResponse.error(500, "获取角色列表失败: " + e.getMessage()); + } + } + + + + @PostMapping("/test-smtp") + @RequiresPermission("system:config:management") + public ApiResponse testSmtpConfig(@RequestBody TestSmtpRequestDTO testSmtpRequestDTO) { + String email = testSmtpRequestDTO.getEmail(); + mailService.sendSimpleMail(email, "测试邮件", "这是一封测试邮件"); + + return ApiResponse.success("邮件发送成功"); + } + + + @GetMapping("/article-status-options") + @RequiresPermission("system:config:management") + public ApiResponse>> getArticleStatusOptions() { + log.info("获取文章状态选项"); + + List> statusOptions = List.of( + Map.of("value", 0, "label", "删除"), + Map.of("value", 1, "label", "正常"), + Map.of("value", 2, "label", "未发布"), + Map.of("value", 3, "label", "待审核"), + Map.of("value", 4, "label", "违规") + ); + + return ApiResponse.success(statusOptions); + } + + @GetMapping("/icp-record") + public String getIcpRecordNumber() { + return siteConfigService.getIcpRecordNumber(); + } + + @GetMapping("/mps-record") + public String getMpsRecordNumber() { + return siteConfigService.getMpsRecordNumber(); + } + + + @GetMapping("/enable-comment") + public ApiResponse> getEnableCommentStatus() { + try { + Integer enableComment = siteConfigService.getEnableComment(); + return ApiResponse.success(Map.of("enableComment", enableComment == null || enableComment == 1)); + } catch (Exception e) { + log.error("获取评论区启用状态失败", e); + return ApiResponse.error(500, "获取评论区启用状态失败: " + e.getMessage()); + } + } + + @GetMapping("/enable-register") + public ApiResponse> getEnableRegisterStatus() { + try { + Integer enableRegister = siteConfigService.getEnableRegister(); + return ApiResponse.success(Map.of("enableRegister", enableRegister != null && enableRegister == 1)); + } catch (Exception e) { + log.error("获取用户注册状态失败", e); + return ApiResponse.error(500, "获取用户注册状态失败: " + e.getMessage()); + } + } + + @GetMapping("/admin-post-no-review") + public ApiResponse> getAdminPostNoReviewStatus() { + try { + Integer adminPostNoReview = siteConfigService.getAdminPostNoReview(); + return ApiResponse.success(Map.of("adminPostNoReview", adminPostNoReview != null && adminPostNoReview == 1)); + } catch (Exception e) { + log.error("获取管理员文章审核状态失败", e); + return ApiResponse.error(500, "获取管理员文章审核状态失败: " + e.getMessage()); + } + } + + @GetMapping("/enable-llm") + public ApiResponse> getEnableLlmStatus() { + try { + Integer enableLlm = siteConfigService.getEnableLlm(); + return ApiResponse.success(Map.of("enableLlm", enableLlm != null && enableLlm == 1)); + } catch (Exception e) { + log.error("获取LLM启用状态失败", e); + return ApiResponse.error(500, "获取LLM启用状态失败: " + e.getMessage()); + } + } + + @GetMapping("/enable-llm-article-review") + public ApiResponse> getEnableLlmArticleReviewStatus() { + try { + Integer enableLlmArticleReview = siteConfigService.getEnableLlmArticleReview(); + return ApiResponse.success(Map.of("enableLlmArticleReview", enableLlmArticleReview != null && enableLlmArticleReview == 1)); + } catch (Exception e) { + log.error("获取LLM自动审核文章状态失败", e); + return ApiResponse.error(500, "获取LLM自动审核文章状态失败: " + e.getMessage()); + } + } + + @GetMapping("/enable-llm-create-article") + public ApiResponse> getEnableLlmCreateArticleStatus() { + try { + Integer enableLlmCreateArticle = siteConfigService.getEnableLlmCreateArticle(); + return ApiResponse.success(Map.of("enableLlmCreateArticle", enableLlmCreateArticle != null && enableLlmCreateArticle == 1)); + } catch (Exception e) { + log.error("获取LLM生成文章状态失败", e); + return ApiResponse.error(500, "获取LLM生成文章状态失败: " + e.getMessage()); + } + } + + @GetMapping("/global-head-code") + public ApiResponse> getGlobalHeadCode() { + try { + String globalHeadCode = siteConfigService.getGlobalHeadCode(); + return ApiResponse.success(Map.of("globalHeadCode", globalHeadCode != null ? globalHeadCode : "")); + } catch (Exception e) { + log.error("获取全局Head代码失败", e); + return ApiResponse.error(500, "获取全局Head代码失败: " + e.getMessage()); + } + } + + @GetMapping("/content-head-code") + public ApiResponse> getContentHeadCode() { + try { + String contentHeadCode = siteConfigService.getContentHeadCode(); + return ApiResponse.success(Map.of("contentHeadCode", contentHeadCode != null ? contentHeadCode : "")); + } catch (Exception e) { + log.error("获取内容页Head代码失败", e); + return ApiResponse.error(500, "获取内容页Head代码失败: " + e.getMessage()); + } + } + + @GetMapping("/footer-code") + public ApiResponse> getFooterCode() { + try { + String footerCode = siteConfigService.getFooterCode(); + return ApiResponse.success(Map.of("footerCode", footerCode != null ? footerCode : "")); + } catch (Exception e) { + log.error("获取页脚代码失败", e); + return ApiResponse.error(500, "获取页脚代码失败: " + e.getMessage()); + } + } + +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/SitemapController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/SitemapController.java new file mode 100644 index 0000000..c4efe69 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/SitemapController.java @@ -0,0 +1,172 @@ +package xyz.lingview.dimstack.controller; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import xyz.lingview.dimstack.domain.ReadArticle; +import xyz.lingview.dimstack.service.ReadArticleService; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.Date; +import java.util.List; + +@RestController +@RequiredArgsConstructor +public class SitemapController { + + private final ReadArticleService readArticleService; + + private String getDomain(HttpServletRequest request) { + String scheme = request.getHeader("X-Forwarded-Proto"); + if (scheme == null) scheme = request.getScheme(); + + String host = request.getHeader("X-Forwarded-Host"); + if (host == null) + host = request.getServerName() + + ((request.getServerPort() == 80 || request.getServerPort() == 443) + ? "" : ":" + request.getServerPort()); + + return scheme + "://" + host; + } + + private String escapeXml10(String input) { + if (input == null) return null; + return input.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } + + @GetMapping(value = "/sitemap-index.xml", produces = "application/xml;charset=UTF-8") + public String sitemapIndex(HttpServletRequest request) { + String domain = escapeXml10(getDomain(request)); + + return """ + + + + %s/sitemap.xml + + + %s/sitemap-article.xml + + + %s/sitemap-category.xml + + + %s/sitemap-tag.xml + + + """.formatted(domain, domain, domain, domain); + } + + @GetMapping(value = "/sitemap.xml", produces = "application/xml;charset=UTF-8") + public String sitemap(HttpServletRequest request) { + String domain = escapeXml10(getDomain(request)); + + return """ + + + %s + + """.formatted( + buildUrl(domain + "/", "daily", "1.0", null) + ); + } + + @GetMapping(value = "/sitemap-article.xml", produces = "application/xml;charset=UTF-8") + public String sitemapArticle(HttpServletRequest request) { + String domain = escapeXml10(getDomain(request)); + List articles = readArticleService.listAllArticles(); + + StringBuilder xml = new StringBuilder(""" + + + """); + + for (ReadArticle a : articles) { + String urlPath = domain + "/article/" + url(a.getAlias()); + xml.append(buildUrl(urlPath, "weekly", "0.9", a.getCreate_time())); + } + + xml.append(""); + return xml.toString(); + } + + @GetMapping(value = "/sitemap-category.xml", produces = "application/xml;charset=UTF-8") + public String sitemapCategory(HttpServletRequest request) { + String domain = escapeXml10(getDomain(request)); + List categories = readArticleService.listAllCategories(); + + StringBuilder xml = new StringBuilder(""" + + + """); + + for (String c : categories) { + String urlPath = domain + "/category/" + url(c); + xml.append(buildUrl(urlPath, "weekly", "0.6", null)); + } + + xml.append(""); + return xml.toString(); + } + + @GetMapping(value = "/sitemap-tag.xml", produces = "application/xml;charset=UTF-8") + public String sitemapTag(HttpServletRequest request) { + String domain = escapeXml10(getDomain(request)); + List tags = readArticleService.listAllTags(); + + StringBuilder xml = new StringBuilder(""" + + + """); + + for (String t : tags) { + String urlPath = domain + "/tag/" + url(t); + xml.append(buildUrl(urlPath, "weekly", "0.5", null)); + } + + xml.append(""); + return xml.toString(); + } + + private String buildUrl(String loc, String changefreq, String priority, Date lastmod) { + String escapedLoc = escapeXml10(loc); + + StringBuilder sb = new StringBuilder(); + sb.append(""); + sb.append("").append(escapedLoc).append(""); + sb.append("").append(changefreq).append(""); + sb.append("").append(priority).append(""); + + if (lastmod != null) { + LocalDateTime ldt = toLocalDateTime(lastmod); + String formatted = ldt.atOffset(ZoneOffset.ofHours(8)) + .format(DateTimeFormatter.ISO_OFFSET_DATE_TIME); + sb.append("").append(escapeXml10(formatted)).append(""); + } + + sb.append(""); + + return sb.toString(); + } + + private String url(String s) { + if (s == null) return ""; + return URLEncoder.encode(s, StandardCharsets.UTF_8).replace("+", "%20"); + } + + private LocalDateTime toLocalDateTime(Date date) { + if (date == null) return null; + return date.toInstant() + .atZone(ZoneOffset.ofHours(8)) + .toLocalDateTime(); + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/StatisticsController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/StatisticsController.java new file mode 100644 index 0000000..88f5a8e --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/StatisticsController.java @@ -0,0 +1,38 @@ +package xyz.lingview.dimstack.controller; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.service.StatisticsService; + +@RestController +@RequestMapping("/api/statistics") +@Slf4j +public class StatisticsController { + + @Autowired + private StatisticsService statisticsService; + + @GetMapping("/articlecount") + public ApiResponse getArticleCount() { + return ApiResponse.success(statisticsService.getTotalArticles()); + } + + @GetMapping("/usercount") + public ApiResponse getUserCount() { + return ApiResponse.success(statisticsService.getTotalUsers()); + } + + @GetMapping("/commentcount") + public ApiResponse getCommentCount() { + return ApiResponse.success(statisticsService.getTotalComments()); + } + + @GetMapping("/browsecount") + public ApiResponse getBrowseCount() { + return ApiResponse.success(statisticsService.getTotalPageViews()); + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/StorageMethodController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/StorageMethodController.java new file mode 100644 index 0000000..5c94274 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/StorageMethodController.java @@ -0,0 +1,106 @@ +package xyz.lingview.dimstack.controller; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +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 xyz.lingview.dimstack.annotation.RequiresPermission; +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.domain.StorageMethod; +import xyz.lingview.dimstack.service.CurrentUserService; +import xyz.lingview.dimstack.service.StorageMethodService; + +import java.util.List; +import java.util.Map; + +/** + * @Author: lingview + * @Date: 2026/07/17 17:23:48 + * @Description: 存储方式管理控制器 + * @Version: 1.0 + */ +@Slf4j +@RestController +@RequestMapping("/api/storage") +public class StorageMethodController { + + @Autowired + private StorageMethodService storageMethodService; + + @Autowired + private CurrentUserService currentUserService; + + @GetMapping("/list") + @RequiresPermission("system:config:management") + public ApiResponse> list() { + return ApiResponse.success(storageMethodService.list()); + } + + @PostMapping("/add") + @RequiresPermission("system:config:management") + public ApiResponse> add(@RequestBody StorageMethod storageMethod) { + String userUuid = currentUserService.getCurrentUserUuid(); + Map result = storageMethodService.add(storageMethod, userUuid); + if (result.containsKey("error")) { + return ApiResponse.error(400, result.get("error")); + } + return ApiResponse.success(result); + } + + @PostMapping("/edit") + @RequiresPermission("system:config:management") + public ApiResponse> edit(@RequestBody StorageMethod storageMethod) { + Map result = storageMethodService.edit(storageMethod); + if (result.containsKey("error")) { + return ApiResponse.error(400, result.get("error")); + } + return ApiResponse.success(result); + } + + @PostMapping("/disable") + @RequiresPermission("system:config:management") + public ApiResponse> disable(@RequestBody Map payload) { + String uuid = payload.get("uuid"); + Map result = storageMethodService.disable(uuid); + if (result.containsKey("error")) { + return ApiResponse.error(400, result.get("error")); + } + return ApiResponse.success(result); + } + + @PostMapping("/enable") + @RequiresPermission("system:config:management") + public ApiResponse> enable(@RequestBody Map payload) { + String uuid = payload.get("uuid"); + Map result = storageMethodService.enable(uuid); + if (result.containsKey("error")) { + return ApiResponse.error(400, result.get("error")); + } + return ApiResponse.success(result); + } + + @PostMapping("/delete") + @RequiresPermission("system:config:management") + public ApiResponse> delete(@RequestBody Map payload) { + String uuid = payload.get("uuid"); + Map result = storageMethodService.deletePhysical(uuid); + if (result.containsKey("error")) { + return ApiResponse.error(400, result.get("error")); + } + return ApiResponse.success(result); + } + + @PostMapping("/set-default") + @RequiresPermission("system:config:management") + public ApiResponse> setDefault(@RequestBody Map payload) { + String uuid = payload.get("uuid"); + Map result = storageMethodService.setDefault(uuid); + if (result.containsKey("error")) { + return ApiResponse.error(400, result.get("error")); + } + return ApiResponse.success(result); + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/SystematicNotificationController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/SystematicNotificationController.java new file mode 100644 index 0000000..4e7e10a --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/SystematicNotificationController.java @@ -0,0 +1,123 @@ +package xyz.lingview.dimstack.controller; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.domain.SystematicNotification; +import xyz.lingview.dimstack.dto.request.PageResult; +import xyz.lingview.dimstack.service.CurrentUserService; +import xyz.lingview.dimstack.service.NotificationService; +import xyz.lingview.dimstack.service.UserService; + +/** + * @Author: lingview + * @Date: 2026/01/31 12:52:01 + * @Description: 系统通知控制器 + * @Version: 1.0 + */ +@Slf4j +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class SystematicNotificationController { + + @Autowired + private NotificationService notificationService; + + @Autowired + private UserService userService; + + @Autowired + private CurrentUserService currentUserService; + + @GetMapping("/getsystematicNotification") + public ApiResponse> getSystematicNotification( + @RequestParam(defaultValue = "1") int pageNum, + @RequestParam(defaultValue = "10") int pageSize) { + try { + String username = currentUserService.getCurrentUsername(); + + String uuid = userService.getUserUUID(username); + PageResult notifications = + notificationService.getSystematicNotifications(uuid, pageNum, pageSize); + return ApiResponse.success(notifications); + } catch (Exception e) { + log.error("获取系统通知失败", e); + return ApiResponse.error(500, "获取系统通知失败"); + } + } + + @GetMapping("/getReadNotifications") + public ApiResponse> getReadNotifications( + @RequestParam(defaultValue = "1") int pageNum, + @RequestParam(defaultValue = "10") int pageSize) { + try { + String username = currentUserService.getCurrentUsername(); + + String uuid = userService.getUserUUID(username); + PageResult notifications = + notificationService.getReadNotifications(uuid, pageNum, pageSize); + return ApiResponse.success(notifications); + } catch (Exception e) { + log.error("获取已读通知失败", e); + return ApiResponse.error(500, "获取已读通知失败"); + } + } + + @GetMapping("/getUnreadNotifications") + public ApiResponse> getUnreadNotifications( + @RequestParam(defaultValue = "1") int pageNum, + @RequestParam(defaultValue = "10") int pageSize) { + try { + String username = currentUserService.getCurrentUsername(); + + String uuid = userService.getUserUUID(username); + PageResult notifications = + notificationService.getUnreadNotifications(uuid, pageNum, pageSize); + return ApiResponse.success(notifications); + } catch (Exception e) { + log.error("获取未读通知失败", e); + return ApiResponse.error(500, "获取未读通知失败"); + } + } + + @PostMapping("/readSystematicNotification") + public ApiResponse readSystematicNotification( + @RequestParam Integer id) { + try { + String username = currentUserService.getCurrentUsername(); + + String uuid = userService.getUserUUID(username); + boolean success = notificationService.updateNotificationStatus(id, uuid); + if (success) { + return ApiResponse.success("操作成功"); + } else { + return ApiResponse.error(400, "操作失败或无权限"); + } + } catch (Exception e) { + log.error("设置通知为已读失败", e); + return ApiResponse.error(500, "设置通知为已读失败"); + } + } + + @PostMapping("/deleteSystematicNotification") + public ApiResponse deleteSystematicNotification( + @RequestParam Integer id) { + try { + String username = currentUserService.getCurrentUsername(); + + String uuid = userService.getUserUUID(username); + boolean success = notificationService.deleteSystematicNotification(id, uuid); + if (success) { + return ApiResponse.success("删除成功"); + } else { + return ApiResponse.error(400, "删除失败或无权限"); + } + } catch (Exception e) { + log.error("删除通知失败", e); + return ApiResponse.error(500, "删除通知失败"); + } + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/TagAndCategoryController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/TagAndCategoryController.java new file mode 100644 index 0000000..98aba19 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/TagAndCategoryController.java @@ -0,0 +1,95 @@ +package xyz.lingview.dimstack.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.domain.ArticleCategory; +import xyz.lingview.dimstack.domain.ArticleCategoryAndCount; +import xyz.lingview.dimstack.domain.ArticleTag; +import xyz.lingview.dimstack.dto.request.ArticleDTO; +import xyz.lingview.dimstack.dto.request.PageResult; +import xyz.lingview.dimstack.service.ArticleCategoryService; +import xyz.lingview.dimstack.service.ArticleTagService; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.List; + +@RestController +@RequestMapping("/api") +public class TagAndCategoryController { + + @Autowired + private ArticleTagService articleTagService; + + @Autowired + private ArticleCategoryService articleCategoryService; + + @GetMapping("/tags") + public ApiResponse> getAllEnabledTags() { + return ApiResponse.success(articleTagService.findAllEnabledTags()); + } + + @GetMapping("/categories") + public ApiResponse> getAllEnabledCategories() { + return ApiResponse.success(articleCategoryService.findAllEnabledCategories()); + } + + @GetMapping("/categoriesandcount") + public ApiResponse> getAllEnabledCategoriesAndCount() { + return ApiResponse.success(articleCategoryService.findAllEnabledCategoriesAndCount()); + } + + @GetMapping("/categories/articles") + public ApiResponse> getArticlesByCategory( + @RequestParam String category, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int size) { + + int offset = (page - 1) * size; + + // 获取文章列表 + List articles = articleCategoryService.findArticlesByCategory(category, offset, size); + + // 获取总数 + int total = articleCategoryService.countArticlesByCategory(category); + + // 计算总页数 + int total_pages = (int) Math.ceil((double) total / size); + + PageResult pageResult = new PageResult<>(); + pageResult.setData(articles); + pageResult.setTotal(total); + pageResult.setPage(page); + pageResult.setSize(size); + pageResult.setTotal_pages(total_pages); + + return ApiResponse.success(pageResult); + } + + + @GetMapping("/tags/{tag}/articles") + public ApiResponse> getArticlesByTag( + @PathVariable String tag, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int size) { + + int offset = (page - 1) * size; + + List articles = articleTagService.findArticlesByTag(tag, offset, size); + int total = articleTagService.countArticlesByTag(tag); + + int total_pages = (int) Math.ceil((double) total / size); + + PageResult pageResult = new PageResult<>(); + pageResult.setData(articles); + pageResult.setTotal(total); + pageResult.setPage(page); + pageResult.setSize(size); + pageResult.setTotal_pages(total_pages); + + return ApiResponse.success(pageResult); + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/TagCategoryController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/TagCategoryController.java new file mode 100644 index 0000000..0df9bdf --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/TagCategoryController.java @@ -0,0 +1,246 @@ +package xyz.lingview.dimstack.controller; + +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import xyz.lingview.dimstack.annotation.RequiresPermission; +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.dto.request.ArticleCategoryDTO; +import xyz.lingview.dimstack.dto.request.ArticleTagDTO; +import xyz.lingview.dimstack.service.ArticleCategoryService; +import xyz.lingview.dimstack.service.ArticleTagService; +import xyz.lingview.dimstack.service.CurrentUserService; + +@RestController +@RequestMapping("/api/tags-categories") +@Slf4j +public class TagCategoryController { + + @Autowired + private ArticleTagService articleTagService; + + @Autowired + private ArticleCategoryService articleCategoryService; + + @Autowired + private CurrentUserService currentUserService; + + @GetMapping("/tags") + @RequiresPermission("system:tags:management") + public ApiResponse getAllTags() { + try { + var tags = articleTagService.getAllTags(); + return ApiResponse.success(tags); + } catch (Exception e) { + log.error("获取标签列表失败", e); + return ApiResponse.error(500, "获取标签列表失败: " + e.getMessage()); + } + } + + @GetMapping("/tags/active") + @RequiresPermission("system:tags:management") + public ApiResponse getActiveTags() { + try { + var tags = articleTagService.getActiveTags(); + return ApiResponse.success(tags); + } catch (Exception e) { + log.error("获取启用标签列表失败", e); + return ApiResponse.error(500, "获取启用标签列表失败: " + e.getMessage()); + } + } + + @PostMapping("/tags") + @RequiresPermission("system:tags:management") + public ApiResponse createTag(@Valid @RequestBody ArticleTagDTO tagDTO) { + try { + String username = currentUserService.getCurrentUsername(); + + boolean result = articleTagService.createTag(tagDTO, username); + if (result) { + return ApiResponse.success("标签创建成功"); + } else { + return ApiResponse.error(500, "标签创建失败"); + } + } catch (Exception e) { + log.error("创建标签失败", e); + return ApiResponse.error(500, "创建标签失败: " + e.getMessage()); + } + } + + @PutMapping("/tags/{id}") + @RequiresPermission("system:tags:management") + public ApiResponse updateTag(@PathVariable Integer id, @Valid @RequestBody ArticleTagDTO tagDTO) { + try { + tagDTO.setId(id); + boolean result = articleTagService.updateTag(tagDTO); + if (result) { + return ApiResponse.success("标签更新成功"); + } else { + return ApiResponse.error(500, "标签更新失败"); + } + } catch (Exception e) { + log.error("更新标签失败", e); + return ApiResponse.error(500, "更新标签失败: " + e.getMessage()); + } + } + + @DeleteMapping("/tags/{id}") + @RequiresPermission("system:tags:management") + public ApiResponse deleteTag(@PathVariable Integer id) { + try { + boolean result = articleTagService.deleteTag(id); + if (result) { + return ApiResponse.success("标签删除成功"); + } else { + return ApiResponse.error(500, "标签删除失败"); + } + } catch (Exception e) { + log.error("删除标签失败", e); + return ApiResponse.error(500, "删除标签失败: " + e.getMessage()); + } + } + + @PutMapping("/tags/{id}/activate") + @RequiresPermission("system:tags:management") + public ApiResponse activateTag(@PathVariable Integer id) { + try { + boolean result = articleTagService.activateTag(id); + if (result) { + return ApiResponse.success("标签激活成功"); + } else { + return ApiResponse.error(500, "标签激活失败"); + } + } catch (Exception e) { + log.error("激活标签失败", e); + return ApiResponse.error(500, "激活标签失败: " + e.getMessage()); + } + } + + @GetMapping("/categories") + @RequiresPermission("system:categories:management") + public ApiResponse getAllCategories() { + try { + var categories = articleCategoryService.getAllCategories(); + return ApiResponse.success(categories); + } catch (Exception e) { + log.error("获取分类列表失败", e); + return ApiResponse.error(500, "获取分类列表失败: " + e.getMessage()); + } + } + + @GetMapping("/categories/active") + @RequiresPermission("system:categories:management") + public ApiResponse getActiveCategories() { + try { + var categories = articleCategoryService.getActiveCategories(); + return ApiResponse.success(categories); + } catch (Exception e) { + log.error("获取启用分类列表失败", e); + return ApiResponse.error(500, "获取启用分类列表失败: " + e.getMessage()); + } + } + + @GetMapping("/categories/tree") + @RequiresPermission("system:categories:management") + public ApiResponse getCategoryTree() { + try { + var categories = articleCategoryService.getCategoryTree(); + return ApiResponse.success(categories); + } catch (Exception e) { + log.error("获取分类树形结构失败", e); + return ApiResponse.error(500, "获取分类树形结构失败: " + e.getMessage()); + } + } + + @GetMapping("/categories/top-level") + @RequiresPermission("system:categories:management") + public ApiResponse getTopLevelCategories() { + try { + var categories = articleCategoryService.getTopLevelCategories(); + return ApiResponse.success(categories); + } catch (Exception e) { + log.error("获取顶级分类列表失败", e); + return ApiResponse.error(500, "获取顶级分类列表失败: " + e.getMessage()); + } + } + + @GetMapping("/categories/children/{parentId}") + @RequiresPermission("system:categories:management") + public ApiResponse getChildrenByParentId(@PathVariable Integer parentId) { + try { + var categories = articleCategoryService.getChildrenByParentId(parentId); + return ApiResponse.success(categories); + } catch (Exception e) { + log.error("获取子分类列表失败", e); + return ApiResponse.error(500, "获取子分类列表失败: " + e.getMessage()); + } + } + + @PostMapping("/categories") + @RequiresPermission("system:categories:management") + public ApiResponse createCategory(@RequestBody ArticleCategoryDTO categoryDTO) { + try { + String username = currentUserService.getCurrentUsername(); + + boolean result = articleCategoryService.createCategory(categoryDTO, username); + if (result) { + return ApiResponse.success("分类创建成功"); + } else { + return ApiResponse.error(500, "分类创建失败"); + } + } catch (Exception e) { + log.error("创建分类失败", e); + return ApiResponse.error(500, "创建分类失败: " + e.getMessage()); + } + } + + @PutMapping("/categories/{id}") + @RequiresPermission("system:categories:management") + public ApiResponse updateCategory(@PathVariable Integer id, @RequestBody ArticleCategoryDTO categoryDTO) { + try { + categoryDTO.setId(id); + boolean result = articleCategoryService.updateCategory(categoryDTO); + if (result) { + return ApiResponse.success("分类更新成功"); + } else { + return ApiResponse.error(500, "分类更新失败"); + } + } catch (Exception e) { + log.error("更新分类失败", e); + return ApiResponse.error(500, "更新分类失败: " + e.getMessage()); + } + } + + @DeleteMapping("/categories/{id}") + @RequiresPermission("system:categories:management") + public ApiResponse deleteCategory(@PathVariable Integer id) { + try { + boolean result = articleCategoryService.deleteCategory(id); + if (result) { + return ApiResponse.success("分类删除成功"); + } else { + return ApiResponse.error(500, "分类删除失败"); + } + } catch (Exception e) { + log.error("删除分类失败", e); + return ApiResponse.error(500, "删除分类失败: " + e.getMessage()); + } + } + + @PutMapping("/categories/{id}/activate") + @RequiresPermission("system:categories:management") + public ApiResponse activateCategory(@PathVariable Integer id) { + try { + boolean result = articleCategoryService.activateCategory(id); + if (result) { + return ApiResponse.success("分类激活成功"); + } else { + return ApiResponse.error(500, "分类激活失败"); + } + } catch (Exception e) { + log.error("激活分类失败", e); + return ApiResponse.error(500, "激活分类失败: " + e.getMessage()); + } + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ThemeController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ThemeController.java new file mode 100644 index 0000000..4f402c9 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/ThemeController.java @@ -0,0 +1,188 @@ +package xyz.lingview.dimstack.controller; + +import lombok.extern.slf4j.Slf4j; +import xyz.lingview.dimstack.annotation.RequiresPermission; +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.config.ThemeProperties; +import xyz.lingview.dimstack.service.SiteConfigService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +@RestController +@RequestMapping("/api/theme") +public class ThemeController { + + private static final String DEFAULT_THEME = "default"; + + @Autowired + private ThemeProperties themeProperties; + + @Autowired + private SiteConfigService siteConfigService; + + @PostMapping("/switch") + @RequiresPermission("system:theme:management") + public ApiResponse switchTheme(@RequestParam String themeName) { + String themesPath = themeProperties.getThemesPath(); + Path themeDir = Path.of(themesPath, themeName); + + if (!Files.exists(themeDir) || !Files.isDirectory(themeDir)) { + return ApiResponse.error(400, "主题目录不存在: " + themeName); + } + + themeProperties.setActiveTheme(themeName); + boolean success = siteConfigService.updateSiteTheme(themeName); + + if (!success) { + return ApiResponse.error(500, "主题保存到数据库失败"); + } + + String currentTheme = themeProperties.getActiveTheme(); + if (currentTheme.equals(themeName)) { + return ApiResponse.success("主题已切换至: " + themeName, themeName); + } else { + return ApiResponse.error(500, "主题切换失败: " + themeName); + } + } + + @PostMapping("/deactivate") + @RequiresPermission("system:theme:management") + public ApiResponse deactivateTheme() { + String activeTheme = themeProperties.getActiveTheme(); + if (DEFAULT_THEME.equals(activeTheme)) { + return ApiResponse.error(400, "默认主题为系统内置主题,无需取消激活"); + } + + themeProperties.setActiveTheme(DEFAULT_THEME); + boolean success = siteConfigService.updateSiteTheme(DEFAULT_THEME); + + if (!success) { + return ApiResponse.error(500, "Failed to save theme to database"); + } + + return ApiResponse.success("已取消激活,已切换回默认主题", DEFAULT_THEME); + } + + @GetMapping("/current") + @RequiresPermission("system:theme:management") + public ApiResponse getCurrentTheme() { + String activeTheme = themeProperties.getActiveTheme(); + return ApiResponse.success("", activeTheme); + } + + @GetMapping("/list") + @RequiresPermission("system:theme:management") + public ApiResponse listAvailableThemes() { + try { + StringBuilder result = new StringBuilder("Available themes:\n"); + String themesPath = themeProperties.getThemesPath(); + Path themesDir = Path.of(themesPath); + + if (Files.exists(themesDir) && Files.isDirectory(themesDir)) { + Files.list(themesDir) + .filter(Files::isDirectory) + .forEach(dir -> { + String themeName = dir.getFileName().toString(); + String currentMarker = themeName.equals(themeProperties.getActiveTheme()) ? " (current)" : ""; + result.append("- ").append(themeName).append(currentMarker).append("\n"); + }); + } else { + result.append("Themes directory not found: ").append(themesPath); + } + + return ApiResponse.success("", result.toString()); + } catch (Exception e) { + log.error("主题读取失败", e); + return ApiResponse.error(500, "主题读取失败"); + } + } + + @PostMapping("/validate") + @RequiresPermission("system:theme:management") + public ApiResponse validateTheme(@RequestParam String themeName) { + try { + String themesPath = themeProperties.getThemesPath(); + Path themeDir = Path.of(themesPath, themeName); + + if (!Files.exists(themeDir)) { + return ApiResponse.error(400, "Theme '" + themeName + "' does not exist"); + } + + if (!Files.isDirectory(themeDir)) { + return ApiResponse.error(400, "Path '" + themeName + "' is not a directory"); + } + + Path indexFile = themeDir.resolve("index.html"); + if (Files.exists(indexFile)) { + return ApiResponse.success("", "Theme '" + themeName + "' is valid and has index.html"); + } else { + return ApiResponse.success("", "Theme '" + themeName + "' is valid but does not have index.html"); + } + } catch (Exception e) { + log.error("主题验证出错", e); + return ApiResponse.error(500, "Error 验证主题出错"); + } + } + + private String readThemeVersion(Path themeDir) { + try { + Path configFile = themeDir.resolve("config.ini"); + if (!Files.exists(configFile) || !Files.isRegularFile(configFile)) { + return null; + } + for (String rawLine : Files.readAllLines(configFile)) { + String line = rawLine.replace("\uFEFF", "").trim(); + if (line.isEmpty() || line.startsWith("#") || line.startsWith(";") || line.startsWith("[")) { + continue; + } + int eq = line.indexOf('='); + if (eq > 0 && line.substring(0, eq).trim().equalsIgnoreCase("version")) { + String value = line.substring(eq + 1).trim(); + return value.isEmpty() ? null : value; + } + } + } catch (Exception e) { + log.warn("读取主题版本失败: {}", e.getMessage()); + } + return null; + } + + @GetMapping("/list/json") + @RequiresPermission("system:theme:management") + public ApiResponse>> listAvailableThemesJson() { + try { + List> themes = new ArrayList<>(); + String themesPath = themeProperties.getThemesPath(); + Path themesDir = Path.of(themesPath); + String activeTheme = themeProperties.getActiveTheme(); + + if (Files.exists(themesDir) && Files.isDirectory(themesDir)) { + Files.list(themesDir) + .filter(Files::isDirectory) + .forEach(dir -> { + String themeName = dir.getFileName().toString(); + Map themeInfo = new HashMap<>(); + themeInfo.put("name", themeName); + themeInfo.put("slug", themeName); + themeInfo.put("isCurrent", themeName.equals(activeTheme)); + themeInfo.put("version", readThemeVersion(dir)); + themes.add(themeInfo); + }); + } + + return ApiResponse.success(themes); + } catch (Exception e) { + log.error("获取主题列表失败", e); + return ApiResponse.error(500, "Error listing themes"); + } + } + +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/UpdateController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/UpdateController.java new file mode 100644 index 0000000..f2ead86 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/UpdateController.java @@ -0,0 +1,219 @@ +package xyz.lingview.dimstack.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import xyz.lingview.dimstack.annotation.RequiresPermission; +import xyz.lingview.dimstack.domain.UpdateInfo; +import xyz.lingview.dimstack.dto.request.PerformUpdateRequestDTO; +import xyz.lingview.dimstack.service.UpdateService; +import tools.jackson.databind.ObjectMapper; +import xyz.lingview.dimstack.common.ApiResponse; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +/** + * @Author: lingview + * @Date: 2026/01/27 09:44:15 + * @Description: 更新控制器 + * @Version: 1.0 + */ +@RestController +@RequestMapping("/api/update") +public class UpdateController { + + @Autowired + private UpdateService updateService; + + // 检查更新接口 + @GetMapping("/check") + @RequiresPermission("system:update:management") + public ApiResponse> checkForUpdates() { + + Map response = new HashMap<>(); + String updateUrl = "https://update.apilinks.cn/dimstack.json"; + + try { + String currentVersion = updateService.getCurrentVersion(); + String updateInfoJson = updateService.fetchUpdateInfo(updateUrl); + + if (updateInfoJson == null || updateInfoJson.isEmpty()) { + response.put("success", false); + response.put("message", "无法获取更新信息"); + return ApiResponse.error(400, "无法获取更新信息", response); + } + // 解析更新信息 + ObjectMapper objectMapper = new ObjectMapper(); + UpdateInfo updateInfo = objectMapper.readValue(updateInfoJson, UpdateInfo.class); + + // 比较版本号 + boolean hasUpdate = updateService.isNewerVersion(currentVersion, updateInfo.getVersion()); + boolean isCompatible = updateService.isVersionCompatible(currentVersion, updateInfo.getMinCompatibleVersion()); + + response.put("success", true); + response.put("hasUpdate", hasUpdate); + response.put("isCompatible", isCompatible); + response.put("currentVersion", currentVersion); + response.put("newVersion", updateInfo.getVersion()); + response.put("updateInfo", updateInfo); + + return ApiResponse.success(response); + } catch (Exception e) { + response.put("success", false); + response.put("message", "检查更新时发生错误: " + e.getMessage()); + return ApiResponse.error(500, "检查更新时发生错误: " + e.getMessage(), response); + } + } + + // 下载更新包接口 + @PostMapping("/download") + @RequiresPermission("system:update:management") + public ApiResponse> downloadUpdate() { + + Map response = new HashMap<>(); + String updateUrl = "https://update.lingview.xyz/dimstack.json"; + + try { + String updateInfoJson = updateService.fetchUpdateInfo(updateUrl); + if (updateInfoJson == null || updateInfoJson.isEmpty()) { + return ApiResponse.error(400, "无法获取更新信息", response); + } + + ObjectMapper objectMapper = new ObjectMapper(); + UpdateInfo updateInfo = objectMapper.readValue(updateInfoJson, UpdateInfo.class); + + String downloadUrl = updateInfo.getDownloadUrl(); + String checksum = updateInfo.getChecksum(); + + if (downloadUrl == null || downloadUrl.isEmpty()) { + return ApiResponse.error(400, "更新信息中未包含下载地址", response); + } + + if (checksum == null || checksum.isEmpty()) { + return ApiResponse.error(400, "更新信息中未包含校验值", response); + } + + String updateDir = System.getProperty("user.dir") + "/.update"; + Path dirPath = Path.of(updateDir); + Files.createDirectories(dirPath); + + String targetJarPath = updateDir + "/dimstack_update.jar"; + boolean downloadSuccess = updateService.downloadNewJar(downloadUrl, targetJarPath); + + if (!downloadSuccess) { + return ApiResponse.error(500, "下载失败", response); + } + + Path checksumPath = Path.of(updateDir, "dimstack_update.sha256"); + Files.writeString( + checksumPath, + checksum.trim(), + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING + ); + + response.put("success", true); + response.put("message", "下载成功"); + response.put("targetPath", targetJarPath); + response.put("checksumSaved", true); + + return ApiResponse.success(response); + + } catch (Exception e) { + return ApiResponse.error(500, "下载更新时发生错误: " + e.getMessage(), response); + } + } + + + // 执行更新接口 + @PostMapping("/perform") + @RequiresPermission("system:update:management") + public ApiResponse> performUpdate( + @RequestBody PerformUpdateRequestDTO request + ) { + Map response = new HashMap<>(); + + boolean restartAfterUpdate = request.isRestartAfterUpdate(); + + try { + String updateDir = System.getProperty("user.dir") + "/.update"; + String newJarPath = updateDir + "/dimstack_update.jar"; + + String expectedChecksum = updateService.getExpectedChecksum(); + if (expectedChecksum != null) { + if (!updateService.verifyChecksum(newJarPath, expectedChecksum)) { + return ApiResponse.error(400, "文件校验失败", response); + } + } + + boolean success = restartAfterUpdate + ? updateService.performUpdateAndRestart(newJarPath) + : updateService.performUpdateOnly(newJarPath); + + return ApiResponse.success(Map.of( + "success", true, + "message", restartAfterUpdate + ? "更新并重启成功" + : "更新成功,未重启" + )); + + } catch (Exception e) { + return ApiResponse.error(500, e.getMessage(), response); + } + } + + /** + * 必须在这里狠狠记宝塔面板一笔 + * 详细原因:https://lingview.xyz/article/a8fa4e12-c527-4080-8a36-1c3620cc01fe + */ + @GetMapping("/updatelog") + @RequiresPermission("system:update:management") + public ApiResponse> getChangelog() { + String changelogUrl = "https://update.lingview.xyz/update_log.yaml"; + Map response = new HashMap<>(); + + try { + HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(changelogUrl)) + .timeout(Duration.ofSeconds(15)) + .GET() + .build(); + + HttpResponse httpResponse = client.send(request, HttpResponse.BodyHandlers.ofString()); + + if (httpResponse.statusCode() == 200) { + response.put("success", true); + response.put("content", httpResponse.body()); + return ApiResponse.success(response); + } else { + response.put("success", false); + response.put("message", "获取更新日志失败,状态码: " + httpResponse.statusCode()); + return ApiResponse.error(httpResponse.statusCode(), "获取更新日志失败", response); + } + } catch (IOException e) { + response.put("success", false); + response.put("message", "获取更新日志时发生错误: " + e.getMessage()); + return ApiResponse.error(500, "获取更新日志时发生错误", response); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + response.put("success", false); + response.put("message", "获取更新日志时被中断: " + e.getMessage()); + return ApiResponse.error(500, "获取更新日志时被中断", response); + } + } + +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/UploadController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/UploadController.java new file mode 100644 index 0000000..7289443 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/UploadController.java @@ -0,0 +1,142 @@ +package xyz.lingview.dimstack.controller; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import xyz.lingview.dimstack.annotation.RequiresPermission; +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.domain.UploadArticle; +import xyz.lingview.dimstack.service.UploadService; + +import java.util.Map; + +@Slf4j +@RestController +@RequestMapping("/api") +public class UploadController { + + @Autowired + private UploadService uploadService; + + @PostMapping("/uploadattachment") + @RequiresPermission("attachment:add") + public ApiResponse> uploadAttachment(HttpServletRequest request, + @RequestParam("file") MultipartFile file) { + Map result = uploadService.uploadAttachment(request, file); + if (result != null && !result.containsKey("error")) { + String message = result.getOrDefault("message", "上传成功"); + return ApiResponse.success(message, result); + } else { + String message = result != null ? result.get("error") : "上传失败"; + return ApiResponse.error(400, message, result); + } + } + + @PostMapping("/uploadattachment/init") + @RequiresPermission("attachment:add") + public ApiResponse> initMultipartUpload(HttpServletRequest request, + @RequestBody Map payload) { + Map result = uploadService.initMultipartUpload(request, payload); + if (result != null && !result.containsKey("error")) { + return ApiResponse.success(result); + } else { + String message = result != null ? result.get("error") : "初始化上传失败"; + return ApiResponse.error(400, message, result); + } + } + + @PostMapping("/uploadattachment/part") + @RequiresPermission("attachment:add") + public ApiResponse> uploadChunk( + HttpServletRequest request, + @RequestHeader("Upload-Id") String uploadId, + @RequestHeader("Chunk-Index") int chunkIndex, + @RequestBody byte[] chunkData) { + Map result = uploadService.uploadChunk(request, uploadId, chunkIndex, chunkData); + if (result != null && !result.containsKey("error")) { + String message = result.getOrDefault("message", "分片上传成功"); + return ApiResponse.success(message, result); + } else { + String message = result != null ? result.get("error") : "分片上传失败"; + return ApiResponse.error(400, message, result); + } + } + + @PostMapping("/uploadattachment/complete") + @RequiresPermission("attachment:add") + public ApiResponse> completeUpload( + HttpServletRequest request, + @RequestBody Map payload) { + Map result = uploadService.completeUpload(request, payload); + if (result != null && !result.containsKey("error")) { + return ApiResponse.success(result); + } else { + String message = result != null ? result.get("error") : "合并上传失败"; + return ApiResponse.error(400, message, result); + } + } + + @PostMapping("/uploadarticle") + @RequiresPermission({"post:add", "post:edit"}) + public ApiResponse> uploadArticle(HttpServletRequest request, + @RequestBody UploadArticle uploadArticle) { + Map result = uploadService.uploadArticle(request, uploadArticle); + if (result != null && !result.containsKey("error")) { + String message = result.containsKey("message") ? result.get("message").toString() : "文章保存成功"; + return ApiResponse.success(message, result); + } else { + String message = result != null ? result.get("error").toString() : "文章保存失败"; + return ApiResponse.error(400, message, result); + } + } + + @PostMapping("/uploadavatar") + public ApiResponse> uploadAvatar(HttpServletRequest request, + @RequestParam("file") MultipartFile file) { + Map result = uploadService.uploadAvatar(request, file); + if (result != null && !result.containsKey("error")) { + String message = result.getOrDefault("message", "上传成功"); + return ApiResponse.success(message, result); + } else { + String message = result != null ? result.get("error") : "上传失败"; + return ApiResponse.error(400, message, result); + } + } + + // 用户管理模块修改头像用 + @PostMapping("/admin/uploadavatar") + @RequiresPermission("system:user:management") + public ApiResponse> adminUploadAvatar( + HttpServletRequest request, + @RequestParam("file") MultipartFile file) { + + Map result = uploadService.adminUploadAvatar(request, file); + + if (result != null && !result.containsKey("error")) { + return ApiResponse.success(result); + } else { + String errorMessage = result != null ? result.get("error") : "上传失败"; + return ApiResponse.error(400, errorMessage); + } + } + + // 代理下载外部资源 + @PostMapping("/download-external-resource") + @RequiresPermission("attachment:add") + public ApiResponse> downloadExternalResource( + HttpServletRequest request, + @RequestBody Map payload) { + String url = payload.get("url"); + Map result = uploadService.downloadAndUploadExternalResource(request, url); + if (result != null && !result.containsKey("error")) { + String message = result.getOrDefault("message", "下载成功"); + return ApiResponse.success(message, result); + } else { + String message = result != null ? result.get("error") : "下载失败"; + return ApiResponse.error(400, message, result); + } + } + +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/UserController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/UserController.java new file mode 100644 index 0000000..f5b7148 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/UserController.java @@ -0,0 +1,210 @@ +package xyz.lingview.dimstack.controller; + +import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import xyz.lingview.dimstack.annotation.RequiresPermission; +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.domain.Role; +import xyz.lingview.dimstack.domain.UserInformation; +import xyz.lingview.dimstack.dto.request.UserDTO; +import xyz.lingview.dimstack.dto.request.UserUpdateDTO; +import xyz.lingview.dimstack.dto.response.UserStatusResponseDTO; +import xyz.lingview.dimstack.service.UserService; +import xyz.lingview.dimstack.service.UserBlacklistService; +import xyz.lingview.dimstack.service.UserRoleService; +import xyz.lingview.dimstack.service.CurrentUserService; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@RestController +@RequestMapping("/api/user") +public class UserController { + + @Autowired + private UserService userService; + @Autowired + private UserBlacklistService userBlacklistService; + @Autowired + private UserRoleService userRoleService; + @Autowired + private CurrentUserService currentUserService; + + @GetMapping("/status") + public ApiResponse getUserStatus() { + UserStatusResponseDTO responseDTO = new UserStatusResponseDTO(); + + String username = currentUserService.getCurrentUsername(); + + if (currentUserService.isAuthenticated() && username != null) { + if (userBlacklistService.isUserInBlacklist(username)) { + responseDTO.setLoggedIn(false); + responseDTO.setUsername(null); + responseDTO.setMessage("用户已被封禁"); + return ApiResponse.success(responseDTO); + } + + responseDTO.setLoggedIn(true); + responseDTO.setUsername(username); + responseDTO.setAvatar(userService.getAvatarByUsername(username)); + responseDTO.setMessage("用户已登录"); + } else { + responseDTO.setLoggedIn(false); + responseDTO.setUsername(null); + responseDTO.setMessage("用户未登录"); + } + + return ApiResponse.success(responseDTO); + } + + + @GetMapping("/has-permission") + public ApiResponse> hasPermission( + @RequestParam List codes, + @RequestParam(defaultValue = "any") String mode) { + String username = currentUserService.getCurrentUsername(); + if (!currentUserService.isAuthenticated() || username == null) { + return ApiResponse.success(Map.of("hasPermission", false)); + } + List userPermissions = userService.getPermissionCodesByUsername(username); + Set userPermSet = new HashSet<>(userPermissions); + boolean hasPermission; + if ("all".equals(mode)) { + hasPermission = userPermSet.containsAll(codes); + } else { + hasPermission = codes.stream().anyMatch(userPermSet::contains); + } + return ApiResponse.success(Map.of("hasPermission", hasPermission)); + } + + + @GetMapping("/{uuid}") + public ApiResponse getUserInfo(@PathVariable String uuid) { + UserInformation userInfo = userService.getUserByUUID(uuid); + return ApiResponse.success(userInfo); + } + + + @PutMapping("/update") + public ApiResponse updateUserInfo(@RequestBody @Valid UserUpdateDTO userUpdateDTO) { + String currentUsername = currentUserService.getCurrentUsername(); + return userService.updateUserInfo(userUpdateDTO, currentUsername); + } + + @GetMapping("/uuid") + public ApiResponse> getUserIdByUsername(@RequestParam String username) { + String userId = userService.getUserUUID(username); + + if (userId != null) { + return ApiResponse.success(Map.of("username", username, "uuid", userId)); + } else { + return ApiResponse.success(Map.of("username", username, "uuid", "")); + } + } + + + @GetMapping("/list") + @RequiresPermission("system:user:management") + public ApiResponse> getUserEnableList() { + List users = userService.getAllEnableUsers(); + return ApiResponse.success(users); + } + + @GetMapping("/list/page") + @RequiresPermission("system:user:management") + public ApiResponse> getUserPageList( + @RequestParam(defaultValue = "1") Integer page, + @RequestParam(defaultValue = "10") Integer size, + @RequestParam(required = false) String keyword) { + int safePage = page == null || page < 1 ? 1 : page; + int safeSize = size == null || size < 1 ? 10 : Math.min(size, 100); + String trimmedKeyword = keyword == null || keyword.isBlank() ? null : keyword.trim(); + + List users = userService.getUsersPage(safePage, safeSize, trimmedKeyword); + int total = userService.countUsers(trimmedKeyword); + int totalAll = userService.countUsers(null); + + Map result = new HashMap<>(); + result.put("users", users); + result.put("total", total); + result.put("totalAll", totalAll); + result.put("page", safePage); + result.put("size", safeSize); + result.put("totalPages", (int) Math.ceil((double) total / safeSize)); + return ApiResponse.success(result); + } + + @GetMapping("/listall") + @RequiresPermission("system:user:management") + public ApiResponse> getUserList() { + List users = userService.getAllUsers(); + return ApiResponse.success(users); + } + + @GetMapping("/detail/{id}") + @RequiresPermission("system:user:management") + public ApiResponse getUserDetail(@PathVariable Integer id) { + UserDTO user = userService.getUserById(id); + return ApiResponse.success(user); + } + + @PostMapping("/updateRole") + @RequiresPermission("system:user:management") + public ApiResponse updateUserRole(@RequestParam Integer userId, @RequestParam Integer roleId) { + boolean result = userService.updateUserRole(userId, roleId); + if (result) { + return ApiResponse.success("", "success"); + } else { + return ApiResponse.error(500, "更新角色失败"); + } + } + + @PostMapping("/updateStatus") + @RequiresPermission("system:user:management") + public ApiResponse updateUserStatus(@RequestParam Integer userId, @RequestParam Byte status) { + boolean result = userService.updateUserStatus(userId, status); + if (result) { + return ApiResponse.success("", "success"); + } else { + return ApiResponse.error(500, "更新状态失败"); + } + } + + @GetMapping("/roles") + @RequiresPermission("system:user:management") + public ApiResponse> getAllRoles() { + List roles = userService.getAllRoles(); + return ApiResponse.success(roles); + } + + @GetMapping("/permissions/{userId}") + @RequiresPermission("system:user:management") + public ApiResponse> getUserPermissions(@PathVariable Integer userId) { + List permissions = userService.getUserPermissions(userId); + return ApiResponse.success(permissions); + } + + + @PostMapping("/add") + @RequiresPermission("system:user:management") + public ApiResponse addUser(@RequestBody UserUpdateDTO userUpdateDTO) { + return userService.addUser(userUpdateDTO); + } + + @PostMapping("/admin/update") + @RequiresPermission("system:user:management") + public ApiResponse updateUserByAdmin(@RequestBody UserUpdateDTO userUpdateDTO) { + return userService.updateUserByAdmin(userUpdateDTO); + } + + @GetMapping("/{userId}/roles") + @RequiresPermission("system:user:management") + public ApiResponse> getUserRoles(@PathVariable Integer userId) { + List roles = userRoleService.getUserRoles(userId); + return ApiResponse.success(roles); + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/UserRoleController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/UserRoleController.java new file mode 100644 index 0000000..3798e92 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/controller/UserRoleController.java @@ -0,0 +1,50 @@ +package xyz.lingview.dimstack.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import xyz.lingview.dimstack.annotation.RequiresPermission; +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.domain.Role; +import xyz.lingview.dimstack.service.UserRoleService; + +import java.util.List; + +@RestController +@RequestMapping("/api/user-role") +public class UserRoleController { + + @Autowired + private UserRoleService userRoleService; + + // 获取用户的所有角色 + @GetMapping("/{userId}/roles") + @RequiresPermission("system:user:management") + public ApiResponse> getUserRoles(@PathVariable Integer userId) { + List roles = userRoleService.getUserRoles(userId); + return ApiResponse.success(roles); + } + + // 为用户添加角色 + @PostMapping("/{userId}/role/add") + @RequiresPermission("system:user:management") + public ApiResponse addUserRole(@PathVariable Integer userId, + @RequestParam Integer roleId) { + return userRoleService.addUserRole(userId, roleId); + } + + // 从用户移除角色 + @DeleteMapping("/{userId}/role/remove") + @RequiresPermission("system:user:management") + public ApiResponse removeUserRole(@PathVariable Integer userId, + @RequestParam Integer roleId) { + return userRoleService.removeUserRole(userId, roleId); + } + + // 设置用户的角色(替换所有现有角色) + @PostMapping("/{userId}/roles/set") + @RequiresPermission("system:user:management") + public ApiResponse setUserRoles(@PathVariable Integer userId, + @RequestBody List roleIds) { + return userRoleService.setUserRoles(userId, roleIds); + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/Announcement.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/Announcement.java new file mode 100644 index 0000000..52b7f3c --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/Announcement.java @@ -0,0 +1,13 @@ +package xyz.lingview.dimstack.domain; + +import lombok.Data; + +import java.util.Date; + +@Data +public class Announcement { + private Integer id; + private String content; + private Date create_time; + private Date update_time; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/ApiKey.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/ApiKey.java new file mode 100644 index 0000000..c8b5a9b --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/ApiKey.java @@ -0,0 +1,16 @@ +package xyz.lingview.dimstack.domain; + +import lombok.Data; + +import java.util.Date; + +@Data +public class ApiKey { + private Integer id; + private String user_id; + private String key_hash; + private String description; + private Date create_time; + private Date update_time; + private Integer status; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/Article.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/Article.java new file mode 100644 index 0000000..73e85bc --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/Article.java @@ -0,0 +1,25 @@ +package xyz.lingview.dimstack.domain; + +import lombok.Data; +import java.time.LocalDateTime; + +@Data +public class Article { + private Integer id; + private String uuid; + private String article_id; + private String article_name; + private String article_cover; + private String excerpt; + private String article_content; + private Long page_views; + private Long like_count; + private Long favorite_count; + private String password; + private String tag; + private String category; + private String alias; + private LocalDateTime create_time; + private Byte status; + private Integer enable_comment; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/ArticleCategory.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/ArticleCategory.java new file mode 100644 index 0000000..ecf988b --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/ArticleCategory.java @@ -0,0 +1,19 @@ +package xyz.lingview.dimstack.domain; + +import lombok.Data; +import java.time.LocalDateTime; + +@Data +public class ArticleCategory { + private Integer id; + private Integer parent_id; + private String article_categories; + private String categories_explain; + private String founder; + private LocalDateTime create_time; + private Integer status; + private Integer article_count; + private String full_path; + private Integer level; + private Integer sort_order; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/ArticleCategoryAndCount.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/ArticleCategoryAndCount.java new file mode 100644 index 0000000..c9ee02c --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/ArticleCategoryAndCount.java @@ -0,0 +1,15 @@ +package xyz.lingview.dimstack.domain; + +import lombok.Data; +import java.time.LocalDateTime; + +@Data +public class ArticleCategoryAndCount { + private Integer id; + private String article_categories; + private String categories_explain; + private String founder; + private LocalDateTime create_time; + private Integer status; + private Long articleCount; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/ArticleLike.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/ArticleLike.java new file mode 100644 index 0000000..6bc17f4 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/ArticleLike.java @@ -0,0 +1,13 @@ +package xyz.lingview.dimstack.domain; + +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class ArticleLike { + private Integer id; + private String user_id; + private String article_id; + private LocalDateTime create_time; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/ArticleTag.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/ArticleTag.java new file mode 100644 index 0000000..bf03cbc --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/ArticleTag.java @@ -0,0 +1,14 @@ +package xyz.lingview.dimstack.domain; + +import lombok.Data; +import java.time.LocalDateTime; + +@Data +public class ArticleTag { + private Integer id; + private String tag_name; + private String tag_explain; + private String founder; + private LocalDateTime create_time; + private Integer status; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/AttachmentManagement.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/AttachmentManagement.java new file mode 100644 index 0000000..02a8b1c --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/AttachmentManagement.java @@ -0,0 +1,25 @@ +package xyz.lingview.dimstack.domain; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * @Author: lingview + * @Date: 2026/02/08 16:50:52 + * @Description: 附件管理实体 + * @Version: 1.0 + */ +@Data +public class AttachmentManagement { + private String uuid; + private String attachment_id; + private String original_filename; + private String attachment_path; + private String access_key; + private String storage_id; + private LocalDateTime create_time; + private LocalDateTime deleted_time; + private int status; + private String username; +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/Comment.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/Comment.java new file mode 100644 index 0000000..8041e3c --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/Comment.java @@ -0,0 +1,19 @@ +package xyz.lingview.dimstack.domain; + +import lombok.Data; +import java.time.LocalDateTime; + +@Data +public class Comment { + private Integer id; + private String user_id; + private String comment_id; + private String article_id; + private String root_comment_id; + private String to_comment_id; + private LocalDateTime create_time; + private LocalDateTime update_time; + private String content; + private Long comment_like_count; + private Integer status; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/CommentLike.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/CommentLike.java new file mode 100644 index 0000000..56e8da6 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/CommentLike.java @@ -0,0 +1,13 @@ +package xyz.lingview.dimstack.domain; + +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class CommentLike { + private Integer id; + private String user_id; + private String comment_id; + private LocalDateTime create_time; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/CustomPage.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/CustomPage.java new file mode 100644 index 0000000..dda3705 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/CustomPage.java @@ -0,0 +1,22 @@ +package xyz.lingview.dimstack.domain; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * @Author: lingview + * @Date: 2026/01/17 20:09:44 + * @Description: + * @Version: 1.0 + */ +@Data +public class CustomPage { + private Integer id; + private String uuid; + private String pageName; + private String pageCode; + private String alias; + private LocalDateTime createTime; + private Integer status; +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/DashboardMenu.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/DashboardMenu.java new file mode 100644 index 0000000..c9e92ec --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/DashboardMenu.java @@ -0,0 +1,18 @@ +package xyz.lingview.dimstack.domain; + +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class DashboardMenu { + private Integer id; + private String title; + private String icon; + private String link; + private Integer parent_id; + private String permission_code; + private Integer sort_order; + private LocalDateTime create_time; + private String type; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/FriendLinks.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/FriendLinks.java new file mode 100644 index 0000000..c6afcc7 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/FriendLinks.java @@ -0,0 +1,25 @@ +package xyz.lingview.dimstack.domain; + +import lombok.Data; + +import java.util.Date; + +/** + * @Author: lingview + * @Date: 2025/12/04 19:18:05 + * @Description: 友链表实体映射 + * @Version: 1.0 + */ +@Data +public class FriendLinks { + private Integer id; + private String uuid; + private String siteName; + private String siteUrl; + private String siteIcon; + private String siteDescription; + private String webmasterName; + private String contact; + private Date createTime; + private Integer status; +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/FriendLinksConfig.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/FriendLinksConfig.java new file mode 100644 index 0000000..d1f827a --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/FriendLinksConfig.java @@ -0,0 +1,17 @@ +package xyz.lingview.dimstack.domain; + +import lombok.Data; + +import java.util.Date; + +@Data +public class FriendLinksConfig { + private Integer id; + private String siteName; + private String siteUrl; + private String siteLogo; + private String description; + private String applyRules; + private Date createTime; + private Integer status; +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/HomeArticle.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/HomeArticle.java new file mode 100644 index 0000000..db01467 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/HomeArticle.java @@ -0,0 +1,24 @@ +package xyz.lingview.dimstack.domain; + +import lombok.Data; +import java.util.Date; + +@Data +public class HomeArticle { + private Integer id; + private String uuid; + private String article_id; + private String article_name; + private String article_cover; + private String excerpt; + private String article_content; + private Long page_views; + private Long like_count; + private Long favorite_count; + private String password; + private String tag; + private String category; + private String alias; + private Date create_time; + private Integer status; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/LlmConfig.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/LlmConfig.java new file mode 100644 index 0000000..e5d0d41 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/LlmConfig.java @@ -0,0 +1,10 @@ +package xyz.lingview.dimstack.domain; + +import lombok.Data; + +@Data +public class LlmConfig { + private String api_key; + private String api_url; + private String model; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/LlmPromptConfig.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/LlmPromptConfig.java new file mode 100644 index 0000000..0d42b1b --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/LlmPromptConfig.java @@ -0,0 +1,13 @@ +package xyz.lingview.dimstack.domain; + +import lombok.Data; +import java.time.LocalDateTime; + +@Data +public class LlmPromptConfig { + private Integer id; + private String prompt_name; + private String prompt_content; + private LocalDateTime create_time; + private Integer status; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/Login.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/Login.java new file mode 100644 index 0000000..0d8cbb8 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/Login.java @@ -0,0 +1,9 @@ +package xyz.lingview.dimstack.domain; + +import lombok.Data; + +@Data +public class Login { + private String username; + private String password; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/MailConfig.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/MailConfig.java new file mode 100644 index 0000000..636a130 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/MailConfig.java @@ -0,0 +1,17 @@ +package xyz.lingview.dimstack.domain; + +import lombok.Data; + +@Data +public class MailConfig { + private String smtp_host; + private Integer smtp_port; + private String mail_sender_email; + private String mail_sender_name; + private String mail_username; + private String mail_password; + private String mail_protocol; + private Boolean mail_enable_tls; + private Boolean mail_enable_ssl; + private String mail_default_encoding; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/Menus.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/Menus.java new file mode 100644 index 0000000..00360ae --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/Menus.java @@ -0,0 +1,19 @@ +package xyz.lingview.dimstack.domain; + +import lombok.Data; +import java.util.UUID; + +@Data +public class Menus { + private Integer id; + private String menus_id; + private String user_id; + private String menus_name; + private String menus_url; + private Integer sort_order; + private Integer status; + + public static String generateMenusId() { + return "menu_" + UUID.randomUUID().toString().replace("-", ""); + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/Music.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/Music.java new file mode 100644 index 0000000..d4637af --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/Music.java @@ -0,0 +1,20 @@ +package xyz.lingview.dimstack.domain; + +import lombok.Data; + +/** + * @Author: lingview + * @Date: 2026/01/24 00:39:25 + * @Description: 音乐播放器实体 + * @Version: 1.0 + */ +@Data +public class Music { + private Integer id; + private String uuid; + private String musicName; + private String musicAuthor; + private String musicUrl; + private String createTime; + private Integer status; +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/Permission.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/Permission.java new file mode 100644 index 0000000..6f77066 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/Permission.java @@ -0,0 +1,12 @@ +package xyz.lingview.dimstack.domain; + +import lombok.Data; + +@Data +public class Permission { + private Integer id; + private String code; + private String name; + private String module; + private java.sql.Timestamp create_time; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/PluginConfig.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/PluginConfig.java new file mode 100644 index 0000000..1a881a5 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/PluginConfig.java @@ -0,0 +1,20 @@ +package xyz.lingview.dimstack.domain; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * @Author: lingview + * @Date: 2026/08/09 22:11:27 + * @Description: 插件配置记录 + * @Version: 1.0 + */ +@Data +public class PluginConfig { + private Integer id; + private String plugin_name; + private String config_key; + private String config_value; + private LocalDateTime update_time; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/PluginInfo.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/PluginInfo.java new file mode 100644 index 0000000..86d9fd7 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/PluginInfo.java @@ -0,0 +1,40 @@ +package xyz.lingview.dimstack.domain; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * @Author: lingview + * @Date: 2026/08/03 20:23:41 + * @Description: 插件注册信息 + * @Version: 1.0 + */ +@Data +public class PluginInfo { + private Integer id; + + private String name; + + private String version; + + private String display_name; + private String description; + private String author; + + private String requires; + + private String jar_file; + + private String sha256; + + private Boolean enabled; + private String setting_name; + private String config_map_name; + private LocalDateTime create_time; + private LocalDateTime update_time; + + private String state; + + private String last_error; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/PluginSqlAudit.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/PluginSqlAudit.java new file mode 100644 index 0000000..4cf0ab4 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/PluginSqlAudit.java @@ -0,0 +1,21 @@ +package xyz.lingview.dimstack.domain; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * @Author: lingview + * @Date: 2026/10/01 10:26:35 + * @Description: 插件SQL审计记录 + * @Version: 1.0 + */ +@Data +public class PluginSqlAudit { + private Long id; + private String plugin_name; + private String category; + private String sql_text; + private Long cost_millis; + private LocalDateTime create_time; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/ReadArticle.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/ReadArticle.java new file mode 100644 index 0000000..0278daf --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/ReadArticle.java @@ -0,0 +1,27 @@ +package xyz.lingview.dimstack.domain; + +import lombok.Data; +import java.util.Date; + +@Data +public class ReadArticle { + private Integer id; + private String uuid; + private String article_id; + private String article_name; + private String article_cover; + private String excerpt; + private String article_content; + private Long page_views; + private Long like_count; + private Long favorite_count; + private String password; + private String tag; + private String category; + private String alias; + private String author; + private String avatar; + private Date create_time; + private Integer status; + private Integer enable_comment; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/Register.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/Register.java new file mode 100644 index 0000000..a411998 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/Register.java @@ -0,0 +1,15 @@ +package xyz.lingview.dimstack.domain; + +import lombok.Data; + +@Data +public class Register { + private String uuid; + private String username; + private String avatar; + private String phone; + private String email; + private String password; + private int role_id; + private String status; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/Role.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/Role.java new file mode 100644 index 0000000..e16bb46 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/Role.java @@ -0,0 +1,12 @@ +package xyz.lingview.dimstack.domain; + +import lombok.Data; + +@Data +public class Role { + private Integer id; + private String code; + private String name; + private String description; + private Byte status; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/SiteConfig.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/SiteConfig.java new file mode 100644 index 0000000..7b89364 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/SiteConfig.java @@ -0,0 +1,48 @@ +package xyz.lingview.dimstack.domain; + +import lombok.Data; + +@Data +public class SiteConfig { + private Integer id; + private String site_name; + private String register_user_permission; + private String copyright; + private int article_status; + private String hero_image; + private String hero_title; + private String hero_subtitle; + private String site_icon; + private String site_theme; + private String expansion_server; + private Boolean enable_notification; + private String smtp_host; + private Integer smtp_port; + private String mail_sender_email; + private String mail_sender_name; + private String mail_username; + private String mail_password; + private String mail_protocol; + private Boolean mail_enable_tls; + private Boolean mail_enable_ssl; + private String mail_default_encoding; + private String icp_record_number; + private String mps_record_number; + private Integer enable_register; + private Integer enable_music; + private Integer admin_post_no_review; + private Integer enable_llm; + private Integer enable_llm_article_review; + private Integer enable_llm_create_article; + private String global_head_code; + private String content_head_code; + private String footer_code; + private Integer enable_image_compression; + private Integer image_compression_threads; + private Integer proxy_resource_download; + private Integer enable_comment; + private Integer comment_status; + private Integer enable_llm_comment_review; + private Integer admin_comment_no_review; + private String default_storage; +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/StorageMethod.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/StorageMethod.java new file mode 100644 index 0000000..d22c411 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/StorageMethod.java @@ -0,0 +1,23 @@ +package xyz.lingview.dimstack.domain; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * @Author: lingview + * @Date: 2026/07/17 20:00:00 + * @Description: 存储方式实体 + * @Version: 1.0 + */ +@Data +public class StorageMethod { + private String uuid; + private String user_uuid; + private String name; + private String type; + private String config; + private int status; + private LocalDateTime created_at; + private LocalDateTime updated_at; +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/StorageMigrationFailedItem.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/StorageMigrationFailedItem.java new file mode 100644 index 0000000..92682da --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/StorageMigrationFailedItem.java @@ -0,0 +1,15 @@ +package xyz.lingview.dimstack.domain; + +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class StorageMigrationFailedItem { + private int id; + private int migration_id; + private String attachment_id; + private String file_path; + private String error_msg; + private LocalDateTime created_at; +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/StorageMigrationLog.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/StorageMigrationLog.java new file mode 100644 index 0000000..9dd3041 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/StorageMigrationLog.java @@ -0,0 +1,20 @@ +package xyz.lingview.dimstack.domain; + +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Data +public class StorageMigrationLog { + private int id; + private String source_storage_id; + private String target_storage_id; + private int total; + private int success; + private int failed; + private int status; + private LocalDateTime created_at; + private LocalDateTime updated_at; + private List failedItems; +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/SystematicNotification.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/SystematicNotification.java new file mode 100644 index 0000000..41a69fe --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/SystematicNotification.java @@ -0,0 +1,25 @@ +package xyz.lingview.dimstack.domain; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * @Author: lingview + * @Date: 2026/01/31 12:42:42 + * @Description: 系统通知实体 + * @Version: 1.0 + */ +@Data +public class SystematicNotification { + + private Integer id; + private String uuid; + private String content; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime create_time; + private String type; + private Integer status; + +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/UpdateInfo.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/UpdateInfo.java new file mode 100644 index 0000000..01ee81d --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/UpdateInfo.java @@ -0,0 +1,22 @@ +package xyz.lingview.dimstack.domain; + +import lombok.Data; + +/** + * @Author: lingview + * @Date: 2026/01/27 20:30:36 + * @Description: 系统更新实体 + * @Version: 1.0 + */ +@Data +public class UpdateInfo { + private String version; + private String releaseDate; + private String downloadUrl; + private String changelog; + private String minJavaVersion; + private boolean requiredDatabaseMigration; + private String minCompatibleVersion; + private String checksum; + private boolean backupRecommended; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/UploadArticle.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/UploadArticle.java new file mode 100644 index 0000000..a4e112c --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/UploadArticle.java @@ -0,0 +1,23 @@ +package xyz.lingview.dimstack.domain; + +import lombok.Data; +import java.time.LocalDateTime; + +@Data +public class UploadArticle { + private String uuid; + private String article_id; + private String article_name; + private String article_cover; + private String excerpt; + private String article_content; + private String password; + private String tag; + private String category; + private String parentCategory; + private String childCategory; + private String alias; + private int status; + private LocalDateTime create_time; + private Integer enable_comment; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/UploadAttachment.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/UploadAttachment.java new file mode 100644 index 0000000..c860ad8 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/UploadAttachment.java @@ -0,0 +1,15 @@ +package xyz.lingview.dimstack.domain; + +import lombok.Data; + +@Data +public class UploadAttachment { + private String uuid; + private String attachment_id; + private String original_filename; + private String attachment_path; + private String access_key; + private String storage_id; + private String content_type; + private int status; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/UserInformation.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/UserInformation.java new file mode 100644 index 0000000..a5101a8 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/domain/UserInformation.java @@ -0,0 +1,20 @@ +package xyz.lingview.dimstack.domain; + +import lombok.Data; +import java.time.LocalDateTime; +import java.util.Date; + +@Data +public class UserInformation { + private Integer id; + private String uuid; + private String username; + private String avatar; + private String phone; + private String email; + private String gender; + private String password; + private Date birthday; + private LocalDateTime create_time; + private Byte status; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/AddCommentRequestDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/AddCommentRequestDTO.java new file mode 100644 index 0000000..9035993 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/AddCommentRequestDTO.java @@ -0,0 +1,10 @@ +package xyz.lingview.dimstack.dto.request; + +import lombok.Data; + +@Data +public class AddCommentRequestDTO { + private String article_alias; + private String content; + private String to_comment_id; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/ApiKeyCreateRequestDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/ApiKeyCreateRequestDTO.java new file mode 100644 index 0000000..b93c9af --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/ApiKeyCreateRequestDTO.java @@ -0,0 +1,10 @@ +package xyz.lingview.dimstack.dto.request; + +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class ApiKeyCreateRequestDTO { + @Size(max = 255, message = "备注描述长度不能超过255个字符") + private String description; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/ArticleCategoryDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/ArticleCategoryDTO.java new file mode 100644 index 0000000..a675b68 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/ArticleCategoryDTO.java @@ -0,0 +1,23 @@ +package xyz.lingview.dimstack.dto.request; + +import lombok.Data; +import java.util.List; + +@Data +public class ArticleCategoryDTO { + private Integer id; + private Integer parent_id; + private String category_name; + private String category_explain; + private String founder; + private String create_time; + private Integer status; + private Integer article_count; + private String full_path; + private Integer level; + private Integer sort_order; + private List children; + private Boolean has_children; + private String parent_name; + private Integer child_count; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/ArticleDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/ArticleDTO.java new file mode 100644 index 0000000..f784b15 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/ArticleDTO.java @@ -0,0 +1,20 @@ +package xyz.lingview.dimstack.dto.request; + +import lombok.Data; +import java.util.Date; + +@Data +public class ArticleDTO { + private Integer id; + private String article_id; + private String title; + private String excerpt; + private String image; + private Date date; + private String author; + private String author_avatar; + private String category; + private String tag; + private String alias; + private Boolean password; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/ArticleDetailDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/ArticleDetailDTO.java new file mode 100644 index 0000000..230fe27 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/ArticleDetailDTO.java @@ -0,0 +1,20 @@ +package xyz.lingview.dimstack.dto.request; + +import lombok.Data; +import java.time.LocalDateTime; + +@Data +public class ArticleDetailDTO { + private String article_id; + private String article_name; + private String article_cover; + private String excerpt; + private String article_content; + private String password; + private String tag; + private String category; + private String alias; + private Integer status; + private LocalDateTime create_time; + private Integer enable_comment; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/ArticleReviewDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/ArticleReviewDTO.java new file mode 100644 index 0000000..8e2d848 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/ArticleReviewDTO.java @@ -0,0 +1,18 @@ +package xyz.lingview.dimstack.dto.request; + +import lombok.Data; +import java.time.LocalDateTime; + +@Data +public class ArticleReviewDTO { + private String article_id; + private String article_name; + private String article_cover; + private String uuid; + private String author_name; + private String excerpt; + private LocalDateTime create_time; + private String tag; + private String category; + private Byte status; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/ArticleStatusUpdateRequestDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/ArticleStatusUpdateRequestDTO.java new file mode 100644 index 0000000..ea3b749 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/ArticleStatusUpdateRequestDTO.java @@ -0,0 +1,9 @@ +package xyz.lingview.dimstack.dto.request; + +import lombok.Data; + +@Data +public class ArticleStatusUpdateRequestDTO { + private String articleId; + private Byte status; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/ArticleTagDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/ArticleTagDTO.java new file mode 100644 index 0000000..778e6e3 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/ArticleTagDTO.java @@ -0,0 +1,15 @@ +package xyz.lingview.dimstack.dto.request; + +import jakarta.validation.constraints.Pattern; +import lombok.Data; + +@Data +public class ArticleTagDTO { + private Integer id; + @Pattern(regexp = "^[\\u4e00-\\u9fa5a-zA-Z0-9]+$", message = "标签名称只能包含中文、英文和数字") + private String tag_name; + private String tag_explain; + private String founder; + private String create_time; + private Integer status; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/CommentDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/CommentDTO.java new file mode 100644 index 0000000..e326ca5 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/CommentDTO.java @@ -0,0 +1,26 @@ +package xyz.lingview.dimstack.dto.request; + +import lombok.Data; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Data +public class CommentDTO { + private String comment_id; + private String user_id; + private String username; + private String avatar; + private String content; + private LocalDateTime create_time; + private Long comment_like_count; + private String to_comment_id; + private String to_comment_user_id; + private String to_comment_username; + private String article_id; + private String article_title; + private Integer status; + private Boolean is_liked = false; + + private List children = new ArrayList<>(); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/CustomPageRequest.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/CustomPageRequest.java new file mode 100644 index 0000000..96c037f --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/CustomPageRequest.java @@ -0,0 +1,17 @@ +package xyz.lingview.dimstack.dto.request; + +import lombok.Data; + +/** + * @Author: lingview + * @Date: 2026/01/17 20:08:28 + * @Description: 自定义页面请求体 + * @Version: 1.0 + */ +@Data +public class CustomPageRequest { + private String pageName; + private String pageCode; + private String alias; + private Integer status; +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/DashboardMenuDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/DashboardMenuDTO.java new file mode 100644 index 0000000..db762e1 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/DashboardMenuDTO.java @@ -0,0 +1,18 @@ +package xyz.lingview.dimstack.dto.request; + +import lombok.Data; +import java.util.List; + +@Data +public class DashboardMenuDTO { + private Integer id; + private String title; + private String icon; + private String link; + private List children; + private String permission_code; + private Integer sort_order; + private String type; + private List sidebarMenu; + private List quickActions; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/EditArticleDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/EditArticleDTO.java new file mode 100644 index 0000000..dcfffbb --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/EditArticleDTO.java @@ -0,0 +1,24 @@ +package xyz.lingview.dimstack.dto.request; + +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.Date; + +@Data +public class EditArticleDTO { + private String article_id; + private String article_name; + private String alias; + private String author_name; + private LocalDateTime create_time; + private Integer status; + private String password; + private boolean hasPassword; + private String category; + private String tag; + + public boolean isHasPassword() { + return password != null && !password.isEmpty(); + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/ForgotPasswordDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/ForgotPasswordDTO.java new file mode 100644 index 0000000..e790708 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/ForgotPasswordDTO.java @@ -0,0 +1,19 @@ +package xyz.lingview.dimstack.dto.request; + +import lombok.Data; + +/** + * @Author: lingview + * @Date: 2026/01/13 19:08:42 + * @Description: 密码找回请求体 + * @Version: 1.0 + */ +@Data +public class ForgotPasswordDTO { + private String username; + private String email; + private String newPassword; + private String confirmNewPassword; + private String captcha; + private String captchaKey; +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/FriendLinksRequestDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/FriendLinksRequestDTO.java new file mode 100644 index 0000000..ca97191 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/FriendLinksRequestDTO.java @@ -0,0 +1,32 @@ +package xyz.lingview.dimstack.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Data; + +/** + * @Author: lingview + * @Date: 2025/12/04 19:49:24 + * @Description: 友链请求实体 + * @Version: 1.0 + */ +@Data +public class FriendLinksRequestDTO { + @NotBlank(message = "站点名称不能为空") + private String siteName; + + @NotBlank(message = "站点URL不能为空") + @Pattern(regexp = "^https?://.+", message = "站点URL格式不正确") + private String siteUrl; + + private String siteIcon; + + @NotBlank(message = "站点介绍不能为空") + private String siteDescription; + + @NotBlank(message = "站长名称不能为空") + private String webmasterName; + + @NotBlank(message = "联系方式不能为空") + private String contact; +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/HeroDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/HeroDTO.java new file mode 100644 index 0000000..95757ce --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/HeroDTO.java @@ -0,0 +1,11 @@ +package xyz.lingview.dimstack.dto.request; + +import lombok.Data; + +@Data +public class HeroDTO { + private String title; + private String subtitle; + private String image; + +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/HotArticleDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/HotArticleDTO.java new file mode 100644 index 0000000..326a4e6 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/HotArticleDTO.java @@ -0,0 +1,18 @@ +package xyz.lingview.dimstack.dto.request; + +import lombok.Data; + +import java.util.Date; + +@Data +public class HotArticleDTO { + private Integer id; + private String article_id; + private String title; + private String excerpt; + private Date date; + private String author; + private String category; + private int page_views ; + private String alias; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/LoginDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/LoginDTO.java new file mode 100644 index 0000000..e170f57 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/LoginDTO.java @@ -0,0 +1,11 @@ +package xyz.lingview.dimstack.dto.request; + +import lombok.Data; + +@Data +public class LoginDTO { + private String username; + private String password; + private String captcha; + private String captchaKey; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/MenusDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/MenusDTO.java new file mode 100644 index 0000000..3b8e691 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/MenusDTO.java @@ -0,0 +1,14 @@ +package xyz.lingview.dimstack.dto.request; + +import lombok.Data; + +@Data +public class MenusDTO { + private String menus_id; + private String user_id; + private String username; + private String menus_name; + private String menus_url; + private Integer sort_order; + private Integer status; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/PageRequest.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/PageRequest.java new file mode 100644 index 0000000..51bded0 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/PageRequest.java @@ -0,0 +1,10 @@ +package xyz.lingview.dimstack.dto.request; + +import lombok.Data; + +@Data +public class PageRequest { + private int page = 1; + private int size = 10; + private String category; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/PageResult.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/PageResult.java new file mode 100644 index 0000000..5becd3f --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/PageResult.java @@ -0,0 +1,13 @@ +package xyz.lingview.dimstack.dto.request; + +import lombok.Data; +import java.util.List; + +@Data +public class PageResult { + private List data; + private int total; + private int page; + private int size; + private int total_pages; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/PerformUpdateRequestDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/PerformUpdateRequestDTO.java new file mode 100644 index 0000000..4d22975 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/PerformUpdateRequestDTO.java @@ -0,0 +1,14 @@ +package xyz.lingview.dimstack.dto.request; + +import lombok.Data; + +/** + * @Author: lingview + * @Date: 2026/01/28 14:09:08 + * @Description: 是否更新重启请求体 + * @Version: 1.0 + */ +@Data +public class PerformUpdateRequestDTO { + private boolean restartAfterUpdate; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/RegisterDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/RegisterDTO.java new file mode 100644 index 0000000..fe3a27a --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/RegisterDTO.java @@ -0,0 +1,33 @@ +package xyz.lingview.dimstack.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Data; + +/** + * @Auther: lingview + * @Date: 2025/09/22 + * @Description: 用户注册请求 DTO + */ +@Data +public class RegisterDTO { + + @NotBlank(message = "用户名不能为空") + @Pattern(regexp = "^[\\u4e00-\\u9fa5a-zA-Z0-9_]+$", message = "用户名只能包含中文、英文、数字和下划线") + private String username; + + private String email; + + @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确") + private String phone; + + @NotBlank(message = "密码不能为空") + private String password; + + @NotBlank(message = "验证码不能为空") + private String captcha; + + @NotBlank(message = "验证码标识符不能为空") + private String captchaKey; + +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/SearchResultDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/SearchResultDTO.java new file mode 100644 index 0000000..c7b1994 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/SearchResultDTO.java @@ -0,0 +1,18 @@ +package xyz.lingview.dimstack.dto.request; + +import lombok.Data; + +@Data +public class SearchResultDTO { + private String title; + private String alias; + private Integer id; + private String excerpt; + + public SearchResultDTO(String title, String alias, Integer id, String excerpt) { + this.title = title; + this.alias = alias; + this.id = id; + this.excerpt = excerpt; + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/TestSmtpRequestDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/TestSmtpRequestDTO.java new file mode 100644 index 0000000..9ea12e1 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/TestSmtpRequestDTO.java @@ -0,0 +1,14 @@ +package xyz.lingview.dimstack.dto.request; + +import lombok.Data; + +/** + * @Author: lingview + * @Date: 2026/03/17 18:32:21 + * @Description: 邮件测试请求实体 + * @Version: 1.0 + */ +@Data +public class TestSmtpRequestDTO { + private String email; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/ThemeSlugRequestDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/ThemeSlugRequestDTO.java new file mode 100644 index 0000000..325063f --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/ThemeSlugRequestDTO.java @@ -0,0 +1,8 @@ +package xyz.lingview.dimstack.dto.request; + +import lombok.Data; + +@Data +public class ThemeSlugRequestDTO { + private String slug; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/UpdateArticleDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/UpdateArticleDTO.java new file mode 100644 index 0000000..9f8159e --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/UpdateArticleDTO.java @@ -0,0 +1,20 @@ +package xyz.lingview.dimstack.dto.request; + +import lombok.Data; +import java.time.LocalDateTime; + +@Data +public class UpdateArticleDTO { + private String article_id; + private String article_name; + private String article_cover; + private String excerpt; + private String article_content; + private String password; + private String tag; + private String category; + private String alias; + private int status; + private LocalDateTime create_time; + private Integer enable_comment; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/UserDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/UserDTO.java new file mode 100644 index 0000000..35e028f --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/UserDTO.java @@ -0,0 +1,22 @@ +package xyz.lingview.dimstack.dto.request; + +import lombok.Data; +import java.time.LocalDateTime; +import java.util.Date; + +@Data +public class UserDTO { + private Integer id; + private String uuid; + private String username; + private String avatar; + private String phone; + private String email; + private String gender; + private Date birthday; + private String role_id; + private String role_name; + private String role_code; + private LocalDateTime create_time; + private Byte status; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/UserUpdateDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/UserUpdateDTO.java new file mode 100644 index 0000000..b990175 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/request/UserUpdateDTO.java @@ -0,0 +1,18 @@ +package xyz.lingview.dimstack.dto.request; + +import jakarta.validation.constraints.Pattern; +import lombok.Data; + +@Data +public class UserUpdateDTO { + private String uuid; + + @Pattern(regexp = "^[\\u4e00-\\u9fa5a-zA-Z0-9_]+$", message = "用户名只能包含中文、英文、数字和下划线") + private String username; + private String avatar; + private String phone; + private String email; + private String gender; + private String password; + private String birthday; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ApiKeyCreatedResponseDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ApiKeyCreatedResponseDTO.java new file mode 100644 index 0000000..0e2f013 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ApiKeyCreatedResponseDTO.java @@ -0,0 +1,12 @@ +package xyz.lingview.dimstack.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class ApiKeyCreatedResponseDTO { + private Integer id; + private String description; + private String apiKey; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ApiKeyResponseDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ApiKeyResponseDTO.java new file mode 100644 index 0000000..de90135 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ApiKeyResponseDTO.java @@ -0,0 +1,14 @@ +package xyz.lingview.dimstack.dto.response; + +import lombok.Data; + +import java.util.Date; + +@Data +public class ApiKeyResponseDTO { + private Integer id; + private String description; + private Date createTime; + private Date updateTime; + private Integer status; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ArticleOperationResult.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ArticleOperationResult.java new file mode 100644 index 0000000..937eebd --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ArticleOperationResult.java @@ -0,0 +1,16 @@ +package xyz.lingview.dimstack.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ArticleOperationResult { + private boolean success; + private String message; + private Object data; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ArticleReviewDetailResponseDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ArticleReviewDetailResponseDTO.java new file mode 100644 index 0000000..b7379df --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ArticleReviewDetailResponseDTO.java @@ -0,0 +1,9 @@ +package xyz.lingview.dimstack.dto.response; + +import lombok.Data; +import xyz.lingview.dimstack.domain.Article; + +@Data +public class ArticleReviewDetailResponseDTO { + private Article article; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ArticleReviewListResponseDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ArticleReviewListResponseDTO.java new file mode 100644 index 0000000..37bd43b --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ArticleReviewListResponseDTO.java @@ -0,0 +1,15 @@ +package xyz.lingview.dimstack.dto.response; + +import lombok.Data; +import xyz.lingview.dimstack.dto.request.ArticleReviewDTO; + +import java.util.List; + +@Data +public class ArticleReviewListResponseDTO { + private List articles; + private int total; + private int page; + private int size; + private int totalPages; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ArticleReviewStatusResponseDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ArticleReviewStatusResponseDTO.java new file mode 100644 index 0000000..e283226 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ArticleReviewStatusResponseDTO.java @@ -0,0 +1,9 @@ +package xyz.lingview.dimstack.dto.response; + +import lombok.Data; + +@Data +public class ArticleReviewStatusResponseDTO { + private boolean success; + private String message; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ArticleSearchResponseDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ArticleSearchResponseDTO.java new file mode 100644 index 0000000..ba9c9a1 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ArticleSearchResponseDTO.java @@ -0,0 +1,10 @@ +package xyz.lingview.dimstack.dto.response; + +import lombok.Data; +import java.util.List; + +@Data +public class ArticleSearchResponseDTO { + private List data; + private int count; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ArticleSearchResultDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ArticleSearchResultDTO.java new file mode 100644 index 0000000..874bd10 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ArticleSearchResultDTO.java @@ -0,0 +1,11 @@ +package xyz.lingview.dimstack.dto.response; + +import lombok.Data; + +@Data +public class ArticleSearchResultDTO { + private String title; + private String alias; + private Integer id; + private String excerpt; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/CustomPageResponse.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/CustomPageResponse.java new file mode 100644 index 0000000..85cb41f --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/CustomPageResponse.java @@ -0,0 +1,22 @@ +package xyz.lingview.dimstack.dto.response; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * @Author: lingview + * @Date: 2026/01/17 20:08:58 + * @Description: 自定义页面响应体 + * @Version: 1.0 + */ +@Data +public class CustomPageResponse { + private Integer id; + private String pageName; + private String pageCode; + private String alias; + private String creatorUsername; + private LocalDateTime createTime; + private Integer status; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ForgotPasswordResponseDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ForgotPasswordResponseDTO.java new file mode 100644 index 0000000..097fed6 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ForgotPasswordResponseDTO.java @@ -0,0 +1,15 @@ +package xyz.lingview.dimstack.dto.response; + +import lombok.Data; + +/** + * @Author: lingview + * @Date: 2026/01/13 19:09:21 + * @Description: 密码找回响应体 + * @Version: 1.0 + */ +@Data +public class ForgotPasswordResponseDTO { + private Boolean success; + private String message; +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/LoginResponseDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/LoginResponseDTO.java new file mode 100644 index 0000000..523b276 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/LoginResponseDTO.java @@ -0,0 +1,10 @@ +package xyz.lingview.dimstack.dto.response; + +import lombok.Data; + +@Data +public class LoginResponseDTO { + private boolean success; + private String message; + private int code; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/LogoutResponseDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/LogoutResponseDTO.java new file mode 100644 index 0000000..3a5137c --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/LogoutResponseDTO.java @@ -0,0 +1,9 @@ +package xyz.lingview.dimstack.dto.response; + +import lombok.Data; + +@Data +public class LogoutResponseDTO { + private boolean success; + private String message; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/RegisterResponseDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/RegisterResponseDTO.java new file mode 100644 index 0000000..5d36748 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/RegisterResponseDTO.java @@ -0,0 +1,10 @@ +package xyz.lingview.dimstack.dto.response; + +import lombok.Data; + +@Data +public class RegisterResponseDTO { + private boolean success; + private String message; + private int code; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ThemeDetailResponseDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ThemeDetailResponseDTO.java new file mode 100644 index 0000000..47605ac --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ThemeDetailResponseDTO.java @@ -0,0 +1,10 @@ +package xyz.lingview.dimstack.dto.response; + +import tools.jackson.databind.JsonNode; +import lombok.Data; + +@Data +public class ThemeDetailResponseDTO { + private JsonNode data; + private String message; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ThemeListResponseDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ThemeListResponseDTO.java new file mode 100644 index 0000000..0f670bb --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/ThemeListResponseDTO.java @@ -0,0 +1,9 @@ +package xyz.lingview.dimstack.dto.response; + +import tools.jackson.databind.JsonNode; +import lombok.Data; + +@Data +public class ThemeListResponseDTO { + private JsonNode data; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/UserStatusResponseDTO.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/UserStatusResponseDTO.java new file mode 100644 index 0000000..ed40148 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/dto/response/UserStatusResponseDTO.java @@ -0,0 +1,11 @@ +package xyz.lingview.dimstack.dto.response; + +import lombok.Data; + +@Data +public class UserStatusResponseDTO { + private boolean loggedIn; + private String username; + private String avatar; + private String message; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/enums/AiReviewResult.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/enums/AiReviewResult.java new file mode 100644 index 0000000..ae55ee0 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/enums/AiReviewResult.java @@ -0,0 +1,12 @@ +package xyz.lingview.dimstack.enums; + +public enum AiReviewResult { + // 审核通过 + PASS, + + // 审核不通过(明确违规) + REJECT, + + // 审核异常(需要人工审核) + ERROR +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/init/InitApplication.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/init/InitApplication.java new file mode 100644 index 0000000..277ad1e --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/init/InitApplication.java @@ -0,0 +1,63 @@ +package xyz.lingview.dimstack.init; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.ApplicationListener; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.core.env.Environment; + +import java.net.Inet4Address; +import java.net.UnknownHostException; + +@Configuration +@EnableAutoConfiguration +@Slf4j +@ComponentScan(basePackages = "xyz.lingview.dimstack.init") +public class InitApplication { + + @Bean + public ServerInfoPrinter serverInfoPrinter(Environment environment) { + return new ServerInfoPrinter(environment); + } + + public static class ServerInfoPrinter implements ApplicationListener { + + private final Environment environment; + + public ServerInfoPrinter(Environment environment) { + this.environment = environment; + } + + public String getPort() { + return environment.getProperty("local.server.port"); + } + + private boolean printed = false; + + @Override + public void onApplicationEvent(ContextRefreshedEvent event) { + if (!printed && event.getApplicationContext().getParent() == null) { + printed = true; + + String appMode = event.getApplicationContext().getEnvironment().getProperty("app.mode"); + boolean isInitMode = "init".equals(appMode); + + if (isInitMode) { + String hostAddress = null; + try { + hostAddress = Inet4Address.getLocalHost().getHostAddress(); + } catch (UnknownHostException e) { + log.error("DNS 解析失败,主机未知: {}", e.getMessage(), e); + } + + String localHost = hostAddress + ":" + getPort(); + String url = "http://" + localHost + "/init/setup"; + log.info("初始化地址为:{}", url); + } + } + } + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/init/InitController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/init/InitController.java new file mode 100644 index 0000000..f9d061b --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/init/InitController.java @@ -0,0 +1,414 @@ +package xyz.lingview.dimstack.init; + +import lombok.extern.slf4j.Slf4j; +import org.flywaydb.core.Flyway; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestMapping; +import xyz.lingview.dimstack.config.ConfigInfo; +import xyz.lingview.dimstack.util.PasswordUtil; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.sql.*; + +/** + * @Author: lingview + * @Date: 2026/01/10 19:15:59 + * @Description: 初始化控制器 + * @Version: 1.0 + */ +@Controller +@RequestMapping("/init") +@ConditionalOnProperty(name = "app.mode", havingValue = "init") +@Slf4j +public class InitController { + + @GetMapping("/setup") + public String initSetup(Model model) { + model.addAttribute("hasAdvanced", false); + return "init/setup"; + } + + @PostMapping("/generate-config") + public String generateConfig( + @RequestParam String adminUsername, + @RequestParam String adminPassword, + @RequestParam Integer serverPort, + @RequestParam String mysqlHost, + @RequestParam Integer mysqlPort, + @RequestParam String mysqlDatabase, + @RequestParam String mysqlUser, + @RequestParam String mysqlPassword, + @RequestParam(defaultValue = "false") boolean enableRedis, + @RequestParam(required = false) String redisHost, + @RequestParam(required = false) Integer redisPort, + @RequestParam(required = false) String redisPassword, + @RequestParam(required = false) Integer redisDatabase, + @RequestParam(defaultValue = "false") boolean hasAdvanced, + @RequestParam(required = false) String logLevel, + @RequestParam(required = false) String dataRoot, + @RequestParam(required = false) String uploadDir, + @RequestParam(required = false) String logRoot, + @RequestParam(defaultValue = "false") boolean onlyGenerateConfig, + @RequestParam(defaultValue = "false") boolean enableVirtualThreads, + Model model) { + + try { + // 生成配置文件 + generateApplicationYml(adminUsername, adminPassword, serverPort, + mysqlHost, mysqlPort, mysqlDatabase, mysqlUser, mysqlPassword, + enableRedis, redisHost, redisPort, redisPassword, redisDatabase, + hasAdvanced, logLevel, dataRoot, uploadDir, logRoot, enableVirtualThreads); + + if (!onlyGenerateConfig) { + // 初始化数据库 + initializeDatabase(mysqlHost, mysqlPort, mysqlDatabase, mysqlUser, mysqlPassword, + adminUsername, adminPassword); + + String latestVersion = readLatestFlywayVersion(); + baselineFlyway(mysqlHost, mysqlPort, mysqlDatabase, mysqlUser, mysqlPassword, latestVersion); + model.addAttribute("success", true); + model.addAttribute("message", "配置文件生成成功!数据库初始化完成!请重启应用。"); + } else { + // 仅生成配置文件 + String latestVersion = readLatestFlywayVersion(); + + if (!checkFlywaySchemaHistoryExists(mysqlHost, mysqlPort, mysqlDatabase, mysqlUser, mysqlPassword)) { + baselineFlyway(mysqlHost, mysqlPort, mysqlDatabase, mysqlUser, mysqlPassword, latestVersion); + model.addAttribute("message", "配置文件已重新生成!由于数据库中没有flyway历史记录,已执行基线设置。请重启应用以使新配置生效。"); + } else { + model.addAttribute("message", "配置文件已重新生成!数据库中已有flyway历史记录,未执行基线设置。请重启应用以使新配置生效。"); + } + model.addAttribute("success", true); + } + } catch (Exception e) { + model.addAttribute("success", false); + model.addAttribute("message", "初始化失败: " + e.getMessage()); + } + + return "init/result"; + } + + private void validateDatabaseName(String dbName) { + if (dbName == null || dbName.isBlank()) { + throw new IllegalArgumentException("数据库名不能为空"); + } + if (dbName.length() > 64) { + throw new IllegalArgumentException("数据库名长度不能超过64个字符"); + } + if (!dbName.matches("^[a-zA-Z0-9_]+$")) { + throw new IllegalArgumentException("数据库名只能包含字母、数字和下划线,非法输入: " + dbName); + } + } + + private boolean checkFlywaySchemaHistoryExists(String mysqlHost, Integer mysqlPort, String mysqlDatabase, + String mysqlUser, String mysqlPassword) throws SQLException { + String jdbcUrl = "jdbc:mysql://%s:%d/%s?characterEncoding=utf-8&nullCatalogMeansCurrent=true&serverTimezone=GMT%%2B8&useSSL=false&allowPublicKeyRetrieval=true".formatted( + mysqlHost, mysqlPort, mysqlDatabase); + + try (Connection connection = DriverManager.getConnection(jdbcUrl, mysqlUser, mysqlPassword)) { + String sql = "SELECT COUNT(*) FROM information_schema.tables " + + "WHERE table_schema = ? AND table_name = 'flyway_schema_history'"; + try (PreparedStatement pstmt = connection.prepareStatement(sql)) { + pstmt.setString(1, mysqlDatabase); + try (ResultSet rs = pstmt.executeQuery()) { + if (rs.next()) { + return rs.getInt(1) > 0; + } + } + } + } + return false; + } + + // 初始化数据库 + private void initializeDatabase(String mysqlHost, Integer mysqlPort, String mysqlDatabase, + String mysqlUser, String mysqlPassword, + String adminUsername, String adminPassword) throws SQLException, IOException { + + String jdbcUrl = "jdbc:mysql://%s:%d/?characterEncoding=utf-8&nullCatalogMeansCurrent=true&serverTimezone=GMT%%2B8&useSSL=false&allowPublicKeyRetrieval=true".formatted( + mysqlHost, mysqlPort); + + try (Connection connection = DriverManager.getConnection(jdbcUrl, mysqlUser, mysqlPassword)) { + + dropDatabaseIfExists(connection, mysqlDatabase); + + createDatabase(connection, mysqlDatabase); + + String targetDbUrl = "jdbc:mysql://%s:%d/%s?characterEncoding=utf-8&nullCatalogMeansCurrent=true&serverTimezone=GMT%%2B8&useSSL=false&allowPublicKeyRetrieval=true".formatted( + mysqlHost, mysqlPort, mysqlDatabase); + + try (Connection dbConnection = DriverManager.getConnection(targetDbUrl, mysqlUser, mysqlPassword)) { + + importSqlFile(dbConnection, adminUsername, adminPassword); + } + } + } + + private String readLatestFlywayVersion() throws IOException { + org.springframework.core.io.Resource resource = + new org.springframework.core.io.ClassPathResource("db/migration/database_version.txt"); + + if (!resource.exists()) { + throw new IllegalStateException("未找到 db/migration/database_version.txt,请确保该文件存在!"); + } + + try (InputStream is = resource.getInputStream(); + BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { + String version = reader.readLine(); + if (version == null || version.trim().isEmpty()) { + throw new IllegalStateException("version.txt 为空!"); + } + return version.trim(); + } + } + + private void baselineFlyway(String mysqlHost, Integer mysqlPort, String mysqlDatabase, + String mysqlUser, String mysqlPassword, String latestVersion) throws Exception { + + String jdbcUrl = "jdbc:mysql://%s:%d/%s?characterEncoding=utf-8&nullCatalogMeansCurrent=true&serverTimezone=GMT%%2B8&useSSL=false&allowPublicKeyRetrieval=true".formatted( + mysqlHost, mysqlPort, mysqlDatabase + ); + + Flyway flyway = Flyway.configure() + .dataSource(jdbcUrl, mysqlUser, mysqlPassword) + .locations("classpath:db/migration") + .baselineOnMigrate(true) + .baselineVersion(latestVersion) + .load(); + + flyway.baseline(); + + log.info("Flyway baseline 成功,版本: {}", latestVersion); + } + + /** + * 删除数据库 + */ + private void dropDatabaseIfExists(Connection connection, String dbName) throws SQLException { + validateDatabaseName(dbName); + try (Statement stmt = connection.createStatement()) { + stmt.executeUpdate("DROP DATABASE IF EXISTS `" + dbName + "`"); + } + } + + /** + * 创建数据库 + */ + private void createDatabase(Connection connection, String dbName) throws SQLException { + validateDatabaseName(dbName); + try (Statement stmt = connection.createStatement()) { + stmt.executeUpdate("CREATE DATABASE `" + dbName + "` CHARACTER SET utf8mb4"); + } + } + + private void importSqlFile(Connection connection, String newAdminUsername, String newAdminPassword) throws SQLException, IOException { + try { + org.springframework.core.io.ClassPathResource resource = + new org.springframework.core.io.ClassPathResource("init.sql"); + // init.sql 是 UTF-8 编码,必须显式指定,否则 Windows(JDK 17 默认字符集为 GBK)会按平台默认编码误读导致中文乱码入库 + org.springframework.jdbc.datasource.init.ScriptUtils.executeSqlScript(connection, + new org.springframework.core.io.support.EncodedResource(resource, StandardCharsets.UTF_8)); + + updateAdminUser(connection, newAdminUsername, newAdminPassword); + } catch (Exception e) { + throw new IOException("执行SQL脚本失败: " + e.getMessage(), e); + } + } + + + private void updateAdminUser(Connection connection, String newAdminUsername, String newAdminPassword) throws SQLException { + String hashedPassword = PasswordUtil.hashPassword(newAdminPassword); + + String selectSql = "SELECT ui.id FROM user_information ui " + + "INNER JOIN user_role ur ON ui.id = ur.user_id " + + "INNER JOIN role r ON ur.role_id = r.id " + + "WHERE r.code = 'ADMIN' LIMIT 1"; + + Integer adminUserId = null; + try (PreparedStatement pstmt = connection.prepareStatement(selectSql)) { + ResultSet rs = pstmt.executeQuery(); + if (rs.next()) { + adminUserId = rs.getInt("id"); + } + } + + if (adminUserId != null) { + String updateSql = "UPDATE user_information SET username = ?, password = ? WHERE id = ?"; + try (PreparedStatement pstmt = connection.prepareStatement(updateSql)) { + pstmt.setString(1, newAdminUsername); + pstmt.setString(2, hashedPassword); + pstmt.setInt(3, adminUserId); + + int affectedRows = pstmt.executeUpdate(); + log.info("更新了 {} 条管理员用户记录", affectedRows); + } + } else { + log.warn("未找到管理员用户,跳过更新"); + } + } + + + private void generateApplicationYml(String adminUsername, String adminPassword, Integer serverPort, + String mysqlHost, Integer mysqlPort, String mysqlDatabase, + String mysqlUser, String mysqlPassword, + boolean enableRedis, String redisHost, Integer redisPort, String redisPassword, + Integer redisDatabase, boolean hasAdvanced, + String logLevel, String dataRoot, String uploadDir, String logRoot, + boolean enableVirtualThreads) throws IOException { + + StringBuilder ymlContent = new StringBuilder(); + + // 基础配置 + ymlContent.append("# Generated by DimStack Initialization Wizard\n"); + ymlContent.append("\n"); + + // Spring配置 + ymlContent.append("spring:\n"); + if (enableVirtualThreads) { + ymlContent.append(" threads:\n"); + ymlContent.append(" virtual:\n"); + ymlContent.append(" enabled: true\n"); + } + ymlContent.append(" jackson:\n"); + ymlContent.append(" time-zone: GMT+8\n"); + ymlContent.append(" date-format: yyyy-MM-dd HH:mm:ss\n"); + ymlContent.append("\n"); + + // MySQL配置 + ymlContent.append(" datasource:\n"); + ymlContent.append(" driver-class-name: com.mysql.cj.jdbc.Driver\n"); + ymlContent.append(" url: jdbc:mysql://").append(mysqlHost).append(":").append(mysqlPort) + .append("/").append(mysqlDatabase).append("?characterEncoding=utf-8&nullCatalogMeansCurrent=true&serverTimezone=GMT%2B8&useSSL=false&allowPublicKeyRetrieval=true&useAffectedRows=true\n"); + ymlContent.append(" username: ").append(mysqlUser).append("\n"); + ymlContent.append(" password: \"").append(mysqlPassword).append("\"\n"); + ymlContent.append(" hikari:\n"); + ymlContent.append(" maximum-pool-size: 20\n"); + ymlContent.append(" minimum-idle: 5\n"); + ymlContent.append(" connection-timeout: 30000\n"); + ymlContent.append(" idle-timeout: 600000\n"); + ymlContent.append(" max-lifetime: 1800000\n"); + ymlContent.append("\n"); + + // Servlet配置 + ymlContent.append(" servlet:\n"); + ymlContent.append(" multipart:\n"); + ymlContent.append(" enabled: true\n"); + ymlContent.append(" max-file-size: 100MB\n"); + ymlContent.append(" max-request-size: 100MB\n"); + ymlContent.append("\n"); + + // Redis配置 + if (enableRedis && redisHost != null && redisPort != null) { + ymlContent.append(" data:\n"); + ymlContent.append(" redis:\n"); + ymlContent.append(" host: ").append(redisHost).append("\n"); + ymlContent.append(" port: ").append(redisPort).append("\n"); + if (redisPassword != null && !redisPassword.isEmpty()) { + ymlContent.append(" password: \"").append(redisPassword).append("\"\n"); + } + ymlContent.append(" database: ").append(redisDatabase != null ? redisDatabase : 0).append("\n"); + ymlContent.append(" timeout: 5s\n"); + ymlContent.append(" lettuce:\n"); + ymlContent.append(" pool:\n"); + ymlContent.append(" max-active: 8\n"); + ymlContent.append(" max-idle: 8\n"); + ymlContent.append(" min-idle: 0\n"); + ymlContent.append(" max-wait: -1ms\n"); + ymlContent.append("\n"); + } + + // Thymeleaf配置 + ymlContent.append(" thymeleaf:\n"); + ymlContent.append(" cache: true\n"); + ymlContent.append(" prefix: classpath:/templates/\n"); + ymlContent.append(" suffix: .html\n"); + ymlContent.append(" encoding: UTF-8\n"); + ymlContent.append(" servlet:\n"); + ymlContent.append(" content-type: text/html\n"); + ymlContent.append("\n"); + + // MyBatis配置 + ymlContent.append("mybatis:\n"); + ymlContent.append(" type-aliases-package: xyz.lingview.dimstack.**.domain\n"); + ymlContent.append(" mapper-locations: classpath*:mapper/*Mapper.xml\n"); + ymlContent.append(" config-location: classpath:mybatis-config.xml\n"); + ymlContent.append("\n"); + + // Server配置 + ymlContent.append("server:\n"); + ymlContent.append(" port: ").append(serverPort).append("\n"); + ymlContent.append(" servlet:\n"); + ymlContent.append(" context-path: /\n"); + ymlContent.append(" tomcat:\n"); + ymlContent.append(" uri-encoding: UTF-8\n"); + ymlContent.append(" threads.max: 200\n"); + ymlContent.append(" threads.min-spare: 10\n"); + ymlContent.append(" remoteip.protocol-header: X-Forwarded-Proto\n"); + ymlContent.append(" remoteip.remote-ip-header: X-Forwarded-For\n"); + ymlContent.append("\n"); + ymlContent.append(" forward-headers-strategy: native\n"); + ymlContent.append("\n"); + + // Logging配置 + if (hasAdvanced && logLevel != null && !logLevel.trim().isEmpty()) { + ymlContent.append("logging:\n"); + ymlContent.append(" level:\n"); + ymlContent.append(" xyz.lingview.dimstack: ").append(logLevel).append("\n"); + ymlContent.append(" org.springframework: warn\n"); + ymlContent.append(" org.springframework.security: info\n"); + ymlContent.append(" org.springframework.session: info\n"); + ymlContent.append(" org.springframework.web: info\n"); + ymlContent.append("\n"); + } else { + // 默认日志级别 + ymlContent.append("logging:\n"); + ymlContent.append(" level:\n"); + ymlContent.append(" xyz.lingview.dimstack: info\n"); + ymlContent.append(" org.springframework: warn\n"); + ymlContent.append(" org.springframework.security: info\n"); + ymlContent.append(" org.springframework.session: info\n"); + ymlContent.append(" org.springframework.web: info\n"); + ymlContent.append("\n"); + } + + // File配置 + if (hasAdvanced) { + ymlContent.append("file:\n"); + ymlContent.append(" data-root: ").append(dataRoot != null ? dataRoot : ".").append("\n"); + ymlContent.append(" upload-dir: ").append(uploadDir != null ? uploadDir : "upload").append("\n"); + ymlContent.append(" log-root: ").append(logRoot != null ? logRoot : ".").append("\n"); + ymlContent.append("\n"); + } else { + // 默认文件配置 + ymlContent.append("file:\n"); + ymlContent.append(" data-root: .\n"); + ymlContent.append(" upload-dir: upload\n"); + ymlContent.append(" log-root: .\n"); + ymlContent.append("\n"); + } + + // App配置 + ymlContent.append("app:\n"); + ymlContent.append(" redis:\n"); + ymlContent.append(" enabled: ").append(enableRedis).append("\n"); + ymlContent.append(" theme:\n"); + ymlContent.append(" active-theme: default\n"); + ymlContent.append(" themes-path: themes\n"); + + // 写入文件 + if (!Files.exists(ConfigInfo.CONFIG_DIR)) { + Files.createDirectories(ConfigInfo.CONFIG_DIR); + } + + try (BufferedWriter writer = Files.newBufferedWriter(ConfigInfo.MAIN_CONFIG_FILE, StandardCharsets.UTF_8)) { + writer.write(ymlContent.toString()); + } + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/interceptor/GlobalExceptionHandler.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/interceptor/GlobalExceptionHandler.java new file mode 100644 index 0000000..7f37e83 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/interceptor/GlobalExceptionHandler.java @@ -0,0 +1,44 @@ +package xyz.lingview.dimstack.interceptor; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import xyz.lingview.dimstack.common.RateLimitException; + +import java.util.HashMap; +import java.util.Map; + +/** + * @Author: lingview + * @Date: 2026/03/14 22:41:05 + * @Description: 全局异常处理器 + * @Version: 1.0 + */ +@ControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + /** + * + * @param e + * @return + */ + @ExceptionHandler(RateLimitException.class) + public ResponseEntity> handleRateLimitException(RateLimitException e) { + logger.warn("请求被限流:{}", e.getMessage()); + + Map response = new HashMap<>(); + response.put("success", false); + response.put("code", 429); + response.put("message", e.getMessage()); + response.put("timestamp", System.currentTimeMillis()); + + return ResponseEntity + .status(HttpStatus.TOO_MANY_REQUESTS) + .body(response); + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/interceptor/RateLimitAspect.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/interceptor/RateLimitAspect.java new file mode 100644 index 0000000..127a5ed --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/interceptor/RateLimitAspect.java @@ -0,0 +1,146 @@ +package xyz.lingview.dimstack.interceptor; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import xyz.lingview.dimstack.annotation.RateLimit; +import xyz.lingview.dimstack.common.RateLimitException; +import xyz.lingview.dimstack.service.CacheService; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import java.lang.reflect.Method; +import java.util.concurrent.TimeUnit; + +/** + * @Author: lingview + * @Date: 2026/03/14 22:43:07 + * @Description: 请求限流切面 + * @Version: 1.0 + */ +@Aspect +@Component +public class RateLimitAspect { + + private static final Logger logger = LoggerFactory.getLogger(RateLimitAspect.class); + + private static final String RATE_LIMIT_PREFIX = "rate_limit:"; + + @Autowired + private CacheService cacheService; + + @Around("@annotation(xyz.lingview.dimstack.annotation.RateLimit)") + public Object around(ProceedingJoinPoint joinPoint) throws Throwable { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + + RateLimit rateLimit = method.getAnnotation(RateLimit.class); + if (rateLimit == null) { + return joinPoint.proceed(); + } + + ServletRequestAttributes attributes = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes == null) { + throw new RateLimitException("无法获取请求上下文"); + } + + HttpServletRequest request = attributes.getRequest(); + HttpSession session = request.getSession(false); + + String sessionId = session != null ? session.getId() : getClientIp(request); + String rateLimitKey = buildRateLimitKey(method, sessionId); + + long windowInSeconds = convertToSeconds(rateLimit.window(), rateLimit.timeUnit()); + int maxRequests = rateLimit.maxRequests(); + + if (isRateLimited(rateLimitKey, windowInSeconds, maxRequests)) { + logger.warn("请求被限流:key={}, window={}s, maxRequests={}", + rateLimitKey, windowInSeconds, maxRequests); + throw new RateLimitException(rateLimit.message()); + } + + return joinPoint.proceed(); + } + + /** + * + * @param key + * @param windowInSeconds + * @param maxRequests + * @return + */ + private boolean isRateLimited(String key, long windowInSeconds, int maxRequests) { + synchronized (key.intern()) { + RateLimitInfo info = cacheService.get(key + "_info", RateLimitInfo.class); + long currentTime = System.currentTimeMillis(); + + if (info == null || currentTime > info.expireTime) { + info = new RateLimitInfo(1, currentTime + windowInSeconds * 1000); + cacheService.set(key + "_info", info, windowInSeconds, TimeUnit.SECONDS); + return false; + } + + if (info.count >= maxRequests) { + return true; + } + + info.count++; + cacheService.set(key + "_info", info, windowInSeconds, TimeUnit.SECONDS); + return false; + } + } + + + private static class RateLimitInfo implements java.io.Serializable { + private static final long serialVersionUID = 1L; + public long count; + public long expireTime; + + public RateLimitInfo() {} + + public RateLimitInfo(long count, long expireTime) { + this.count = count; + this.expireTime = expireTime; + } + } + + + private String buildRateLimitKey(Method method, String sessionId) { + String className = method.getDeclaringClass().getName(); + String methodName = method.getName(); + return RATE_LIMIT_PREFIX + className + ":" + methodName + ":" + sessionId; + } + + + private long convertToSeconds(long time, RateLimit.TimeUnit timeUnit) { + return switch (timeUnit) { + case MILLISECONDS -> Math.max(1, time / 1000); + case SECONDS -> time; + case MINUTES -> time * 60; + case HOURS -> time * 3600; + }; + } + + + private String getClientIp(HttpServletRequest request) { + String ip = request.getHeader("X-Forwarded-For"); + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("X-Real-IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getRemoteAddr(); + } + if (ip != null && ip.contains(",")) { + ip = ip.split(",")[0].trim(); + } + return ip; + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/interceptor/ThemeResourceFilter.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/interceptor/ThemeResourceFilter.java new file mode 100644 index 0000000..325e91b --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/interceptor/ThemeResourceFilter.java @@ -0,0 +1,171 @@ +package xyz.lingview.dimstack.interceptor; + +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import xyz.lingview.dimstack.config.ThemeProperties; +import xyz.lingview.dimstack.util.BotUtil; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.stereotype.Component; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +@Component +public class ThemeResourceFilter implements Filter { + + @Autowired + private ThemeProperties themeProperties; + + private static final Map CONTENT_TYPES = new HashMap<>(); + + static { + CONTENT_TYPES.put(".html", "text/html"); + CONTENT_TYPES.put(".htm", "text/html"); + CONTENT_TYPES.put(".css", "text/css"); + CONTENT_TYPES.put(".js", "application/javascript"); + CONTENT_TYPES.put(".mjs", "text/javascript"); + CONTENT_TYPES.put(".wasm", "application/wasm"); + CONTENT_TYPES.put(".json", "application/json"); + CONTENT_TYPES.put(".xml", "application/xml"); + CONTENT_TYPES.put(".png", "image/png"); + CONTENT_TYPES.put(".jpg", "image/jpeg"); + CONTENT_TYPES.put(".jpeg", "image/jpeg"); + CONTENT_TYPES.put(".gif", "image/gif"); + CONTENT_TYPES.put(".bmp", "image/bmp"); + CONTENT_TYPES.put(".ico", "image/x-icon"); + CONTENT_TYPES.put(".svg", "image/svg+xml"); + CONTENT_TYPES.put(".woff", "font/woff"); + CONTENT_TYPES.put(".woff2", "font/woff2"); + CONTENT_TYPES.put(".ttf", "font/ttf"); + CONTENT_TYPES.put(".eot", "application/vnd.ms-fontobject"); + CONTENT_TYPES.put(".txt", "text/plain"); + CONTENT_TYPES.put(".pdf", "application/pdf"); + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + + HttpServletRequest httpRequest = (HttpServletRequest) request; + HttpServletResponse httpResponse = (HttpServletResponse) response; + + String uri = httpRequest.getRequestURI(); + String contextPath = httpRequest.getContextPath(); + + String resourcePath = uri.substring(contextPath.length()); + + if (!resourcePath.startsWith("/")) { + resourcePath = "/" + resourcePath; + } + + if (resourcePath.startsWith("/api/") || + resourcePath.startsWith("/v3/api-docs") || + resourcePath.startsWith("/swagger-ui") || + resourcePath.startsWith("/upload/") || + resourcePath.startsWith("/file/") || + resourcePath.startsWith("/dashboard")) { + chain.doFilter(request, response); + return; + } + + // 搜索引擎爬虫访问页面路由时放行,交给SSR控制器渲染,避免直接返回主题SPA的根标签 + boolean bot = BotUtil.isBot(httpRequest.getHeader("User-Agent")); + + if (resourcePath.equals("/") || resourcePath.equals("")) { + if (bot) { + chain.doFilter(request, response); + return; + } + resourcePath = "/index.html"; + } + + Resource resource = resolveResource(resourcePath); + if (resource != null && resource.exists()) { + serveResource(resource, httpResponse); + return; + } + + String lastSegment = resourcePath.substring(resourcePath.lastIndexOf('/') + 1); + if (!lastSegment.contains(".")) { + if (bot) { + chain.doFilter(request, response); + return; + } + Resource indexResource = resolveResource("/index.html"); + if (indexResource != null && indexResource.exists()) { + serveResource(indexResource, httpResponse); + return; + } + } + + chain.doFilter(request, response); + } + + private boolean shouldHandleAsThemeResource(String resourcePath) { + return resourcePath.startsWith("/assets/") || + resourcePath.startsWith("/static/") || + resourcePath.contains(".") || + resourcePath.equals("/index.html"); + } + + private Resource resolveResource(String resourcePath) { + try { + String themesPath = themeProperties.getThemesPath(); + String activeTheme = themeProperties.getActiveTheme(); + + String cleanResourcePath = resourcePath.startsWith("/") ? resourcePath.substring(1) : resourcePath; + + Path themeResourcePath = Path.of(themesPath, activeTheme, cleanResourcePath); + File themeResourceFile = themeResourcePath.toFile(); + + if (themeResourceFile.exists() && themeResourceFile.isFile()) { + return new UrlResource(themeResourceFile.toURI()); + } + + return null; + } catch (Exception e) { + return null; + } + } + + private void serveResource(Resource resource, HttpServletResponse response) throws IOException { + try { + Path path = resource.getFile().toPath(); + String fileName = path.getFileName().toString(); + String contentType = getContentType(fileName); + + response.setContentType(contentType); + response.setContentLengthLong(Files.size(path)); + + if (fileName.endsWith(".html") || fileName.endsWith(".htm")) { + response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); + response.setHeader("Pragma", "no-cache"); + response.setDateHeader("Expires", 0); + } + try (OutputStream out = response.getOutputStream()) { + Files.copy(path, out); + out.flush(); + } + } catch (Exception e) { + response.sendError(HttpServletResponse.SC_NOT_FOUND); + } + } + + private String getContentType(String fileName) { + String extension = ""; + int lastDot = fileName.lastIndexOf('.'); + if (lastDot > 0) { + extension = fileName.substring(lastDot).toLowerCase(); + } + + return CONTENT_TYPES.getOrDefault(extension, "application/octet-stream"); + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/interceptor/UserAgentSessionFilter.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/interceptor/UserAgentSessionFilter.java new file mode 100644 index 0000000..fa35345 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/interceptor/UserAgentSessionFilter.java @@ -0,0 +1,49 @@ +package xyz.lingview.dimstack.interceptor; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +/** + * @Author: lingview + * @Date: 2026/02/02 13:30:17 + * @Description: 用户浏览器UA和Session绑定过滤 + * @Version: 1.0 + */ +@Slf4j +@Component +public class UserAgentSessionFilter implements HandlerInterceptor { + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + HttpSession session = request.getSession(false); + + if (session != null && session.getAttribute("username") != null) { + String storedUA = (String) session.getAttribute("userAgent"); + String currentUA = request.getHeader("User-Agent"); + + if (storedUA == null) { + return true; + } + + if (!storedUA.equals(currentUA)) { + log.warn("User-Agent 不匹配!可能存在会话劫持。Stored: '{}', Current: '{}'", storedUA, currentUA); + session.invalidate(); + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + try { + response.getWriter().write(""" + {"code":401,"message":"会话异常,请重新登录"} + """); + } catch (Exception e) { + log.error("返回错误响应失败", e); + } + return false; + } + } + return true; + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/interceptor/UserPermissionInterceptor.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/interceptor/UserPermissionInterceptor.java new file mode 100644 index 0000000..aada8aa --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/interceptor/UserPermissionInterceptor.java @@ -0,0 +1,60 @@ +package xyz.lingview.dimstack.interceptor; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import xyz.lingview.dimstack.annotation.RequiresPermission; +import xyz.lingview.dimstack.service.CurrentUserService; +import xyz.lingview.dimstack.service.UserPermissionCheckService; + +@Component +public class UserPermissionInterceptor implements HandlerInterceptor { + + @Autowired + private UserPermissionCheckService permissionCheckService; + + @Autowired + private CurrentUserService currentUserService; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) + throws Exception { + + if (handler instanceof HandlerMethod handlerMethod) { + RequiresPermission requiresPermission = handlerMethod.getMethodAnnotation(RequiresPermission.class); + + if (requiresPermission == null) { + return true; + } + + String username = currentUserService.getCurrentUsername(); + + if (username == null) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return false; + } + + // 检查权限 + String[] permissions = requiresPermission.value(); + boolean allRequired = requiresPermission.all(); + + boolean hasPermission; + if (allRequired) { + hasPermission = permissionCheckService.hasAllPermissions(username, permissions); + } else { + hasPermission = permissionCheckService.hasAnyPermission(username, permissions); + } + + if (!hasPermission) { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + return false; + } + } + + return true; + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/AnnouncementMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/AnnouncementMapper.java new file mode 100644 index 0000000..7cfb8fc --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/AnnouncementMapper.java @@ -0,0 +1,15 @@ +package xyz.lingview.dimstack.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.springframework.stereotype.Repository; +import xyz.lingview.dimstack.domain.Announcement; + +@Mapper +@Repository +public interface AnnouncementMapper { + + Announcement selectLatest(); + + int insert(@Param("content") String content); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/ApiKeyMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/ApiKeyMapper.java new file mode 100644 index 0000000..4a1e8e0 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/ApiKeyMapper.java @@ -0,0 +1,29 @@ +package xyz.lingview.dimstack.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.springframework.stereotype.Repository; +import xyz.lingview.dimstack.domain.ApiKey; + +import java.util.List; + +@Mapper +@Repository +public interface ApiKeyMapper { + + int insert(ApiKey apiKey); + + List selectByUserId(@Param("userId") String userId); + + ApiKey selectByIdAndUserId(@Param("id") Integer id, @Param("userId") String userId); + + ApiKey selectByKeyHash(@Param("keyHash") String keyHash); + + int updateStatusByIdAndUserId(@Param("id") Integer id, + @Param("userId") String userId, + @Param("status") Integer status); + + int deleteByIdAndUserId(@Param("id") Integer id, @Param("userId") String userId); + + int countByKeyHash(@Param("keyHash") String keyHash); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/ArticleCategoryMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/ArticleCategoryMapper.java new file mode 100644 index 0000000..fdb7688 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/ArticleCategoryMapper.java @@ -0,0 +1,45 @@ +package xyz.lingview.dimstack.mapper; + +import xyz.lingview.dimstack.dto.request.ArticleDTO; +import xyz.lingview.dimstack.domain.ArticleCategory; +import xyz.lingview.dimstack.domain.ArticleCategoryAndCount; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import java.util.List; + +@Mapper +public interface ArticleCategoryMapper { + + List findAllEnabledCategories(); + + List findAllEnabledCategoriesAndCount(); + + List findArticlesByCategory(@Param("category") String category, + @Param("offset") int offset, + @Param("limit") int limit); + + int countArticlesByCategory(@Param("category") String category); + + List findAll(); + List findByStatus(@Param("status") Integer status); + ArticleCategory findById(@Param("id") Integer id); + ArticleCategory findByName(@Param("categoryName") String categoryName); + int insert(ArticleCategory category); + int update(ArticleCategory category); + int updateStatus(@Param("id") Integer id, @Param("status") Integer status); + + // 多级分类相关方法 + List findTopLevelCategories(); + List findChildrenByParentId(@Param("parentId") Integer parentId); + List findTreeStructure(); + ArticleCategory findByParentAndName(@Param("parentId") Integer parentId, @Param("categoryName") String categoryName); + int updateParentId(@Param("id") Integer id, @Param("parentId") Integer parentId); + int deleteCategoryAndChildren(@Param("id") Integer id); + + // 通过文章id查询文章所属分类 + String getCategoryByArticleId(@Param("article_id") String article_id); + // 增加分类文章数量 + void incrementCount(@Param("category") String category); + // 分类文章数量-1 + void decrementCount(@Param("category") String category); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/ArticleLikeMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/ArticleLikeMapper.java new file mode 100644 index 0000000..735b527 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/ArticleLikeMapper.java @@ -0,0 +1,13 @@ +package xyz.lingview.dimstack.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.springframework.stereotype.Repository; +import xyz.lingview.dimstack.domain.ArticleLike; + +@Mapper +@Repository +public interface ArticleLikeMapper { + void insertLike(ArticleLike like); + void deleteLike(String userId, String articleId); + boolean existsLike(String userId, String articleId); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/ArticleMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/ArticleMapper.java new file mode 100644 index 0000000..6667446 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/ArticleMapper.java @@ -0,0 +1,27 @@ +package xyz.lingview.dimstack.mapper; + +import org.springframework.stereotype.Repository; +import xyz.lingview.dimstack.domain.Article; +import xyz.lingview.dimstack.dto.request.ArticleDTO; +import org.apache.ibatis.annotations.Mapper; +import java.util.List; + +@Mapper +@Repository +public interface ArticleMapper { + + List selectArticlesForHomePage(int offset, int size, String category); + + int countArticles(String category); + + // 评论区 + Article selectArticleByAlias(String alias); + Article selectArticleByArticleId(String articleId); + + // 根据文章 uuid 查询文章所有者 uuid + String selectUserUuidByArticleId(String articleId); + + // 更新文章点赞数 + void updateArticleLikeCount(String articleId, long likeCount); + +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/ArticleReviewMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/ArticleReviewMapper.java new file mode 100644 index 0000000..a7e9955 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/ArticleReviewMapper.java @@ -0,0 +1,28 @@ +package xyz.lingview.dimstack.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import xyz.lingview.dimstack.domain.Article; +import xyz.lingview.dimstack.dto.request.ArticleReviewDTO; +import java.util.List; + +@Mapper +public interface ArticleReviewMapper { + + List selectUnreviewedArticles(@Param("offset") int offset, @Param("size") int size); + + int countUnreviewedArticles(); + + Article selectArticleById(@Param("article_id") String articleId); + + String getArticleNameByArticleId(@Param("article_id") String articleId); + + void updateArticleStatus(@Param("article_id") String articleId, @Param("status") Byte status); + + // 审核已发布的文章 + List selectAllArticles(@Param("offset") int offset, @Param("size") int size); + + int countAllArticles(); + + +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/ArticleSearchMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/ArticleSearchMapper.java new file mode 100644 index 0000000..11b5403 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/ArticleSearchMapper.java @@ -0,0 +1,17 @@ +package xyz.lingview.dimstack.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.springframework.stereotype.Repository; +import xyz.lingview.dimstack.domain.Article; + +import java.util.List; + +@Mapper +@Repository +public interface ArticleSearchMapper { + + List
searchArticlesCn(String keyword); + List
searchArticlesEn(String keyword); + List
findAllArticles(); + +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/ArticleTagMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/ArticleTagMapper.java new file mode 100644 index 0000000..12876b1 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/ArticleTagMapper.java @@ -0,0 +1,27 @@ +package xyz.lingview.dimstack.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import xyz.lingview.dimstack.domain.ArticleTag; +import xyz.lingview.dimstack.dto.request.ArticleDTO; + +import java.util.List; + +@Mapper +public interface ArticleTagMapper { + List findAllEnabledTags(); + + List findAll(); + List findByStatus(@Param("status") Integer status); + ArticleTag findById(@Param("id") Integer id); + ArticleTag findByName(@Param("tagName") String tagName); + int insert(ArticleTag tag); + int update(ArticleTag tag); + int updateStatus(@Param("id") Integer id, @Param("status") Integer status); + + // 根据标签名称查询文章数量 + int countArticlesByTag(@Param("tagName") String tagName); + List findArticlesByTag(@Param("tagName") String tagName, + @Param("offset") int offset, + @Param("size") int size); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/AttachmentManagementMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/AttachmentManagementMapper.java new file mode 100644 index 0000000..a7c2c9c --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/AttachmentManagementMapper.java @@ -0,0 +1,102 @@ +package xyz.lingview.dimstack.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import xyz.lingview.dimstack.domain.AttachmentManagement; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * @Author: lingview + * @Date: 2026/02/08 16:45:31 + * @Description: 附件管理 + * @Version: 1.0 + */ +@Mapper +public interface AttachmentManagementMapper { + + // 根据uuid查询全部附件 + AttachmentManagement selectByUuid(String uuid); + + // 分页查询附件(管理员) + List selectPage(@Param("offset") int offset, @Param("limit") int limit); + + // 查询附件总数 + int countAll(); + + // 删除附件 + int deleteByAttachmentId(@Param("attachmentId") String attachmentId, @Param("deletedTime") LocalDateTime deletedTime); + + // 基于UUID分页查询附件列表 + List selectPageByUuid(@Param("uuid") String uuid, @Param("offset") int offset, @Param("limit") int limit); + + // 基于UUID查询附件总数 + int countByUuid(@Param("uuid") String uuid); + + // 根据attachmentId查询附件 + AttachmentManagement selectByAttachmentId(String attachmentId); + + // 撤销删除附件 + int restoreAttachment(@Param("attachmentId") String attachmentId); + + // 分页查询所有附件(包含最近删除的,6小时内) + List selectAllPageWithRecentDeleted(@Param("offset") int offset, @Param("limit") int limit); + + // 查询所有附件总数(包含最近删除的,6小时内) + int countAllWithRecentDeleted(); + + // 分页查询所有附件(仅已删除的,6小时内) + List selectDeletedOnlyPage(@Param("offset") int offset, @Param("limit") int limit); + + // 查询所有已删除附件总数(6小时内) + int countDeletedOnly(); + + // 基于UUID分页查询附件(包含最近删除的,6小时内) + List selectPageByUuidWithRecentDeleted(@Param("uuid") String uuid, @Param("offset") int offset, @Param("limit") int limit); + + // 基于UUID查询附件总数(包含最近删除的,6小时内) + int countByUuidWithRecentDeleted(@Param("uuid") String uuid); + + // 基于UUID分页查询附件(仅已删除的,6小时内) + List selectDeletedOnlyPageByUuid(@Param("uuid") String uuid, @Param("offset") int offset, @Param("limit") int limit); + + // 基于UUID查询已删除附件总数(6小时内) + int countDeletedOnlyByUuid(@Param("uuid") String uuid); + + // 根据用户UUID分页查询附件(正常状态) + List selectPageByUserUuid(@Param("userUuid") String userUuid, @Param("offset") int offset, @Param("limit") int limit); + + // 根据用户UUID查询附件总数(正常状态) + int countByUserUuid(@Param("userUuid") String userUuid); + + // 根据用户UUID分页查询附件(包含删除状态,6小时内) + List selectPageByUserUuidWithDeleted(@Param("userUuid") String userUuid, @Param("offset") int offset, @Param("limit") int limit); + + // 根据用户UUID查询附件总数(包含删除状态,6小时内) + int countByUserUuidWithDeleted(@Param("userUuid") String userUuid); + + // 根据用户UUID分页查询已删除附件(仅已删除的,6小时内) + List selectDeletedOnlyPageByUserUuid(@Param("userUuid") String userUuid, @Param("offset") int offset, @Param("limit") int limit); + + // 根据用户UUID查询已删除附件总数(6小时内) + int countDeletedOnlyByUserUuid(@Param("userUuid") String userUuid); + + // 查询删除超过6小时的附件 + List selectExpiredDeletedAttachments(); + + // 物理删除附件记录(将状态改为2) + int physicallyDeleteAttachment(@Param("attachmentId") String attachmentId); + + // 批量删除附件(软删除) + int batchDeleteByAttachmentIds(@Param("attachmentIds") List attachmentIds, @Param("deletedTime") LocalDateTime deletedTime); + + // 批量撤销删除附件 + int batchRestoreByAttachmentIds(@Param("attachmentIds") List attachmentIds); + + // 根据存储方式UUID查询所有附件 + List selectByStorageId(@Param("storageId") String storageId); + + // 更新附件的存储方式 + int updateStorageId(@Param("attachmentId") String attachmentId, @Param("storageId") String storageId); +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/BackendCommentMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/BackendCommentMapper.java new file mode 100644 index 0000000..3229aa5 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/BackendCommentMapper.java @@ -0,0 +1,49 @@ +package xyz.lingview.dimstack.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.springframework.stereotype.Repository; +import xyz.lingview.dimstack.domain.Comment; +import xyz.lingview.dimstack.dto.request.CommentDTO; +import java.util.List; +import java.util.Map; + +@Mapper +@Repository +public interface BackendCommentMapper { + // 查询文章的所有评论 + List selectCommentsWithUserInfoByArticleId(@Param("article_id") String article_id); + + // 根据评论id获取评论详情 + Comment selectCommentByCommentId(@Param("comment_id") String comment_id); + + // 更新评论内容 + int updateCommentContent(@Param("comment_id") String comment_id, @Param("content") String content); + + // 删除评论 + int deleteComment(@Param("comment_id") String comment_id); + + // 获取所有评论 + List selectAllCommentsWithPagination(@Param("offset") int offset, @Param("limit") int limit); + + // 根据文章id获取文章标题 + String selectArticleTitleByArticleId(@Param("article_id") String article_id); + + int countTotalComments(); + + int updateCommentTime(@Param("comment_id") String comment_id, @Param("create_time") String create_time); + + int updateCommentUser(@Param("comment_id") String comment_id, @Param("user_id") String user_id); + + List> searchUsersByPrefix(@Param("prefix") String prefix); + + List selectCommentsByStatus(@Param("status") int status, @Param("offset") int offset, @Param("limit") int limit); + + int countByStatus(@Param("status") int status); + + List selectAllReviewComments(@Param("offset") int offset, @Param("limit") int limit); + + int countAllReview(); + + int updateCommentStatus(@Param("comment_id") String comment_id, @Param("status") int status); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/CommentLikeMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/CommentLikeMapper.java new file mode 100644 index 0000000..08b2f4b --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/CommentLikeMapper.java @@ -0,0 +1,13 @@ +package xyz.lingview.dimstack.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.springframework.stereotype.Repository; +import xyz.lingview.dimstack.domain.CommentLike; + +@Mapper +@Repository +public interface CommentLikeMapper { + void insertLike(CommentLike like); + void deleteLike(String userId, String commentId); + boolean existsLike(String userId, String commentId); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/CommentMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/CommentMapper.java new file mode 100644 index 0000000..5ba9a83 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/CommentMapper.java @@ -0,0 +1,20 @@ +package xyz.lingview.dimstack.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.springframework.stereotype.Repository; +import xyz.lingview.dimstack.domain.Comment; + +import java.util.List; + +@Mapper +@Repository +public interface CommentMapper { + void insertComment(Comment comment); + List selectCommentsByArticleId(String articleId); + Comment selectCommentByCommentId(String commentId); + void updateCommentLikeCount(String commentId, Long likeCount); + void deleteComment(String commentId); + + int updateCommentStatus(@Param("commentId") String commentId, @Param("status") int status); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/CustomPageMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/CustomPageMapper.java new file mode 100644 index 0000000..8c9fba7 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/CustomPageMapper.java @@ -0,0 +1,35 @@ +package xyz.lingview.dimstack.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import xyz.lingview.dimstack.domain.CustomPage; + +import java.util.List; + +/** + * @Author: lingview + * @Date: 2026/01/17 20:10:26 + * @Description: + * @Version: 1.0 + */ + +@Mapper +@Repository +public interface CustomPageMapper { + int insert(CustomPage customPage); + + int updateByPrimaryKey(CustomPage customPage); + + int deleteByPrimaryKey(Integer id); + + CustomPage selectByPrimaryKey(Integer id); + + CustomPage selectByAlias(String alias); + + List selectByUserId(String uuid); + + List selectAll(); + + int updateStatus(@Param("id") Integer id, @Param("status") Integer status); +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/DashboardMenuMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/DashboardMenuMapper.java new file mode 100644 index 0000000..6f47b85 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/DashboardMenuMapper.java @@ -0,0 +1,32 @@ +package xyz.lingview.dimstack.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import xyz.lingview.dimstack.domain.DashboardMenu; + +import java.util.List; + +@Mapper +public interface DashboardMenuMapper { + // 根据类型查询菜单 + List findByType(@Param("type") String type); + + // 根据父级id查询子菜单 + List findByParentId(@Param("parent_id") Integer parentId); + + // 根据权限码查询菜单 + List findByPermissionCode(@Param("permission_code") String permissionCode); + + // 查询所有菜单 + List findAll(); + + int insert(DashboardMenu menu); + + // 父菜单下最大排序号 + Integer selectMaxSortByParent(@Param("parent_id") Integer parentId); + + // 按路径前缀取候选菜单 + List findByLinkPrefix(@Param("linkPrefix") String linkPrefix); + + int deleteByIds(@Param("ids") List ids); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/EditArticleMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/EditArticleMapper.java new file mode 100644 index 0000000..0708115 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/EditArticleMapper.java @@ -0,0 +1,57 @@ +package xyz.lingview.dimstack.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import xyz.lingview.dimstack.dto.request.ArticleDetailDTO; +import xyz.lingview.dimstack.dto.request.EditArticleDTO; +import xyz.lingview.dimstack.dto.request.UpdateArticleDTO; + +import java.util.List; +import java.util.Map; + +@Mapper +public interface EditArticleMapper { + List selectArticlesByUuid(@Param("uuid") String uuid, + @Param("keyword") String keyword, + @Param("status") Integer status, + @Param("categoryIds") List categoryIds, + @Param("tag") String tag, + @Param("startTime") String startTime, + @Param("endTime") String endTime, + @Param("offset") int offset, + @Param("size") int size); + + int countArticlesByUuid(@Param("uuid") String uuid, + @Param("keyword") String keyword, + @Param("status") Integer status, + @Param("categoryIds") List categoryIds, + @Param("tag") String tag, + @Param("startTime") String startTime, + @Param("endTime") String endTime); + + String getUuidByUsername(String username); + + ArticleDetailDTO getArticleDetailById(String articleId); + + String getUuidByArticleId(String articleId); + String getUsernameByUuid(String uuid); + + // updateArticle接口已废弃(后续可能移除),请使用updateArticleWithCategory接口 + int updateArticle(UpdateArticleDTO updateArticleDTO); + int updateArticleWithCategory(Map params); + + int deleteArticle(Map params); + int unpublishArticle(Map params); + int publishArticle(Map params); + + int removeArticlePassword(Map params); + + int deleteArticleTagRelations(@Param("articleId") String articleId); + int insertArticleTagRelation(@Param("articleId") String articleId, @Param("tagName") String tagName); + + String getCategoryByArticleId(@Param("article_id") String article_id); + + String getArticleContentById(@Param("articleId") String articleId); + + int updateArticleStatus(@Param("articleId") String articleId, @Param("status") Integer status); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/FriendLinksConfigMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/FriendLinksConfigMapper.java new file mode 100644 index 0000000..670a63e --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/FriendLinksConfigMapper.java @@ -0,0 +1,13 @@ +package xyz.lingview.dimstack.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.springframework.stereotype.Repository; +import xyz.lingview.dimstack.domain.FriendLinksConfig; + +@Mapper +@Repository +public interface FriendLinksConfigMapper { + FriendLinksConfig selectActiveConfig(); + int updateConfig(FriendLinksConfig config); + int insertConfig(FriendLinksConfig config); +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/FriendLinksMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/FriendLinksMapper.java new file mode 100644 index 0000000..8680a33 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/FriendLinksMapper.java @@ -0,0 +1,41 @@ +package xyz.lingview.dimstack.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import xyz.lingview.dimstack.domain.FriendLinks; + +import java.util.List; + +/** + * @Author: lingview + * @Date: 2025/12/04 18:58:34 + * @Description: 友链接口 + * @Version: 1.0 + */ +@Mapper +@Repository +public interface FriendLinksMapper { + + int insert(FriendLinks friendLink); + + int updateStatusById(@Param("id") Integer id, @Param("status") Integer status); + + int updateById(FriendLinks friendLink); + + int deleteById(Integer id); + + int deleteByIdPermanent(Integer id); + + FriendLinks selectById(Integer id); + + List selectByPage(@Param("offset") Integer offset, + @Param("limit") Integer limit, + @Param("status") Integer status); + + int count(@Param("status") Integer status); + + List selectByStatus(Integer status); + + List selectActiveLinks(); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/HotArticleMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/HotArticleMapper.java new file mode 100644 index 0000000..9f84fcf --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/HotArticleMapper.java @@ -0,0 +1,10 @@ +package xyz.lingview.dimstack.mapper; + +import org.apache.ibatis.annotations.Mapper; +import xyz.lingview.dimstack.dto.request.HotArticleDTO; +import java.util.List; + +@Mapper +public interface HotArticleMapper { + List selectHotArticles(); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/LlmConfigMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/LlmConfigMapper.java new file mode 100644 index 0000000..9385278 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/LlmConfigMapper.java @@ -0,0 +1,15 @@ +package xyz.lingview.dimstack.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.springframework.stereotype.Repository; +import xyz.lingview.dimstack.domain.LlmConfig; + +@Mapper +@Repository +public interface LlmConfigMapper { + LlmConfig getLlmConfig(); + + int updateLlmConfig(LlmConfig llmConfig); + + int insertLlmConfig(LlmConfig llmConfig); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/LlmPromptConfigMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/LlmPromptConfigMapper.java new file mode 100644 index 0000000..abc0511 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/LlmPromptConfigMapper.java @@ -0,0 +1,12 @@ +package xyz.lingview.dimstack.mapper; + +import org.apache.ibatis.annotations.Mapper; +import xyz.lingview.dimstack.domain.LlmPromptConfig; + +@Mapper +public interface LlmPromptConfigMapper { + + LlmPromptConfig getPromptByName(String promptName); + + int updatePrompt(LlmPromptConfig config); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/LoginMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/LoginMapper.java new file mode 100644 index 0000000..e038ad8 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/LoginMapper.java @@ -0,0 +1,11 @@ +package xyz.lingview.dimstack.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.springframework.stereotype.Repository; +import xyz.lingview.dimstack.domain.Login; + +@Mapper +@Repository +public interface LoginMapper { + Login loginUser(Login login); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/MenusMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/MenusMapper.java new file mode 100644 index 0000000..8af2726 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/MenusMapper.java @@ -0,0 +1,15 @@ +package xyz.lingview.dimstack.mapper; + +import org.apache.ibatis.annotations.Mapper; +import xyz.lingview.dimstack.domain.Menus; +import xyz.lingview.dimstack.dto.request.MenusDTO; +import java.util.List; + +@Mapper +public interface MenusMapper { + List selectAllMenus(); + void insertMenus(Menus menus); + void updateMenus(Menus menus); + void deleteMenus(String menusId); + void updateSortOrder(Menus menu); +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/MusicMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/MusicMapper.java new file mode 100644 index 0000000..54308c0 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/MusicMapper.java @@ -0,0 +1,35 @@ +package xyz.lingview.dimstack.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.springframework.stereotype.Repository; +import xyz.lingview.dimstack.domain.Music; + +import java.util.List; + +/** + * @Author: lingview + * @Date: 2026/01/24 00:40:05 + * @Description: 站点音乐接口 + * @Version: 1.0 + */ +@Mapper +@Repository +public interface MusicMapper { + // 添加音乐 + int insertMusic(Music music); + + // 根据ID更新音乐 + int updateMusicById(Music music); + + // 软删除音乐 + int deleteMusicById(Integer id); + + // 获取所有启用的音乐 + List selectAllEnabledMusics(); + + // 根据ID获取音乐 + Music selectMusicById(Integer id); + + // 获取所有音乐(包括已删除的) + List selectAllMusics(); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/PermissionMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/PermissionMapper.java new file mode 100644 index 0000000..d87d0fa --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/PermissionMapper.java @@ -0,0 +1,16 @@ +package xyz.lingview.dimstack.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface PermissionMapper { + + List findCodesByLiteralPrefix(@Param("prefix") String prefix); + + int upsertCode(@Param("code") String code, @Param("name") String name); + + int deleteByCodes(@Param("codes") List codes); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/PluginConfigMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/PluginConfigMapper.java new file mode 100644 index 0000000..eab9660 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/PluginConfigMapper.java @@ -0,0 +1,22 @@ +package xyz.lingview.dimstack.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +/** + * @Author: lingview + * @Date: 2026/08/09 22:11:27 + * @Description: 插件配置数据访问 + * @Version: 1.0 + */ +@Mapper +public interface PluginConfigMapper { + + String selectValue(@Param("pluginName") String pluginName, @Param("configKey") String configKey); + + int upsert(@Param("pluginName") String pluginName, + @Param("configKey") String configKey, + @Param("configValue") String configValue); + + int deleteByPluginName(@Param("pluginName") String pluginName); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/PluginMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/PluginMapper.java new file mode 100644 index 0000000..ace3965 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/PluginMapper.java @@ -0,0 +1,29 @@ +package xyz.lingview.dimstack.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import xyz.lingview.dimstack.domain.PluginInfo; + +import java.util.List; + +/** + * @Author: lingview + * @Date: 2026/08/03 20:23:41 + * @Description: 插件数据访问 + * @Version: 1.0 + */ +@Mapper +public interface PluginMapper { + + List selectAll(); + + PluginInfo selectByName(String name); + + int insert(PluginInfo info); + + int updateEnabled(@Param("name") String name, @Param("enabled") boolean enabled); + + int updateInfo(PluginInfo info); + + int deleteByName(String name); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/PluginSqlAuditMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/PluginSqlAuditMapper.java new file mode 100644 index 0000000..3e322c2 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/PluginSqlAuditMapper.java @@ -0,0 +1,25 @@ +package xyz.lingview.dimstack.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import xyz.lingview.dimstack.domain.PluginSqlAudit; + +import java.util.List; + +/** + * @Author: lingview + * @Date: 2026/10/01 15:58:41 + * @Description: 插件SQL审计数据访问 + * @Version: 1.0 + */ +@Mapper +public interface PluginSqlAuditMapper { + + int insert(PluginSqlAudit audit); + + List selectPage(@Param("pluginName") String pluginName, + @Param("offset") int offset, + @Param("size") int size); + + long countByPlugin(@Param("pluginName") String pluginName); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/ReadArticleMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/ReadArticleMapper.java new file mode 100644 index 0000000..1b0ff7e --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/ReadArticleMapper.java @@ -0,0 +1,28 @@ +package xyz.lingview.dimstack.mapper; + +import xyz.lingview.dimstack.domain.ReadArticle; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Mapper; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Mapper +@Repository +public interface ReadArticleMapper { + ReadArticle selectByArticleId(@Param("article_id") String articleId); + + ReadArticle selectByAlias(@Param("alias") String alias); + + ReadArticle selectRandomArticle(); + + boolean isArticleNeedPassword(@Param("alias") String alias); + + void updatePageViews(@Param("alias") String alias); + + void batchUpdatePageViews(@Param("alias") String alias, @Param("count") Long count); + // seo优化用 + List listAllArticles(); + List listAllCategories(); + List listAllTags(); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/RegisterMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/RegisterMapper.java new file mode 100644 index 0000000..c1c6af7 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/RegisterMapper.java @@ -0,0 +1,15 @@ +package xyz.lingview.dimstack.mapper; + + +import org.apache.ibatis.annotations.Mapper; +import org.springframework.stereotype.Repository; +import xyz.lingview.dimstack.domain.Login; +import xyz.lingview.dimstack.domain.Register; + +@Mapper +@Repository +public interface RegisterMapper { + int selectUser(String username); + int insertUser(Register register); + +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/RoleMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/RoleMapper.java new file mode 100644 index 0000000..f77f2b0 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/RoleMapper.java @@ -0,0 +1,50 @@ +package xyz.lingview.dimstack.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import xyz.lingview.dimstack.domain.Permission; +import xyz.lingview.dimstack.domain.Role; + +import java.util.List; + +@Mapper +public interface RoleMapper { + // 查询所有角色 + List selectAllRoles(); + + // 根据id查询角色 + Role selectRoleById(@Param("id") Integer id); + + // 根据编码查询角色 + Role selectRoleByCode(@Param("code") String code); + + // 创建新角色 + int insertRole(Role role); + + // 更新角色信息 + int updateRole(Role role); + + // 删除角色 + int deleteRole(@Param("id") Integer id); + + // 为角色添加权限 + int insertRolePermission(@Param("roleId") Integer roleId, @Param("permissionId") Integer permissionId); + + // 移除角色权限 + int deleteRolePermission(@Param("roleId") Integer roleId, @Param("permissionId") Integer permissionId); + + // 获取角色的所有权限id + List selectPermissionIdsByRoleId(@Param("roleId") Integer roleId); + + // 获取角色的所有权限码 + List selectPermissionCodesByRoleId(@Param("roleId") Integer roleId); + + // 根据权限码获取权限id + Integer selectPermissionIdByCode(@Param("code") String code); + + // 插入新权限 + int insertPermission(Permission permission); + + // 查询所有权限列表 + List selectAllPermissions(); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/SiteConfigMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/SiteConfigMapper.java new file mode 100644 index 0000000..ac4f024 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/SiteConfigMapper.java @@ -0,0 +1,42 @@ +package xyz.lingview.dimstack.mapper; + +import org.springframework.stereotype.Repository; +import xyz.lingview.dimstack.domain.SiteConfig; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +@Repository +public interface SiteConfigMapper { + // 查询站点配置信息 + SiteConfig getSiteConfig(); + + // 查询站点名称 + String getSiteName(); + + // 查询注册用户默认角色代码 + int getRegisterUserPermission(); + + // 查询版权信息 + String getCopyright(); + + // 获取文章默认状态 + int getArticleStatus(); + + // 获取站点图标 + String getSiteIcon(); + + // 获取ICP备案号 + String getIcpRecordNumber(); + + // 获取公安联网备案号 + String getMpsRecordNumber(); + + // 更新站点配置信息 + int updateSiteConfig(SiteConfig siteConfig); + + // 获取是否允许用户注册 + Integer getEnableRegister(); + + // 获取管理员文章是否无需审核 + Integer getAdminPostNoReview(); +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/StatisticsMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/StatisticsMapper.java new file mode 100644 index 0000000..e9a5339 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/StatisticsMapper.java @@ -0,0 +1,11 @@ +package xyz.lingview.dimstack.mapper; + +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface StatisticsMapper { + Long countArticles(); + Long countUsers(); + Long countComments(); + Long sumPageViews(); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/StorageMethodMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/StorageMethodMapper.java new file mode 100644 index 0000000..c69648b --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/StorageMethodMapper.java @@ -0,0 +1,29 @@ +package xyz.lingview.dimstack.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.springframework.stereotype.Repository; +import xyz.lingview.dimstack.domain.StorageMethod; + +import java.util.List; + +@Mapper +@Repository +public interface StorageMethodMapper { + StorageMethod selectByUuid(String uuid); + + StorageMethod selectByName(String name); + + StorageMethod selectByType(String type); + + List selectAll(); + + int insert(StorageMethod storageMethod); + + int update(StorageMethod storageMethod); + + int disable(String uuid); + + int enable(String uuid); + + int deletePhysical(String uuid); +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/StorageMigrationLogMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/StorageMigrationLogMapper.java new file mode 100644 index 0000000..4ae059c --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/StorageMigrationLogMapper.java @@ -0,0 +1,24 @@ +package xyz.lingview.dimstack.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.springframework.stereotype.Repository; +import xyz.lingview.dimstack.domain.StorageMigrationFailedItem; +import xyz.lingview.dimstack.domain.StorageMigrationLog; + +import java.util.List; + +@Mapper +@Repository +public interface StorageMigrationLogMapper { + int insertLog(StorageMigrationLog log); + + int updateLog(StorageMigrationLog log); + + void insertFailedItem(StorageMigrationFailedItem item); + + void insertFailedItems(List items); + + StorageMigrationLog selectById(int id); + + List selectAll(); +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/SystematicNotificationMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/SystematicNotificationMapper.java new file mode 100644 index 0000000..df92f0c --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/SystematicNotificationMapper.java @@ -0,0 +1,57 @@ +package xyz.lingview.dimstack.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import xyz.lingview.dimstack.domain.SystematicNotification; + +import java.util.List; + +/** + * @Author: lingview + * @Date: 2026/01/31 12:40:48 + * @Description: 系统通知 + * @Version: 1.0 + */ +@Mapper +public interface SystematicNotificationMapper { + + // 插入通知 + int insertSystematicNotification(SystematicNotification systematicNotification); + + // 获取用户通知总数 + int getCountByUuid(@Param("uuid") String uuid); + + // 获取用户已读通知总数 + int getReadCountByUuid(@Param("uuid") String uuid); + + // 获取用户未读通知总数 + int getUnreadCountByUuid(@Param("uuid") String uuid); + + // 获取用户通知列表 + List getSystematicNotificationsWithPagination( + @Param("uuid") String uuid, + @Param("offset") int offset, + @Param("limit") int limit); + + // 获取用户已读通知列表 + List getReadNotificationsWithPagination( + @Param("uuid") String uuid, + @Param("offset") int offset, + @Param("limit") int limit); + + // 获取用户未读通知列表 + List getUnreadNotificationsWithPagination( + @Param("uuid") String uuid, + @Param("offset") int offset, + @Param("limit") int limit); + + // 通知标为已读 + int updateNotificationStatus(@Param("id") Integer id, @Param("status") Integer status); + + // 删除通知 + int deleteSystematicNotification(@Param("id") Integer id); + + // 根据ID和UUID查询通知 + SystematicNotification selectByIdAndUuid(@Param("id") Integer id, @Param("uuid") String uuid); + +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/UploadMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/UploadMapper.java new file mode 100644 index 0000000..9628cd0 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/UploadMapper.java @@ -0,0 +1,20 @@ +package xyz.lingview.dimstack.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import xyz.lingview.dimstack.domain.UploadArticle; +import xyz.lingview.dimstack.domain.UploadAttachment; + +@Mapper +@Repository +public interface UploadMapper { + int insertUploadAttachment(UploadAttachment UploadAttachment); + int insertUploadArticle(UploadArticle UploadArticle); + int insertUploadArticleWithCategory(UploadArticle uploadArticle); + int selectArticleCountByAliasAndUuid(String alias, String uuid); + int insertArticleTagRelation(@Param("articleId") String articleId, @Param("tagName") String tagName); + int deleteArticleTagRelations(@Param("articleId") String articleId); + + UploadAttachment selectByAccessKey(@Param("accessKey") String accessKey); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/UserInformationMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/UserInformationMapper.java new file mode 100644 index 0000000..0aa1e12 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/UserInformationMapper.java @@ -0,0 +1,69 @@ +package xyz.lingview.dimstack.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.springframework.stereotype.Repository; +import xyz.lingview.dimstack.domain.UserInformation; +import xyz.lingview.dimstack.dto.request.UserDTO; +import xyz.lingview.dimstack.domain.Role; +import java.util.List; + +@Mapper +@Repository +public interface UserInformationMapper { + String selectUserUUID(String username); + + UserInformation selectUserByUUID(@Param("uuid") String uuid); + + String selectUsernameByUUID(@Param("uuid") String uuid); + + int updateUserByUUID(UserInformation userInformation); + + String getUsernameByUuid(@Param("uuid") String uuid); + + String getEmailByUsername(@Param("username") String username); + + String getUsernameByEmail(@Param("email") String email); + + List getEmailsByPermissionCode(@Param("permissionCode") String permissionCode); + + List getUsernamesByPermissionCode(@Param("permissionCode") String permissionCode); + + String getUsernameByArticleId(@Param("articleId") String articleId); + + List selectAllEnableUsers(); + + List selectAllUsers(); + + List selectUsersPage(@Param("keyword") String keyword, + @Param("offset") int offset, + @Param("size") int size); + + int countUsers(@Param("keyword") String keyword); + + UserDTO selectUserById(@Param("id") Integer id); + + int updateUserRole(@Param("userId") Integer userId, @Param("roleId") Integer roleId); + + int updateUserStatus(@Param("userId") Integer userId, @Param("status") Byte status); + + List selectAllRoles(); + + List selectPermissionsByUserId(@Param("userId") Integer userId); + + UserInformation getUserByUsernameAndEmail(@Param("username") String username, @Param("email") String email); + + int updatePasswordByUsername(@Param("username") String username, @Param("password") String password); + + String selectAvatarByUsername(@Param("username") String username); + + int selectUserByUsername(@Param("username") String username); + + UserInformation getUserByUsername(@Param("username") String username); + + int selectUserByPhone(@Param("phone") String phone); + + int selectUserByEmail(@Param("email") String email); + // 后台添加新用户 + int insertUser(UserInformation user); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/UserPermissionMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/UserPermissionMapper.java new file mode 100644 index 0000000..b422049 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/UserPermissionMapper.java @@ -0,0 +1,11 @@ +package xyz.lingview.dimstack.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import java.util.List; + +@Mapper +public interface UserPermissionMapper { + // 根据用户名查询拥有的权限 + List findPermissionCodesByUserName(@Param("username") String username); +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/UserRoleMapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/UserRoleMapper.java new file mode 100644 index 0000000..9e50b32 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/mapper/UserRoleMapper.java @@ -0,0 +1,24 @@ +package xyz.lingview.dimstack.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface UserRoleMapper { + // 为用户添加角色 + int insertUserRole(@Param("userId") Integer userId, @Param("roleId") Integer roleId); + + // 移除用户的角色 + int deleteUserRole(@Param("userId") Integer userId, @Param("roleId") Integer roleId); + + // 获取用户的所有角色id + List selectRoleIdsByUserId(@Param("userId") Integer userId); + + // 获取用户的所有角色 + List selectRolesByUserId(@Param("userId") Integer userId); + + // 检查用户是否拥有某个角色 + int countUserRole(@Param("userId") Integer userId, @Param("roleId") Integer roleId); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/DefaultPluginApplicationContextFactory.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/DefaultPluginApplicationContextFactory.java new file mode 100644 index 0000000..04d8ff2 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/DefaultPluginApplicationContextFactory.java @@ -0,0 +1,234 @@ +package xyz.lingview.dimstack.plugin; + +import lombok.extern.slf4j.Slf4j; +import org.pf4j.PluginWrapper; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.type.filter.AnnotationTypeFilter; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; +import xyz.lingview.dimstack.plugin.api.ExtensionGetter; +import xyz.lingview.dimstack.plugin.api.PluginContext; +import xyz.lingview.dimstack.plugin.api.PluginDb; +import xyz.lingview.dimstack.plugin.api.SettingFetcher; +import xyz.lingview.dimstack.service.CacheService; +import xyz.lingview.dimstack.service.SiteConfigService; +import xyz.lingview.dimstack.service.StorageFacadeService; + +import java.io.InputStream; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * @Author: lingview + * @Date: 2026/07/31 22:47:09 + * @Description: 插件Spring容器工厂 + * @Version: 1.0 + */ +@Slf4j +public class DefaultPluginApplicationContextFactory implements PluginApplicationContextFactory { + + private static final List SHARED_BEANS = List.of( + new SharedBean(SiteConfigService.class, "siteConfigService", ctx -> ctx.getBean(SiteConfigService.class)), + new SharedBean(CacheService.class, "cacheService", ctx -> ctx.getBean(CacheService.class)), + new SharedBean(StorageFacadeService.class, "storageFacadeService", ctx -> ctx.getBean(StorageFacadeService.class)), + new SharedBean(ExtensionGetter.class, "extensionGetter", ctx -> ctx.getBean(ExtensionGetter.class)) + ); + + private final ApplicationContext rootContext; + private final YamlPluginDescriptorFinder descriptorFinder; + private final SettingFetcherImpl settingFetcher; + private final PluginDbRegistry pluginDbRegistry; + + private volatile AnnotationConfigApplicationContext sharedContext; + + public DefaultPluginApplicationContextFactory(ApplicationContext rootContext, + YamlPluginDescriptorFinder descriptorFinder, + SettingFetcherImpl settingFetcher, + PluginDbRegistry pluginDbRegistry) { + this.rootContext = rootContext; + this.descriptorFinder = descriptorFinder; + this.settingFetcher = settingFetcher; + this.pluginDbRegistry = pluginDbRegistry; + } + + @Override + public AnnotationConfigApplicationContext create(PluginWrapper wrapper) { + PluginManifest manifest = descriptorFinder.getManifest(wrapper.getPluginId()); + + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.setParent(getSharedContext()); + context.setClassLoader(wrapper.getPluginClassLoader()); + context.setId("plugin-" + wrapper.getPluginId()); + + PluginContext pluginContext = new PluginContext( + wrapper, + manifest != null ? manifest.getSettingName() : null, + manifest != null ? manifest.getConfigMapName() : null, + PluginContext.RuntimeMode.DEPLOYMENT); + + DefaultListableBeanFactory beanFactory = (DefaultListableBeanFactory) context.getBeanFactory(); + RootBeanDefinition pluginContextDef = new RootBeanDefinition(PluginContext.class); + pluginContextDef.setInstanceSupplier(() -> pluginContext); + pluginContextDef.setPrimary(true); + beanFactory.registerBeanDefinition("pluginContext", pluginContextDef); + + RootBeanDefinition settingFetcherDef = new RootBeanDefinition(SettingFetcher.class); + settingFetcherDef.setInstanceSupplier(() -> new BoundSettingFetcher(wrapper.getPluginId(), + defaultConfigName(manifest), settingFetcher)); + settingFetcherDef.setPrimary(true); + beanFactory.registerBeanDefinition("boundSettingFetcher", settingFetcherDef); + + RootBeanDefinition pluginDbDef = new RootBeanDefinition(PluginDb.class); + pluginDbDef.setInstanceSupplier(() -> pluginDbRegistry.get(wrapper.getPluginId())); + pluginDbDef.setPrimary(true); + beanFactory.registerBeanDefinition("pluginDb", pluginDbDef); + + RootBeanDefinition jdbcTemplateDef = new RootBeanDefinition(JdbcTemplate.class); + jdbcTemplateDef.setInstanceSupplier(() -> { + JdbcTemplate jdbcTemplate = new JdbcTemplate(pluginDbRegistry.get(wrapper.getPluginId()).dataSource()); + jdbcTemplate.setQueryTimeout(10); + return jdbcTemplate; + }); + jdbcTemplateDef.setPrimary(true); + beanFactory.registerBeanDefinition("pluginJdbcTemplate", jdbcTemplateDef); + + registerConfigYaml(context, wrapper); + + registerComponents(context, wrapper, manifest); + + Thread original = Thread.currentThread(); + ClassLoader originalLoader = original.getContextClassLoader(); + Thread.currentThread().setContextClassLoader(wrapper.getPluginClassLoader()); + try { + context.refresh(); + } finally { + Thread.currentThread().setContextClassLoader(originalLoader); + } + log.info("插件上下文创建成功: {}@{}", wrapper.getPluginId(), wrapper.getDescriptor().getVersion()); + return context; + } + + @Override + public void destroy(PluginWrapper wrapper, AnnotationConfigApplicationContext context) { + try { + context.close(); + } catch (Exception e) { + log.warn("插件上下文关闭异常: {}", wrapper.getPluginId(), e); + } + pluginDbRegistry.dispose(wrapper.getPluginId()); + } + + private AnnotationConfigApplicationContext getSharedContext() { + if (sharedContext == null) { + synchronized (this) { + if (sharedContext == null) { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + // 不设置parent=rootContext: 插件只能取到白名单服务, 拿不到宿主内部Bean + context.setId("plugin-shared"); + DefaultListableBeanFactory beanFactory = (DefaultListableBeanFactory) context.getBeanFactory(); + for (SharedBean bean : SHARED_BEANS) { + @SuppressWarnings({"rawtypes", "unchecked"}) + Class beanType = (Class) bean.type; + String beanName = beanType.getName(); + beanFactory.registerSingleton(beanName, bean.resolver.apply(rootContext)); + beanFactory.registerAlias(beanName, bean.name); + } + context.refresh(); + sharedContext = context; + } + } + } + return sharedContext; + } + + private void registerComponents(AnnotationConfigApplicationContext context, + PluginWrapper wrapper, + PluginManifest manifest) { + if (manifest == null || !StringUtils.hasText(manifest.getScanPackage())) { + log.warn("插件 {} 未配置 scanPackage, 不注册 Spring 组件", wrapper.getPluginId()); + return; + } + ClassPathScanningCandidateComponentProvider scanner = new ClassPathScanningCandidateComponentProvider(false); + scanner.setResourceLoader(new PathMatchingResourcePatternResolver(wrapper.getPluginClassLoader())); + scanner.addIncludeFilter(new AnnotationTypeFilter(Component.class, true, true)); + + Set candidates; + try { + candidates = scanner.findCandidateComponents(manifest.getScanPackage()); + } catch (Exception e) { + log.warn("插件 {} 组件扫描失败: {}", wrapper.getPluginId(), e.getMessage()); + return; + } + for (BeanDefinition definition : candidates) { + String className = definition.getBeanClassName(); + if (!StringUtils.hasText(className)) { + continue; + } + try { + Class clazz = ClassUtils.forName(className, wrapper.getPluginClassLoader()); + context.registerBean(clazz); + } catch (Exception e) { + log.warn("插件 {} 组件注册失败: {}", wrapper.getPluginId(), className, e); + } + } + log.info("插件 {} 注册组件 {} 个", wrapper.getPluginId(), candidates.size()); + } + + private void registerConfigYaml(AnnotationConfigApplicationContext context, PluginWrapper wrapper) { + try (InputStream in = wrapper.getPluginClassLoader().getResourceAsStream("config.yaml")) { + if (in == null) { + return; + } + Map loaded = PluginYamlLoader.load(in); + if (loaded.isEmpty()) { + return; + } + Map flat = new HashMap<>(); + flatten("", loaded, flat); + context.getEnvironment().getPropertySources().addLast( + new MapPropertySource("plugin-config-" + wrapper.getPluginId(), flat)); + } catch (Exception e) { + log.warn("插件默认配置加载失败: {}", wrapper.getPluginId(), e); + } + } + + @SuppressWarnings("unchecked") + private void flatten(String prefix, Map source, Map target) { + source.forEach((key, value) -> { + String fullKey = prefix.isEmpty() ? key : prefix + "." + key; + if (value instanceof Map nested) { + flatten(fullKey, (Map) nested, target); + } else { + target.put(fullKey, value); + } + }); + } + + private record BoundSettingFetcher(String pluginId, String defaultConfigName, SettingFetcherImpl delegate) + implements SettingFetcher { + @Override + public T fetch(String configMapName, String key, Class clazz) { + String mapName = configMapName != null && !configMapName.isBlank() ? configMapName : defaultConfigName; + return delegate.fetch(pluginId, mapName, key, clazz); + } + } + + private static String defaultConfigName(PluginManifest manifest) { + String name = manifest != null ? manifest.getConfigMapName() : null; + return name != null && !name.isBlank() ? name : SettingFetcherImpl.DEFAULT_CONFIG_KEY; + } + + private record SharedBean(Class type, String name, java.util.function.Function resolver) { + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/DefaultSpringPlugin.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/DefaultSpringPlugin.java new file mode 100644 index 0000000..5ea534b --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/DefaultSpringPlugin.java @@ -0,0 +1,76 @@ +package xyz.lingview.dimstack.plugin; + +import lombok.extern.slf4j.Slf4j; +import org.pf4j.Plugin; +import org.pf4j.PluginRuntimeException; +import org.pf4j.PluginWrapper; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; + +/** + * @Author: lingview + * @Date: 2026/07/31 22:47:09 + * @Description: 插件Spring生命周期 + * @Version: 1.0 + */ +@Slf4j +public class DefaultSpringPlugin extends Plugin { + + private final PluginApplicationContextFactory contextFactory; + private final DimStackPluginManager pluginManager; + private final PluginRequestMappingHandlerMapping handlerMapping; + + private volatile AnnotationConfigApplicationContext applicationContext; + + public DefaultSpringPlugin(PluginWrapper wrapper, + PluginApplicationContextFactory contextFactory, + DimStackPluginManager pluginManager, + PluginRequestMappingHandlerMapping handlerMapping) { + super(wrapper); + this.contextFactory = contextFactory; + this.pluginManager = pluginManager; + this.handlerMapping = handlerMapping; + } + + @Override + public void start() { + try { + AnnotationConfigApplicationContext context = contextFactory.create(getWrapper()); + this.applicationContext = context; + pluginManager.registerPluginContext(getWrapper().getPluginId(), context); + if (handlerMapping != null) { + handlerMapping.registerPlugin(getWrapper().getPluginId(), context); + } + } catch (Exception e) { + throw new PluginRuntimeException("插件启动失败: " + getWrapper().getPluginId() + + " (" + causeMessage(e) + ")", e); + } + } + + private static String causeMessage(Throwable e) { + Throwable cause = e; + while (cause.getCause() != null && cause.getCause() != cause) { + cause = cause.getCause(); + } + return cause.getMessage() != null ? cause.getMessage() : cause.getClass().getSimpleName(); + } + + @Override + public void stop() { + AnnotationConfigApplicationContext context = this.applicationContext; + if (context != null) { + try { + if (handlerMapping != null) { + handlerMapping.unregisterPlugin(getWrapper().getPluginId()); + } + } finally { + contextFactory.destroy(getWrapper(), context); + this.applicationContext = null; + pluginManager.removePluginContext(getWrapper().getPluginId()); + } + } + } + + public AnnotationConfigApplicationContext getApplicationContext() { + return applicationContext; + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/DimStackPluginManager.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/DimStackPluginManager.java new file mode 100644 index 0000000..a5581b0 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/DimStackPluginManager.java @@ -0,0 +1,124 @@ +package xyz.lingview.dimstack.plugin; + +import lombok.extern.slf4j.Slf4j; +import org.pf4j.DefaultPluginManager; +import org.pf4j.PluginDescriptorFinder; +import org.pf4j.PluginFactory; +import org.pf4j.PluginState; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; + +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * @Author: lingview + * @Date: 2026/07/31 22:47:09 + * @Description: 插件管理器 + * @Version: 1.0 + */ +@Slf4j +public class DimStackPluginManager extends DefaultPluginManager { + + private final PluginApplicationContextFactory contextFactory; + private final YamlPluginDescriptorFinder descriptorFinder; + private final Map pluginContexts = new ConcurrentHashMap<>(); + + private PluginRequestMappingHandlerMapping requestMappingHandlerMapping; + + private PluginPermissionRegistrar permissionRegistrar; + + public DimStackPluginManager(Path pluginsRoot, + PluginApplicationContextFactory contextFactory, + YamlPluginDescriptorFinder descriptorFinder) { + super(pluginsRoot); + this.contextFactory = contextFactory; + this.descriptorFinder = descriptorFinder; + // pf4j的initialize()在父构造器期间即调用本类覆写的工厂方法, 子类字段此时未初始化, 故字段就绪后重新initialize() + + initialize(); + } + + @Override + protected PluginDescriptorFinder createPluginDescriptorFinder() { + return descriptorFinder; + } + + @Override + protected PluginFactory createPluginFactory() { + return new SpringPluginFactory(contextFactory, this); + } + + @Override + protected org.pf4j.VersionManager createVersionManager() { + return new org.pf4j.DefaultVersionManager() { + @Override + public boolean checkVersionConstraint(String requires, String version) { + return true; + } + }; + } + + public void setRequestMappingHandlerMapping(PluginRequestMappingHandlerMapping mapping) { + this.requestMappingHandlerMapping = mapping; + } + + public void setPermissionRegistrar(PluginPermissionRegistrar permissionRegistrar) { + this.permissionRegistrar = permissionRegistrar; + } + + @Override + public PluginState startPlugin(String pluginId) { + PluginState state = super.startPlugin(pluginId); + if (state == PluginState.STARTED && permissionRegistrar != null) { + permissionRegistrar.registerOnStart(pluginId, getManifest(pluginId)); + } + return state; + } + + public PluginRequestMappingHandlerMapping getRequestMappingHandlerMapping() { + return requestMappingHandlerMapping; + } + + public PluginManifest getManifest(String pluginId) { + return descriptorFinder.getManifest(pluginId); + } + + public void removeManifest(String pluginId) { + descriptorFinder.removeManifest(pluginId); + } + + public AnnotationConfigApplicationContext getPluginContext(String pluginId) { + return pluginContexts.get(pluginId); + } + + public void registerPluginContext(String pluginId, AnnotationConfigApplicationContext context) { + pluginContexts.put(pluginId, context); + } + + public void removePluginContext(String pluginId) { + pluginContexts.remove(pluginId); + } + + public void cleanupPlugin(String pluginId) { + removePluginContext(pluginId); + removeManifest(pluginId); + } + + public void unloadAndClose(String pluginId) { + ClassLoader classLoader = getPluginClassLoader(pluginId); + log.info("卸载插件前获取类加载器: {} -> {}", pluginId, classLoader); + unloadPlugin(pluginId); + cleanupPlugin(pluginId); + if (classLoader instanceof AutoCloseable closeable) { + try { + closeable.close(); + log.info("插件类加载器已关闭: {}", pluginId); + } catch (Exception e) { + log.warn("关闭插件类加载器失败: {}", pluginId, e); + } + } else { + log.warn("插件类加载器不是 AutoCloseable: {}", classLoader); + } + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/ExtensionGetterImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/ExtensionGetterImpl.java new file mode 100644 index 0000000..92d59f4 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/ExtensionGetterImpl.java @@ -0,0 +1,58 @@ +package xyz.lingview.dimstack.plugin; + +import lombok.extern.slf4j.Slf4j; +import org.pf4j.PluginWrapper; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.core.annotation.AnnotationAwareOrderComparator; +import org.springframework.stereotype.Service; +import xyz.lingview.dimstack.plugin.api.ExtensionGetter; +import xyz.lingview.dimstack.plugin.api.ExtensionPoint; + +import java.util.ArrayList; +import java.util.List; + +/** + * @Author: lingview + * @Date: 2026/07/31 22:47:09 + * @Description: 扩展点获取实现 + * @Version: 1.0 + */ +@Slf4j +@Service +public class ExtensionGetterImpl implements ExtensionGetter { + + private final DimStackPluginManager pluginManager; + private final ApplicationContext applicationContext; + private final PluginLifecycleGuard lifecycleGuard; + + public ExtensionGetterImpl(DimStackPluginManager pluginManager, + ApplicationContext applicationContext, + PluginLifecycleGuard lifecycleGuard) { + this.pluginManager = pluginManager; + this.applicationContext = applicationContext; + this.lifecycleGuard = lifecycleGuard; + } + + @Override + public List getExtensions(Class type) { + return lifecycleGuard.withReadLock(() -> doGetExtensions(type)); + } + + private List doGetExtensions(Class type) { + List extensions = new ArrayList<>(); + for (PluginWrapper wrapper : pluginManager.getStartedPlugins()) { + AnnotationConfigApplicationContext context = pluginManager.getPluginContext(wrapper.getPluginId()); + if (context != null && context.isActive()) { + try { + extensions.addAll(context.getBeansOfType(type).values()); + } catch (Exception e) { + log.warn("收集插件 {} 的扩展失败: {}", wrapper.getPluginId(), e.getMessage()); + } + } + } + extensions.addAll(applicationContext.getBeanProvider(type).orderedStream().toList()); + extensions.sort(AnnotationAwareOrderComparator.INSTANCE); + return extensions; + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginApplicationContextFactory.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginApplicationContextFactory.java new file mode 100644 index 0000000..739ec19 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginApplicationContextFactory.java @@ -0,0 +1,17 @@ +package xyz.lingview.dimstack.plugin; + +import org.pf4j.PluginWrapper; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; + +/** + * @Author: lingview + * @Date: 2026/07/31 22:47:09 + * @Description: 插件容器工厂接口 + * @Version: 1.0 + */ +public interface PluginApplicationContextFactory { + + AnnotationConfigApplicationContext create(PluginWrapper wrapper); + + void destroy(PluginWrapper wrapper, AnnotationConfigApplicationContext context); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginAssetController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginAssetController.java new file mode 100644 index 0000000..71556e1 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginAssetController.java @@ -0,0 +1,98 @@ +package xyz.lingview.dimstack.plugin; + +import lombok.extern.slf4j.Slf4j; +import org.pf4j.PluginState; +import org.pf4j.PluginWrapper; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.HandlerMapping; + +import jakarta.servlet.http.HttpServletRequest; +import java.io.InputStream; +import java.nio.file.Path; +import java.util.Set; + +/** + * @Author: lingview + * @Date: 2026/07/31 22:47:09 + * @Description: 插件前端资源接口 + * @Version: 1.0 + */ +@Slf4j +@RestController +public class PluginAssetController { + + private static final AntPathMatcher PATH_MATCHER = new AntPathMatcher(); + private static final String ASSET_PATTERN = "/plugins/{name}/assets/ui/**"; + + private final DimStackPluginManager pluginManager; + + public PluginAssetController(DimStackPluginManager pluginManager) { + this.pluginManager = pluginManager; + } + + @GetMapping(ASSET_PATTERN) + public ResponseEntity asset(@PathVariable String name, HttpServletRequest request) { + PluginWrapper wrapper = pluginManager.getPlugin(name); + if (wrapper == null || wrapper.getPluginState() != PluginState.STARTED) { + return ResponseEntity.notFound().build(); + } + + String relative = PATH_MATCHER.extractPathWithinPattern(ASSET_PATTERN, request.getRequestURI()); + String resourcePath; + try { + + Path resolved = Path.of("ui").resolve(relative == null ? "" : relative).normalize(); + resourcePath = resolved.toString().replace('\\', '/'); + } catch (Exception e) { + return ResponseEntity.badRequest().build(); + } + if (!resourcePath.startsWith("ui/") || resourcePath.length() <= "ui/".length()) { + log.warn("插件资源路径被拒绝: /plugins/{}/assets/ui/{}", name, relative); + return ResponseEntity.badRequest().build(); + } + if (!isAllowedAssetType(resourcePath)) { + log.warn("插件资源类型不在白名单, 拒绝服务: {}", resourcePath); + return ResponseEntity.status(415).build(); + } + InputStream in = wrapper.getPluginClassLoader().getResourceAsStream(resourcePath); + if (in == null) { + return ResponseEntity.notFound().build(); + } + MediaType mediaType = guessMediaType(resourcePath); + return ResponseEntity.ok() + .contentType(mediaType) + .body(new InputStreamResource(in)); + } + + private static final Set ALLOWED_ASSET_SUFFIXES = Set.of( + ".js", ".mjs", ".css", ".json", ".map", ".txt", ".wasm", + ".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", + ".woff", ".woff2", ".ttf", ".otf", ".eot"); + + private boolean isAllowedAssetType(String resourcePath) { + int dot = resourcePath.lastIndexOf('.'); + return dot > 0 && ALLOWED_ASSET_SUFFIXES.contains(resourcePath.substring(dot).toLowerCase()); + } + + private MediaType guessMediaType(String path) { + String lower = path.toLowerCase(); + if (lower.endsWith(".js")) return MediaType.valueOf("text/javascript"); + if (lower.endsWith(".css")) return MediaType.valueOf("text/css"); + if (lower.endsWith(".json")) return MediaType.APPLICATION_JSON; + if (lower.endsWith(".svg")) return MediaType.valueOf("image/svg+xml"); + if (lower.endsWith(".png")) return MediaType.IMAGE_PNG; + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return MediaType.IMAGE_JPEG; + if (lower.endsWith(".gif")) return MediaType.IMAGE_GIF; + if (lower.endsWith(".woff2")) return MediaType.valueOf("font/woff2"); + if (lower.endsWith(".woff")) return MediaType.valueOf("font/woff"); + if (lower.endsWith(".ttf")) return MediaType.valueOf("font/ttf"); + return MediaType.APPLICATION_OCTET_STREAM; + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginAuditLogger.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginAuditLogger.java new file mode 100644 index 0000000..5f6d96f --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginAuditLogger.java @@ -0,0 +1,31 @@ +package xyz.lingview.dimstack.plugin; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import xyz.lingview.dimstack.security.AuthenticatedUser; +import xyz.lingview.dimstack.security.UserContextHolder; + +/** + * @Author: lingview + * @Date: 2026/08/12 21:52:04 + * @Description: 插件操作审计 + * @Version: 1.0 + */ +@Slf4j +@Component +public class PluginAuditLogger { + + public void log(String action, String pluginId, String version, boolean success, String detail) { + String operator = "unknown"; + AuthenticatedUser user = UserContextHolder.get(); + if (user != null) { + operator = user.getUsername(); + } + String plugin = pluginId + (version != null ? "@" + version : ""); + if (success) { + log.info("[插件审计] 操作者={} 动作={} 插件={} 详情={}", operator, action, plugin, detail == null ? "-" : detail); + } else { + log.warn("[插件审计] 操作者={} 动作={} 插件={} 失败: {}", operator, action, plugin, detail == null ? "-" : detail); + } + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginAutoConfiguration.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginAutoConfiguration.java new file mode 100644 index 0000000..5519aba --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginAutoConfiguration.java @@ -0,0 +1,96 @@ +package xyz.lingview.dimstack.plugin; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.HandlerMapping; +import xyz.lingview.dimstack.service.PluginService; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +/** + * @Author: lingview + * @Date: 2026/07/31 22:47:09 + * @Description: 插件自动装配 + * @Version: 1.0 + */ +@Slf4j +@Configuration +public class PluginAutoConfiguration implements SmartInitializingSingleton { + + private final ApplicationContext applicationContext; + + public PluginAutoConfiguration(ApplicationContext applicationContext) { + this.applicationContext = applicationContext; + } + + @Bean + public YamlPluginDescriptorFinder yamlPluginDescriptorFinder() { + return new YamlPluginDescriptorFinder(); + } + + @Bean + public PluginApplicationContextFactory pluginApplicationContextFactory(ApplicationContext rootContext, + YamlPluginDescriptorFinder finder, + SettingFetcherImpl settingFetcher, + PluginDbRegistry pluginDbRegistry) { + return new DefaultPluginApplicationContextFactory(rootContext, finder, settingFetcher, pluginDbRegistry); + } + + @Bean + public HandlerMapping pluginRequestMappingHandlerMapping(PluginPermissionInterceptor pluginPermissionInterceptor) { + PluginRequestMappingHandlerMapping mapping = new PluginRequestMappingHandlerMapping(); + mapping.setOrder(1); + mapping.setInterceptors(pluginPermissionInterceptor); + return mapping; + } + + @Bean(destroyMethod = "stopPlugins") + public DimStackPluginManager dimStackPluginManager(PluginApplicationContextFactory factory, + HandlerMapping pluginRequestMappingHandlerMapping, + YamlPluginDescriptorFinder yamlPluginDescriptorFinder, + PluginPermissionRegistrar pluginPermissionRegistrar) { + Path pluginsRoot = Path.of(System.getProperty("user.dir"), "plugins"); + try { + Files.createDirectories(pluginsRoot); + } catch (IOException e) { + log.warn("创建插件目录失败: {}", pluginsRoot, e); + } + DimStackPluginManager manager = new DimStackPluginManager(pluginsRoot, factory, yamlPluginDescriptorFinder); + if (pluginRequestMappingHandlerMapping instanceof PluginRequestMappingHandlerMapping mapping) { + manager.setRequestMappingHandlerMapping(mapping); + } + manager.setPermissionRegistrar(pluginPermissionRegistrar); + try { + manager.loadPlugins(); + } catch (Exception e) { + + log.error("插件扫描加载失败(不影响宿主启动, 请检查 plugins 目录)", e); + } + log.info("插件管理器初始化完成, 插件目录: {}", pluginsRoot); + return manager; + } + + @Override + public void afterSingletonsInstantiated() { + PluginService pluginService = applicationContext.getBean(PluginService.class); + + try { + pluginService.startEnabledPlugins(); + } catch (Exception e) { + log.error("插件启动阶段失败(不影响宿主启动)", e); + } + + try { + pluginService.syncLoadedPlugins(); + } catch (Exception e) { + log.error("插件启动同步失败(不影响宿主启动)", e); + } + + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginDbImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginDbImpl.java new file mode 100644 index 0000000..947145f --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginDbImpl.java @@ -0,0 +1,69 @@ +package xyz.lingview.dimstack.plugin; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import org.springframework.boot.jdbc.autoconfigure.DataSourceProperties; +import xyz.lingview.dimstack.plugin.api.PluginDb; + +import javax.sql.DataSource; +import java.util.regex.Pattern; + +/** + * @Author: lingview + * @Date: 2026/10/02 20:09:53 + * @Description: 插件数据库访问实现 + * @Version: 1.0 + */ +public class PluginDbImpl implements PluginDb, AutoCloseable { + + private static final Pattern TABLE_SUFFIX = Pattern.compile("[a-z0-9_]{1,40}"); + private static final int MAX_POOL_SIZE = 4; + + private final String prefix; + private final HikariDataSource pool; + private final DataSource audited; + + PluginDbImpl(String pluginId, DataSourceProperties properties, PluginSqlAuditor auditor) { + // 前缀归一化: id 白名单允许 . 和 -, 表名里统一替换为 _, 避免生成"库.表"语义或大小写混用的前缀 + this.prefix = "plugin_" + pluginId.toLowerCase().replace('-', '_').replace('.', '_') + "_"; + HikariConfig config = new HikariConfig(); + config.setJdbcUrl(properties.getUrl()); + config.setUsername(properties.getUsername()); + config.setPassword(properties.getPassword()); + if (properties.getDriverClassName() != null) { + config.setDriverClassName(properties.getDriverClassName()); + } + config.setPoolName("plugin-" + pluginId); + config.setMaximumPoolSize(MAX_POOL_SIZE); + config.setMinimumIdle(0); + config.setConnectionTimeout(3000); + this.pool = new HikariDataSource(config); + this.audited = PluginSqlAuditProxy.wrap(pool, pluginId, auditor); + } + + @Override + public String table(String name) { + if (name == null || !TABLE_SUFFIX.matcher(name).matches()) { + throw new IllegalArgumentException("表名只允许小写字母/数字/下划线: " + name); + } + return prefix + name; + } + + @Override + public DataSource dataSource() { + return audited; + } + + String tablePrefix() { + return prefix; + } + + boolean isClosed() { + return pool.isClosed(); + } + + @Override + public void close() { + pool.close(); + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginDbRegistry.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginDbRegistry.java new file mode 100644 index 0000000..6b85547 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginDbRegistry.java @@ -0,0 +1,58 @@ +package xyz.lingview.dimstack.plugin; + +import lombok.extern.slf4j.Slf4j; +import org.pf4j.PluginRuntimeException; +import org.springframework.boot.jdbc.autoconfigure.DataSourceProperties; +import org.springframework.stereotype.Component; +import xyz.lingview.dimstack.plugin.api.PluginDb; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * @Author: lingview + * @Date: 2026/10/03 10:37:18 + * @Description: 插件数据库连接池注册表 + * @Version: 1.0 + */ +@Slf4j +@Component +public class PluginDbRegistry { + + private final DataSourceProperties dataSourceProperties; + private final PluginSqlAuditor auditor; + private final Map instances = new ConcurrentHashMap<>(); + + public PluginDbRegistry(DataSourceProperties dataSourceProperties, PluginSqlAuditor auditor) { + this.dataSourceProperties = dataSourceProperties; + this.auditor = auditor; + } + + public PluginDb get(String pluginId) { + PluginDbImpl cached = instances.get(pluginId); + if (cached != null && cached.isClosed()) { + dispose(pluginId); + } + return instances.computeIfAbsent(pluginId, id -> { + PluginDbImpl db = new PluginDbImpl(id, dataSourceProperties, auditor); + for (Map.Entry entry : instances.entrySet()) { + // 归一化后前缀相同会共用表空间, 直接拒绝启动(如 Demo 与 demo, a.b 与 a-b) + if (!entry.getKey().equals(id) && entry.getValue().tablePrefix().equals(db.tablePrefix())) { + db.close(); + throw new PluginRuntimeException("插件表前缀冲突: " + id + + " 与 " + entry.getKey() + " 归一化后同为 " + db.tablePrefix()); + } + } + log.info("插件连接池已创建: {} (最大连接 {})", id, 4); + return db; + }); + } + + public void dispose(String pluginId) { + PluginDbImpl db = instances.remove(pluginId); + if (db != null) { + db.close(); + log.info("插件连接池已释放: {}", pluginId); + } + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginExtensionLoader.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginExtensionLoader.java new file mode 100644 index 0000000..dd01674 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginExtensionLoader.java @@ -0,0 +1,116 @@ +package xyz.lingview.dimstack.plugin; + +import lombok.extern.slf4j.Slf4j; +import org.pf4j.PluginWrapper; +import org.springframework.stereotype.Component; +import xyz.lingview.dimstack.domain.DashboardMenu; +import xyz.lingview.dimstack.mapper.DashboardMenuMapper; + +import java.io.InputStream; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * @Author: lingview + * @Date: 2026/08/07 19:38:56 + * @Description: 插件菜单加载 + * @Version: 1.0 + */ +@Slf4j +@Component +public class PluginExtensionLoader { + + private static final Integer PLUGIN_MENU_PARENT_ID = 113; + private static final String MENU_FILE = "extensions/menu.yaml"; + private static final String DEFAULT_PERMISSION = "plugin:management"; + + private final DashboardMenuMapper dashboardMenuMapper; + private final YamlPluginDescriptorFinder descriptorFinder; + + public PluginExtensionLoader(DashboardMenuMapper dashboardMenuMapper, + YamlPluginDescriptorFinder descriptorFinder) { + this.dashboardMenuMapper = dashboardMenuMapper; + this.descriptorFinder = descriptorFinder; + } + + public void loadPluginExtensions(String pluginId, PluginWrapper wrapper) { + deletePluginMenus(pluginId); + + InputStream in = wrapper.getPluginClassLoader().getResourceAsStream(MENU_FILE); + if (in == null) { + return; + } + try (in) { + Map loaded = PluginYamlLoader.load(in); + Object menus = loaded.get("dashboard-menu"); + if (!(menus instanceof List menuList) || menuList.isEmpty()) { + return; + } + + Set declaredPermissions = declaredPermissions(pluginId); + Integer baseSort = dashboardMenuMapper.selectMaxSortByParent(PLUGIN_MENU_PARENT_ID); + int sort = baseSort == null ? 0 : baseSort; + int count = 0; + for (Object item : menuList) { + if (!(item instanceof Map menuMap)) { + continue; + } + DashboardMenu menu = new DashboardMenu(); + menu.setTitle(String.valueOf(((Map) menuMap).get("title"))); + menu.setLink(String.valueOf(((Map) menuMap).get("link"))); + Object icon = ((Map) menuMap).get("icon"); + menu.setIcon(icon != null ? icon.toString() : "plugin"); + Object permission = ((Map) menuMap).get("permission"); + String permissionCode = permission != null ? permission.toString() : DEFAULT_PERMISSION; + if (!permissionCode.contains(":")) { + String expanded = "plugin:" + pluginId + ":" + permissionCode; + if (!declaredPermissions.contains(expanded)) { + log.warn("插件菜单权限码未在 plugin.yaml 的 permissions 中声明, 已跳过该菜单项: {} (permission={})", + pluginId, permissionCode); + continue; + } + permissionCode = expanded; + } + menu.setPermission_code(permissionCode); + menu.setParent_id(PLUGIN_MENU_PARENT_ID); + menu.setSort_order(++sort); + menu.setType("sidebar"); + dashboardMenuMapper.insert(menu); + count++; + } + if (count > 0) { + log.info("插件菜单贡献已加载: {} ({} 条)", pluginId, count); + } + } catch (Exception e) { + log.warn("插件数据贡献加载失败: {}", pluginId, e); + } + } + + public void cleanupPluginExtensions(String pluginId) { + int deleted = deletePluginMenus(pluginId); + if (deleted > 0) { + log.info("插件菜单贡献已清理: {} ({} 条)", pluginId, deleted); + } + } + + private Set declaredPermissions(String pluginId) { + PluginManifest manifest = descriptorFinder.getManifest(pluginId); + if (manifest == null || manifest.getPermissions() == null) { + return Set.of(); + } + return manifest.getPermissions().stream() + .map(permission -> "plugin:" + pluginId + ":" + permission.getCode()) + .collect(Collectors.toSet()); + } + + private int deletePluginMenus(String pluginId) { + String prefix = "/dashboard/plugins/" + pluginId + "/"; + List ids = dashboardMenuMapper.findByLinkPrefix(prefix).stream() + .filter(menu -> menu.getLink() != null && menu.getLink().startsWith(prefix)) + .map(DashboardMenu::getId) + .toList(); + return ids.isEmpty() ? 0 : dashboardMenuMapper.deleteByIds(ids); + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginLifecycleGuard.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginLifecycleGuard.java new file mode 100644 index 0000000..6a1a50c --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginLifecycleGuard.java @@ -0,0 +1,48 @@ +package xyz.lingview.dimstack.plugin; + +import org.springframework.stereotype.Component; + +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.function.Supplier; + +/** + * @Author: lingview + * @Date: 2026/10/04 09:41:22 + * @Description: 插件生命周期并发屏障 + * @Version: 1.0 + */ +@Component +public class PluginLifecycleGuard { + + private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); + + /** 生命周期变更(安装/启停/重载/升级/卸载)全局串行: pf4j 内部结构非线程安全 */ + public T withWriteLock(Supplier action) { + Lock write = lock.writeLock(); + write.lock(); + try { + return action.get(); + } finally { + write.unlock(); + } + } + + public void withWriteLock(Runnable action) { + withWriteLock(() -> { + action.run(); + return null; + }); + } + + /** 读取扩展等只读操作: 与生命周期变更互斥, 避免拿到已关闭容器里的 bean */ + public T withReadLock(Supplier action) { + Lock read = lock.readLock(); + read.lock(); + try { + return action.get(); + } finally { + read.unlock(); + } + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginManifest.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginManifest.java new file mode 100644 index 0000000..aedd4e8 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginManifest.java @@ -0,0 +1,43 @@ +package xyz.lingview.dimstack.plugin; + +import lombok.Data; + +import java.util.List; + +/** + * @Author: lingview + * @Date: 2026/07/31 22:47:09 + * @Description: 插件描述信息 + * @Version: 1.0 + */ +@Data +public class PluginManifest { + + private String id; + + private String version; + + private String requires; + + private String displayName; + + private String description; + + private String author; + + private String settingName; + + private String configMapName; + + private String scanPackage; + + private String pluginClass; + + private List publicApiPaths; + + private List managedPaths; + + private List permissions; + + private boolean enabled; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginPermission.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginPermission.java new file mode 100644 index 0000000..ef9de64 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginPermission.java @@ -0,0 +1,17 @@ +package xyz.lingview.dimstack.plugin; + +import lombok.Data; + +/** + * @Author: lingview + * @Date: 2026/10/09 14:40:31 + * @Description: 插件权限码声明 + * @Version: 1.0 + */ +@Data +public class PluginPermission { + + private String code; + + private String name; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginPermissionInterceptor.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginPermissionInterceptor.java new file mode 100644 index 0000000..50902ad --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginPermissionInterceptor.java @@ -0,0 +1,57 @@ +package xyz.lingview.dimstack.plugin; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; +import xyz.lingview.dimstack.plugin.api.RequiresPermission; +import xyz.lingview.dimstack.service.CurrentUserService; +import xyz.lingview.dimstack.service.UserPermissionCheckService; + +/** + * @Author: lingview + * @Date: 2026/10/09 14:39:46 + * @Description: 插件接口权限拦截 + * @Version: 1.0 + */ +@Component +public class PluginPermissionInterceptor implements HandlerInterceptor { + + private final CurrentUserService currentUserService; + private final UserPermissionCheckService permissionCheckService; + + public PluginPermissionInterceptor(CurrentUserService currentUserService, + UserPermissionCheckService permissionCheckService) { + this.currentUserService = currentUserService; + this.permissionCheckService = permissionCheckService; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + if (!(handler instanceof HandlerMethod handlerMethod)) { + return true; + } + RequiresPermission requiresPermission = handlerMethod.getMethodAnnotation(RequiresPermission.class); + if (requiresPermission == null) { + requiresPermission = handlerMethod.getBeanType().getAnnotation(RequiresPermission.class); + } + if (requiresPermission == null) { + return true; + } + String username = currentUserService.getCurrentUsername(); + if (username == null) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return false; + } + String[] permissions = requiresPermission.value(); + boolean hasPermission = requiresPermission.all() + ? permissionCheckService.hasAllPermissions(username, permissions) + : permissionCheckService.hasAnyPermission(username, permissions); + if (!hasPermission) { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + return false; + } + return true; + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginPermissionRegistrar.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginPermissionRegistrar.java new file mode 100644 index 0000000..f28518f --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginPermissionRegistrar.java @@ -0,0 +1,74 @@ +package xyz.lingview.dimstack.plugin; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import xyz.lingview.dimstack.mapper.PermissionMapper; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * @Author: lingview + * @Date: 2026/10/09 14:40:31 + * @Description: 插件权限码注册与清理 + * @Version: 1.0 + */ +@Slf4j +@Component +public class PluginPermissionRegistrar { + + private static final String CODE_LITERAL_PREFIX = "plugin:"; + + private final PermissionMapper permissionMapper; + + public PluginPermissionRegistrar(PermissionMapper permissionMapper) { + this.permissionMapper = permissionMapper; + } + + public void registerOnStart(String pluginId, PluginManifest manifest) { + try { + if (manifest == null || manifest.getPermissions() == null) { + return; + } + Set declared = new HashSet<>(); + for (PluginPermission permission : manifest.getPermissions()) { + String fullCode = fullCode(pluginId, permission.getCode()); + declared.add(fullCode); + permissionMapper.upsertCode(fullCode, permission.getName()); + } + List stale = ownedCodes(pluginId).stream() + .filter(code -> !declared.contains(code)) + .toList(); + if (!stale.isEmpty()) { + permissionMapper.deleteByCodes(stale); + log.info("已清理插件未再声明的权限码: {} ({})", pluginId, stale); + } + } catch (Exception e) { + log.error("插件权限码注册失败(插件继续启动, 相关接口可能不可授权): {} ({})", pluginId, e.getMessage(), e); + } + } + + public void unregisterOnUninstall(String pluginId) { + try { + List owned = ownedCodes(pluginId); + if (!owned.isEmpty()) { + permissionMapper.deleteByCodes(owned); + log.info("插件权限码已清理: {} ({})", pluginId, owned); + } + } catch (Exception e) { + log.error("插件权限码清理失败: {} ({})", pluginId, e.getMessage(), e); + } + } + + private List ownedCodes(String pluginId) { + String ownedPrefix = fullCode(pluginId, ""); + return permissionMapper.findCodesByLiteralPrefix(CODE_LITERAL_PREFIX).stream() + .filter(code -> code.startsWith(ownedPrefix)) + .toList(); + } + + private String fullCode(String pluginId, String shortCode) { + return CODE_LITERAL_PREFIX + pluginId + ":" + shortCode; + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginRequestMappingHandlerMapping.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginRequestMappingHandlerMapping.java new file mode 100644 index 0000000..b0aeff6 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginRequestMappingHandlerMapping.java @@ -0,0 +1,106 @@ +package xyz.lingview.dimstack.plugin; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; +import org.springframework.web.servlet.mvc.method.RequestMappingInfo; +import org.springframework.web.servlet.mvc.condition.PathPatternsRequestCondition; +import org.springframework.web.util.pattern.PathPattern; + +import jakarta.servlet.http.HttpServletRequest; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * @Author: lingview + * @Date: 2026/07/31 22:47:09 + * @Description: 插件路由注册 + * @Version: 1.0 + */ +@Slf4j +public class PluginRequestMappingHandlerMapping extends RequestMappingHandlerMapping { + + private final Map pluginContexts = new ConcurrentHashMap<>(); + private final Map> pluginMappings = new ConcurrentHashMap<>(); + + private final Map> pluginEndpointPaths = new ConcurrentHashMap<>(); + + private final Map beanOwners = new ConcurrentHashMap<>(); + + private final Set stoppedPlugins = ConcurrentHashMap.newKeySet(); + + public synchronized void registerPlugin(String pluginId, AnnotationConfigApplicationContext context) { + pluginContexts.put(pluginId, context); + stoppedPlugins.remove(pluginId); + + Map before = new HashMap<>(getHandlerMethods()); + for (String beanName : context.getBeanDefinitionNames()) { + Object bean = context.getBean(beanName); + if (isHandler(bean.getClass())) { + detectHandlerMethods(bean); + } + } + Set added = new HashSet<>(getHandlerMethods().keySet()); + added.removeAll(before.keySet()); + Set endpointPaths = new HashSet<>(); + for (RequestMappingInfo info : added) { + HandlerMethod handler = getHandlerMethods().get(info); + if (handler != null) { + beanOwners.put(handler.getBeanType().getName(), pluginId); + } + PathPatternsRequestCondition condition = info.getPathPatternsCondition(); + if (condition != null) { + for (PathPattern pattern : condition.getPatterns()) { + endpointPaths.add(pattern.getPatternString()); + } + } + } + pluginMappings.put(pluginId, added); + pluginEndpointPaths.put(pluginId, endpointPaths); + + if (!added.isEmpty()) { + log.info("插件路由注册完成: {} ({} 条路由)", pluginId, added.size()); + } + } + + public synchronized void unregisterPlugin(String pluginId) { + pluginContexts.remove(pluginId); + + stoppedPlugins.add(pluginId); + pluginEndpointPaths.remove(pluginId); + Set mappings = pluginMappings.remove(pluginId); + if (mappings != null) { + for (RequestMappingInfo mapping : mappings) { + unregisterMapping(mapping); + } + log.info("插件路由已注销: {}", pluginId); + } + + } + + @Override + protected HandlerMethod getHandlerInternal(HttpServletRequest request) throws Exception { + HandlerMethod handler = super.getHandlerInternal(request); + if (handler != null) { + String owner = beanOwners.get(handler.getBeanType().getName()); + if (owner != null && stoppedPlugins.contains(owner)) { + + return null; + } + } + return handler; + } + + public boolean hasEndpoint(String pluginId, String path) { + if (stoppedPlugins.contains(pluginId)) { + return false; + } + Set paths = pluginEndpointPaths.get(pluginId); + return paths != null && paths.contains(path); + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginSqlAuditProxy.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginSqlAuditProxy.java new file mode 100644 index 0000000..129b7ba --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginSqlAuditProxy.java @@ -0,0 +1,144 @@ +package xyz.lingview.dimstack.plugin; + +import javax.sql.DataSource; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.sql.CallableStatement; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; + +/** + * @Author: lingview + * @Date: 2026/10/02 11:44:29 + * @Description: 插件SQL审计代理 + * @Version: 1.0 + */ +final class PluginSqlAuditProxy { + + private PluginSqlAuditProxy() { + } + + static DataSource wrap(DataSource delegate, String pluginId, PluginSqlAuditor auditor) { + return (DataSource) Proxy.newProxyInstance(PluginSqlAuditProxy.class.getClassLoader(), + new Class[]{DataSource.class}, (proxy, method, args) -> { + if (isUnwrap(method, args)) { + return wrapUnwrapped(invoke(delegate, method, args), null, pluginId, auditor); + } + Object result = invoke(delegate, method, args); + return result instanceof Connection connection + ? wrapConnection(connection, pluginId, auditor) : result; + }); + } + + private static Connection wrapConnection(Connection connection, String pluginId, PluginSqlAuditor auditor) { + return (Connection) Proxy.newProxyInstance(PluginSqlAuditProxy.class.getClassLoader(), + new Class[]{Connection.class}, (proxy, method, args) -> { + if (isUnwrap(method, args)) { + return wrapUnwrapped(invoke(connection, method, args), null, pluginId, auditor); + } + Object result = invoke(connection, method, args); + if (result instanceof Statement statement) { + String sql = args != null && args.length > 0 && args[0] instanceof String s ? s : null; + return wrapStatement(statement, sql, pluginId, auditor); + } + if ("getMetaData".equals(method.getName()) && result instanceof DatabaseMetaData metaData) { + return wrapMetaData(metaData, pluginId, auditor); + } + return result; + }); + } + + private static Statement wrapStatement(Statement statement, String preparedSql, + String pluginId, PluginSqlAuditor auditor) { + Class[] interfaces; + if (statement instanceof CallableStatement) { + interfaces = new Class[]{CallableStatement.class}; + } else if (statement instanceof PreparedStatement) { + interfaces = new Class[]{PreparedStatement.class}; + } else { + interfaces = new Class[]{Statement.class}; + } + List batchSql = preparedSql == null ? new ArrayList<>() : null; + return (Statement) Proxy.newProxyInstance(PluginSqlAuditProxy.class.getClassLoader(), interfaces, + (proxy, method, args) -> { + String name = method.getName(); + if (isUnwrap(method, args)) { + return wrapUnwrapped(invoke(statement, method, args), preparedSql, pluginId, auditor); + } + if ("getConnection".equals(name)) { + Object connection = invoke(statement, method, args); + return connection instanceof Connection conn ? wrapConnection(conn, pluginId, auditor) : connection; + } + if (batchSql != null && "addBatch".equals(name) && args != null && args.length == 1 + && args[0] instanceof String sql) { + batchSql.add(sql); + } else if (batchSql != null && "clearBatch".equals(name)) { + batchSql.clear(); + } + long start = System.nanoTime(); + Object result = invoke(statement, method, args); + if (name.startsWith("execute")) { + long cost = (System.nanoTime() - start) / 1_000_000; + boolean batchFlush = "executeBatch".equals(name) || "executeLargeBatch".equals(name); + if (batchFlush && batchSql != null && !batchSql.isEmpty()) { + long per = cost / batchSql.size(); + for (String sql : batchSql) { + auditor.record(pluginId, sql, per); + } + batchSql.clear(); + } else { + String sql = preparedSql; + if (sql == null && args != null && args.length > 0 && args[0] instanceof String direct) { + sql = direct; + } + auditor.record(pluginId, sql, cost); + } + } + return result; + }); + } + + private static DatabaseMetaData wrapMetaData(DatabaseMetaData metaData, String pluginId, PluginSqlAuditor auditor) { + return (DatabaseMetaData) Proxy.newProxyInstance(PluginSqlAuditProxy.class.getClassLoader(), + new Class[]{DatabaseMetaData.class}, (proxy, method, args) -> { + if ("getConnection".equals(method.getName())) { + Object connection = invoke(metaData, method, args); + return connection instanceof Connection conn ? wrapConnection(conn, pluginId, auditor) : connection; + } + return invoke(metaData, method, args); + }); + } + + private static Object wrapUnwrapped(Object unwrapped, String preparedSql, String pluginId, PluginSqlAuditor auditor) { + if (unwrapped instanceof DataSource dataSource) { + return wrap(dataSource, pluginId, auditor); + } + if (unwrapped instanceof Connection connection) { + return wrapConnection(connection, pluginId, auditor); + } + if (unwrapped instanceof Statement statement) { + return wrapStatement(statement, preparedSql, pluginId, auditor); + } + if (unwrapped instanceof DatabaseMetaData metaData) { + return wrapMetaData(metaData, pluginId, auditor); + } + return unwrapped; + } + + private static boolean isUnwrap(Method method, Object[] args) { + return "unwrap".equals(method.getName()) && args != null && args.length == 1 && args[0] instanceof Class; + } + + private static Object invoke(Object target, Method method, Object[] args) throws Throwable { + try { + return method.invoke(target, args); + } catch (InvocationTargetException e) { + throw e.getTargetException(); + } + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginSqlAuditor.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginSqlAuditor.java new file mode 100644 index 0000000..85244d1 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginSqlAuditor.java @@ -0,0 +1,111 @@ +package xyz.lingview.dimstack.plugin; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import xyz.lingview.dimstack.domain.PluginSqlAudit; +import xyz.lingview.dimstack.mapper.PluginSqlAuditMapper; + +import java.util.List; +import java.util.regex.Pattern; + +/** + * @Author: lingview + * @Date: 2026/10/01 22:13:07 + * @Description: 插件SQL审计 + * @Version: 1.0 + */ +@Slf4j +@Component +public class PluginSqlAuditor { + + private static final int MAX_SQL_LENGTH = 2000; + private static final long SLOW_MILLIS = 3000; + + private static final Pattern DDL = Pattern.compile("^(CREATE|ALTER|DROP|TRUNCATE|RENAME)\\b", Pattern.CASE_INSENSITIVE); + private static final Pattern WRITE = Pattern.compile("^(INSERT|UPDATE|DELETE|REPLACE|MERGE)\\b", Pattern.CASE_INSENSITIVE); + private static final Pattern CTE_WRITE = Pattern.compile("\\b(UPDATE|DELETE|INSERT|REPLACE|MERGE)\\b", Pattern.CASE_INSENSITIVE); + + private static final List CORE_TABLES = List.of( + "article", "article_categories", "article_tag", "article_tag_relation", "article_like", + "comment", "comment_like", "user_information", "user_role", "role", "role_permission", + "permission", "api_key", "site_config", "attachment", "dashboard_menu", "menus", + "storage_method", "llm_config", "systematic_notification", "plugin", "plugin_config" + ).stream().map(t -> Pattern.compile("\\b" + t + "\\b", Pattern.CASE_INSENSITIVE)).toList(); + + private final PluginSqlAuditMapper auditMapper; + + public PluginSqlAuditor(PluginSqlAuditMapper auditMapper) { + this.auditMapper = auditMapper; + } + + public void record(String pluginId, String sql, long costMillis) { + String category = classify(sql, costMillis); + if (category == null) { + return; + } + String text = sql == null ? "" : sql.trim(); + if (text.length() > MAX_SQL_LENGTH) { + text = text.substring(0, MAX_SQL_LENGTH); + } + try { + PluginSqlAudit audit = new PluginSqlAudit(); + audit.setPlugin_name(pluginId); + audit.setCategory(category); + audit.setSql_text(text); + audit.setCost_millis(costMillis); + auditMapper.insert(audit); + } catch (Exception e) { + log.warn("插件SQL审计写入失败: plugin={}, category={}", pluginId, category, e); + } + if ("core_write".equals(category)) { + log.warn("[插件审计] 插件修改宿主核心表: plugin={} 耗时={}ms SQL={}", pluginId, costMillis, text); + } else if ("slow".equals(category)) { + log.warn("[插件审计] 插件慢SQL: plugin={} 耗时={}ms SQL={}", pluginId, costMillis, text); + } else { + log.info("[插件审计] 插件DDL: plugin={} SQL={}", pluginId, text); + } + } + + private String classify(String sql, long costMillis) { + if (sql == null || sql.isBlank()) { + return null; + } + String statement = stripLeadingComments(sql); + if (DDL.matcher(statement).find()) { + return "ddl"; + } + boolean writeStatement = WRITE.matcher(statement).find() + || statement.regionMatches(true, 0, "WITH", 0, 4) && CTE_WRITE.matcher(statement).find(); + if (writeStatement) { + for (Pattern table : CORE_TABLES) { + if (table.matcher(statement).find()) { + return "core_write"; + } + } + } + return costMillis >= SLOW_MILLIS ? "slow" : null; + } + + private String stripLeadingComments(String sql) { + String s = sql.strip(); + while (true) { + if (s.startsWith("--") || s.startsWith("#")) { + int end = s.indexOf('\n'); + if (end < 0) { + return ""; + } + s = s.substring(end + 1).strip(); + continue; + } + if (s.startsWith("/*")) { + int end = s.indexOf("*/"); + if (end < 0) { + return ""; + } + s = s.substring(end + 2).strip(); + continue; + } + return s; + } + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginYamlLoader.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginYamlLoader.java new file mode 100644 index 0000000..58a911d --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/PluginYamlLoader.java @@ -0,0 +1,34 @@ +package xyz.lingview.dimstack.plugin; + +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; + +import java.io.InputStream; +import java.util.Collections; +import java.util.Map; + +// 插件jar内的yaml属不受信任输入: SafeConstructor禁!!任意类实例化, 并限制码点数与别名数; Yaml非线程安全, 故每次新建 +/** + * @Author: lingview + * @Date: 2026/09/19 20:33:56 + * @Description: 插件YAML安全加载 + * @Version: 1.0 + */ +public final class PluginYamlLoader { + + private static final int MAX_CODE_POINTS = 1_048_576; + private static final int MAX_ALIASES_FOR_COLLECTIONS = 50; + + private PluginYamlLoader() { + } + + @SuppressWarnings("unchecked") + public static Map load(InputStream in) { + LoaderOptions options = new LoaderOptions(); + options.setCodePointLimit(MAX_CODE_POINTS); + options.setMaxAliasesForCollections(MAX_ALIASES_FOR_COLLECTIONS); + Object loaded = new Yaml(new SafeConstructor(options)).load(in); + return loaded instanceof Map map ? (Map) map : Collections.emptyMap(); + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/SettingFetcherImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/SettingFetcherImpl.java new file mode 100644 index 0000000..63c063f --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/SettingFetcherImpl.java @@ -0,0 +1,46 @@ +package xyz.lingview.dimstack.plugin; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import xyz.lingview.dimstack.mapper.PluginConfigMapper; + +/** + * @Author: lingview + * @Date: 2026/07/31 22:47:09 + * @Description: 插件配置读取实现 + * @Version: 1.0 + */ +@Slf4j +@Service +public class SettingFetcherImpl { + + public static final String DEFAULT_CONFIG_KEY = "config"; + + private final PluginConfigMapper configMapper; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public SettingFetcherImpl(PluginConfigMapper configMapper) { + this.configMapper = configMapper; + } + + public T fetch(String pluginName, String configMapName, String key, Class clazz) { + String configKey = configMapName != null && !configMapName.isBlank() ? configMapName : DEFAULT_CONFIG_KEY; + String json = configMapper.selectValue(pluginName, configKey); + if (json == null || json.isBlank()) { + return null; + } + try { + JsonNode root = objectMapper.readTree(json); + JsonNode node = root.get(key); + if (node == null || node.isNull()) { + return null; + } + return objectMapper.treeToValue(node, clazz); + } catch (Exception e) { + log.warn("插件配置读取失败: plugin={}, key={}", pluginName, key, e); + return null; + } + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/SpringPluginFactory.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/SpringPluginFactory.java new file mode 100644 index 0000000..4a2a70c --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/SpringPluginFactory.java @@ -0,0 +1,29 @@ +package xyz.lingview.dimstack.plugin; + +import org.pf4j.DefaultPluginFactory; +import org.pf4j.Plugin; +import org.pf4j.PluginWrapper; + +/** + * @Author: lingview + * @Date: 2026/07/31 22:47:09 + * @Description: 插件实例工厂 + * @Version: 1.0 + */ +public class SpringPluginFactory extends DefaultPluginFactory { + + private final PluginApplicationContextFactory contextFactory; + private final DimStackPluginManager pluginManager; + + public SpringPluginFactory(PluginApplicationContextFactory contextFactory, + DimStackPluginManager pluginManager) { + this.contextFactory = contextFactory; + this.pluginManager = pluginManager; + } + + @Override + public Plugin create(PluginWrapper pluginWrapper) { + return new DefaultSpringPlugin(pluginWrapper, contextFactory, pluginManager, + pluginManager.getRequestMappingHandlerMapping()); + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/UiPluginBundleService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/UiPluginBundleService.java new file mode 100644 index 0000000..523b541 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/UiPluginBundleService.java @@ -0,0 +1,14 @@ +package xyz.lingview.dimstack.plugin; + +import java.util.List; + +/** + * @Author: lingview + * @Date: 2026/08/14 20:47:33 + * @Description: 插件前端信息接口 + * @Version: 1.0 + */ +public interface UiPluginBundleService { + + List listProviders(); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/UiPluginBundleServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/UiPluginBundleServiceImpl.java new file mode 100644 index 0000000..1868a4f --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/UiPluginBundleServiceImpl.java @@ -0,0 +1,123 @@ +package xyz.lingview.dimstack.plugin; + +import lombok.extern.slf4j.Slf4j; +import org.pf4j.PluginState; +import org.pf4j.PluginWrapper; +import org.springframework.stereotype.Service; +import tools.jackson.databind.ObjectMapper; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; + +import org.springframework.web.util.UriUtils; +import xyz.lingview.dimstack.domain.PluginInfo; +import xyz.lingview.dimstack.mapper.PluginMapper; + +/** + * @Author: lingview + * @Date: 2026/08/14 20:47:33 + * @Description: 插件前端信息聚合 + * @Version: 1.0 + */ +@Slf4j +@Service +public class UiPluginBundleServiceImpl implements UiPluginBundleService { + + private static final String MANIFEST_FILE = "ui/ui-plugin.json"; + + private final DimStackPluginManager pluginManager; + private final PluginMapper pluginMapper; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public UiPluginBundleServiceImpl(DimStackPluginManager pluginManager, PluginMapper pluginMapper) { + this.pluginManager = pluginManager; + this.pluginMapper = pluginMapper; + } + + @Override + public List listProviders() { + List providers = new ArrayList<>(); + for (PluginWrapper wrapper : pluginManager.getPlugins()) { + if (wrapper.getPluginState() != PluginState.STARTED) { + continue; + } + UiPluginProviderDescriptor descriptor = buildProvider(wrapper); + if (descriptor != null) { + providers.add(descriptor); + } + } + return providers; + } + + private UiPluginProviderDescriptor buildProvider(PluginWrapper wrapper) { + InputStream in = wrapper.getPluginClassLoader().getResourceAsStream(MANIFEST_FILE); + if (in == null) { + return null; + } + try (in) { + UiPluginProviderDescriptor.UiPluginManifest manifest = + objectMapper.readValue(in, UiPluginProviderDescriptor.UiPluginManifest.class); + if (manifest == null || !"esm".equals(manifest.getFormat()) + || manifest.getEntry() == null || manifest.getEntry().isBlank()) { + log.warn("插件 {} 的 ui-plugin.json 无效(格式非 esm 或缺少 entry), 已忽略", wrapper.getPluginId()); + return null; + } + + if (!isSafeAssetPath(manifest.getEntry()) || !hasSuffix(manifest.getEntry(), ENTRY_SUFFIXES)) { + log.warn("插件 {} 的 ui 入口非法(仅支持 .js/.mjs 且路径字符受限), 已忽略", wrapper.getPluginId()); + return null; + } + if (manifest.getStyle() != null && !manifest.getStyle().isBlank() + && (!isSafeAssetPath(manifest.getStyle()) || !hasSuffix(manifest.getStyle(), STYLE_SUFFIXES))) { + log.warn("插件 {} 的 ui 样式非法(仅支持 .css 且路径字符受限), 已忽略", wrapper.getPluginId()); + return null; + } + + String idSegment = UriUtils.encodePathSegment(wrapper.getPluginId(), StandardCharsets.UTF_8); + UiPluginProviderDescriptor descriptor = new UiPluginProviderDescriptor(); + descriptor.setName(wrapper.getPluginId()); + descriptor.setVersion(wrapper.getDescriptor().getVersion()); + descriptor.setAssetHash(resolveAssetHash(wrapper.getPluginId())); + descriptor.setManifest(manifest); + descriptor.setEntryUrl("/plugins/" + idSegment + "/assets/ui/" + manifest.getEntry()); + if (manifest.getStyle() != null && !manifest.getStyle().isBlank()) { + descriptor.setStyleUrl("/plugins/" + idSegment + "/assets/ui/" + manifest.getStyle()); + } + return descriptor; + } catch (Exception e) { + log.warn("插件 {} 的 ui-plugin.json 解析失败, 已忽略", wrapper.getPluginId(), e); + return null; + } + } + + private String resolveAssetHash(String pluginId) { + try { + PluginInfo info = pluginMapper.selectByName(pluginId); + return info != null ? info.getSha256() : null; + } catch (Exception e) { + log.warn("读取插件 {} 的内容指纹失败, 前端将回退用版本号做缓存键: {}", pluginId, e.getMessage()); + return null; + } + } + + private static final Pattern SAFE_ASSET_PATH = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._/-]*"); + + private static final Set ENTRY_SUFFIXES = Set.of(".js", ".mjs"); + private static final Set STYLE_SUFFIXES = Set.of(".css"); + + private boolean isSafeAssetPath(String path) { + if (path == null || !SAFE_ASSET_PATH.matcher(path).matches() || path.contains("..")) { + return false; + } + return true; + } + + private boolean hasSuffix(String path, Set suffixes) { + String lower = path.toLowerCase(); + return suffixes.stream().anyMatch(lower::endsWith); + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/UiPluginController.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/UiPluginController.java new file mode 100644 index 0000000..b586377 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/UiPluginController.java @@ -0,0 +1,35 @@ +package xyz.lingview.dimstack.plugin; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import xyz.lingview.dimstack.common.ApiResponse; + +import java.util.List; + +/** + * @Author: lingview + * @Date: 2026/08/14 20:47:33 + * @Description: 插件前端信息接口 + * @Version: 1.0 + */ +@Slf4j +@RestController +public class UiPluginController { + + private final UiPluginBundleService uiPluginBundleService; + + public UiPluginController(UiPluginBundleService uiPluginBundleService) { + this.uiPluginBundleService = uiPluginBundleService; + } + + @GetMapping("/api/ui-plugins/providers") + public ApiResponse> providers() { + try { + return ApiResponse.success(uiPluginBundleService.listProviders()); + } catch (Exception e) { + log.error("获取插件 UI providers 失败", e); + return ApiResponse.error(500, "获取插件 UI providers 失败: " + e.getMessage()); + } + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/UiPluginProviderDescriptor.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/UiPluginProviderDescriptor.java new file mode 100644 index 0000000..38161bc --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/UiPluginProviderDescriptor.java @@ -0,0 +1,34 @@ +package xyz.lingview.dimstack.plugin; + +import lombok.Data; + +/** + * @Author: lingview + * @Date: 2026/08/23 21:07:59 + * @Description: 插件前端描述 + * @Version: 1.0 + */ +@Data +public class UiPluginProviderDescriptor { + + private String name; + private String version; + + private UiPluginManifest manifest; + + private String entryUrl; + + private String styleUrl; + + private String assetHash; + + @Data + public static class UiPluginManifest { + + private String format; + + private String entry; + + private String style; + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/YamlPluginDescriptorFinder.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/YamlPluginDescriptorFinder.java new file mode 100644 index 0000000..89e85f4 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/plugin/YamlPluginDescriptorFinder.java @@ -0,0 +1,242 @@ +package xyz.lingview.dimstack.plugin; + +import org.pf4j.DefaultPluginDescriptor; +import org.pf4j.PluginDescriptor; +import org.pf4j.PluginDescriptorFinder; +import org.pf4j.PluginRuntimeException; +import org.pf4j.util.StringUtils; + +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.jar.JarFile; +import java.util.regex.Pattern; +import java.util.zip.ZipEntry; + +/** + * @Author: lingview + * @Date: 2026/07/31 22:47:09 + * @Description: 插件描述文件解析 + * @Version: 1.0 + */ +public class YamlPluginDescriptorFinder implements PluginDescriptorFinder { + + private static final String DESCRIPTOR_FILE = "plugin.yaml"; + + private static final Pattern PLUGIN_ID_PATTERN = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"); + + private static final Pattern PLUGIN_VERSION_PATTERN = Pattern.compile("[A-Za-z0-9._+-]{1,32}"); + + private static final Pattern PUBLIC_PATH_SEGMENT_PATTERN = Pattern.compile("[A-Za-z0-9._~-]+"); + + private static final Set RESERVED_PUBLIC_PATHS = Set.of("/config", "/start", "/stop", "/upgrade", "/reload"); + + private static final int PUBLIC_PATH_MAX_LENGTH = 128; + + private static final Pattern MANAGED_PATH_PATTERN = Pattern.compile("themes/[A-Za-z0-9][A-Za-z0-9._-]{0,63}"); + + private static final Pattern PERMISSION_DECL_CODE_PATTERN = Pattern.compile("[a-z0-9_]{1,40}"); + + private static final int PERMISSION_FULL_CODE_MAX_LENGTH = 100; + + private final Map manifests = new ConcurrentHashMap<>(); + + @Override + public boolean isApplicable(Path pluginPath) { + return true; + } + + @Override + public PluginDescriptor find(Path pluginPath) { + Map data = readDescriptor(pluginPath); + PluginManifest manifest = parseManifest(data); + + if (StringUtils.isNullOrEmpty(manifest.getId()) || StringUtils.isNullOrEmpty(manifest.getVersion())) { + throw new PluginRuntimeException("plugin.yaml 缺少必填字段 id/version: " + pluginPath); + } + if (StringUtils.isNullOrEmpty(manifest.getDisplayName())) { + throw new PluginRuntimeException("plugin.yaml 缺少必填字段 displayName: " + pluginPath); + } + if (!PLUGIN_ID_PATTERN.matcher(manifest.getId()).matches()) { + throw new PluginRuntimeException( + "plugin.yaml 的 id 含非法字符(仅允许字母/数字/._-, 且以字母或数字开头): " + manifest.getId()); + } + if (!PLUGIN_VERSION_PATTERN.matcher(manifest.getVersion()).matches()) { + throw new PluginRuntimeException( + "plugin.yaml 的 version 含非法字符(仅允许字母/数字/._+-): " + manifest.getVersion()); + } + + // pf4j3.15构造器参数顺序: (pluginId, description, pluginClass, version, requires, provider, license) + // pf4j3.15的isPluginValid()直接对getRequires()调trim(), 缺省该字段会让NPE冒到宿主启动流程, 故补空串 + + DefaultPluginDescriptor descriptor = new DefaultPluginDescriptor( + manifest.getId(), + manifest.getDescription(), + manifest.getPluginClass(), + manifest.getVersion(), + manifest.getRequires() == null ? "" : manifest.getRequires(), + manifest.getAuthor(), + null); + + manifests.put(manifest.getId(), manifest); + return descriptor; + } + + public PluginManifest getManifest(String pluginId) { + return manifests.get(pluginId); + } + + public void removeManifest(String pluginId) { + manifests.remove(pluginId); + } + + private void validatePublicApiPaths(String pluginId, List paths) { + for (String path : paths) { + if (path == null || !path.startsWith("/") || path.length() > PUBLIC_PATH_MAX_LENGTH) { + throw new PluginRuntimeException( + "插件 " + pluginId + " 的 publicApiPaths 非法(必须以 / 开头且不超过 " + PUBLIC_PATH_MAX_LENGTH + " 字符): " + path); + } + for (String segment : path.substring(1).split("/", -1)) { + if (segment.isEmpty()) { + throw new PluginRuntimeException( + "插件 " + pluginId + " 的 publicApiPaths 不得包含空路径段(// 或尾随 /): " + path); + } + if (segment.equals(".") || segment.equals("..")) { + throw new PluginRuntimeException( + "插件 " + pluginId + " 的 publicApiPaths 不得包含 . 或 .. 路径段: " + path); + } + if (!PUBLIC_PATH_SEGMENT_PATTERN.matcher(segment).matches()) { + throw new PluginRuntimeException( + "插件 " + pluginId + " 的 publicApiPaths 仅允许字母/数字/._~- 且不支持占位符或通配符: " + path); + } + } + if (RESERVED_PUBLIC_PATHS.contains(path)) { + throw new PluginRuntimeException( + "插件 " + pluginId + " 的 publicApiPaths 不得使用宿主保留路径(见 PluginController): " + path); + } + } + } + + private PluginManifest parseManifest(Map data) { + PluginManifest manifest = new PluginManifest(); + manifest.setId(toString(data.get("id"))); + manifest.setVersion(toString(data.get("version"))); + manifest.setRequires(toString(data.get("requires"))); + manifest.setDisplayName(toString(data.get("displayName"))); + manifest.setDescription(toString(data.get("description"))); + manifest.setAuthor(parseAuthor(data.get("author"))); + manifest.setSettingName(toString(data.get("settingName"))); + manifest.setConfigMapName(toString(data.get("configMapName"))); + manifest.setScanPackage(toString(data.get("scanPackage"))); + manifest.setPluginClass(toString(data.get("pluginClass"))); + Object publicPaths = data.get("publicApiPaths"); + if (publicPaths instanceof List list && !list.isEmpty()) { + List paths = list.stream().map(String::valueOf).toList(); + validatePublicApiPaths(manifest.getId(), paths); + manifest.setPublicApiPaths(paths); + } + Object managedPaths = data.get("managedPaths"); + if (managedPaths instanceof List list && !list.isEmpty()) { + List paths = list.stream().map(String::valueOf).toList(); + for (String path : paths) { + if (path == null || !MANAGED_PATH_PATTERN.matcher(path).matches()) { + throw new PluginRuntimeException( + "插件 " + manifest.getId() + " 的 managedPaths 仅允许 themes/<主题名> 形式(单层目录): " + path); + } + } + manifest.setManagedPaths(paths); + } + Object permissions = data.get("permissions"); + if (permissions != null) { + manifest.setPermissions(parsePermissions(manifest.getId(), permissions)); + } + Object enabled = data.get("enabled"); + manifest.setEnabled(enabled instanceof Boolean b && b); + return manifest; + } + + @SuppressWarnings("unchecked") + private List parsePermissions(String pluginId, Object permissions) { + if (!(permissions instanceof List list)) { + throw new PluginRuntimeException("插件 " + pluginId + " 的 permissions 必须是列表"); + } + List result = new ArrayList<>(); + Set seen = new HashSet<>(); + for (Object item : list) { + if (!(item instanceof Map map)) { + throw new PluginRuntimeException("插件 " + pluginId + " 的 permissions 每一项必须是 {code, name} 结构"); + } + String code = toString(((Map) map).get("code")); + String name = toString(((Map) map).get("name")); + if (code == null || !PERMISSION_DECL_CODE_PATTERN.matcher(code).matches()) { + throw new PluginRuntimeException( + "插件 " + pluginId + " 的 permissions code 仅允许小写字母/数字/下划线(1-40): " + code); + } + if (!seen.add(code)) { + throw new PluginRuntimeException("插件 " + pluginId + " 的 permissions code 重复: " + code); + } + if (name == null || name.isBlank()) { + throw new PluginRuntimeException("插件 " + pluginId + " 的 permissions name 不能为空: " + code); + } + String fullCode = "plugin:" + pluginId + ":" + code; + if (fullCode.length() > PERMISSION_FULL_CODE_MAX_LENGTH) { + throw new PluginRuntimeException( + "插件 " + pluginId + " 的权限码超长(最长 " + PERMISSION_FULL_CODE_MAX_LENGTH + " 字符): " + fullCode); + } + PluginPermission permission = new PluginPermission(); + permission.setCode(code); + permission.setName(name); + result.add(permission); + } + return result; + } + + @SuppressWarnings("unchecked") + private String parseAuthor(Object author) { + if (author instanceof Map map) { + Object name = ((Map) map).get("name"); + return name != null ? name.toString() : null; + } + return author != null ? author.toString() : null; + } + + private String toString(Object value) { + return value != null ? value.toString() : null; + } + + @SuppressWarnings("unchecked") + private Map readDescriptor(Path pluginPath) { + try { + if (Files.isDirectory(pluginPath)) { + Path yaml = pluginPath.resolve(DESCRIPTOR_FILE); + if (Files.exists(yaml)) { + try (InputStream in = Files.newInputStream(yaml)) { + return load(in); + } + } + } else { + try (JarFile jar = new JarFile(pluginPath.toFile())) { + ZipEntry entry = jar.getEntry(DESCRIPTOR_FILE); + if (entry != null) { + try (InputStream in = jar.getInputStream(entry)) { + return load(in); + } + } + } + } + } catch (Exception e) { + throw new PluginRuntimeException("读取 plugin.yaml 失败: " + pluginPath, e); + } + throw new PluginRuntimeException("未找到 plugin.yaml: " + pluginPath); + } + + private Map load(InputStream in) { + return PluginYamlLoader.load(in); + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/security/AuthType.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/security/AuthType.java new file mode 100644 index 0000000..944a01d --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/security/AuthType.java @@ -0,0 +1,8 @@ +package xyz.lingview.dimstack.security; + +public enum AuthType { + // 基于 Cookie 的会话认证(浏览器) + SESSION, + // 基于 Authorization: Bearer 的 API Key 令牌认证(CLI / 程序) + TOKEN +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/security/AuthenticatedUser.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/security/AuthenticatedUser.java new file mode 100644 index 0000000..364e7d6 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/security/AuthenticatedUser.java @@ -0,0 +1,30 @@ +package xyz.lingview.dimstack.security; + +public class AuthenticatedUser { + + private final String username; + private volatile String uuid; + private final AuthType authType; + + public AuthenticatedUser(String username, String uuid, AuthType authType) { + this.username = username; + this.uuid = uuid; + this.authType = authType; + } + + public String getUsername() { + return username; + } + + public String getUuid() { + return uuid; + } + + public void setUuid(String uuid) { + this.uuid = uuid; + } + + public AuthType getAuthType() { + return authType; + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/security/RepeatableReadRequestWrapper.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/security/RepeatableReadRequestWrapper.java new file mode 100644 index 0000000..4411f24 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/security/RepeatableReadRequestWrapper.java @@ -0,0 +1,54 @@ +//已弃用 +//package xyz.lingview.dimstack.security; +// +//import jakarta.servlet.ReadListener; +//import jakarta.servlet.ServletInputStream; +//import jakarta.servlet.http.HttpServletRequest; +//import jakarta.servlet.http.HttpServletRequestWrapper; +// +//import java.io.*; +// +//public class RepeatableReadRequestWrapper extends HttpServletRequestWrapper { +// private final byte[] cachedBody; +// +// public RepeatableReadRequestWrapper(HttpServletRequest request) throws IOException { +// super(request); +// InputStream inputStream = request.getInputStream(); +// this.cachedBody = inputStream.readAllBytes(); +// } +// +// @Override +// public ServletInputStream getInputStream() { +// ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(cachedBody); +// return new ServletInputStream() { +// @Override +// public boolean isFinished() { +// return byteArrayInputStream.available() == 0; +// } +// +// @Override +// public boolean isReady() { +// return true; +// } +// +// @Override +// public void setReadListener(ReadListener listener) { +// throw new UnsupportedOperationException(); +// } +// +// @Override +// public int read() { +// return byteArrayInputStream.read(); +// } +// }; +// } +// +// @Override +// public BufferedReader getReader() { +// return new BufferedReader(new InputStreamReader(getInputStream())); +// } +// +// public String getCachedBodyAsString() { +// return new String(cachedBody); +// } +//} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/security/SecurityFilter.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/security/SecurityFilter.java new file mode 100644 index 0000000..4082120 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/security/SecurityFilter.java @@ -0,0 +1,132 @@ +//已弃用 +//package xyz.lingview.dimstack.security; +// +//import jakarta.servlet.*; +//import jakarta.servlet.http.HttpServletRequest; +//import jakarta.servlet.http.HttpServletResponse; +//import lombok.extern.slf4j.Slf4j; +//import org.springframework.core.annotation.Order; +//import org.springframework.stereotype.Component; +// +//import java.io.IOException; +//import java.time.LocalDateTime; +//import java.util.Map; +//import java.util.Set; +//import java.util.regex.Pattern; +// +//@Component +//@Order(1) +//@Slf4j +//public class SecurityFilter implements Filter { +// +// // 白名单(用于富文本等特殊情况) +// private static final Set WHITELIST_VALUES = Set.of("

Rich text allowed

"); +// +// // 敏感字段(不检查) +// private static final Set SENSITIVE_FIELDS = Set.of("token"); +// +// // XSS检测正则 +// private static final Pattern[] XSS_PATTERNS = { +// +// }; +// +// // SQL注入检测正则 +// private static final Pattern[] SQLI_PATTERNS = { +// +// }; +// +// @Override +// public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) +// throws IOException, ServletException { +// +// HttpServletRequest httpRequest = (HttpServletRequest) request; +// HttpServletResponse httpResponse = (HttpServletResponse) response; +// +// String clientIp = getClientIP(httpRequest); +// +// Map params = httpRequest.getParameterMap(); +// for (Map.Entry entry : params.entrySet()) { +// String key = entry.getKey(); +// if (SENSITIVE_FIELDS.contains(key)) continue; +// for (String value : entry.getValue()) { +// if (isMalicious(value)) { +// logAndReject(httpResponse, clientIp, key, value, "RequestParam attack"); +// return; +// } +// } +// } +// +// if (needsBodyCheck(httpRequest)) { +// try { +// RepeatableReadRequestWrapper wrapped = new RepeatableReadRequestWrapper(httpRequest); +// String body = wrapped.getCachedBodyAsString(); +// +// // 检查body是否恶意 +// if (body != null && !body.trim().isEmpty() && !WHITELIST_VALUES.contains(body) && isMalicious(body)) { +// logAndReject(httpResponse, clientIp, "request_body", body, "JSON Body attack"); +// return; +// } +// +// wrapped.setAttribute("wrappedRequest", wrapped); +// chain.doFilter(wrapped, response); +// return; +// } catch (Exception e) { +// log.error("Error reading request body", e); +// httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "Invalid request body"); +// return; +// } +// } +// +// chain.doFilter(request, response); +// } +// +// // 判断字符串是否包含XSS或SQL注入特征 +// +// private boolean isMalicious(String value) { +// if (value == null || value.trim().isEmpty()) return false; +// for (Pattern p : XSS_PATTERNS) { +// if (p.matcher(value).find()) { +// return true; +// } +// } +// for (Pattern p : SQLI_PATTERNS) { +// if (p.matcher(value).find()) { +// return true; +// } +// } +// return false; +// } +// +// +// private boolean needsBodyCheck(HttpServletRequest request) { +// String method = request.getMethod(); +// if (!("POST".equalsIgnoreCase(method) || "PUT".equalsIgnoreCase(method) || "PATCH".equalsIgnoreCase(method))) { +// return false; +// } +// String contentType = request.getContentType(); +// if (contentType == null) return false; +// contentType = contentType.toLowerCase(); +// return contentType.contains("application/json") && !contentType.startsWith("multipart/"); +// } +// +// +// private String getClientIP(HttpServletRequest request) { +// String ip = request.getHeader("X-Forwarded-For"); +// if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { +// ip = request.getRemoteAddr(); +// } +// return ip; +// } +// +// +// private void logAndReject(HttpServletResponse response, String ip, String field, String value, String type) throws IOException { +// log.error("{} - [SECURITY] {} detected, IP: {}, Field: {}, Value: {}", +// LocalDateTime.now(), type, ip, field, mask(value)); +// response.sendError(HttpServletResponse.SC_BAD_REQUEST, type + " detected in field: " + field); +// } +// +// private String mask(String s) { +// if (s == null) return null; +// return s.length() > 512 ? s.substring(0, 512) + "...(truncated)" : s; +// } +//} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/security/SessionAuthFilter.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/security/SessionAuthFilter.java new file mode 100644 index 0000000..6b4f48e --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/security/SessionAuthFilter.java @@ -0,0 +1,222 @@ +package xyz.lingview.dimstack.security; + +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.method.HandlerMethod; +import xyz.lingview.dimstack.annotation.RequiresPermission; +import xyz.lingview.dimstack.plugin.DimStackPluginManager; +import xyz.lingview.dimstack.plugin.PluginManifest; +import xyz.lingview.dimstack.plugin.PluginRequestMappingHandlerMapping; +import xyz.lingview.dimstack.service.UserBlacklistService; +import xyz.lingview.dimstack.service.UserPermissionCheckService; + + +import java.io.IOException; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +@Slf4j +public class SessionAuthFilter implements Filter { + + @Autowired + private UserBlacklistService userBlacklistService; + + @Autowired + private UserPermissionCheckService userPermissionCheckService; + + @Autowired + private TokenAuthResolver tokenAuthResolver; + + /** 插件管理器(用于插件公开 API 路径判断) */ + @Autowired(required = false) + private DimStackPluginManager pluginManager; + + // 不需要认证的路径 + private static final Set WHITE_LIST = new HashSet<>(Arrays.asList( + "/api/login", + "/api/register", + "/swagger-ui.html", + "/v3/api-docs", + "/swagger-ui/**", + "/webjars/**", + "/api/captcha", + "/api/articles", + "/api/categories", + "/api/categories/**", + "/api/tags/**", + "/api/categoriesandcount", + "/api/hot/articles", + "/api/comments/article/**", + "/api/frontendgetmenus", + "/api/site/**", + "/api/article/**", + "/api/friend-links/apply", + "/api/friend-links/approved/page", + "/v3/api-docs/**", + "/api/forgot-password/send-captcha", + "/api/forgot-password/reset", + "/api/forgot-password/verify", + "/api/custom-pages/**", + "/api/proxy/**", + "/api/music/enabled", + "/api/friend-links/site-info", + "/api/announcement", + "/api/random-article", + "/api/ui-plugins/providers" + )); + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + + HttpServletRequest httpRequest = (HttpServletRequest) request; + HttpServletResponse httpResponse = (HttpServletResponse) response; + + String requestURI = httpRequest.getRequestURI(); + boolean whitelisted = isWhitelisted(requestURI); + + try { + AuthenticatedUser principal = resolvePrincipal(httpRequest); + + if (principal != null) { + if (userBlacklistService.isUserInBlacklist(principal.getUsername())) { + log.warn("用户在黑名单中: {}", principal.getUsername()); + if (principal.getAuthType() == AuthType.SESSION) { + HttpSession session = httpRequest.getSession(false); + if (session != null) { + session.invalidate(); + } + } + writeUnauthorized(httpResponse, "该用户已被拉黑"); + return; + } + UserContextHolder.set(principal); + } + + if (whitelisted) { + log.debug("路径在白名单中,放行: {}", requestURI); + chain.doFilter(request, response); + return; + } + if (principal == null) { + log.info("用户未登录或会话已过期: {}", requestURI); + writeUnauthorized(httpResponse, "未登录或会话已过期"); + return; + } + + // if (!checkPermission(httpRequest, httpResponse, principal.getUsername())) { + // return; + // } + + chain.doFilter(request, response); + } finally { + UserContextHolder.clear(); + } + } + + private AuthenticatedUser resolvePrincipal(HttpServletRequest httpRequest) { + if (tokenAuthResolver.hasToken(httpRequest)) { + AuthenticatedUser tokenUser = tokenAuthResolver.resolve(httpRequest); + if (tokenUser != null) { + return tokenUser; + } + return null; + } + + HttpSession session = httpRequest.getSession(false); + if (session != null && Boolean.TRUE.equals(session.getAttribute("isLoggedIn"))) { + Object username = session.getAttribute("username"); + if (username != null) { + return new AuthenticatedUser((String) username, null, AuthType.SESSION); + } + } + return null; + } + + private void writeUnauthorized(HttpServletResponse response, String message) throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"success\":false,\"message\":\"" + message + "\"}"); + } + + + // private boolean checkPermission(HttpServletRequest request, HttpServletResponse response, String username) + // throws IOException { + // + // Object handler = request.getAttribute("handler"); + // if (handler instanceof HandlerMethod handlerMethod) { + // + // RequiresPermission requiresPermission = handlerMethod.getMethodAnnotation(RequiresPermission.class); + // if (requiresPermission != null) { + // String[] requiredPermissions = requiresPermission.value(); + // boolean allRequired = requiresPermission.all(); + // + // boolean hasPermission; + // if (allRequired) { + // hasPermission = userPermissionCheckService.hasAllPermissions(username, requiredPermissions); + // } else { + // hasPermission = userPermissionCheckService.hasAnyPermission(username, requiredPermissions); + // } + // + // if (!hasPermission) { + // log.warn("用户权限不足: 用户={}, 权限={}", username, Arrays.toString(requiredPermissions)); + // response.setStatus(HttpServletResponse.SC_FORBIDDEN); + // response.setContentType("application/json;charset=UTF-8"); + // response.getWriter().write("{\"success\":false,\"message\":\"权限不足\"}"); + // return false; + // } + // + // return true; + // } + // } + // + // // 如果没有@RequiresPermission注解,则放行 + // return true; + // } + + private boolean isWhitelisted(String requestURI) { + boolean isWhitelisted = WHITE_LIST.stream().anyMatch(pattern -> { + if (pattern.endsWith("/**")) { + String prefix = pattern.substring(0, pattern.length() - 3); + return requestURI.startsWith(prefix); + } else { + return requestURI.equals(pattern); + } + }); + if (!isWhitelisted) { + isWhitelisted = isPluginPublicPath(requestURI); + } + log.debug("路径是否在白名单中: {} -> {}", requestURI, isWhitelisted); + return isWhitelisted; + } + + /** + * 插件声明的公开 API 路径(plugin.yaml 的 publicApiPaths, 相对 /api/plugins/{name}), + * 无需登录即可访问。插件自行决定哪些接口公开。 + */ + private boolean isPluginPublicPath(String requestURI) { + if (pluginManager == null || !requestURI.startsWith("/api/plugins/")) { + return false; + } + String rest = requestURI.substring("/api/plugins/".length()); + int slash = rest.indexOf('/'); + if (slash <= 0) { + return false; + } + String pluginName = rest.substring(0, slash); + String subPath = rest.substring(slash); + PluginManifest manifest = pluginManager.getManifest(pluginName); + if (manifest == null || manifest.getPublicApiPaths() == null + || !manifest.getPublicApiPaths().contains(subPath)) { + return false; + } + // 声明的公开路径必须真的由该插件注册的 Controller 提供, 防止声明占用宿主管理端点或其他插件的端点 + PluginRequestMappingHandlerMapping handlerMapping = pluginManager.getRequestMappingHandlerMapping(); + return handlerMapping != null && handlerMapping.hasEndpoint(pluginName, requestURI); + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/security/TokenAuthResolver.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/security/TokenAuthResolver.java new file mode 100644 index 0000000..810b3b0 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/security/TokenAuthResolver.java @@ -0,0 +1,52 @@ +package xyz.lingview.dimstack.security; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import xyz.lingview.dimstack.mapper.UserInformationMapper; +import xyz.lingview.dimstack.service.ApiKeyService; + +@Component +@Slf4j +public class TokenAuthResolver { + + private static final String AUTH_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + + @Autowired + private ApiKeyService apiKeyService; + + @Autowired + private UserInformationMapper userInformationMapper; + + // 检查请求中是否携带 Bearer 令牌 + public boolean hasToken(HttpServletRequest request) { + String header = request.getHeader(AUTH_HEADER); + return header != null && header.startsWith(BEARER_PREFIX); + } + + public AuthenticatedUser resolve(HttpServletRequest request) { + String header = request.getHeader(AUTH_HEADER); + if (header == null || !header.startsWith(BEARER_PREFIX)) { + return null; + } + String token = header.substring(BEARER_PREFIX.length()).trim(); + if (token.isEmpty()) { + return null; + } + + String uuid = apiKeyService.validateAndGetUserId(token); + if (uuid == null) { + return null; + } + + String username = userInformationMapper.getUsernameByUuid(uuid); + if (username == null) { + log.warn("API Key 校验通过但未找到对应用户, uuid={}", uuid); + return null; + } + + return new AuthenticatedUser(username, uuid, AuthType.TOKEN); + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/security/UserContextHolder.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/security/UserContextHolder.java new file mode 100644 index 0000000..7ef4ea8 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/security/UserContextHolder.java @@ -0,0 +1,22 @@ +package xyz.lingview.dimstack.security; + + +public final class UserContextHolder { + + private static final ThreadLocal CONTEXT = new ThreadLocal<>(); + + private UserContextHolder() { + } + + public static void set(AuthenticatedUser user) { + CONTEXT.set(user); + } + + public static AuthenticatedUser get() { + return CONTEXT.get(); + } + + public static void clear() { + CONTEXT.remove(); + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/AnnouncementService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/AnnouncementService.java new file mode 100644 index 0000000..bc791a4 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/AnnouncementService.java @@ -0,0 +1,10 @@ +package xyz.lingview.dimstack.service; + +import xyz.lingview.dimstack.domain.Announcement; + +public interface AnnouncementService { + + Announcement getLatest(); + + boolean save(String content); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/ApiKeyService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/ApiKeyService.java new file mode 100644 index 0000000..da8aa58 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/ApiKeyService.java @@ -0,0 +1,25 @@ +package xyz.lingview.dimstack.service; + +import xyz.lingview.dimstack.dto.response.ApiKeyCreatedResponseDTO; +import xyz.lingview.dimstack.dto.response.ApiKeyResponseDTO; + +import java.util.List; + +/** + * @Author: lingview + * @Date: 2026/06/09 11:39:23 + * @Description: API Key 管理服务 + * @Version: 1.0 + */ +public interface ApiKeyService { + + ApiKeyCreatedResponseDTO createApiKey(String userId, String description); + + List listApiKeys(String userId); + + boolean updateApiKeyStatus(String userId, Integer id, Integer status); + + boolean deleteApiKey(String userId, Integer id); + + String validateAndGetUserId(String apiKey); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/ArticleCategoryService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/ArticleCategoryService.java new file mode 100644 index 0000000..eb0a536 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/ArticleCategoryService.java @@ -0,0 +1,27 @@ +package xyz.lingview.dimstack.service; + +import xyz.lingview.dimstack.domain.ArticleCategory; +import xyz.lingview.dimstack.domain.ArticleCategoryAndCount; +import xyz.lingview.dimstack.dto.request.ArticleCategoryDTO; +import xyz.lingview.dimstack.dto.request.ArticleDTO; + +import java.util.List; + +public interface ArticleCategoryService { + List getAllCategories(); + List getActiveCategories(); + List getCategoryTree(); + List getTopLevelCategories(); + List getChildrenByParentId(Integer parentId); + ArticleCategoryDTO getCategoryById(Integer id); + boolean createCategory(ArticleCategoryDTO categoryDTO, String founder); + boolean updateCategory(ArticleCategoryDTO categoryDTO); + boolean deleteCategory(Integer id); + boolean activateCategory(Integer id); + + // 公开前端接口方法 + List findAllEnabledCategories(); + List findAllEnabledCategoriesAndCount(); + List findArticlesByCategory(String category, int offset, int size); + int countArticlesByCategory(String category); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/ArticleReviewService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/ArticleReviewService.java new file mode 100644 index 0000000..b307cb2 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/ArticleReviewService.java @@ -0,0 +1,18 @@ +package xyz.lingview.dimstack.service; + +import xyz.lingview.dimstack.domain.Article; +import xyz.lingview.dimstack.dto.response.ArticleReviewListResponseDTO; +import xyz.lingview.dimstack.dto.response.ArticleReviewStatusResponseDTO; + +public interface ArticleReviewService { + + ArticleReviewListResponseDTO getUnreviewedArticles(Integer page, Integer size); + + ArticleReviewListResponseDTO getAllArticles(Integer page, Integer size); + + Article getArticleContent(String articleId); + + ArticleReviewStatusResponseDTO deleteArticle(String articleId); + + ArticleReviewStatusResponseDTO updateArticleStatus(String articleId, Byte status); +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/ArticleSearchService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/ArticleSearchService.java new file mode 100644 index 0000000..dad13f2 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/ArticleSearchService.java @@ -0,0 +1,10 @@ +package xyz.lingview.dimstack.service; + +import xyz.lingview.dimstack.domain.Article; + +import java.util.List; + +public interface ArticleSearchService { + + List
searchArticles(String keyword); +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/ArticleService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/ArticleService.java new file mode 100644 index 0000000..2c8b4f0 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/ArticleService.java @@ -0,0 +1,12 @@ +package xyz.lingview.dimstack.service; + +import xyz.lingview.dimstack.dto.request.ArticleDTO; +import xyz.lingview.dimstack.dto.request.PageRequest; +import xyz.lingview.dimstack.dto.request.PageResult; + +public interface ArticleService { + + PageResult getArticlesForHomePage(PageRequest pageRequest); + + void clearArticleCache(); +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/ArticleTagService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/ArticleTagService.java new file mode 100644 index 0000000..36a53ce --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/ArticleTagService.java @@ -0,0 +1,22 @@ +package xyz.lingview.dimstack.service; + +import xyz.lingview.dimstack.domain.ArticleTag; +import xyz.lingview.dimstack.dto.request.ArticleDTO; +import xyz.lingview.dimstack.dto.request.ArticleTagDTO; + +import java.util.List; + +public interface ArticleTagService { + List getAllTags(); + List getActiveTags(); + ArticleTagDTO getTagById(Integer id); + boolean createTag(ArticleTagDTO tagDTO, String founder); + boolean updateTag(ArticleTagDTO tagDTO); + boolean deleteTag(Integer id); + boolean activateTag(Integer id); + + // 公开前端接口方法 + List findAllEnabledTags(); + List findArticlesByTag(String tagName, int offset, int size); + int countArticlesByTag(String tagName); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/AttachmentManagementService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/AttachmentManagementService.java new file mode 100644 index 0000000..95702c2 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/AttachmentManagementService.java @@ -0,0 +1,60 @@ +package xyz.lingview.dimstack.service; + + +import xyz.lingview.dimstack.domain.StorageMigrationLog; + +import java.util.List; +import java.util.Map; + +/** + * @Author: lingview + * @Date: 2026/02/08 17:30:00 + * @Description: 附件管理服务接口 + * @Version: 1.0 + */ +public interface AttachmentManagementService { + + Map getPage(int page, int size); + + Map getPageByUuid(String uuid, int page, int size); + + Map getPageWithRecentDeleted(int page, int size); + + Map getDeletedPageOnly(int page, int size); + + Map getPageByUuidWithRecentDeleted(String uuid, int page, int size); + + Map getDeletedPageByUuidOnly(String uuid, int page, int size); + + Map getPageByUserUuid(String userUuid, int page, int size); + + Map getPageByUserUuidWithDeleted(String userUuid, int page, int size); + + Map getDeletedPageByUserUuidOnly(String userUuid, int page, int size); + + boolean deleteAttachment(String attachmentId); + + int batchDeleteAttachments(List attachmentIds); + + int batchRestoreAttachments(List attachmentIds); + + boolean restoreAttachment(String attachmentId); + + String getAttachmentOwnerUuid(String attachmentId); + + boolean physicallyDeleteAttachment(String attachmentId); + + int batchPhysicallyDeleteAttachments(List attachmentIds); + + Map migrateStorage(String sourceStorageId, String targetStorageId); + + Map switchAttachmentStorage(String attachmentId, String targetStorageId); + + Map retryMigrateStorage(String sourceStorageId, String targetStorageId, List attachmentIds); + + List getMigrateLogs(); + + StorageMigrationLog getMigrateLogDetail(int id); + + int cleanupExpiredDeletedAttachments(); +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/BackendCommentService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/BackendCommentService.java new file mode 100644 index 0000000..70e1164 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/BackendCommentService.java @@ -0,0 +1,40 @@ +package xyz.lingview.dimstack.service; + +import xyz.lingview.dimstack.domain.Comment; +import xyz.lingview.dimstack.dto.request.CommentDTO; + +import java.util.List; +import java.util.Map; + +public interface BackendCommentService { + + List getCommentsByArticleId(String article_id); + + List getAllCommentsWithPagination(int page, int size); + + String getArticleTitle(String article_id); + + Comment getCommentDetail(String comment_id); + + boolean updateCommentContent(String comment_id, String content); + + boolean deleteComment(String comment_id); + + int getTotalCommentsCount(); + + boolean updateCommentTime(String comment_id, String create_time); + + boolean updateCommentUser(String comment_id, String username); + + List> searchUsers(String prefix); + + List getCommentsByStatus(int status, int page, int size); + + int countByStatus(int status); + + List getAllReviewComments(int page, int size); + + int countAllReview(); + + boolean updateCommentReviewStatus(String comment_id, int status); +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/CacheService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/CacheService.java new file mode 100644 index 0000000..b744ab4 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/CacheService.java @@ -0,0 +1,29 @@ +package xyz.lingview.dimstack.service; + +import java.util.concurrent.TimeUnit; + +/** + * @Author: lingview + * @Date: 2026/02/15 20:47:02 + * @Description: 缓存服务 + * @Version: 1.0 + */ +public interface CacheService { + void set(String key, Object value); + void set(String key, Object value, long timeout, TimeUnit unit); + T get(String key, Class type); + void delete(String key); + void deleteByPrefix(String prefix); + + void addToSet(String key, Object value); + void removeFromSet(String key, Object value); + boolean isMemberOfSet(String key, Object value); + void deleteSet(String key); + java.util.Set getSetMembers(String key, Class type); + + long incrementAndGet(String key); + long incrementAndGet(String key, long delta); + long decrementAndGet(String key); + long getAndIncrement(String key); + long getAndDecrement(String key); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/CommentService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/CommentService.java new file mode 100644 index 0000000..ef33d30 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/CommentService.java @@ -0,0 +1,13 @@ +package xyz.lingview.dimstack.service; + +import xyz.lingview.dimstack.dto.request.AddCommentRequestDTO; +import xyz.lingview.dimstack.dto.request.CommentDTO; + +import java.util.List; + +public interface CommentService { + List getCommentsByArticleAlias(String articleAlias, String username); + int addComment(String username, AddCommentRequestDTO request); + void likeComment(String username, String commentId); + void deleteComment(String username, String commentId); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/CurrentUserService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/CurrentUserService.java new file mode 100644 index 0000000..7eafa3b --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/CurrentUserService.java @@ -0,0 +1,23 @@ +package xyz.lingview.dimstack.service; + +import xyz.lingview.dimstack.security.AuthType; +import xyz.lingview.dimstack.security.AuthenticatedUser; + + +public interface CurrentUserService { + + // 检查请求是否认证 + boolean isAuthenticated(); + + // 获取当前请求用户名 + String getCurrentUsername(); + + // 获取当前请求用户uuid + String getCurrentUserUuid(); + + // 获取当前认证主体,未认证返回 null + AuthenticatedUser getCurrentUser(); + + // 获取当前认证方式,未认证返回 null + AuthType getCurrentAuthType(); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/CustomPageService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/CustomPageService.java new file mode 100644 index 0000000..9f7c6b6 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/CustomPageService.java @@ -0,0 +1,27 @@ +package xyz.lingview.dimstack.service; + +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.dto.request.CustomPageRequest; +import xyz.lingview.dimstack.dto.response.CustomPageResponse; + +import java.util.List; + +/** + * @Author: lingview + * @Date: 2026/01/17 20:12:57 + * @Description: + * @Version: 1.0 + */ +public interface CustomPageService { + ApiResponse createCustomPage(CustomPageRequest request, String userUuid); + + ApiResponse updateCustomPage(Integer id, CustomPageRequest request, String userUuid); + + ApiResponse deleteCustomPage(Integer id, String userUuid); + + ApiResponse getCustomPageByAlias(String alias); + + ApiResponse> getCustomPagesByUser(String userUuid); + + ApiResponse> getAllCustomPages(); +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/DashboardMenuService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/DashboardMenuService.java new file mode 100644 index 0000000..4fbd1cb --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/DashboardMenuService.java @@ -0,0 +1,7 @@ +package xyz.lingview.dimstack.service; + +import xyz.lingview.dimstack.dto.request.DashboardMenuDTO; + +public interface DashboardMenuService { + DashboardMenuDTO getDashboardMenus(String username); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/EditArticleService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/EditArticleService.java new file mode 100644 index 0000000..6c89a2e --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/EditArticleService.java @@ -0,0 +1,35 @@ +package xyz.lingview.dimstack.service; + +import xyz.lingview.dimstack.dto.request.ArticleDetailDTO; +import xyz.lingview.dimstack.dto.request.UpdateArticleDTO; +import xyz.lingview.dimstack.dto.response.ArticleOperationResult; + +import java.util.Map; + +public interface EditArticleService { + + Map getArticleListByUsername(String username, Integer page, Integer size, String keyword, + Integer status, Integer categoryId, Boolean includeSubCategories, + String tag, String startDate, String endDate); + + ArticleDetailDTO getArticleDetailById(String articleId, String username); + + ArticleOperationResult updateArticleWithNotification(UpdateArticleDTO updateArticleDTO, String sessionUsername); + + ArticleOperationResult deleteArticleWithNotification(String articleId, String sessionUsername); + + ArticleOperationResult unpublishArticleWithValidation(String articleId, String sessionUsername); + + ArticleOperationResult publishArticleWithReview(String articleId, String sessionUsername); + + ArticleOperationResult removeArticlePasswordWithValidation(String articleId, String sessionUsername); + + boolean updateArticle(UpdateArticleDTO updateArticleDTO, String sessionUsername); + + boolean deleteArticle(String articleId, String sessionUsername); + boolean unpublishArticle(String articleId, String sessionUsername); + boolean publishArticle(String articleId, String sessionUsername); + String getArticleUuid(String articleId); + + String getArticleContent(String articleId); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/FileAccessService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/FileAccessService.java new file mode 100644 index 0000000..5d65478 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/FileAccessService.java @@ -0,0 +1,25 @@ +package xyz.lingview.dimstack.service; + +import org.springframework.core.io.Resource; + +public interface FileAccessService { + + /** + * 获取文件资源 + * @param accessKey 访问密钥 + * @param download 是否下载模式 + * @return 文件访问结果 + */ + FileAccessResult getFile(String accessKey, boolean download); + + /** + * 文件访问结果 + */ + record FileAccessResult( + Resource resource, + String contentType, + String filename, + boolean found, + String redirectUrl + ) {} +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/FileStorage.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/FileStorage.java new file mode 100644 index 0000000..3e25a65 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/FileStorage.java @@ -0,0 +1,31 @@ +package xyz.lingview.dimstack.service; + +import java.io.InputStream; + +/** + * @Author: lingview + * @Date: 2026/07/17 21:23:12 + * @Description: 文件存储抽象接口,定义所有存储后端统一操作 + * @Version: 1.0 + */ +public interface FileStorage { + + void store(String objectKey, InputStream data, long contentLength, String contentType); + + InputStream retrieve(String objectKey); + + void delete(String objectKey); + + boolean exists(String objectKey); + + void copy(String sourceKey, String destKey); + + String getType(); + + default boolean supportsPresignedUrl() { + return false; + } + + default void close() { + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/ForgotPasswordService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/ForgotPasswordService.java new file mode 100644 index 0000000..de4bf3c --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/ForgotPasswordService.java @@ -0,0 +1,42 @@ +package xyz.lingview.dimstack.service; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import xyz.lingview.dimstack.dto.request.ForgotPasswordDTO; +import xyz.lingview.dimstack.dto.response.ForgotPasswordResponseDTO; +import xyz.lingview.dimstack.common.ApiResponse; + +import java.util.Map; + +/** + * @Author: lingview + * @Date: 2026/01/13 21:19:46 + * @Description: 忘记密码服务接口 + * @Version: 1.0 + */ +public interface ForgotPasswordService { + + /** + * 发送忘记密码验证码到邮箱 + */ + ApiResponse> sendForgotPasswordCaptcha( + ForgotPasswordDTO request, + HttpSession session, + HttpServletRequest httpRequest); + + /** + * 验证验证码 + */ + ApiResponse verifyForgotPassword( + ForgotPasswordDTO request, + HttpSession session, + HttpServletRequest httpRequest); + + /** + * 重置密码 + */ + ApiResponse resetPassword( + ForgotPasswordDTO request, + HttpSession session, + HttpServletRequest httpRequest); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/FriendLinksConfigService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/FriendLinksConfigService.java new file mode 100644 index 0000000..1396b1c --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/FriendLinksConfigService.java @@ -0,0 +1,8 @@ +package xyz.lingview.dimstack.service; + +import xyz.lingview.dimstack.domain.FriendLinksConfig; + +public interface FriendLinksConfigService { + FriendLinksConfig getActiveConfig(); + boolean updateConfig(FriendLinksConfig config); +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/FriendLinksService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/FriendLinksService.java new file mode 100644 index 0000000..cabd84e --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/FriendLinksService.java @@ -0,0 +1,25 @@ +package xyz.lingview.dimstack.service; + +import xyz.lingview.dimstack.domain.FriendLinks; +import xyz.lingview.dimstack.dto.request.FriendLinksRequestDTO; + +import java.util.List; +import java.util.Map; + +/** + * @Author: lingview + * @Date: 2025/12/04 19:53:18 + * @Description: 友链控制服务 + * @Version: 1.0 + */ +public interface FriendLinksService { + boolean applyFriendLink(FriendLinksRequestDTO requestDTO); + boolean addFriendLink(FriendLinksRequestDTO requestDTO); + List getApprovedFriendLinks(); + Map getFriendLinksByPage(Integer page, Integer size, Integer status); + boolean updateFriendLinkStatus(Integer id, Integer status); + boolean deleteFriendLink(Integer id); + boolean deleteFriendLinkPermanent(Integer id); + boolean updateFriendLink(Integer id, FriendLinksRequestDTO requestDTO); + +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/HotArticleService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/HotArticleService.java new file mode 100644 index 0000000..9ef80b8 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/HotArticleService.java @@ -0,0 +1,9 @@ +package xyz.lingview.dimstack.service; + +import xyz.lingview.dimstack.dto.request.HotArticleDTO; +import java.util.List; + +public interface HotArticleService { + List getHotArticles(); + void refreshHotArticlesCache(); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/ImageCompressionService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/ImageCompressionService.java new file mode 100644 index 0000000..eb39609 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/ImageCompressionService.java @@ -0,0 +1,5 @@ +package xyz.lingview.dimstack.service; + +public interface ImageCompressionService { + void compressImageAsync(String originalImagePath); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/LLMService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/LLMService.java new file mode 100644 index 0000000..30a1302 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/LLMService.java @@ -0,0 +1,21 @@ +package xyz.lingview.dimstack.service; + +import xyz.lingview.dimstack.enums.AiReviewResult; + +/** + * @Author: lingview + * @Date: 2026/04/08 17:14:04 + * @Description: 大模型调用服务 + * @Version: 1.0 + */ +public interface LLMService { + + // 审核文章内容 + AiReviewResult reviewArticle(String articleContent); + + // 审核评论内容 + String reviewComment(String commentContent); + + // 生成文章 + String generateArticle(String description); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/LlmConfigService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/LlmConfigService.java new file mode 100644 index 0000000..4c4ca4d --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/LlmConfigService.java @@ -0,0 +1,11 @@ +package xyz.lingview.dimstack.service; + +import xyz.lingview.dimstack.domain.LlmConfig; + +public interface LlmConfigService { + // 获取LLM配置 + LlmConfig getLlmConfig(); + + // 更新LLM配置 + boolean updateLlmConfig(LlmConfig llmConfig); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/LlmPromptConfigService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/LlmPromptConfigService.java new file mode 100644 index 0000000..9072ffe --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/LlmPromptConfigService.java @@ -0,0 +1,10 @@ +package xyz.lingview.dimstack.service; + +import xyz.lingview.dimstack.domain.LlmPromptConfig; + +public interface LlmPromptConfigService { + + LlmPromptConfig getPromptByName(String promptName); + + boolean updatePrompt(LlmPromptConfig config); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/LoginService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/LoginService.java new file mode 100644 index 0000000..5430e98 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/LoginService.java @@ -0,0 +1,11 @@ +package xyz.lingview.dimstack.service; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import xyz.lingview.dimstack.dto.request.LoginDTO; +import xyz.lingview.dimstack.dto.response.LoginResponseDTO; + +public interface LoginService { + LoginResponseDTO login(LoginDTO loginDTO, HttpServletRequest request); + void logout(HttpSession session); +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/MailService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/MailService.java new file mode 100644 index 0000000..b1e998b --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/MailService.java @@ -0,0 +1,57 @@ +package xyz.lingview.dimstack.service; + +import jakarta.mail.MessagingException; +import org.springframework.scheduling.annotation.Async; + +import java.util.Map; + +public interface MailService { + /** + * 发送简单文本邮件 + * @param to 收件人邮箱 + * @param subject 邮件主题 + * @param content 邮件内容 + */ + @Async + void sendSimpleMail(String to, String subject, String content); + + /** + * 发送HTML格式邮件 + * @param to 收件人邮箱 + * @param subject 邮件主题 + * @param content HTML内容 + */ + @Async + void sendHtmlMail(String to, String subject, String content) throws MessagingException; + + /** + * 发送带附件的邮件 + * @param to 收件人邮箱 + * @param subject 邮件主题 + * @param content 邮件内容 + * @param filePath 附件路径 + */ + @Async + void sendAttachmentsMail(String to, String subject, String content, String filePath) throws MessagingException; + + /** + * 发送带静态资源的邮件 + * @param to 收件人邮箱 + * @param subject 邮件主题 + * @param content HTML内容 + * @param rscPath 静态资源路径 + * @param rscId 静态资源ID + */ + @Async + void sendInlineResourceMail(String to, String subject, String content, String rscPath, String rscId) throws MessagingException; + + /** + * 发送模板邮件 + * @param to 收件人邮箱 + * @param subject 邮件主题 + * @param templateName 模板名称 + * @param model 模板参数 + */ + @Async + void sendTemplateMail(String to, String subject, String templateName, Map model) throws MessagingException; +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/MenusService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/MenusService.java new file mode 100644 index 0000000..c33ace9 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/MenusService.java @@ -0,0 +1,14 @@ +package xyz.lingview.dimstack.service; + +import xyz.lingview.dimstack.domain.Menus; +import xyz.lingview.dimstack.dto.request.MenusDTO; +import java.util.List; + +public interface MenusService { + List getAllMenus(); + void addMenus(Menus menus, String username); + void updateMenus(Menus menus, String username); + void deleteMenus(String menusId); + void updateSortOrder(List menusList); + +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/MusicService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/MusicService.java new file mode 100644 index 0000000..2da8a81 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/MusicService.java @@ -0,0 +1,20 @@ +package xyz.lingview.dimstack.service; + +import xyz.lingview.dimstack.domain.Music; + +import java.util.List; + +/** + * @Author: lingview + * @Date: 2026/01/24 00:43:33 + * @Description: 站点音乐服务定义 + * @Version: 1.0 + */ +public interface MusicService { + boolean addMusic(Music music); + boolean updateMusic(Music music); + boolean deleteMusic(Integer id); + List getAllEnabledMusics(); + List getAllMusics(); + Music getMusicById(Integer id); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/NotificationService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/NotificationService.java new file mode 100644 index 0000000..44b39b9 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/NotificationService.java @@ -0,0 +1,26 @@ +package xyz.lingview.dimstack.service; + +import xyz.lingview.dimstack.domain.SystematicNotification; +import xyz.lingview.dimstack.dto.request.PageResult; + +/** + * @Author: lingview + * @Date: 2026/01/31 16:07:35 + * @Description: 系统通知服务 + * @Version: 1.0 + */ +public interface NotificationService { + + void sendSystemNotification(String username, String type, String content); + + PageResult getSystematicNotifications(String uuid, int pageNum, int pageSize); + + PageResult getReadNotifications(String uuid, int pageNum, int pageSize); + + PageResult getUnreadNotifications(String uuid, int pageNum, int pageSize); + + boolean updateNotificationStatus(Integer id, String uuid); + + boolean deleteSystematicNotification(Integer id, String uuid); + +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/PageViewCounterService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/PageViewCounterService.java new file mode 100644 index 0000000..c12ad28 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/PageViewCounterService.java @@ -0,0 +1,12 @@ +package xyz.lingview.dimstack.service; + +public interface PageViewCounterService { + + void initializePageView(String alias, Long dbPageViews); + + void incrementPageView(String alias); + + Long getPageView(String alias); + + void removePageView(String alias); +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/PluginService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/PluginService.java new file mode 100644 index 0000000..bda2ed7 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/PluginService.java @@ -0,0 +1,44 @@ +package xyz.lingview.dimstack.service; + +import org.springframework.web.multipart.MultipartFile; +import xyz.lingview.dimstack.domain.PluginInfo; +import xyz.lingview.dimstack.domain.PluginSqlAudit; + +import java.util.List; +import java.util.Map; + +/** + * @Author: lingview + * @Date: 2026/08/03 20:23:41 + * @Description: 插件管理服务 + * @Version: 1.0 + */ +public interface PluginService { + + List list(); + + PluginInfo install(MultipartFile file); + + + boolean start(String name); + + boolean stop(String name); + + boolean uninstall(String name); + + PluginInfo upgrade(String name, MultipartFile file); + + boolean reload(String name); + + void syncLoadedPlugins(); + + void startEnabledPlugins(); + + Map getConfig(String name); + + boolean saveConfig(String name, Map config); + + List listSqlAudit(String name, int page, int size); + + long countSqlAudit(String name); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/ReadArticleService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/ReadArticleService.java new file mode 100644 index 0000000..b9b9114 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/ReadArticleService.java @@ -0,0 +1,24 @@ +package xyz.lingview.dimstack.service; + +import xyz.lingview.dimstack.domain.ReadArticle; + +import java.util.List; + +public interface ReadArticleService { + boolean isArticleNeedPassword(String alias); + ReadArticle getArticleByAlias(String alias, String password) throws Exception; + ReadArticle getRandomArticle(); + void updatePageViews(String alias); + + // seo用 + List listAllArticles(); + List listAllCategories(); + List listAllTags(); + + // 文章点赞 + void likeArticle(String username, String articleAlias); + + // 获取用户是否已点赞 + boolean isUserLikedArticle(String username, String articleAlias); + +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/RegisterService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/RegisterService.java new file mode 100644 index 0000000..85d3ed8 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/RegisterService.java @@ -0,0 +1,8 @@ +package xyz.lingview.dimstack.service; + +import xyz.lingview.dimstack.dto.request.RegisterDTO; +import xyz.lingview.dimstack.dto.response.RegisterResponseDTO; + +public interface RegisterService { + RegisterResponseDTO register(RegisterDTO requestDTO); +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/RoleService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/RoleService.java new file mode 100644 index 0000000..57f6714 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/RoleService.java @@ -0,0 +1,39 @@ +package xyz.lingview.dimstack.service; + +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.domain.Permission; +import xyz.lingview.dimstack.domain.Role; + +import java.util.List; + +public interface RoleService { + // 获取所有角色列表 + List getAllRoles(); + + // 根据id获取角色详情 + Role getRoleById(Integer id); + + // 创建新角色 + ApiResponse createRole(Role role); + + // 更新角色信息 + ApiResponse updateRole(Role role); + + // 删除角色 + ApiResponse deleteRole(Integer id); + + // 为角色添加权限(通过权限码) + ApiResponse addPermissionToRole(Integer roleId, String permissionCode); + + // 从角色移除权限(通过权限码) + ApiResponse removePermissionFromRole(Integer roleId, String permissionCode); + + // 获取角色的所有权限码 + List getPermissionCodesByRoleId(Integer roleId); + + // 创建新权限 + ApiResponse createPermission(Permission permission); + + // 获取所有权限列表 + List selectAllPermissions(); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/SiteConfigService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/SiteConfigService.java new file mode 100644 index 0000000..6762863 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/SiteConfigService.java @@ -0,0 +1,49 @@ +package xyz.lingview.dimstack.service; + +import xyz.lingview.dimstack.domain.SiteConfig; +import xyz.lingview.dimstack.dto.request.HeroDTO; + +public interface SiteConfigService { + SiteConfig getSiteConfig(); + HeroDTO getHeroConfig(); + String getCopyright(); + String getSiteName(); + String getSiteIcon(); + String getSiteTheme(); + String getExpansionServer(); + boolean updateSiteConfig(SiteConfig siteConfig); + boolean updateSiteTheme(String themeName); + String getIcpRecordNumber(); + String getMpsRecordNumber(); + Integer getEnableRegister(); + Integer getAdminPostNoReview(); + Integer getEnableLlm(); + Integer getEnableLlmArticleReview(); + Integer getEnableLlmCreateArticle(); + String getGlobalHeadCode(); + String getContentHeadCode(); + String getFooterCode(); + Integer getEnableImageCompression(); + Integer getImageCompressionThreads(); + Integer getProxyResourceDownload(); + Integer getEnableComment(); + + default xyz.lingview.dimstack.domain.MailConfig getMailConfig() { + SiteConfig siteConfig = getSiteConfig(); + if (siteConfig != null) { + xyz.lingview.dimstack.domain.MailConfig mailConfig = new xyz.lingview.dimstack.domain.MailConfig(); + mailConfig.setSmtp_host(siteConfig.getSmtp_host()); + mailConfig.setSmtp_port(siteConfig.getSmtp_port()); + mailConfig.setMail_sender_email(siteConfig.getMail_sender_email()); + mailConfig.setMail_sender_name(siteConfig.getMail_sender_name()); + mailConfig.setMail_username(siteConfig.getMail_username()); + mailConfig.setMail_password(siteConfig.getMail_password()); + mailConfig.setMail_protocol(siteConfig.getMail_protocol()); + mailConfig.setMail_enable_tls(siteConfig.getMail_enable_tls()); + mailConfig.setMail_enable_ssl(siteConfig.getMail_enable_ssl()); + mailConfig.setMail_default_encoding(siteConfig.getMail_default_encoding()); + return mailConfig; + } + return null; + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/StatisticsService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/StatisticsService.java new file mode 100644 index 0000000..d5b6d84 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/StatisticsService.java @@ -0,0 +1,8 @@ +package xyz.lingview.dimstack.service; + +public interface StatisticsService { + long getTotalArticles(); + long getTotalComments(); + long getTotalPageViews(); + long getTotalUsers(); +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/StorageFacadeService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/StorageFacadeService.java new file mode 100644 index 0000000..c69a68b --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/StorageFacadeService.java @@ -0,0 +1,16 @@ +package xyz.lingview.dimstack.service; + +/** + * @Author: lingview + * @Date: 2026/07/17 20:08:42 + * @Description: 存储选择服务接口 + * @Version: 1.0 + */ +public interface StorageFacadeService { + + FileStorage getDefaultStorage(String defaultStorageUuid); + + FileStorage getStorage(String storageId); + + void invalidateCache(String uuid); +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/StorageMethodService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/StorageMethodService.java new file mode 100644 index 0000000..21cd647 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/StorageMethodService.java @@ -0,0 +1,30 @@ +package xyz.lingview.dimstack.service; + +import xyz.lingview.dimstack.domain.StorageMethod; + +import java.util.List; +import java.util.Map; + +/** + * @Author: lingview + * @Date: 2026/07/17 20:12:05 + * @Description: 存储方式管理服务 + * @Version: 1.0 + */ +public interface StorageMethodService { + List list(); + + StorageMethod getByUuid(String uuid); + + Map add(StorageMethod storageMethod, String userUuid); + + Map edit(StorageMethod storageMethod); + + Map disable(String uuid); + + Map enable(String uuid); + + Map deletePhysical(String uuid); + + Map setDefault(String uuid); +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/UpdateService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/UpdateService.java new file mode 100644 index 0000000..f8580d9 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/UpdateService.java @@ -0,0 +1,36 @@ +package xyz.lingview.dimstack.service; + +import java.io.IOException; + +public interface UpdateService { + + String getCurrentVersion(); + + String fetchUpdateInfo(String updateUrl); + + boolean downloadNewJar(String downloadUrl, String targetPath); + + boolean verifyChecksum(String filePath, String expectedChecksum); + + String getExpectedChecksum() throws IOException; + + boolean performUpdate(String newJarPath); + + /** + * 仅执行更新,不重启 + */ + boolean performUpdateOnly(String newJarPath); + + /** + * 执行更新并重启 + */ + boolean performUpdateAndRestart(String newJarPath); + + boolean isNewerVersion(String currentVersion, String newVersion); + + int compareVersions(String v1, String v2); + + boolean isCoreVersionInRange(String currentVersion, String minVersion, String maxVersion); + + boolean isVersionCompatible(String currentVersion, String minCompatibleVersion); +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/UploadService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/UploadService.java new file mode 100644 index 0000000..9dc8757 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/UploadService.java @@ -0,0 +1,26 @@ +package xyz.lingview.dimstack.service; + +import org.springframework.web.multipart.MultipartFile; +import xyz.lingview.dimstack.domain.UploadAttachment; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Map; + +/** + * @Author: lingview + * @Date: 2025/11/12 16:08:35 + * @Description: 文件上传服务 + * @Version: 1.0 + */ +public interface UploadService { + UploadAttachment selectByAccessKey(String accessKey); + + Map uploadAttachment(HttpServletRequest request, MultipartFile file); + Map initMultipartUpload(HttpServletRequest request, Map payload); + Map uploadChunk(HttpServletRequest request, String uploadId, int chunkIndex, byte[] chunkData); + Map completeUpload(HttpServletRequest request, Map payload); + Map uploadArticle(HttpServletRequest request, xyz.lingview.dimstack.domain.UploadArticle uploadArticle); + Map uploadAvatar(HttpServletRequest request, MultipartFile file); + Map adminUploadAvatar(HttpServletRequest request, MultipartFile file); + Map downloadAndUploadExternalResource(HttpServletRequest request, String url); +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/UserBlacklistService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/UserBlacklistService.java new file mode 100644 index 0000000..b73bf0f --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/UserBlacklistService.java @@ -0,0 +1,16 @@ +package xyz.lingview.dimstack.service; + +import java.util.Set; + +public interface UserBlacklistService { + + void addUserToBlacklist(String username); + + void removeUserFromBlacklist(String username); + + boolean isUserInBlacklist(String username); + + void clearBlacklist(); + + Set getBlacklistedUsers(); +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/UserPermissionCheckService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/UserPermissionCheckService.java new file mode 100644 index 0000000..75505fb --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/UserPermissionCheckService.java @@ -0,0 +1,10 @@ +package xyz.lingview.dimstack.service; + +public interface UserPermissionCheckService { + + // 检查用户是否拥有所有指定权限 + boolean hasAllPermissions(String username, String[] permissions); + + // 检查用户是否拥有任意一个指定权限 + boolean hasAnyPermission(String username, String[] permissions); +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/UserRoleService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/UserRoleService.java new file mode 100644 index 0000000..ce7131c --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/UserRoleService.java @@ -0,0 +1,20 @@ +package xyz.lingview.dimstack.service; + +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.domain.Role; + +import java.util.List; + +public interface UserRoleService { + // 获取用户的所有角色 + List getUserRoles(Integer userId); + + // 为用户添加角色 + ApiResponse addUserRole(Integer userId, Integer roleId); + + // 从用户移除角色 + ApiResponse removeUserRole(Integer userId, Integer roleId); + + // 设置用户的角色 + ApiResponse setUserRoles(Integer userId, List roleIds); +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/UserService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/UserService.java new file mode 100644 index 0000000..b6b6eea --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/UserService.java @@ -0,0 +1,66 @@ +package xyz.lingview.dimstack.service; + +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.domain.Role; +import xyz.lingview.dimstack.domain.UserInformation; +import xyz.lingview.dimstack.dto.request.UserDTO; +import xyz.lingview.dimstack.dto.request.UserUpdateDTO; + +import java.util.List; + +public interface UserService { + UserInformation getUserByUUID(String uuid); + + ApiResponse updateUserInfo(UserUpdateDTO userUpdateDTO, String currentUsername); + + String getUserUUID(String username); + + // 获取所有非删除用户列表 + List getAllEnableUsers(); + + // 获取所有用户列表 + List getAllUsers(); + + // 分页查询用户(用户名/邮箱/手机号模糊匹配) + List getUsersPage(int page, int size, String keyword); + + // 统计用户数量(keyword 为空时统计全部) + int countUsers(String keyword); + + // 根据id获取用户详情 + UserDTO getUserById(Integer id); + + // 更新用户角色 + boolean updateUserRole(Integer userId, Integer roleId); + + // 更新用户状态 + boolean updateUserStatus(Integer userId, Byte status); + + // 获取所有角色列表 + List getAllRoles(); + + // 根据用户id获取权限列表 + List getUserPermissions(Integer userId); + + // 添加用户到黑名单 + void addUserToBlacklist(String username); + + // 从黑名单中移除用户 + void removeUserFromBlacklist(String username); + + // 密码验证方法 + boolean checkPassword(String plaintextPassword, String hashedPassword); + + // 用户管理模块修改用户信息 + ApiResponse updateUserByAdmin(UserUpdateDTO userUpdateDTO); + + // 后台添加新用户 + ApiResponse addUser(UserUpdateDTO userUpdateDTO); + + // 根据用户名获取头像 + String getAvatarByUsername(String username); + + // 根据用户名获取权限码列表 + List getPermissionCodesByUsername(String username); + +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/AnnouncementServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/AnnouncementServiceImpl.java new file mode 100644 index 0000000..792ff07 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/AnnouncementServiceImpl.java @@ -0,0 +1,26 @@ +package xyz.lingview.dimstack.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import xyz.lingview.dimstack.domain.Announcement; +import xyz.lingview.dimstack.mapper.AnnouncementMapper; +import xyz.lingview.dimstack.service.AnnouncementService; + +@Service +@Slf4j +public class AnnouncementServiceImpl implements AnnouncementService { + + @Autowired + private AnnouncementMapper announcementMapper; + + @Override + public Announcement getLatest() { + return announcementMapper.selectLatest(); + } + + @Override + public boolean save(String content) { + return announcementMapper.insert(content) > 0; + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/ApiKeyServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/ApiKeyServiceImpl.java new file mode 100644 index 0000000..ff421cc --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/ApiKeyServiceImpl.java @@ -0,0 +1,95 @@ +package xyz.lingview.dimstack.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import xyz.lingview.dimstack.domain.ApiKey; +import xyz.lingview.dimstack.dto.response.ApiKeyCreatedResponseDTO; +import xyz.lingview.dimstack.dto.response.ApiKeyResponseDTO; +import xyz.lingview.dimstack.mapper.ApiKeyMapper; +import xyz.lingview.dimstack.service.ApiKeyService; +import xyz.lingview.dimstack.util.ApiKeyUtil; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * @Author: lingview + * @Date: 2026/06/09 11:40:34 + * @Description: API Key 管理服务实现 + * @Version: 1.0 + */ +@Service +@Slf4j +public class ApiKeyServiceImpl implements ApiKeyService { + + @Autowired + private ApiKeyMapper apiKeyMapper; + + @Override + public ApiKeyCreatedResponseDTO createApiKey(String userId, String description) { + String plainKey; + String keyHash; + int attempt = 0; + do { + plainKey = ApiKeyUtil.generateApiKey(); + keyHash = ApiKeyUtil.sha256Hex(plainKey); + attempt++; + } while (apiKeyMapper.countByKeyHash(keyHash) > 0 && attempt < 5); + + ApiKey apiKey = new ApiKey(); + apiKey.setUser_id(userId); + apiKey.setKey_hash(keyHash); + apiKey.setDescription(description); + apiKey.setStatus(1); + + int result = apiKeyMapper.insert(apiKey); + if (result <= 0) { + throw new IllegalStateException("API Key 创建失败"); + } + + return new ApiKeyCreatedResponseDTO(apiKey.getId(), description, plainKey); + } + + @Override + public List listApiKeys(String userId) { + List apiKeys = apiKeyMapper.selectByUserId(userId); + return apiKeys.stream().map(this::toResponseDTO).collect(Collectors.toList()); + } + + @Override + public boolean updateApiKeyStatus(String userId, Integer id, Integer status) { + if (status == null || (status != 0 && status != 1)) { + return false; + } + return apiKeyMapper.updateStatusByIdAndUserId(id, userId, status) > 0; + } + + @Override + public boolean deleteApiKey(String userId, Integer id) { + return apiKeyMapper.deleteByIdAndUserId(id, userId) > 0; + } + + @Override + public String validateAndGetUserId(String apiKey) { + if (apiKey == null || apiKey.isBlank()) { + return null; + } + String keyHash = ApiKeyUtil.sha256Hex(apiKey.trim()); + ApiKey record = apiKeyMapper.selectByKeyHash(keyHash); + if (record == null || record.getStatus() == null || record.getStatus() != 1) { + return null; + } + return record.getUser_id(); + } + + private ApiKeyResponseDTO toResponseDTO(ApiKey apiKey) { + ApiKeyResponseDTO dto = new ApiKeyResponseDTO(); + dto.setId(apiKey.getId()); + dto.setDescription(apiKey.getDescription()); + dto.setCreateTime(apiKey.getCreate_time()); + dto.setUpdateTime(apiKey.getUpdate_time()); + dto.setStatus(apiKey.getStatus()); + return dto; + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/ArticleCategoryServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/ArticleCategoryServiceImpl.java new file mode 100644 index 0000000..c4c70f5 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/ArticleCategoryServiceImpl.java @@ -0,0 +1,263 @@ +package xyz.lingview.dimstack.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import xyz.lingview.dimstack.domain.ArticleCategory; +import xyz.lingview.dimstack.domain.ArticleCategoryAndCount; +import xyz.lingview.dimstack.dto.request.ArticleCategoryDTO; +import xyz.lingview.dimstack.dto.request.ArticleDTO; +import xyz.lingview.dimstack.mapper.ArticleCategoryMapper; +import xyz.lingview.dimstack.mapper.UserInformationMapper; +import xyz.lingview.dimstack.service.ArticleCategoryService; + +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.stream.Collectors; + +@Service +@Slf4j +public class ArticleCategoryServiceImpl implements ArticleCategoryService { + + @Autowired + private ArticleCategoryMapper articleCategoryMapper; + + @Autowired + private UserInformationMapper userInformationMapper; + + @Override + public List getAllCategories() { + List categories = articleCategoryMapper.findAll(); + return convertToDTOList(categories); + } + + @Override + public List getActiveCategories() { + List categories = articleCategoryMapper.findByStatus(1); + return convertToDTOList(categories); + } + + @Override + public List getCategoryTree() { + List categories = articleCategoryMapper.findTreeStructure(); + return buildTreeStructure(convertToDTOList(categories)); + } + + @Override + public List getTopLevelCategories() { + List categories = articleCategoryMapper.findTopLevelCategories(); + return convertToDTOList(categories); + } + + @Override + public List getChildrenByParentId(Integer parentId) { + List categories = articleCategoryMapper.findChildrenByParentId(parentId); + return convertToDTOList(categories); + } + + @Override + public ArticleCategoryDTO getCategoryById(Integer id) { + ArticleCategory category = articleCategoryMapper.findById(id); + return category != null ? convertToDTO(category) : null; + } + + @Override + public boolean createCategory(ArticleCategoryDTO categoryDTO, String founderUsername) { + try { + // 过滤分类名称首尾空格 + String categoryName = categoryDTO.getCategory_name().trim(); + + // 验证分类名称不能包含路径分隔符 + if (categoryName.contains("/")) { + throw new RuntimeException("分类名称中不能包含 '/' 字符"); + } + + // 检查在同一父分类下分类名称是否已存在 + ArticleCategory existingCategory = articleCategoryMapper.findByParentAndName( + categoryDTO.getParent_id(), categoryName); + if (existingCategory != null) { + throw new RuntimeException("在同一层级下分类名称已存在"); + } + + // 限制分类层级:最多只能有一层子分类 + if (categoryDTO.getParent_id() != null) { + // 检查父分类是否已经是子分类 + ArticleCategory parentCategory = articleCategoryMapper.findById(categoryDTO.getParent_id()); + if (parentCategory != null && parentCategory.getParent_id() != null) { + throw new RuntimeException("只能创建一级子分类,不能创建三级分类"); + } + } + + String founderUUID = userInformationMapper.selectUserUUID(founderUsername); + if (founderUUID == null) { + throw new RuntimeException("未找到用户信息"); + } + + ArticleCategory category = new ArticleCategory(); + category.setParent_id(categoryDTO.getParent_id()); + category.setArticle_categories(categoryName); + category.setCategories_explain(categoryDTO.getCategory_explain()); + category.setFounder(founderUUID); + category.setStatus(1); + category.setArticle_count(0); + + int result = articleCategoryMapper.insert(category); + return result > 0; + } catch (Exception e) { + log.error("创建分类失败", e); + return false; + } + } + @Override + public boolean updateCategory(ArticleCategoryDTO categoryDTO) { + try { + ArticleCategory category = articleCategoryMapper.findById(categoryDTO.getId()); + if (category == null) { + throw new RuntimeException("分类不存在"); + } + + String categoryName = categoryDTO.getCategory_name().trim(); + + // 验证分类名称不能包含路径分隔符 + if (categoryName.contains("/")) { + throw new RuntimeException("分类名称中不能包含 '/' 字符"); + } + + ArticleCategory existingCategory = articleCategoryMapper.findByParentAndName( + categoryDTO.getParent_id(), categoryName); + if (existingCategory != null && !existingCategory.getId().equals(categoryDTO.getId())) { + throw new RuntimeException("在同一层级下分类名称已存在"); + } + + category.setArticle_categories(categoryName); + category.setCategories_explain(categoryDTO.getCategory_explain()); + + int result = articleCategoryMapper.update(category); + return result > 0; + } catch (Exception e) { + log.error("更新分类失败", e); + return false; + } + } + + @Override + public boolean deleteCategory(Integer id) { + try { + // 级联禁用 + disableCategoryAndChildren(id); + return true; + } catch (Exception e) { + log.error("禁用分类失败", e); + return false; + } + } + + + private void disableCategoryAndChildren(Integer categoryId) { + + articleCategoryMapper.updateStatus(categoryId, 0); + + List children = articleCategoryMapper.findChildrenByParentId(categoryId); + + for (ArticleCategory child : children) { + disableCategoryAndChildren(child.getId()); + } + } + + @Override + public boolean activateCategory(Integer id) { + try { + int result = articleCategoryMapper.updateStatus(id, 1); + return result > 0; + } catch (Exception e) { + log.error("激活分类失败", e); + return false; + } + } + + @Override + public List findAllEnabledCategories() { + return articleCategoryMapper.findAllEnabledCategories(); + } + + @Override + public List findAllEnabledCategoriesAndCount() { + return articleCategoryMapper.findAllEnabledCategoriesAndCount(); + } + + @Override + public List findArticlesByCategory(String category, int offset, int size) { + return articleCategoryMapper.findArticlesByCategory(category, offset, size); + } + + @Override + public int countArticlesByCategory(String category) { + return articleCategoryMapper.countArticlesByCategory(category); + } + + private List convertToDTOList(List categories) { + return categories.stream().map(this::convertToDTO).collect(Collectors.toList()); + } + + private ArticleCategoryDTO convertToDTO(ArticleCategory category) { + ArticleCategoryDTO dto = new ArticleCategoryDTO(); + dto.setId(category.getId()); + dto.setParent_id(category.getParent_id()); + dto.setCategory_name(category.getArticle_categories()); + dto.setCategory_explain(category.getCategories_explain()); + dto.setArticle_count(category.getArticle_count()); + dto.setFull_path(category.getFull_path()); + dto.setLevel(category.getLevel()); + + if (category.getFounder() != null) { + String founderName = userInformationMapper.getUsernameByUuid(category.getFounder()); + dto.setFounder(founderName != null ? founderName : category.getFounder()); + } else { + dto.setFounder(null); + } + + dto.setStatus(category.getStatus()); + if (category.getCreate_time() != null) { + dto.setCreate_time(category.getCreate_time().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); + } + + if (category.getId() != null) { + List children = articleCategoryMapper.findChildrenByParentId(category.getId()); + dto.setHas_children(children != null && !children.isEmpty()); + dto.setChild_count(children != null ? children.size() : 0); + } + + if (category.getParent_id() != null) { + ArticleCategory parent = articleCategoryMapper.findById(category.getParent_id()); + if (parent != null) { + dto.setParent_name(parent.getArticle_categories()); + } + } + + return dto; + } + + + private List buildTreeStructure(List flatList) { + Map nodeMap = new HashMap<>(); + List roots = new ArrayList<>(); + + for (ArticleCategoryDTO node : flatList) { + nodeMap.put(node.getId(), node); + node.setChildren(new ArrayList()); + } + + for (ArticleCategoryDTO node : flatList) { + if (node.getParent_id() == null) { + roots.add(node); + } else { + ArticleCategoryDTO parent = nodeMap.get(node.getParent_id()); + if (parent != null) { + parent.getChildren().add(node); + } + } + } + + return roots; + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/ArticleReviewServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/ArticleReviewServiceImpl.java new file mode 100644 index 0000000..f91b86f --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/ArticleReviewServiceImpl.java @@ -0,0 +1,183 @@ +package xyz.lingview.dimstack.service.impl; + +import org.springframework.beans.factory.annotation.Autowired; +import xyz.lingview.dimstack.dto.response.ArticleReviewListResponseDTO; +import xyz.lingview.dimstack.dto.response.ArticleReviewStatusResponseDTO; +import xyz.lingview.dimstack.mapper.ArticleCategoryMapper; +import xyz.lingview.dimstack.mapper.ArticleReviewMapper; +import xyz.lingview.dimstack.mapper.UserInformationMapper; +import xyz.lingview.dimstack.mapper.ReadArticleMapper; +import xyz.lingview.dimstack.domain.Article; +import xyz.lingview.dimstack.dto.request.ArticleReviewDTO; +import xyz.lingview.dimstack.service.ArticleReviewService; +import xyz.lingview.dimstack.service.ArticleService; +import xyz.lingview.dimstack.service.CacheService; +import xyz.lingview.dimstack.service.MailService; +import xyz.lingview.dimstack.service.NotificationService; +import xyz.lingview.dimstack.service.PageViewCounterService; +import xyz.lingview.dimstack.util.SiteConfigUtil; +import org.springframework.stereotype.Service; +import lombok.extern.slf4j.Slf4j; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; + +@Slf4j +@Service +public class ArticleReviewServiceImpl implements ArticleReviewService { + + private final ArticleReviewMapper articleReviewMapper; + private final UserInformationMapper userInformationMapper; + + public ArticleReviewServiceImpl(ArticleReviewMapper articleReviewMapper, + UserInformationMapper userInformationMapper) { + this.articleReviewMapper = articleReviewMapper; + this.userInformationMapper = userInformationMapper; + } + + @Autowired + private SiteConfigUtil siteConfigUtil; + + @Autowired + private NotificationService notificationService; + + @Autowired + MailService mailService; + + @Autowired + private CacheService cacheService; + + @Autowired + private ReadArticleMapper readArticleMapper; + + @Autowired + private PageViewCounterService pageViewCounterService; + + @Autowired + private ArticleService articleService; + + @Autowired + private ArticleCategoryMapper articleCategoryMapper; + + @Override + public ArticleReviewListResponseDTO getUnreviewedArticles(Integer page, Integer size) { + int offset = (page - 1) * size; + List articles = articleReviewMapper.selectUnreviewedArticles(offset, size); + + for (ArticleReviewDTO article : articles) { + String username = userInformationMapper.getUsernameByUuid(article.getUuid()); + article.setAuthor_name(username); + } + + int total = articleReviewMapper.countUnreviewedArticles(); + + ArticleReviewListResponseDTO result = new ArticleReviewListResponseDTO(); + result.setArticles(articles); + result.setTotal(total); + result.setPage(page); + result.setSize(size); + result.setTotalPages((int) Math.ceil((double) total / size)); + return result; + } + + @Override + public ArticleReviewListResponseDTO getAllArticles(Integer page, Integer size) { + int offset = (page - 1) * size; + List articles = articleReviewMapper.selectAllArticles(offset, size); + + for (ArticleReviewDTO article : articles) { + String username = userInformationMapper.getUsernameByUuid(article.getUuid()); + article.setAuthor_name(username); + } + + int total = articleReviewMapper.countAllArticles(); + + ArticleReviewListResponseDTO result = new ArticleReviewListResponseDTO(); + result.setArticles(articles); + result.setTotal(total); + result.setPage(page); + result.setSize(size); + result.setTotalPages((int) Math.ceil((double) total / size)); + return result; + } + + @Override + public Article getArticleContent(String articleId) { + return articleReviewMapper.selectArticleById(articleId); + } + + @Override + public ArticleReviewStatusResponseDTO deleteArticle(String articleId) { + Byte status = 0; + articleReviewMapper.updateArticleStatus(articleId, status); + invalidateArticleCache(articleId); + String category = articleCategoryMapper.getCategoryByArticleId(articleId); + articleCategoryMapper.decrementCount(category); + log.info("文章审核模块删除文章成功,文章id:{}", articleId); + try { + articleService.clearArticleCache(); + log.info("文章状态变更为{},已清除首页文章列表缓存", status); + } catch (Exception e) { + log.warn("清除首页文章列表缓存失败", e); + } + ArticleReviewStatusResponseDTO result = new ArticleReviewStatusResponseDTO(); + result.setSuccess(true); + result.setMessage("文章删除成功"); + return result; + } + + @Override + public ArticleReviewStatusResponseDTO updateArticleStatus(String articleId, Byte status) { + articleReviewMapper.updateArticleStatus(articleId, status); + + invalidateArticleCache(articleId); + + try { + articleService.clearArticleCache(); + log.info("文章状态变更为{},已清除首页文章列表缓存", status); + } catch (Exception e) { + log.warn("清除首页文章列表缓存失败", e); + } + + ArticleReviewStatusResponseDTO result = new ArticleReviewStatusResponseDTO(); + result.setSuccess(true); + result.setMessage("文章状态更新成功"); + if (siteConfigUtil.isNotificationEnabled()) { + Date date = new Date(); + SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + String formattedDate = formatter.format(date); + String username = userInformationMapper.getUsernameByArticleId(articleId); + String email = userInformationMapper.getEmailByUsername(username); + String siteName = siteConfigUtil.getSiteName(); + String article_name = articleReviewMapper.getArticleNameByArticleId(articleId); + String statusDescription = switch (status) { + case 1 -> "通过审核"; + case 3 -> "待审核"; + case 4 -> "违规"; + default -> "未知状态"; + }; + mailService.sendSimpleMail( + email, + siteName + " 审核结果通知", + "您的文章:" + "《" + article_name + "》" + " 于 " + formattedDate + " 完成审核,审核结果为:" + statusDescription + ); + notificationService.sendSystemNotification(username, "系统通知", "您的文章:" + "《" + article_name + "》" + " 于 " + formattedDate + " 完成审核,审核结果为:" + statusDescription); + } + return result; + } + + private void invalidateArticleCache(String articleId) { + try { + xyz.lingview.dimstack.domain.ReadArticle article = readArticleMapper.selectByArticleId(articleId); + if (article != null && article.getAlias() != null) { + String cacheKey = "dimstack:article:" + article.getAlias(); + cacheService.delete(cacheKey); + pageViewCounterService.removePageView(article.getAlias()); + log.info("审核操作已清除文章缓存和浏览量计数器: {}", cacheKey); + } + } catch (Exception e) { + log.warn("清除文章缓存失败,articleId: {}", articleId, e); + } + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/ArticleSearchServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/ArticleSearchServiceImpl.java new file mode 100644 index 0000000..1b2e406 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/ArticleSearchServiceImpl.java @@ -0,0 +1,35 @@ +package xyz.lingview.dimstack.service.impl; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import xyz.lingview.dimstack.domain.Article; +import xyz.lingview.dimstack.mapper.ArticleSearchMapper; +import xyz.lingview.dimstack.service.ArticleSearchService; + +import java.util.List; + +@Service +public class ArticleSearchServiceImpl implements ArticleSearchService { + + @Autowired + private ArticleSearchMapper articleSearchMapper; + + @Override + public List
searchArticles(String keyword) { + if (keyword == null || keyword.trim().isEmpty()) { + return articleSearchMapper.findAllArticles(); + } + boolean isChinese = keyword.matches(".*[\\u4e00-\\u9fa5].*"); + + try { + if (isChinese) { + return articleSearchMapper.searchArticlesCn(keyword); + } else { + return articleSearchMapper.searchArticlesEn(keyword); + } + } catch (Exception e) { + return articleSearchMapper.findAllArticles(); + } + } + +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/ArticleServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/ArticleServiceImpl.java new file mode 100644 index 0000000..f9a761d --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/ArticleServiceImpl.java @@ -0,0 +1,90 @@ +package xyz.lingview.dimstack.service.impl; + +import xyz.lingview.dimstack.mapper.ArticleMapper; +import xyz.lingview.dimstack.dto.request.ArticleDTO; +import xyz.lingview.dimstack.dto.request.PageRequest; +import xyz.lingview.dimstack.dto.request.PageResult; +import xyz.lingview.dimstack.service.ArticleService; +import xyz.lingview.dimstack.service.CacheService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import lombok.extern.slf4j.Slf4j; +import java.util.List; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +public class ArticleServiceImpl implements ArticleService { + + @Autowired + private ArticleMapper articleMapper; + + @Autowired + private CacheService cacheService; + + private static final String ARTICLE_CACHE_PREFIX = "article:home:"; + private static final long CACHE_EXPIRE_HOURS = 1; + + @Override + public PageResult getArticlesForHomePage(PageRequest pageRequest) { + String cacheKey = buildCacheKey(pageRequest); + + try { + @SuppressWarnings("unchecked") + PageResult cachedResult = cacheService.get(cacheKey, PageResult.class); + if (cachedResult != null) { + log.debug("命中文章列表缓存: {}", cacheKey); + return cachedResult; + } + } catch (Exception e) { + log.warn("读取缓存失败,将从数据库查询: {}", cacheKey, e); + } + + log.info("缓存未命中,从数据库查询文章列表: {}", cacheKey); + int offset = (pageRequest.getPage() - 1) * pageRequest.getSize(); + + List articles = articleMapper.selectArticlesForHomePage( + offset, + pageRequest.getSize(), + pageRequest.getCategory() + ); + + int total = articleMapper.countArticles(pageRequest.getCategory()); + + PageResult pageResult = new PageResult<>(); + pageResult.setData(articles); + pageResult.setTotal(total); + pageResult.setPage(pageRequest.getPage()); + pageResult.setSize(pageRequest.getSize()); + pageResult.setTotal_pages((total + pageRequest.getSize() - 1) / pageRequest.getSize()); + + try { + cacheService.set(cacheKey, pageResult, CACHE_EXPIRE_HOURS, TimeUnit.HOURS); + log.debug("文章列表已写入缓存: {}", cacheKey); + } catch (Exception e) { + log.warn("写入缓存失败: {}", cacheKey, e); + } + + return pageResult; + } + + private String buildCacheKey(PageRequest pageRequest) { + StringBuilder key = new StringBuilder(ARTICLE_CACHE_PREFIX); + key.append("page_").append(pageRequest.getPage()); + key.append("_size_").append(pageRequest.getSize()); + if (pageRequest.getCategory() != null && !pageRequest.getCategory().isEmpty()) { + key.append("_category_").append(pageRequest.getCategory()); + } + return key.toString(); + } + + @Override + public void clearArticleCache() { + try { + cacheService.deleteByPrefix(ARTICLE_CACHE_PREFIX); + log.info("已清除所有文章列表缓存"); + } catch (Exception e) { + log.error("清除文章列表缓存失败", e); + } + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/ArticleTagServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/ArticleTagServiceImpl.java new file mode 100644 index 0000000..f601985 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/ArticleTagServiceImpl.java @@ -0,0 +1,160 @@ +package xyz.lingview.dimstack.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import xyz.lingview.dimstack.domain.ArticleTag; +import xyz.lingview.dimstack.dto.request.ArticleDTO; +import xyz.lingview.dimstack.dto.request.ArticleTagDTO; +import xyz.lingview.dimstack.mapper.ArticleTagMapper; +import xyz.lingview.dimstack.mapper.UserInformationMapper; +import xyz.lingview.dimstack.service.ArticleTagService; + +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@Slf4j +public class ArticleTagServiceImpl implements ArticleTagService { + + @Autowired + private ArticleTagMapper articleTagMapper; + + @Autowired + private UserInformationMapper userInformationMapper; + + @Override + public List getAllTags() { + List tags = articleTagMapper.findAll(); + return convertToDTOList(tags); + } + + @Override + public List getActiveTags() { + List tags = articleTagMapper.findByStatus(1); + return convertToDTOList(tags); + } + + @Override + public ArticleTagDTO getTagById(Integer id) { + ArticleTag tag = articleTagMapper.findById(id); + return tag != null ? convertToDTO(tag) : null; + } + + @Override + public boolean createTag(ArticleTagDTO tagDTO, String founderUsername) { + try { + String tagName = tagDTO.getTag_name().trim(); + + ArticleTag existingTag = articleTagMapper.findByName(tagName); + if (existingTag != null) { + throw new RuntimeException("标签名称已存在"); + } + + String founderUUID = userInformationMapper.selectUserUUID(founderUsername); + if (founderUUID == null) { + throw new RuntimeException("未找到用户信息"); + } + + ArticleTag tag = new ArticleTag(); + tag.setTag_name(tagName); + tag.setTag_explain(tagDTO.getTag_explain()); + tag.setFounder(founderUUID); + tag.setStatus(1); + + int result = articleTagMapper.insert(tag); + return result > 0; + } catch (Exception e) { + log.error("创建标签失败", e); + return false; + } + } + + @Override + public boolean updateTag(ArticleTagDTO tagDTO) { + try { + ArticleTag tag = articleTagMapper.findById(tagDTO.getId()); + if (tag == null) { + throw new RuntimeException("标签不存在"); + } + + String tagName = tagDTO.getTag_name().trim(); + + ArticleTag existingTag = articleTagMapper.findByName(tagName); + if (existingTag != null && !existingTag.getId().equals(tagDTO.getId())) { + throw new RuntimeException("标签名称已存在"); + } + + tag.setTag_name(tagName); + tag.setTag_explain(tagDTO.getTag_explain()); + + int result = articleTagMapper.update(tag); + return result > 0; + } catch (Exception e) { + log.error("更新标签失败", e); + return false; + } + } + + @Override + public boolean deleteTag(Integer id) { + try { + int result = articleTagMapper.updateStatus(id, 0); + return result > 0; + } catch (Exception e) { + log.error("删除标签失败", e); + return false; + } + } + + @Override + public boolean activateTag(Integer id) { + try { + int result = articleTagMapper.updateStatus(id, 1); + return result > 0; + } catch (Exception e) { + log.error("激活标签失败", e); + return false; + } + } + + private List convertToDTOList(List tags) { + return tags.stream().map(this::convertToDTO).collect(Collectors.toList()); + } + + @Override + public List findAllEnabledTags() { + return articleTagMapper.findAllEnabledTags(); + } + + @Override + public List findArticlesByTag(String tagName, int offset, int size) { + return articleTagMapper.findArticlesByTag(tagName, offset, size); + } + + @Override + public int countArticlesByTag(String tagName) { + return articleTagMapper.countArticlesByTag(tagName); + } + + private ArticleTagDTO convertToDTO(ArticleTag tag) { + ArticleTagDTO dto = new ArticleTagDTO(); + dto.setId(tag.getId()); + dto.setTag_name(tag.getTag_name()); + dto.setTag_explain(tag.getTag_explain()); + + if (tag.getFounder() != null) { + String founderName = userInformationMapper.getUsernameByUuid(tag.getFounder()); + dto.setFounder(founderName != null ? founderName : tag.getFounder()); + } else { + dto.setFounder(null); + } + + dto.setStatus(tag.getStatus()); + if (tag.getCreate_time() != null) { + dto.setCreate_time(tag.getCreate_time().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); + } + return dto; + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/AttachmentManagementServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/AttachmentManagementServiceImpl.java new file mode 100644 index 0000000..37b2c12 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/AttachmentManagementServiceImpl.java @@ -0,0 +1,619 @@ +package xyz.lingview.dimstack.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import xyz.lingview.dimstack.domain.AttachmentManagement; +import xyz.lingview.dimstack.domain.StorageMethod; +import xyz.lingview.dimstack.domain.StorageMigrationFailedItem; +import xyz.lingview.dimstack.domain.StorageMigrationLog; +import xyz.lingview.dimstack.mapper.AttachmentManagementMapper; +import xyz.lingview.dimstack.mapper.StorageMethodMapper; +import xyz.lingview.dimstack.mapper.StorageMigrationLogMapper; +import xyz.lingview.dimstack.mapper.UserInformationMapper; +import xyz.lingview.dimstack.service.AttachmentManagementService; +import xyz.lingview.dimstack.service.FileStorage; +import xyz.lingview.dimstack.service.StorageFacadeService; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * @Author: lingview + * @Date: 2026/02/08 17:35:00 + * @Description: 附件管理服务实现类 + * @Version: 1.0 + */ +@Service +@Slf4j +public class AttachmentManagementServiceImpl implements AttachmentManagementService { + + @Autowired + private AttachmentManagementMapper attachmentManagementMapper; + + @Autowired + private UserInformationMapper userInformationMapper; + + @Autowired + private StorageFacadeService storageFacadeService; + + @Autowired + private StorageMethodMapper storageMethodMapper; + + @Autowired + private StorageMigrationLogMapper storageMigrationLogMapper; + + @Value("${file.data-root:.}") + private String dataRoot; + + @Override + public Map getPage(int page, int size) { + int offset = (page - 1) * size; + List attachments = attachmentManagementMapper.selectPage(offset, size); + int total = attachmentManagementMapper.countAll(); + + for (AttachmentManagement attachment : attachments) { + String username = userInformationMapper.getUsernameByUuid(attachment.getUuid()); + attachment.setUsername(username != null ? username : "未知用户"); + } + + Map result = new HashMap<>(); + result.put("data", attachments); + result.put("total", total); + result.put("page", page); + result.put("size", size); + result.put("totalPages", (int) Math.ceil((double) total / size)); + + return result; + } + + @Override + public Map getPageByUuid(String uuid, int page, int size) { + int offset = (page - 1) * size; + List attachments = attachmentManagementMapper.selectPageByUuid(uuid, offset, size); + int total = attachmentManagementMapper.countByUuid(uuid); + + for (AttachmentManagement attachment : attachments) { + String username = userInformationMapper.getUsernameByUuid(attachment.getUuid()); + attachment.setUsername(username != null ? username : "未知用户"); + } + + Map result = new HashMap<>(); + result.put("data", attachments); + result.put("total", total); + result.put("page", page); + result.put("size", size); + result.put("totalPages", (int) Math.ceil((double) total / size)); + + return result; + } + + @Override + public Map getPageWithRecentDeleted(int page, int size) { + int offset = (page - 1) * size; + List attachments = attachmentManagementMapper.selectAllPageWithRecentDeleted(offset, size); + int total = attachmentManagementMapper.countAllWithRecentDeleted(); + + for (AttachmentManagement attachment : attachments) { + String username = userInformationMapper.getUsernameByUuid(attachment.getUuid()); + attachment.setUsername(username != null ? username : "未知用户"); + } + + Map result = new HashMap<>(); + result.put("data", attachments); + result.put("total", total); + result.put("page", page); + result.put("size", size); + result.put("totalPages", (int) Math.ceil((double) total / size)); + + return result; + } + + @Override + public Map getDeletedPageOnly(int page, int size) { + int offset = (page - 1) * size; + List attachments = attachmentManagementMapper.selectDeletedOnlyPage(offset, size); + int total = attachmentManagementMapper.countDeletedOnly(); + + for (AttachmentManagement attachment : attachments) { + String username = userInformationMapper.getUsernameByUuid(attachment.getUuid()); + attachment.setUsername(username != null ? username : "未知用户"); + } + + Map result = new HashMap<>(); + result.put("data", attachments); + result.put("total", total); + result.put("page", page); + result.put("size", size); + result.put("totalPages", (int) Math.ceil((double) total / size)); + + return result; + } + + @Override + public Map getPageByUuidWithRecentDeleted(String uuid, int page, int size) { + int offset = (page - 1) * size; + List attachments = attachmentManagementMapper.selectPageByUuidWithRecentDeleted(uuid, offset, size); + int total = attachmentManagementMapper.countByUuidWithRecentDeleted(uuid); + + for (AttachmentManagement attachment : attachments) { + String username = userInformationMapper.getUsernameByUuid(attachment.getUuid()); + attachment.setUsername(username != null ? username : "未知用户"); + } + + Map result = new HashMap<>(); + result.put("data", attachments); + result.put("total", total); + result.put("page", page); + result.put("size", size); + result.put("totalPages", (int) Math.ceil((double) total / size)); + + return result; + } + + @Override + public Map getDeletedPageByUuidOnly(String uuid, int page, int size) { + int offset = (page - 1) * size; + List attachments = attachmentManagementMapper.selectDeletedOnlyPageByUuid(uuid, offset, size); + int total = attachmentManagementMapper.countDeletedOnlyByUuid(uuid); + + for (AttachmentManagement attachment : attachments) { + String username = userInformationMapper.getUsernameByUuid(attachment.getUuid()); + attachment.setUsername(username != null ? username : "未知用户"); + } + + Map result = new HashMap<>(); + result.put("data", attachments); + result.put("total", total); + result.put("page", page); + result.put("size", size); + result.put("totalPages", (int) Math.ceil((double) total / size)); + + return result; + } + + @Override + public Map getPageByUserUuid(String userUuid, int page, int size) { + int offset = (page - 1) * size; + List attachments = attachmentManagementMapper.selectPageByUserUuid(userUuid, offset, size); + int total = attachmentManagementMapper.countByUserUuid(userUuid); + + for (AttachmentManagement attachment : attachments) { + String username = userInformationMapper.getUsernameByUuid(attachment.getUuid()); + attachment.setUsername(username != null ? username : "未知用户"); + } + + Map result = new HashMap<>(); + result.put("data", attachments); + result.put("total", total); + result.put("page", page); + result.put("size", size); + result.put("totalPages", (int) Math.ceil((double) total / size)); + + return result; + } + + @Override + public Map getPageByUserUuidWithDeleted(String userUuid, int page, int size) { + int offset = (page - 1) * size; + List attachments = attachmentManagementMapper.selectPageByUserUuidWithDeleted(userUuid, offset, size); + int total = attachmentManagementMapper.countByUserUuidWithDeleted(userUuid); + + for (AttachmentManagement attachment : attachments) { + String username = userInformationMapper.getUsernameByUuid(attachment.getUuid()); + attachment.setUsername(username != null ? username : "未知用户"); + } + + Map result = new HashMap<>(); + result.put("data", attachments); + result.put("total", total); + result.put("page", page); + result.put("size", size); + result.put("totalPages", (int) Math.ceil((double) total / size)); + + return result; + } + + @Override + public Map getDeletedPageByUserUuidOnly(String userUuid, int page, int size) { + int offset = (page - 1) * size; + List attachments = attachmentManagementMapper.selectDeletedOnlyPageByUserUuid(userUuid, offset, size); + int total = attachmentManagementMapper.countDeletedOnlyByUserUuid(userUuid); + + for (AttachmentManagement attachment : attachments) { + String username = userInformationMapper.getUsernameByUuid(attachment.getUuid()); + attachment.setUsername(username != null ? username : "未知用户"); + } + + Map result = new HashMap<>(); + result.put("data", attachments); + result.put("total", total); + result.put("page", page); + result.put("size", size); + result.put("totalPages", (int) Math.ceil((double) total / size)); + + return result; + } + + @Override + public boolean deleteAttachment(String attachmentId) { + LocalDateTime deletedTime = LocalDateTime.now(); + int result = attachmentManagementMapper.deleteByAttachmentId(attachmentId, deletedTime); + return result > 0; + } + + @Override + public int batchDeleteAttachments(List attachmentIds) { + if (attachmentIds == null || attachmentIds.isEmpty()) { + return 0; + } + LocalDateTime deletedTime = LocalDateTime.now(); + return attachmentManagementMapper.batchDeleteByAttachmentIds(attachmentIds, deletedTime); + } + + @Override + public int batchRestoreAttachments(List attachmentIds) { + if (attachmentIds == null || attachmentIds.isEmpty()) { + return 0; + } + return attachmentManagementMapper.batchRestoreByAttachmentIds(attachmentIds); + } + + @Override + public boolean restoreAttachment(String attachmentId) { + int result = attachmentManagementMapper.restoreAttachment(attachmentId); + return result > 0; + } + + @Override + public String getAttachmentOwnerUuid(String attachmentId) { + AttachmentManagement attachment = attachmentManagementMapper.selectByAttachmentId(attachmentId); + return attachment != null ? attachment.getUuid() : null; + } + + @Override + public boolean physicallyDeleteAttachment(String attachmentId) { + AttachmentManagement attachment = attachmentManagementMapper.selectByAttachmentId(attachmentId); + if (attachment == null) { + return false; + } + + try { + String filePath = attachment.getAttachment_path(); + String storageId = attachment.getStorage_id(); + + if (storageId != null && !isLocalStorage(storageId)) { + if (!deleteFromStorage(attachment, filePath)) { + log.warn("存储删除失败,附件可能存储在已禁用的存储方式中,请先启用存储方式后重试"); + return false; + } + } + + deletePhysicalFile(filePath); + + deleteCompressedFile(filePath); + + int result = attachmentManagementMapper.physicallyDeleteAttachment(attachmentId); + return result > 0; + } catch (Exception e) { + log.error("彻底删除附件失败: {}", attachmentId, e); + return false; + } + } + + @Override + public int batchPhysicallyDeleteAttachments(List attachmentIds) { + if (attachmentIds == null || attachmentIds.isEmpty()) { + return 0; + } + int successCount = 0; + for (String attachmentId : attachmentIds) { + if (physicallyDeleteAttachment(attachmentId)) { + successCount++; + } else { + log.warn("批量彻底删除失败,跳过: {}", attachmentId); + } + } + return successCount; + } + + @Override + public int cleanupExpiredDeletedAttachments() { + List expiredAttachments = attachmentManagementMapper.selectExpiredDeletedAttachments(); + + int cleanedCount = 0; + + for (AttachmentManagement attachment : expiredAttachments) { + try { + String filePath = attachment.getAttachment_path(); + String storageId = attachment.getStorage_id(); + log.debug("准备清理过期附件: {}, storage_id: {}", filePath, storageId); + + boolean fileDeleted; + + if (storageId != null && !isLocalStorage(storageId)) { + // 删 S3 + deleteFromStorage(attachment, filePath); + // 同时删本地(兼容旧回退逻辑遗留的本地文件) + deletePhysicalFile(filePath); + fileDeleted = true; + } else { + fileDeleted = deletePhysicalFile(filePath); + } + + if (fileDeleted) { + if (storageId == null || isLocalStorage(storageId)) { + deleteCompressedFile(filePath); + } + + int result = attachmentManagementMapper.physicallyDeleteAttachment(attachment.getAttachment_id()); + if (result > 0) { + cleanedCount++; + log.info("成功清理过期附件: {}", attachment.getAttachment_id()); + } + } + } catch (Exception e) { + log.error("清理附件失败: {}, 错误: {}", attachment.getAttachment_id(), e.getMessage(), e); + } + } + + log.info("附件清理任务完成,共清理 {} 个附件", cleanedCount); + return cleanedCount; + } + + private boolean isLocalStorage(String storageId) { + try { + StorageMethod method = storageMethodMapper.selectByUuid(storageId); + return method != null && "local".equals(method.getType()); + } catch (Exception e) { + return true; + } + } + + private boolean deleteFromStorage(AttachmentManagement attachment, String filePath) { + try { + FileStorage storage = storageFacadeService.getStorage(attachment.getStorage_id()); + storage.delete(filePath); + log.info("成功从存储删除文件: {}/{}", attachment.getStorage_id(), filePath); + return true; + } catch (Exception e) { + log.warn("从存储删除文件失败: {}, 错误: {}", filePath, e.getMessage()); + return false; + } + } + + private void deleteCompressedFile(String originalFilePath) { + try { + Path originalPath = Path.of(dataRoot, originalFilePath.replace('\\', '/')).toAbsolutePath().normalize(); + Path parentDir = originalPath.getParent(); + String fileName = originalPath.getFileName().toString(); + + int dotIndex = fileName.lastIndexOf('.'); + String baseName = dotIndex > 0 ? fileName.substring(0, dotIndex) : fileName; + String compressedFileName = baseName + "_compressed.jpg"; + + Path compressedPath = parentDir.resolve("compressor").resolve(compressedFileName); + + if (Files.exists(compressedPath)) { + Files.delete(compressedPath); + log.info("成功删除压缩文件: {}", compressedPath); + } + } catch (Exception e) { + log.warn("删除压缩文件失败: {}", e.getMessage()); + } + } + + private boolean deletePhysicalFile(String filePath) { + try { + String normalizedPath = filePath.replace('\\', '/'); + log.debug("原始路径: {}, 标准化后: {}", filePath, normalizedPath); + + Path fullPath = Path.of(dataRoot, normalizedPath).toAbsolutePath().normalize(); + + Path allowedRoot = Path.of(dataRoot, "upload").toAbsolutePath().normalize(); + if (!fullPath.startsWith(allowedRoot)) { + log.error("检测到非法文件删除请求,路径超出允许范围: {}", filePath); + return false; + } + + File file = fullPath.toFile(); + log.debug("完整文件路径: {}", fullPath); + + if (file.exists() && file.isFile()) { + if (!file.canWrite()) { + log.warn("文件没有写权限,无法删除: {}", fullPath); + return false; + } + + boolean deleted = file.delete(); + if (deleted) { + log.info("成功删除文件: {}", fullPath); + } else { + log.warn("文件删除失败: {}", fullPath); + } + return deleted; + } else if (!file.exists()) { + log.info("未找到本地残留,跳过删除: {}", fullPath); + return true; + } else { + log.warn("路径不是普通文件,跳过删除: {}", fullPath); + return false; + } + } catch (Exception e) { + log.error("物理删除文件时发生错误,路径: {}", filePath, e); + return false; + } + } + + @Override + public Map migrateStorage(String sourceStorageId, String targetStorageId) { + List attachments = attachmentManagementMapper.selectByStorageId(sourceStorageId); + return doMigrate(attachments, sourceStorageId, targetStorageId); + } + + @Override + public Map switchAttachmentStorage(String attachmentId, String targetStorageId) { + AttachmentManagement attachment = attachmentManagementMapper.selectByAttachmentId(attachmentId); + if (attachment == null) { + return Map.of("error", "附件不存在"); + } + String sourceStorageId = attachment.getStorage_id(); + if (targetStorageId.equals(sourceStorageId)) { + return Map.of("error", "目标存储与当前存储相同"); + } + + FileStorage sourceStorage; + FileStorage targetStorage; + try { + sourceStorage = storageFacadeService.getStorage(sourceStorageId); + targetStorage = storageFacadeService.getStorage(targetStorageId); + } catch (Exception e) { + return Map.of("error", "存储方式不可用: " + e.getMessage()); + } + + String filePath = attachment.getAttachment_path(); + try { + if (!sourceStorage.exists(filePath)) { + return Map.of("error", "源文件不存在,无法迁移"); + } + + byte[] fileData; + try (InputStream in = sourceStorage.retrieve(filePath)) { + fileData = in.readAllBytes(); + } + + try (InputStream in = new ByteArrayInputStream(fileData)) { + targetStorage.store(filePath, in, fileData.length, null); + } + + try { + sourceStorage.delete(filePath); + } catch (Exception e) { + log.warn("切换后删除源文件失败(不影响切换结果): {}", filePath); + } + + attachmentManagementMapper.updateStorageId(attachment.getAttachment_id(), targetStorageId); + log.info("附件存储切换成功: {} -> {}", filePath, targetStorageId); + return Map.of("success", true, "filePath", filePath); + } catch (Exception e) { + log.error("附件存储切换失败: {}, 错误: {}", filePath, e.getMessage()); + return Map.of("error", "迁移失败: " + (e.getMessage() != null ? e.getMessage() : "未知错误")); + } + } + + @Override + public Map retryMigrateStorage(String sourceStorageId, String targetStorageId, List attachmentIds) { + List attachments = attachmentIds.stream() + .map(attachmentManagementMapper::selectByAttachmentId) + .filter(a -> a != null && sourceStorageId.equals(a.getStorage_id())) + .toList(); + return doMigrate(attachments, sourceStorageId, targetStorageId); + } + + private Map doMigrate(List attachments, String sourceStorageId, String targetStorageId) { + int total = attachments.size(); + int success = 0; + List> failedItems = new java.util.ArrayList<>(); + + FileStorage sourceStorage; + FileStorage targetStorage; + try { + sourceStorage = storageFacadeService.getStorage(sourceStorageId); + targetStorage = storageFacadeService.getStorage(targetStorageId); + } catch (Exception e) { + return Map.of("error", "存储方式不可用: " + e.getMessage()); + } + + // 创建迁移记录 + StorageMigrationLog migrationLog = new StorageMigrationLog(); + migrationLog.setSource_storage_id(sourceStorageId); + migrationLog.setTarget_storage_id(targetStorageId); + migrationLog.setTotal(total); + migrationLog.setStatus(0); + storageMigrationLogMapper.insertLog(migrationLog); + int logId = migrationLog.getId(); + + for (AttachmentManagement attachment : attachments) { + String filePath = attachment.getAttachment_path(); + try { + if (!sourceStorage.exists(filePath)) { + failedItems.add(Map.of( + "attachmentId", attachment.getAttachment_id(), + "filePath", filePath, + "error", "源文件不存在,无法迁移")); + continue; + } + + byte[] fileData; + try (InputStream in = sourceStorage.retrieve(filePath)) { + fileData = in.readAllBytes(); + } + + try (InputStream in = new java.io.ByteArrayInputStream(fileData)) { + targetStorage.store(filePath, in, fileData.length, null); + } + + try { + sourceStorage.delete(filePath); + } catch (Exception e) { + log.warn("迁移后删除源文件失败(不影响迁移结果): {}", filePath); + } + + attachmentManagementMapper.updateStorageId(attachment.getAttachment_id(), targetStorageId); + success++; + log.info("迁移成功: {} -> {}", filePath, targetStorageId); + } catch (Exception e) { + log.error("迁移失败: {}, 错误: {}", filePath, e.getMessage()); + failedItems.add(Map.of( + "attachmentId", attachment.getAttachment_id(), + "filePath", filePath, + "error", e.getMessage() != null ? e.getMessage() : "未知错误" + )); + } + } + + // 更新迁移记录 + migrationLog.setSuccess(success); + migrationLog.setFailed(failedItems.size()); + migrationLog.setStatus(1); + storageMigrationLogMapper.updateLog(migrationLog); + + // 写入失败明细 + if (!failedItems.isEmpty()) { + List items = failedItems.stream().map(f -> { + StorageMigrationFailedItem item = new StorageMigrationFailedItem(); + item.setMigration_id(logId); + item.setAttachment_id(f.get("attachmentId")); + item.setFile_path(f.get("filePath")); + item.setError_msg(f.get("error")); + return item; + }).toList(); + storageMigrationLogMapper.insertFailedItems(items); + } + + Map result = new HashMap<>(); + result.put("total", total); + result.put("success", success); + result.put("failed", failedItems.size()); + result.put("failedItems", failedItems); + return result; + } + + @Override + public List getMigrateLogs() { + return storageMigrationLogMapper.selectAll(); + } + + @Override + public StorageMigrationLog getMigrateLogDetail(int id) { + return storageMigrationLogMapper.selectById(id); + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/BackendCommentServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/BackendCommentServiceImpl.java new file mode 100644 index 0000000..408f750 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/BackendCommentServiceImpl.java @@ -0,0 +1,215 @@ +package xyz.lingview.dimstack.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import xyz.lingview.dimstack.domain.Article; +import xyz.lingview.dimstack.domain.Comment; +import xyz.lingview.dimstack.domain.UserInformation; +import xyz.lingview.dimstack.dto.request.CommentDTO; +import xyz.lingview.dimstack.mapper.ArticleMapper; +import xyz.lingview.dimstack.mapper.BackendCommentMapper; +import xyz.lingview.dimstack.mapper.UserInformationMapper; +import xyz.lingview.dimstack.service.BackendCommentService; +import xyz.lingview.dimstack.service.MailService; +import xyz.lingview.dimstack.service.NotificationService; +import xyz.lingview.dimstack.util.SiteConfigUtil; + +import java.util.*; + +@Slf4j +@Service +public class BackendCommentServiceImpl implements BackendCommentService { + + @Autowired + private BackendCommentMapper backendCommentMapper; + + @Autowired + private ArticleMapper articleMapper; + + @Autowired + private UserInformationMapper userInformationMapper; + + @Autowired + private MailService mailService; + + @Autowired + private NotificationService notificationService; + + @Autowired + private SiteConfigUtil siteConfigUtil; + + @Override + public List getCommentsByArticleId(String article_id) { + log.info("查询文章评论,article_id = {}", article_id); + + List comments = backendCommentMapper.selectCommentsWithUserInfoByArticleId(article_id); + log.info("查询到 {} 条评论", comments.size()); + + for (CommentDTO c : comments) { + log.info("comment_id={}, to_comment_id={}, content={}", c.getComment_id(), c.getTo_comment_id(), c.getContent().substring(0, Math.min(20, c.getContent().length()))); + } + + List tree = buildCommentTree(comments); + log.info("构建树形结构完成,根节点数:{}", tree.size()); + + return tree; + } + + @Override + public List getAllCommentsWithPagination(int page, int size) { + int offset = (page - 1) * size; + return backendCommentMapper.selectAllCommentsWithPagination(offset, size); + } + + @Override + public String getArticleTitle(String article_id) { + Article article = articleMapper.selectArticleByArticleId(article_id); + return article != null ? article.getArticle_name() : "未知文章"; + } + + @Override + public Comment getCommentDetail(String comment_id) { + return backendCommentMapper.selectCommentByCommentId(comment_id); + } + + @Override + public boolean updateCommentContent(String comment_id, String content) { + return backendCommentMapper.updateCommentContent(comment_id, content) > 0; + } + + @Override + public boolean deleteComment(String comment_id) { + return backendCommentMapper.deleteComment(comment_id) > 0; + } + + private List buildCommentTree(List comments) { + if (comments == null || comments.isEmpty()) { + return new ArrayList<>(); + } + + for (CommentDTO comment : comments) { + if (comment.getChildren() == null) { + comment.setChildren(new ArrayList<>()); + } + } + + Map commentMap = new HashMap<>(); + for (CommentDTO comment : comments) { + commentMap.put(comment.getComment_id(), comment); + } + + List rootComments = new ArrayList<>(); + + for (CommentDTO comment : comments) { + if (comment.getTo_comment_id() == null || comment.getTo_comment_id().trim().isEmpty()) { + rootComments.add(comment); + } else { + CommentDTO parent = commentMap.get(comment.getTo_comment_id()); + if (parent != null) { + parent.getChildren().add(comment); + } else { + rootComments.add(comment); + } + } + } + + rootComments.sort(Comparator.comparing(CommentDTO::getCreate_time)); + for (CommentDTO root : rootComments) { + root.getChildren().sort(Comparator.comparing(CommentDTO::getCreate_time)); + } + + return rootComments; + } + + @Override + public int getTotalCommentsCount() { + return backendCommentMapper.countTotalComments(); + } + + @Override + public boolean updateCommentTime(String comment_id, String create_time) { + return backendCommentMapper.updateCommentTime(comment_id, create_time) > 0; + } + + @Override + public boolean updateCommentUser(String comment_id, String username) { + String user_id = userInformationMapper.selectUserUUID(username); + if (user_id == null) { + return false; + } + return backendCommentMapper.updateCommentUser(comment_id, user_id) > 0; + } + + @Override + public List> searchUsers(String prefix) { + return backendCommentMapper.searchUsersByPrefix(prefix); + } + + @Override + public List getCommentsByStatus(int status, int page, int size) { + int offset = (page - 1) * size; + return backendCommentMapper.selectCommentsByStatus(status, offset, size); + } + + @Override + public int countByStatus(int status) { + return backendCommentMapper.countByStatus(status); + } + + @Override + public List getAllReviewComments(int page, int size) { + int offset = (page - 1) * size; + return backendCommentMapper.selectAllReviewComments(offset, size); + } + + @Override + public int countAllReview() { + return backendCommentMapper.countAllReview(); + } + + @Override + public boolean updateCommentReviewStatus(String comment_id, int status) { + boolean result = backendCommentMapper.updateCommentStatus(comment_id, status) > 0; + if (result && (status == 1 || status == 4)) { + sendReviewResultNotification(comment_id, status); + } + return result; + } + + private void sendReviewResultNotification(String comment_id, int status) { + try { + if (!siteConfigUtil.isNotificationEnabled()) return; + + Comment comment = backendCommentMapper.selectCommentByCommentId(comment_id); + if (comment == null) return; + + UserInformation author = userInformationMapper.selectUserByUUID(comment.getUser_id()); + if (author == null || author.getEmail() == null) return; + + String siteName = siteConfigUtil.getSiteName(); + String resultText = status == 1 ? "通过审核" : "被标记为违规"; + String subject = siteName + " 评论审核结果"; + String content = "您的评论 \"" + comment.getContent() + "\" " + resultText + "。"; + + notificationService.sendSystemNotification(author.getUsername(), "系统通知", content); + mailService.sendSimpleMail(author.getEmail(), subject, content); + + // 审核通过时,同时通知文章作者有新评论 + if (status == 1) { + Article article = articleMapper.selectArticleByArticleId(comment.getArticle_id()); + if (article != null && !comment.getUser_id().equals(article.getUuid())) { + UserInformation articleAuthor = userInformationMapper.selectUserByUUID(article.getUuid()); + if (articleAuthor != null && articleAuthor.getEmail() != null) { + String commentSubject = siteName + " 评论通知"; + String commentContent = "用户:" + author.getUsername() + " 评论了您的文章《" + article.getArticle_name() + "》:" + comment.getContent(); + notificationService.sendSystemNotification(articleAuthor.getUsername(), "系统通知", commentContent); + mailService.sendSimpleMail(articleAuthor.getEmail(), commentSubject, commentContent); + } + } + } + } catch (Exception e) { + log.warn("发送评论审核结果通知失败", e); + } + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/CommentServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/CommentServiceImpl.java new file mode 100644 index 0000000..9bf89c9 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/CommentServiceImpl.java @@ -0,0 +1,420 @@ +package xyz.lingview.dimstack.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import xyz.lingview.dimstack.domain.Article; +import xyz.lingview.dimstack.domain.Comment; +import xyz.lingview.dimstack.domain.CommentLike; +import xyz.lingview.dimstack.domain.UserInformation; +import xyz.lingview.dimstack.dto.request.AddCommentRequestDTO; +import xyz.lingview.dimstack.dto.request.CommentDTO; +import xyz.lingview.dimstack.mapper.ArticleMapper; +import xyz.lingview.dimstack.mapper.CommentLikeMapper; +import xyz.lingview.dimstack.mapper.CommentMapper; +import xyz.lingview.dimstack.mapper.UserInformationMapper; +import xyz.lingview.dimstack.mapper.UserPermissionMapper; +import xyz.lingview.dimstack.service.CommentService; +import xyz.lingview.dimstack.service.LLMService; +import xyz.lingview.dimstack.service.MailService; +import xyz.lingview.dimstack.service.NotificationService; +import xyz.lingview.dimstack.util.SiteConfigUtil; + +import java.time.LocalDateTime; +import java.util.*; + +@Service +@Slf4j +public class CommentServiceImpl implements CommentService { + + @Autowired + private CommentMapper commentMapper; + + @Autowired + private ArticleMapper articleMapper; + + @Autowired + private UserInformationMapper userInformationMapper; + + @Autowired + private UserPermissionMapper userPermissionMapper; + + @Autowired + private CommentLikeMapper commentLikeMapper; + + @Autowired + private MailService mailService; + + @Autowired + private SiteConfigUtil siteConfigUtil; + + @Autowired + private NotificationService notificationService; + + @Autowired + private LLMService llmService; + + @Override + public List getCommentsByArticleAlias(String articleAlias, String username) { + Article article = articleMapper.selectArticleByAlias(articleAlias); + if (article == null) { + return new ArrayList<>(); + } + + if (!siteConfigUtil.isCommentEnabled()) { + return new ArrayList<>(); + } + if (article.getEnable_comment() != null && article.getEnable_comment() == 0) { + return new ArrayList<>(); + } + + List comments = commentMapper.selectCommentsByArticleId(article.getArticle_id()); + + String currentUserUuid = null; + if (username != null) { + currentUserUuid = userInformationMapper.selectUserUUID(username); + } + + return buildCommentTree(comments, currentUserUuid); + } + @Override + public int addComment(String username, AddCommentRequestDTO request) { + + String userId = userInformationMapper.selectUserUUID(username); + + Article article = articleMapper.selectArticleByAlias(request.getArticle_alias()); + if (article == null) { + throw new RuntimeException("文章不存在"); + } + + if (!siteConfigUtil.isCommentEnabled()) { + throw new RuntimeException("站点评论区已关闭"); + } + if (article.getEnable_comment() != null && article.getEnable_comment() == 0) { + throw new RuntimeException("该文章评论区已关闭"); + } + + Comment comment = new Comment(); + comment.setUser_id(userId); + comment.setComment_id(UUID.randomUUID().toString()); + comment.setArticle_id(article.getArticle_id()); + comment.setContent(request.getContent()); + comment.setComment_like_count(0L); + int commentStatus; + + List userPermissions = userPermissionMapper.findPermissionCodesByUserName(username); + if (userPermissions.contains("system:comments:review") && siteConfigUtil.adminCommentNoReview()) { + commentStatus = 1; + } else { + commentStatus = siteConfigUtil.getCommentStatus(); + } + comment.setStatus(commentStatus); + comment.setCreate_time(LocalDateTime.now()); + comment.setUpdate_time(LocalDateTime.now()); + + if (request.getTo_comment_id() != null && !request.getTo_comment_id().isEmpty()) { + Comment toComment = commentMapper.selectCommentByCommentId(request.getTo_comment_id()); + if (toComment != null) { + comment.setTo_comment_id(request.getTo_comment_id()); + + if (toComment.getRoot_comment_id() != null) { + comment.setRoot_comment_id(toComment.getRoot_comment_id()); + } else { + comment.setRoot_comment_id(toComment.getComment_id()); + } + } + } + + commentMapper.insertComment(comment); + + // AI审核 + if (commentStatus == 3 && siteConfigUtil.isLlmCommentReviewEnabled()) { + log.info("评论 {}(用户:{})进入大模型审核", comment.getComment_id(), username); + triggerLlmCommentReview(comment, article, username); + } else if (commentStatus == 3) { + sendCommentReviewNotification(comment, article, username); + } else { + sendCommentNotification(comment, article, username); + } + + return commentStatus; + } + + @Override + public void likeComment(String username, String commentId) { + String userId = userInformationMapper.selectUserUUID(username); + + Comment comment = commentMapper.selectCommentByCommentId(commentId); + if (comment == null) { + throw new RuntimeException("评论不存在"); + } + + if (!siteConfigUtil.isCommentEnabled()) { + throw new RuntimeException("站点评论区已关闭"); + } + Article article = articleMapper.selectArticleByArticleId(comment.getArticle_id()); + if (article != null && article.getEnable_comment() != null && article.getEnable_comment() == 0) { + throw new RuntimeException("该文章评论区已关闭"); + } + + // 检查用户是否已经点赞过该评论 + if (commentLikeMapper.existsLike(userId, commentId)) { + // 如果已点赞,则取消点赞 + commentLikeMapper.deleteLike(userId, commentId); + Long newLikeCount = comment.getComment_like_count() - 1; + commentMapper.updateCommentLikeCount(commentId, newLikeCount); + } else { + CommentLike like = new CommentLike(); + like.setUser_id(userId); + like.setComment_id(commentId); + like.setCreate_time(LocalDateTime.now()); + commentLikeMapper.insertLike(like); + + Long newLikeCount = comment.getComment_like_count() + 1; + commentMapper.updateCommentLikeCount(commentId, newLikeCount); + + sendLikeNotification(comment, username); + } + } + + + @Override + public void deleteComment(String username, String commentId) { + String userId = userInformationMapper.selectUserUUID(username); + + Comment comment = commentMapper.selectCommentByCommentId(commentId); + if (comment == null) { + throw new RuntimeException("评论不存在"); + } + + if (!comment.getUser_id().equals(userId)) { + throw new RuntimeException("无权限删除该评论"); + } + + commentMapper.deleteComment(commentId); + } + + private List buildCommentTree(List comments, String currentUserUuid) { + if (comments == null || comments.isEmpty()) { + return new ArrayList<>(); + } + + Map userMap = new HashMap<>(); + for (Comment comment : comments) { + String userId = comment.getUser_id(); + if (userId != null && !userMap.containsKey(userId)) { + UserInformation user = userInformationMapper.selectUserByUUID(userId); + if (user != null) { + userMap.put(userId, user); + } + } + } + + List allCommentDTOs = new ArrayList<>(); + Map dtoMap = new HashMap<>(); + + for (Comment comment : comments) { + CommentDTO dto = convertToDTO(comment, userMap, currentUserUuid); + allCommentDTOs.add(dto); + dtoMap.put(comment.getComment_id(), dto); + } + + List rootComments = new ArrayList<>(); + + for (CommentDTO dto : allCommentDTOs) { + if (dto.getTo_comment_id() == null || dto.getTo_comment_id().isEmpty()) { + rootComments.add(dto); + } else { + CommentDTO parentDTO = dtoMap.get(dto.getTo_comment_id()); + if (parentDTO != null) { + if (parentDTO.getChildren() == null) { + parentDTO.setChildren(new ArrayList<>()); + } + parentDTO.getChildren().add(dto); + } + } + } + + return rootComments; + } + + private CommentDTO convertToDTO(Comment comment, Map userMap, String currentUserUuid) { + CommentDTO dto = new CommentDTO(); + dto.setComment_id(comment.getComment_id()); + dto.setUser_id(comment.getUser_id()); + dto.setContent(comment.getContent()); + dto.setCreate_time(comment.getCreate_time()); + dto.setComment_like_count(comment.getComment_like_count()); + dto.setTo_comment_id(comment.getTo_comment_id()); + + String userId = comment.getUser_id(); + if (userId != null && userMap.containsKey(userId)) { + UserInformation user = userMap.get(userId); + if (user != null) { + dto.setUsername(user.getUsername()); + dto.setAvatar(user.getAvatar()); + } else { + dto.setUsername("未知用户"); + dto.setAvatar(null); + } + } else { + dto.setUsername("未知用户"); + dto.setAvatar(null); + } + + // 检查当前用户是否已点赞该评论 + if (currentUserUuid != null) { + dto.setIs_liked(commentLikeMapper.existsLike(currentUserUuid, comment.getComment_id())); + } + + dto.setChildren(new ArrayList<>()); + + return dto; + } + + /** + * 发送评论通知邮件 + * @param comment 新增的评论 + * @param article 评论所属文章 + * @param commenterName 评论者用户名 + */ + private void sendCommentNotification(Comment comment, Article article, String commenterName) { + try { + if (!siteConfigUtil.isNotificationEnabled()) { + return; + } + + String siteName = siteConfigUtil.getSiteName(); + String subject = siteName + " 评论通知"; + + // 给文章作者发送邮件(如果不是自己评论自己的文章) + if (!comment.getUser_id().equals(article.getUuid())) { + UserInformation articleAuthor = userInformationMapper.selectUserByUUID(article.getUuid()); + if (articleAuthor != null && articleAuthor.getEmail() != null) { + String content = "用户:" + commenterName + " 评论了您的文章《" + article.getArticle_name() + "》:" + comment.getContent(); + mailService.sendSimpleMail(articleAuthor.getEmail(), subject, content); + notificationService.sendSystemNotification(articleAuthor.getUsername(), "系统通知", content); + } + } + + // 如果是回复评论,也给原评论作者发送邮件(如果不是回复自己的评论) + if (comment.getTo_comment_id() != null && !comment.getTo_comment_id().isEmpty()) { + Comment originalComment = commentMapper.selectCommentByCommentId(comment.getTo_comment_id()); + if (originalComment != null && + !originalComment.getUser_id().equals(article.getUuid()) && + !originalComment.getUser_id().equals(comment.getUser_id())) { + UserInformation originalCommentAuthor = userInformationMapper.selectUserByUUID(originalComment.getUser_id()); + if (originalCommentAuthor != null && originalCommentAuthor.getEmail() != null) { + String content = "用户 " + commenterName + " 回复了您的评论:" + comment.getContent(); + mailService.sendSimpleMail(originalCommentAuthor.getEmail(), subject, content); + notificationService.sendSystemNotification(originalCommentAuthor.getUsername(), "系统通知", content); + } + } + } + } catch (Exception e) { + log.warn("评论通知邮件发送失败{}", String.valueOf(e)); + } + } + + /** + * 发送点赞通知邮件 + * @param comment 被点赞的评论 + * @param likerUsername 点赞者用户名 + */ + private void sendLikeNotification(Comment comment, String likerUsername) { + try { + if (!siteConfigUtil.isNotificationEnabled()) { + return; + } + + // 获取被点赞评论的作者信息 + UserInformation commentAuthor = userInformationMapper.selectUserByUUID(comment.getUser_id()); + String likerUserId = userInformationMapper.selectUserUUID(likerUsername); + + // 不给自己点赞发通知 + if (commentAuthor != null && commentAuthor.getEmail() != null && + likerUserId != null && !commentAuthor.getUuid().equals(likerUserId)) { + String siteName = siteConfigUtil.getSiteName(); + String subject = siteName + " 点赞通知"; + String content = "用户 " + likerUsername + " 点赞了您的评论:" + comment.getContent(); + + mailService.sendSimpleMail(commentAuthor.getEmail(), subject, content); + notificationService.sendSystemNotification(commentAuthor.getUsername(), "系统通知", content); + } + } catch (Exception e) { + log.warn("点赞通知邮件发送失败{}", String.valueOf(e)); + } + } + + private void sendCommentReviewNotification(Comment comment, Article article, String commenterName) { + try { + if (!siteConfigUtil.isNotificationEnabled()) { + return; + } + UserInformation commenter = userInformationMapper.selectUserByUUID(comment.getUser_id()); + String siteName = siteConfigUtil.getSiteName(); + String subject = siteName + " 评论审核通知"; + String content = "用户 " + commenterName + " 发表了新评论:《" + article.getArticle_name() + "》\"" + comment.getContent() + "\",需要审核。"; + + List reviewers = userInformationMapper.getUsernamesByPermissionCode("system:comments:review"); + if (reviewers != null) { + for (String reviewer : reviewers) { + notificationService.sendSystemNotification(reviewer, subject, content); + } + } + + List reviewerEmails = userInformationMapper.getEmailsByPermissionCode("system:comments:review"); + if (reviewerEmails != null) { + for (String email : reviewerEmails) { + mailService.sendSimpleMail(email, subject, content); + } + } + } catch (Exception e) { + log.warn("评论审核通知发送失败{}", String.valueOf(e)); + } + } + + private void triggerLlmCommentReview(Comment comment, Article article, String commenterName) { + new Thread(() -> { + log.info("评论 {} 开始执行大模型审核(文章:{})", comment.getComment_id(), article.getArticle_name()); + try { + String result = llmService.reviewComment(comment.getContent()); + if ("PASS".equals(result)) { + log.info("评论 {} 大模型审核通过,自动发布", comment.getComment_id()); + commentMapper.updateCommentStatus(comment.getComment_id(), 1); + sendReviewResultToAuthor(comment, true); + sendCommentNotification(comment, article, commenterName); + } else if ("REJECT".equals(result)) { + log.warn("评论 {} 大模型审核不通过,标记为违规", comment.getComment_id()); + commentMapper.updateCommentStatus(comment.getComment_id(), 4); + sendReviewResultToAuthor(comment, false); + } else { + log.warn("评论 {} 大模型审核异常,保持待审核状态,等待人工审核", comment.getComment_id()); + sendCommentReviewNotification(comment, article, commenterName); + } + } catch (Exception e) { + log.error("评论 {} 大模型审核发生未预期异常,保持待审核状态,等待人工审核", comment.getComment_id(), e); + sendCommentReviewNotification(comment, article, commenterName); + } + }).start(); + } + + private void sendReviewResultToAuthor(Comment comment, boolean passed) { + try { + if (!siteConfigUtil.isNotificationEnabled()) return; + + UserInformation author = userInformationMapper.selectUserByUUID(comment.getUser_id()); + if (author == null || author.getEmail() == null) return; + + String siteName = siteConfigUtil.getSiteName(); + String resultText = passed ? "通过审核" : "被标记为违规"; + String subject = siteName + " 评论审核结果"; + String content = "您的评论 \"" + comment.getContent() + "\" " + resultText + "。"; + + notificationService.sendSystemNotification(author.getUsername(), "系统通知", content); + mailService.sendSimpleMail(author.getEmail(), subject, content); + } catch (Exception e) { + log.warn("发送评论审核结果通知失败", e); + } + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/CurrentUserServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/CurrentUserServiceImpl.java new file mode 100644 index 0000000..a10116f --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/CurrentUserServiceImpl.java @@ -0,0 +1,56 @@ +package xyz.lingview.dimstack.service.impl; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import xyz.lingview.dimstack.mapper.UserInformationMapper; +import xyz.lingview.dimstack.security.AuthType; +import xyz.lingview.dimstack.security.AuthenticatedUser; +import xyz.lingview.dimstack.security.UserContextHolder; +import xyz.lingview.dimstack.service.CurrentUserService; + +/** + * @Author: lingview + * @Data: 2026/06/09 11:45:39 + * @Description: 当前登录用户统一访问抽象层实现 + * @Version: 1.0 + */ +@Service +public class CurrentUserServiceImpl implements CurrentUserService { + + @Autowired + private UserInformationMapper userInformationMapper; + + @Override + public boolean isAuthenticated() { + return UserContextHolder.get() != null; + } + + @Override + public String getCurrentUsername() { + AuthenticatedUser user = UserContextHolder.get(); + return user == null ? null : user.getUsername(); + } + + @Override + public String getCurrentUserUuid() { + AuthenticatedUser user = UserContextHolder.get(); + if (user == null) { + return null; + } + if (user.getUuid() == null && user.getUsername() != null) { + user.setUuid(userInformationMapper.selectUserUUID(user.getUsername())); + } + return user.getUuid(); + } + + @Override + public AuthenticatedUser getCurrentUser() { + return UserContextHolder.get(); + } + + @Override + public AuthType getCurrentAuthType() { + AuthenticatedUser user = UserContextHolder.get(); + return user == null ? null : user.getAuthType(); + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/CustomPageServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/CustomPageServiceImpl.java new file mode 100644 index 0000000..a190058 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/CustomPageServiceImpl.java @@ -0,0 +1,156 @@ +package xyz.lingview.dimstack.service.impl; + +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.domain.CustomPage; +import xyz.lingview.dimstack.domain.UserInformation; +import xyz.lingview.dimstack.dto.request.CustomPageRequest; +import xyz.lingview.dimstack.dto.response.CustomPageResponse; +import xyz.lingview.dimstack.mapper.CustomPageMapper; +import xyz.lingview.dimstack.mapper.UserInformationMapper; +import xyz.lingview.dimstack.service.CustomPageService; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +/** + * @Author: lingview + * @Date: 2026/01/17 20:13:35 + * @Description: + * @Version: 1.0 + */ +@Service +public class CustomPageServiceImpl implements CustomPageService { + + @Autowired + private CustomPageMapper customPageMapper; + + @Autowired + private UserInformationMapper userInformationMapper; + + @Override + public ApiResponse createCustomPage(CustomPageRequest request, String userUuid) { + // 检查别名是否已存在 + CustomPage existingPage = customPageMapper.selectByAlias(request.getAlias()); + if (existingPage != null) { + return ApiResponse.error(400, "页面别名已存在"); + } + + CustomPage customPage = new CustomPage(); + BeanUtils.copyProperties(request, customPage); + customPage.setUuid(userUuid); + customPage.setCreateTime(LocalDateTime.now()); + customPage.setStatus(request.getStatus() != null ? request.getStatus() : 1); + + int result = customPageMapper.insert(customPage); + if (result > 0) { + CustomPageResponse response = convertToResponse(customPage); + UserInformation userInfo = userInformationMapper.selectUserByUUID(userUuid); + response.setCreatorUsername(userInfo != null ? userInfo.getUsername() : ""); + return ApiResponse.success(response); + } + return ApiResponse.error(500, "创建页面失败"); + } + + @Override + public ApiResponse updateCustomPage(Integer id, CustomPageRequest request, String userUuid) { + CustomPage existingPage = customPageMapper.selectByPrimaryKey(id); + if (existingPage == null) { + return ApiResponse.error(404, "页面不存在"); + } + + if (!existingPage.getUuid().equals(userUuid)) { + return ApiResponse.error(403, "没有权限修改此页面"); + } + + if (!existingPage.getAlias().equals(request.getAlias())) { + CustomPage aliasCheck = customPageMapper.selectByAlias(request.getAlias()); + if (aliasCheck != null && !aliasCheck.getId().equals(id)) { + return ApiResponse.error(400, "页面别名已存在"); + } + } + + CustomPage updatedPage = new CustomPage(); + BeanUtils.copyProperties(request, updatedPage); + updatedPage.setId(id); + updatedPage.setUuid(userUuid); + updatedPage.setCreateTime(existingPage.getCreateTime()); + + int result = customPageMapper.updateByPrimaryKey(updatedPage); + if (result > 0) { + CustomPageResponse response = convertToResponse(updatedPage); + UserInformation userInfo = userInformationMapper.selectUserByUUID(userUuid); + response.setCreatorUsername(userInfo != null ? userInfo.getUsername() : ""); + return ApiResponse.success(response); + } + return ApiResponse.error(500, "更新页面失败"); + } + + @Override + public ApiResponse deleteCustomPage(Integer id, String userUuid) { + CustomPage existingPage = customPageMapper.selectByPrimaryKey(id); + if (existingPage == null) { + return ApiResponse.error(404, "页面不存在", false); + } + + if (!existingPage.getUuid().equals(userUuid)) { + return ApiResponse.error(403, "没有权限删除此页面", false); + } + + int result = customPageMapper.deleteByPrimaryKey(id); + if (result > 0) { + return ApiResponse.success(true); + } + return ApiResponse.error(500, "删除页面失败", false); + } + + @Override + public ApiResponse getCustomPageByAlias(String alias) { + CustomPage customPage = customPageMapper.selectByAlias(alias); + if (customPage == null) { + return ApiResponse.error(404, "页面不存在"); + } + + CustomPageResponse response = convertToResponse(customPage); + UserInformation userInfo = userInformationMapper.selectUserByUUID(customPage.getUuid()); + response.setCreatorUsername(userInfo != null ? userInfo.getUsername() : ""); + return ApiResponse.success(response); + } + + @Override + public ApiResponse> getCustomPagesByUser(String userUuid) { + List pages = customPageMapper.selectByUserId(userUuid); + List responses = pages.stream() + .map(page -> { + CustomPageResponse response = convertToResponse(page); + UserInformation userInfo = userInformationMapper.selectUserByUUID(page.getUuid()); + response.setCreatorUsername(userInfo != null ? userInfo.getUsername() : ""); + return response; + }) + .collect(Collectors.toList()); + return ApiResponse.success(responses); + } + + @Override + public ApiResponse> getAllCustomPages() { + List pages = customPageMapper.selectAll(); + List responses = pages.stream() + .map(page -> { + CustomPageResponse response = convertToResponse(page); + UserInformation userInfo = userInformationMapper.selectUserByUUID(page.getUuid()); + response.setCreatorUsername(userInfo != null ? userInfo.getUsername() : ""); + return response; + }) + .collect(Collectors.toList()); + return ApiResponse.success(responses); + } + + private CustomPageResponse convertToResponse(CustomPage customPage) { + CustomPageResponse response = new CustomPageResponse(); + BeanUtils.copyProperties(customPage, response); + return response; + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/DashboardMenuServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/DashboardMenuServiceImpl.java new file mode 100644 index 0000000..f1e9db5 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/DashboardMenuServiceImpl.java @@ -0,0 +1,65 @@ +package xyz.lingview.dimstack.service.impl; + +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import xyz.lingview.dimstack.domain.DashboardMenu; +import xyz.lingview.dimstack.dto.request.DashboardMenuDTO; +import xyz.lingview.dimstack.mapper.DashboardMenuMapper; +import xyz.lingview.dimstack.mapper.UserPermissionMapper; +import xyz.lingview.dimstack.service.DashboardMenuService; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class DashboardMenuServiceImpl implements DashboardMenuService { + + @Autowired + private DashboardMenuMapper dashboardMenuMapper; + + @Autowired + private UserPermissionMapper userPermissionMapper; + + @Override + public DashboardMenuDTO getDashboardMenus(String username) { + DashboardMenuDTO responseDTO = new DashboardMenuDTO(); + + List userPermissions = userPermissionMapper.findPermissionCodesByUserName(username); + userPermissions.add(null); + + List allMenus = dashboardMenuMapper.findAll(); + + List accessibleMenus = allMenus.stream() + .filter(menu -> userPermissions.contains(menu.getPermission_code())) + .collect(Collectors.toList()); + + List sidebarMenu = buildMenuTree(accessibleMenus, "sidebar", null); + responseDTO.setSidebarMenu(sidebarMenu); + + List quickActions = buildMenuTree(accessibleMenus, "quick_action", null); + responseDTO.setQuickActions(quickActions); + + return responseDTO; + } + + + private List buildMenuTree(List menus, String type, Integer parentId) { + return menus.stream() + .filter(menu -> type.equals(menu.getType()) && + Objects.equals(parentId, menu.getParent_id())) + .sorted(Comparator.comparing(DashboardMenu::getSort_order)) + .map(menu -> { + DashboardMenuDTO dto = new DashboardMenuDTO(); + BeanUtils.copyProperties(menu, dto); + + List children = buildMenuTree(menus, type, menu.getId()); + if (!children.isEmpty()) { + dto.setChildren(children); + } + + return dto; + }) + .collect(Collectors.toList()); + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/EditArticleServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/EditArticleServiceImpl.java new file mode 100644 index 0000000..31b44a3 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/EditArticleServiceImpl.java @@ -0,0 +1,962 @@ +package xyz.lingview.dimstack.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.mindrot.jbcrypt.BCrypt; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import xyz.lingview.dimstack.domain.ArticleCategory; +import xyz.lingview.dimstack.domain.ReadArticle; +import xyz.lingview.dimstack.dto.request.ArticleDetailDTO; +import xyz.lingview.dimstack.dto.request.UpdateArticleDTO; +import xyz.lingview.dimstack.dto.request.EditArticleDTO; +import xyz.lingview.dimstack.dto.response.ArticleOperationResult; +import xyz.lingview.dimstack.enums.AiReviewResult; +import xyz.lingview.dimstack.mapper.*; +import xyz.lingview.dimstack.service.*; +import xyz.lingview.dimstack.util.CategoryPathUtil; +import xyz.lingview.dimstack.util.SiteConfigUtil; + +import java.text.SimpleDateFormat; +import java.util.*; + +@Slf4j +@Service +public class EditArticleServiceImpl implements EditArticleService { + + @Autowired + private EditArticleMapper editArticleMapper; + + @Autowired + private ArticleCategoryMapper articleCategoryMapper; + + @Autowired + private CategoryPathUtil categoryPathUtil; + + @Autowired + private UserInformationMapper userInformationMapper; + + @Autowired + private NotificationService notificationService; + + @Autowired + private MailService mailService; + + @Autowired + private SiteConfigUtil siteConfigUtil; + + @Autowired + private SiteConfigService siteConfigService; + + @Autowired + private ArticleReviewMapper articleReviewMapper; + + @Autowired + private LLMService llmService; + + @Autowired + private SiteConfigMapper siteConfigMapper; + + @Autowired + private ArticleMapper articleMapper; + + @Autowired + private UserPermissionMapper userPermissionMapper; + + @Autowired + private ReadArticleMapper readArticleMapper; + + @Autowired + private CacheService cacheService; + + @Autowired + private ArticleService articleService; + + @Autowired + private PageViewCounterService pageViewCounterService; + + @Override + public Map getArticleListByUsername(String username, Integer page, Integer size, String keyword, + Integer status, Integer categoryId, Boolean includeSubCategories, + String tag, String startDate, String endDate) { + Map result = new HashMap<>(); + + try { + String uuid = editArticleMapper.getUuidByUsername(username); + if (uuid == null) { + result.put("articles", List.of()); + result.put("total", 0); + result.put("page", page); + result.put("size", size); + return result; + } + + int safePage = page == null || page < 1 ? 1 : page; + int safeSize = size == null || size < 1 ? 10 : Math.min(size, 100); + String trimmedKeyword = keyword == null || keyword.isBlank() ? null : keyword.trim(); + List categoryIds = resolveCategoryIds(categoryId, !Boolean.FALSE.equals(includeSubCategories)); + String trimmedTag = tag == null || tag.isBlank() ? null : tag.trim(); + String startTime = normalizeDate(startDate, false); + String endTime = normalizeDate(endDate, true); + + int offset = (safePage - 1) * safeSize; + List articles = editArticleMapper.selectArticlesByUuid(uuid, trimmedKeyword, status, + categoryIds, trimmedTag, startTime, endTime, offset, safeSize); + int total = editArticleMapper.countArticlesByUuid(uuid, trimmedKeyword, status, + categoryIds, trimmedTag, startTime, endTime); + + result.put("articles", articles); + result.put("total", total); + result.put("page", safePage); + result.put("size", safeSize); + result.put("totalPages", (int) Math.ceil((double) total / safeSize)); + + return result; + + } catch (Exception e) { + log.error("获取文章列表失败", e); + result.put("articles", List.of()); + result.put("total", 0); + result.put("page", page); + result.put("size", size); + return result; + } + } + + private List resolveCategoryIds(Integer categoryId, boolean includeSubCategories) { + if (categoryId == null) { + return null; + } + if (!includeSubCategories) { + return List.of(categoryId); + } + List allCategories = articleCategoryMapper.findAll(); + Map> childrenByParent = new HashMap<>(); + for (ArticleCategory category : allCategories) { + if (category.getParent_id() != null) { + childrenByParent.computeIfAbsent(category.getParent_id(), key -> new ArrayList<>()).add(category.getId()); + } + } + List result = new ArrayList<>(); + Set visited = new HashSet<>(); + Deque queue = new ArrayDeque<>(); + queue.add(categoryId); + while (!queue.isEmpty()) { + Integer id = queue.poll(); + if (!visited.add(id)) { + continue; + } + result.add(id); + List childIds = childrenByParent.get(id); + if (childIds != null) { + queue.addAll(childIds); + } + } + return result; + } + + private String normalizeDate(String date, boolean endOfDay) { + if (date == null || date.isBlank()) { + return null; + } + String value = date.trim(); + if (!value.matches("\\d{4}-\\d{2}-\\d{2}")) { + return null; + } + return value + (endOfDay ? " 23:59:59" : " 00:00:00"); + } + + @Override + public ArticleDetailDTO getArticleDetailById(String articleId, String username) { + try { + String articleUuid = editArticleMapper.getUuidByArticleId(articleId); + if (articleUuid == null) { + return null; + } + + String articleOwner = editArticleMapper.getUsernameByUuid(articleUuid); + if (articleOwner == null) { + return null; + } + + if (!articleOwner.equals(username)) { + return null; + } + + return editArticleMapper.getArticleDetailById(articleId); + + } catch (Exception e) { + log.error("获取文章详情失败", e); + return null; + } + } + + @Override + public boolean updateArticle(UpdateArticleDTO updateArticleDTO, String sessionUsername) { + try { + String articleUuid = editArticleMapper.getUuidByArticleId(updateArticleDTO.getArticle_id()); + if (articleUuid == null) { + return false; + } + + String articleOwner = editArticleMapper.getUsernameByUuid(articleUuid); + if (articleOwner == null) { + return false; + } + + if (!articleOwner.equals(sessionUsername)) { + return false; + } + + // 获取更新前的文章分类 + String oldCategory = editArticleMapper.getCategoryByArticleId(updateArticleDTO.getArticle_id()); + + editArticleMapper.deleteArticleTagRelations(updateArticleDTO.getArticle_id()); + + String tagsString = updateArticleDTO.getTag(); + if (tagsString != null && !tagsString.isEmpty()) { + String[] tags = tagsString.split(","); + for (String tag : tags) { + if (!tag.trim().isEmpty()) { + editArticleMapper.insertArticleTagRelation(updateArticleDTO.getArticle_id(), tag.trim()); + } + } + } + + CategoryPathUtil.CategoryPath categoryPath = categoryPathUtil.parsePath(updateArticleDTO.getCategory()); + + Map paramMap = new HashMap<>(); + paramMap.put("article_id", updateArticleDTO.getArticle_id()); + paramMap.put("article_name", updateArticleDTO.getArticle_name()); + paramMap.put("article_cover", updateArticleDTO.getArticle_cover()); + paramMap.put("excerpt", updateArticleDTO.getExcerpt()); + paramMap.put("article_content", updateArticleDTO.getArticle_content()); + paramMap.put("password", updateArticleDTO.getPassword()); + paramMap.put("parentCategory", categoryPath.getParentCategory()); + paramMap.put("childCategory", categoryPath.getChildCategory()); + paramMap.put("alias", updateArticleDTO.getAlias()); + paramMap.put("status", updateArticleDTO.getStatus()); + paramMap.put("enable_comment", updateArticleDTO.getEnable_comment()); + paramMap.put("create_time", updateArticleDTO.getCreate_time()); + + editArticleMapper.updateArticleWithCategory(paramMap); + + // 更新分类计数 + String newCategory = updateArticleDTO.getCategory(); + if (oldCategory != null && !oldCategory.equals(newCategory)) { + CategoryPathUtil.CategoryPath oldPath = categoryPathUtil.parsePath(oldCategory); + String oldFullPath = oldPath.getParentCategory() != null + ? oldPath.getParentCategory() + "/" + oldPath.getChildCategory() + : oldPath.getChildCategory(); + articleCategoryMapper.decrementCount(oldFullPath); + + if (newCategory != null) { + CategoryPathUtil.CategoryPath newPath = categoryPathUtil.parsePath(newCategory); + String newFullPath = newPath.getParentCategory() != null + ? newPath.getParentCategory() + "/" + newPath.getChildCategory() + : newPath.getChildCategory(); + articleCategoryMapper.incrementCount(newFullPath); + } + } + + articleService.clearArticleCache(); + return true; + + } catch (Exception e) { + log.error("更新文章失败", e); + return false; + } + } + + + + @Override + public boolean deleteArticle(String articleId, String sessionUsername) { + try { + String articleUuid = editArticleMapper.getUuidByArticleId(articleId); + if (articleUuid == null) { + return false; + } + + String articleOwner = editArticleMapper.getUsernameByUuid(articleUuid); + if (articleOwner == null || !articleOwner.equals(sessionUsername)) { + return false; + } + + Map params = new HashMap<>(); + params.put("article_id", articleId); + params.put("uuid", articleUuid); + + int result = editArticleMapper.deleteArticle(params); + + if (result > 0) { + articleService.clearArticleCache(); + } + + return result > 0; + + } catch (Exception e) { + log.error("删除文章失败", e); + return false; + } + } + + @Override + public boolean unpublishArticle(String articleId, String sessionUsername) { + try { + String articleUuid = editArticleMapper.getUuidByArticleId(articleId); + if (articleUuid == null) { + return false; + } + + String articleOwner = editArticleMapper.getUsernameByUuid(articleUuid); + if (articleOwner == null || !articleOwner.equals(sessionUsername)) { + return false; + } + + Map params = new HashMap<>(); + params.put("article_id", articleId); + params.put("uuid", articleUuid); + + int result = editArticleMapper.unpublishArticle(params); + + if (result > 0) { + articleService.clearArticleCache(); + } + + return result > 0; + + } catch (Exception e) { + log.error("取消发布文章失败", e); + return false; + } + } + + @Override + public boolean publishArticle(String articleId, String sessionUsername) { + try { + String articleUuid = editArticleMapper.getUuidByArticleId(articleId); + if (articleUuid == null) { + return false; + } + + String articleOwner = editArticleMapper.getUsernameByUuid(articleUuid); + if (articleOwner == null || !articleOwner.equals(sessionUsername)) { + return false; + } + + Map params = new HashMap<>(); + params.put("article_id", articleId); + params.put("uuid", articleUuid); + + int result = editArticleMapper.publishArticle(params); + + if (result > 0) { + articleService.clearArticleCache(); + } + + return result > 0; + + } catch (Exception e) { + log.error("发布文章失败", e); + return false; + } + } + + @Override + public String getArticleUuid(String articleId) { + return editArticleMapper.getUuidByArticleId(articleId); + } + + @Override + public String getArticleContent(String articleId) { + try { + return editArticleMapper.getArticleContentById(articleId); + } catch (Exception e) { + log.error("获取文章内容失败", e); + return null; + } + } + + @Override + public ArticleOperationResult updateArticleWithNotification(UpdateArticleDTO updateArticleDTO, String sessionUsername) { + try { + ArticleDetailDTO originalArticle = getArticleDetailById(updateArticleDTO.getArticle_id(), sessionUsername); + if (originalArticle == null) { + return ArticleOperationResult.builder() + .success(false) + .message("文章不存在或无权限访问") + .build(); + } + + log.info("原文章密码: {}", originalArticle.getPassword() != null ? "存在(长度:" + originalArticle.getPassword().length() + ")" : "null"); + log.info("前端传入密码: '{}'", updateArticleDTO.getPassword()); + + String incomingPassword = updateArticleDTO.getPassword(); + if (incomingPassword != null && !incomingPassword.trim().isEmpty()) { + log.info("用户设置新密码,进行加密"); + String hashedPassword = BCrypt.hashpw(incomingPassword.trim(), BCrypt.gensalt()); + updateArticleDTO.setPassword(hashedPassword); + } else { + log.info("保持原密码不变"); + updateArticleDTO.setPassword(originalArticle.getPassword()); + } + + updateArticleDTO.setStatus(2); + + boolean result = updateArticle(updateArticleDTO, sessionUsername); + + if (result) { + log.info("文章更新成功,article_id: {}", updateArticleDTO.getArticle_id()); + invalidateArticleCache(updateArticleDTO.getArticle_id()); + sendUpdateNotification(sessionUsername, updateArticleDTO.getArticle_id()); + + return ArticleOperationResult.builder() + .success(true) + .message("文章更新成功") + .build(); + } else { + return ArticleOperationResult.builder() + .success(false) + .message("文章更新失败:权限不足或文章不存在") + .build(); + } + } catch (Exception e) { + log.error("文章更新失败", e); + return ArticleOperationResult.builder() + .success(false) + .message("文章更新失败: " + e.getMessage()) + .build(); + } + } + + @Override + public ArticleOperationResult deleteArticleWithNotification(String articleId, String sessionUsername) { + try { + boolean result = deleteArticle(articleId, sessionUsername); + + if (result) { + String articleCategory = articleCategoryMapper.getCategoryByArticleId(articleId); + articleCategoryMapper.decrementCount(articleCategory); + invalidateArticleCache(articleId); + sendDeleteNotification(sessionUsername, articleId); + + return ArticleOperationResult.builder() + .success(true) + .message("文章删除成功") + .build(); + } else { + return ArticleOperationResult.builder() + .success(false) + .message("文章删除失败:权限不足或文章不存在") + .build(); + } + } catch (Exception e) { + log.error("文章删除失败", e); + return ArticleOperationResult.builder() + .success(false) + .message("文章删除失败: " + e.getMessage()) + .build(); + } + } + + @Override + public ArticleOperationResult unpublishArticleWithValidation(String articleId, String sessionUsername) { + try { + String articleFromUserUuid = articleMapper.selectUserUuidByArticleId(articleId); + String userUuid = userInformationMapper.selectUserUUID(sessionUsername); + if (!articleFromUserUuid.equals(userUuid)) { + log.warn("检测越权攻击用户{} 尝试取消发布文章{}", sessionUsername, articleId); + return ArticleOperationResult.builder() + .success(false) + .message("这不是您的文章") + .build(); + } + + boolean result = unpublishArticle(articleId, sessionUsername); + + if (result) { + invalidateArticleCache(articleId); + return ArticleOperationResult.builder() + .success(true) + .message("文章已取消发布") + .build(); + } else { + return ArticleOperationResult.builder() + .success(false) + .message("取消发布失败:权限不足或文章不存在") + .build(); + } + } catch (Exception e) { + log.error("取消发布失败", e); + return ArticleOperationResult.builder() + .success(false) + .message("取消发布失败: " + e.getMessage()) + .build(); + } + } + + @Override + public ArticleOperationResult publishArticleWithReview(String articleId, String sessionUsername) { + try { + String articleFromUserUuid = articleMapper.selectUserUuidByArticleId(articleId); + String userUuid = userInformationMapper.selectUserUUID(sessionUsername); + if (!articleFromUserUuid.equals(userUuid)) { + log.warn("检测越权攻击用户{} 尝试发布文章{}", sessionUsername, articleId); + return ArticleOperationResult.builder() + .success(false) + .message("这不是您的文章") + .build(); + } + + boolean adminPostNoReview = siteConfigUtil.adminPostNoReview(); + boolean noReviewNotice = false; + int articleDefault; + List userPermission = userPermissionMapper.findPermissionCodesByUserName(sessionUsername); + if (userPermission.contains("system:post:review")){ + if (adminPostNoReview) { + noReviewNotice = true; + articleDefault = 1; + }else{ + articleDefault = siteConfigMapper.getArticleStatus(); + } + }else { + articleDefault = siteConfigMapper.getArticleStatus(); + } + + Integer enableLlm = siteConfigService.getEnableLlm(); + Integer enableLlmArticleReview = siteConfigService.getEnableLlmArticleReview(); + boolean aiReviewEnabled = (enableLlm != null && enableLlm == 1 && enableLlmArticleReview != null && enableLlmArticleReview == 1); + + if (!noReviewNotice && aiReviewEnabled) { + log.info("文章 {} 使用大模型审核,设为待审核状态", articleId); + articleDefault = 3; + + String articleContent = getArticleContent(articleId); + if (articleContent != null && !articleContent.trim().isEmpty()) { + ReviewContext context = buildReviewContext(articleId, sessionUsername); + asyncAiReview(context); + } else { + log.warn("文章 {} 内容为空,跳过大模型审核,保持待审核状态", articleId); + } + } + + Map params = new HashMap<>(); + params.put("article_id", articleId); + params.put("uuid", getArticleUuid(articleId)); + params.put("status", articleDefault); + + int result = editArticleMapper.publishArticle(params); + + if (!aiReviewEnabled || noReviewNotice) { + if (noReviewNotice) { + sendAutoApprovalNotification(sessionUsername, articleId); + } else if (articleDefault == 4) { + sendViolationNotification(sessionUsername, articleId); + } else { + sendReviewNotification(sessionUsername, articleId); + } + } + + if (result > 0) { + invalidateArticleCache(articleId); + articleService.clearArticleCache(); + return ArticleOperationResult.builder() + .success(true) + .message("文章已发布") + .build(); + } else { + return ArticleOperationResult.builder() + .success(false) + .message("发布失败:权限不足或文章不存在") + .build(); + } + } catch (Exception e) { + log.error("发布失败", e); + return ArticleOperationResult.builder() + .success(false) + .message("发布失败: " + e.getMessage()) + .build(); + } + } + + @Override + public ArticleOperationResult removeArticlePasswordWithValidation(String articleId, String sessionUsername) { + try { + String articleUuid = getArticleUuid(articleId); + if (articleUuid == null) { + return ArticleOperationResult.builder() + .success(false) + .message("文章不存在") + .build(); + } + + String articleOwner = editArticleMapper.getUsernameByUuid(articleUuid); + if (articleOwner == null || !articleOwner.equals(sessionUsername)) { + return ArticleOperationResult.builder() + .success(false) + .message("权限不足,无法操作该文章") + .build(); + } + + Map params = new HashMap<>(); + params.put("article_id", articleId); + params.put("password", null); + + int result = editArticleMapper.removeArticlePassword(params); + + if (result > 0) { + invalidateArticleCache(articleId); + return ArticleOperationResult.builder() + .success(true) + .message("文章密码已移除") + .build(); + } else { + return ArticleOperationResult.builder() + .success(false) + .message("移除文章密码失败") + .build(); + } + } catch (Exception e) { + log.error("移除文章密码失败", e); + return ArticleOperationResult.builder() + .success(false) + .message("移除文章密码失败: " + e.getMessage()) + .build(); + } + } + + private void sendUpdateNotification(String username, String articleId) { + if (!siteConfigUtil.isNotificationEnabled()) { + return; + } + + try { + Date date = new Date(); + SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + String formattedDate = formatter.format(date); + String articleName = articleReviewMapper.getArticleNameByArticleId(articleId); + String email = userInformationMapper.getEmailByUsername(username); + String siteName = siteConfigUtil.getSiteName(); + + notificationService.sendSystemNotification(username, "系统通知", + "您的文章" + "《" + articleName + "》" + " 于 " + formattedDate + " 进行了更新"); + mailService.sendSimpleMail(email, siteName + " 文章更新", + "您的文章" + "《" + articleName + "》" + " 于 " + formattedDate + " 进行了更新"); + log.info("已发送更新通知至用户邮箱: {}", email); + } catch (Exception e) { + log.error("发送更新通知失败", e); + } + } + + private void sendDeleteNotification(String username, String articleId) { + if (!siteConfigUtil.isNotificationEnabled()) { + return; + } + + try { + Date date = new Date(); + SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + String formattedDate = formatter.format(date); + String email = userInformationMapper.getEmailByUsername(username); + String siteName = siteConfigUtil.getSiteName(); + String articleName = articleReviewMapper.getArticleNameByArticleId(articleId); + + notificationService.sendSystemNotification(username, "系统通知", + "用户:" + username + " 于 " + formattedDate + " 删除了文章:" + "《" + articleName + "》"); + mailService.sendSimpleMail(email, siteName + " 文章删除成功", + "用户:" + username + " 于 " + formattedDate + " 成功删除文章:" + "《" + articleName + "》"); + } catch (Exception e) { + log.error("发送删除通知失败", e); + } + } + + private void sendAutoApprovalNotification(String authorUsername, String articleId) { + if (!siteConfigUtil.isNotificationEnabled()) { + return; + } + + try { + Date date = new Date(); + SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + String formattedDate = formatter.format(date); + + String siteName = siteConfigUtil.getSiteName(); + String articleName = articleReviewMapper.getArticleNameByArticleId(articleId); + String email = userInformationMapper.getEmailByUsername(authorUsername); + + notificationService.sendSystemNotification( + authorUsername, + "系统通知", + "您的文章:《" + articleName + "》已自动审核通过" + ); + + mailService.sendSimpleMail( + email, + siteName + " 文章审核", + "您的文章:《" + articleName + "》已自动审核通过" + ); + + log.info("用户 {} 的文章《{}》已自动审核通过,审核时间:{}", authorUsername, articleName, formattedDate); + } catch (Exception e) { + log.error("发送自动审核通知失败,文章ID: {}, 作者: {}", articleId, authorUsername, e); + } + } + + private void sendViolationNotification(String authorUsername, String articleId) { + if (!siteConfigUtil.isNotificationEnabled()) { + return; + } + + try { + Date date = new Date(); + SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + String formattedDate = formatter.format(date); + + String siteName = siteConfigUtil.getSiteName(); + String articleName = articleReviewMapper.getArticleNameByArticleId(articleId); + String email = userInformationMapper.getEmailByUsername(authorUsername); + + notificationService.sendSystemNotification( + authorUsername, + "系统通知", + "您的文章:《" + articleName + "》经大模型审核发现违规内容,已被标记为违规" + ); + + mailService.sendSimpleMail( + email, + siteName + " 文章审核", + "您的文章:《" + articleName + "》经大模型审核发现违规内容,已被标记为违规,请联系管理员处理" + ); + + log.warn("用户 {} 的文章《{}》大模型审核不通过,标记为违规,时间:{}", authorUsername, articleName, formattedDate); + } catch (Exception e) { + log.error("发送违规通知失败,文章ID: {}, 作者: {}", articleId, authorUsername, e); + } + } + + private void sendReviewNotification(String authorUsername, String articleId) { + if (!siteConfigUtil.isNotificationEnabled()) { + return; + } + + try { + Date date = new Date(); + SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + String formattedDate = formatter.format(date); + + String siteName = siteConfigUtil.getSiteName(); + String articleName = articleReviewMapper.getArticleNameByArticleId(articleId); + + List reviewerUsernames = userInformationMapper.getUsernamesByPermissionCode("system:post:review"); + List reviewerEmails = userInformationMapper.getEmailsByPermissionCode("system:post:review"); + + if (reviewerUsernames == null || reviewerUsernames.isEmpty()) { + log.warn("未找到拥有 'system:post:review' 权限的用户,跳过发送审核通知"); + } else { + for (String reviewerUsername : reviewerUsernames) { + try { + notificationService.sendSystemNotification( + reviewerUsername, + "系统通知", + "用户:" + authorUsername + " 于 " + formattedDate + " 发布了新文章:《" + articleName + "》可能需要您审核" + ); + log.info("已发送审核通知至: {}", reviewerUsername); + } catch (Exception e) { + log.error("发送审核通知失败,目标用户: {}", reviewerUsername, e); + } + } + } + + if (reviewerEmails == null || reviewerEmails.isEmpty()) { + log.warn("未找到拥有 'system:post:review' 权限的用户,跳过发送审核通知邮件"); + } else { + for (String email : reviewerEmails) { + try { + mailService.sendSimpleMail( + email, + siteName + " 文章审核", + "用户:" + authorUsername + " 于 " + formattedDate + " 发布了新文章:《" + articleName + "》可能需要您审核" + ); + log.info("已发送审核通知邮件至: {}", email); + } catch (Exception e) { + log.error("发送审核通知邮件失败,目标邮箱: {}", email, e); + } + } + } + } catch (Exception e) { + log.error("发送审核通知失败,文章ID: {}, 作者: {}", articleId, authorUsername, e); + } + } + + private ReviewContext buildReviewContext(String articleId, String username) { + ReviewContext context = new ReviewContext(); + context.articleId = articleId; + context.articleName = articleReviewMapper.getArticleNameByArticleId(articleId); + context.authorUsername = username; + context.authorEmail = userInformationMapper.getEmailByUsername(username); + context.reviewerUsernames = userInformationMapper.getUsernamesByPermissionCode("system:post:review"); + context.reviewerEmails = userInformationMapper.getEmailsByPermissionCode("system:post:review"); + context.siteName = siteConfigUtil.getSiteName(); + context.notificationEnabled = siteConfigUtil.isNotificationEnabled(); + context.articleContent = getArticleContent(articleId); + return context; + } + + private static class ReviewContext { + String articleId; + String articleName; + String authorUsername; + String authorEmail; + List reviewerUsernames; + List reviewerEmails; + String siteName; + boolean notificationEnabled; + String articleContent; + } + + private void asyncAiReview(ReviewContext context) { + new Thread(() -> { + log.info("文章 {}《{}》(作者:{})进入大模型审核", context.articleId, context.articleName, context.authorUsername); + try { + AiReviewResult reviewResult = llmService.reviewArticle(context.articleContent); + + if (reviewResult == AiReviewResult.PASS) { + log.info("文章 {} 大模型审核通过,自动发布", context.articleId); + editArticleMapper.updateArticleStatus(context.articleId, 1); + invalidateArticleCache(context.articleId); + articleService.clearArticleCache(); + sendAutoApprovalNotificationWithInfo(context); + } else if (reviewResult == AiReviewResult.REJECT) { + log.warn("文章 {} 大模型审核不通过,标记为违规", context.articleId); + editArticleMapper.updateArticleStatus(context.articleId, 4); + invalidateArticleCache(context.articleId); + sendViolationNotificationWithInfo(context); + } else { + log.warn("文章 {} 大模型审核异常,保持待审核状态,等待人工审核", context.articleId); + sendReviewNotificationWithInfo(context); + } + } catch (Exception e) { + log.error("文章 {} 大模型审核发生未预期异常,保持待审核状态,等待人工审核", context.articleId, e); + sendReviewNotificationWithInfo(context); + } + }).start(); + } + + private void sendAutoApprovalNotificationWithInfo(ReviewContext context) { + if (!context.notificationEnabled) { + return; + } + + try { + Date date = new Date(); + SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + String formattedDate = formatter.format(date); + + notificationService.sendSystemNotification( + context.authorUsername, + "系统通知", + "您的文章:《" + context.articleName + "》已自动审核通过" + ); + + mailService.sendSimpleMail( + context.authorEmail, + context.siteName + " 文章审核", + "您的文章:《" + context.articleName + "》已自动审核通过" + ); + + log.info("用户 {} 的文章《{}》已自动审核通过,审核时间:{}", context.authorUsername, context.articleName, formattedDate); + } catch (Exception e) { + log.error("发送自动审核通知失败,文章ID: {}, 作者: {}", context.articleId, context.authorUsername, e); + } + } + + private void sendViolationNotificationWithInfo(ReviewContext context) { + if (!context.notificationEnabled) { + return; + } + + try { + Date date = new Date(); + SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + String formattedDate = formatter.format(date); + + notificationService.sendSystemNotification( + context.authorUsername, + "系统通知", + "您的文章:《" + context.articleName + "》经大模型审核发现违规内容,已被标记为违规" + ); + + mailService.sendSimpleMail( + context.authorEmail, + context.siteName + " 文章审核", + "您的文章:《" + context.articleName + "》经大模型审核发现违规内容,已被标记为违规,请联系管理员处理" + ); + + log.warn("用户 {} 的文章《{}》大模型审核不通过,标记为违规,时间:{}", context.authorUsername, context.articleName, formattedDate); + } catch (Exception e) { + log.error("发送违规通知失败,文章ID: {}, 作者: {}", context.articleId, context.authorUsername, e); + } + } + + private void sendReviewNotificationWithInfo(ReviewContext context) { + if (!context.notificationEnabled) { + return; + } + + try { + Date date = new Date(); + SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + String formattedDate = formatter.format(date); + + if (context.reviewerUsernames == null || context.reviewerUsernames.isEmpty()) { + log.warn("未找到拥有 'system:post:review' 权限的用户,跳过发送审核通知"); + } else { + for (String reviewerUsername : context.reviewerUsernames) { + try { + notificationService.sendSystemNotification( + reviewerUsername, + "系统通知", + "用户:" + context.authorUsername + " 于 " + formattedDate + " 发布了新文章:《" + context.articleName + "》可能需要您审核" + ); + log.info("已发送审核通知至: {}", reviewerUsername); + } catch (Exception e) { + log.error("发送审核通知失败,目标用户: {}", reviewerUsername, e); + } + } + } + + if (context.reviewerEmails == null || context.reviewerEmails.isEmpty()) { + log.warn("未找到拥有 'system:post:review' 权限的用户,跳过发送审核通知邮件"); + } else { + for (String email : context.reviewerEmails) { + try { + mailService.sendSimpleMail( + email, + context.siteName + " 文章审核", + "用户:" + context.authorUsername + " 于 " + formattedDate + " 发布了新文章:《" + context.articleName + "》可能需要您审核" + ); + log.info("已发送审核通知邮件至: {}", email); + } catch (Exception e) { + log.error("发送审核通知邮件失败,目标邮箱: {}", email, e); + } + } + } + } catch (Exception e) { + log.error("发送审核通知失败,文章ID: {}, 作者: {}", context.articleId, context.authorUsername, e); + } + } + + private void invalidateArticleCache(String articleId) { + try { + ReadArticle article = readArticleMapper.selectByArticleId(articleId); + if (article != null && article.getAlias() != null) { + String cacheKey = "dimstack:article:" + article.getAlias(); + cacheService.delete(cacheKey); + pageViewCounterService.removePageView(article.getAlias()); + log.info("已清除文章缓存和浏览量计数器: {}", cacheKey); + } + } catch (Exception e) { + log.warn("清除文章缓存失败,articleId: {}", articleId, e); + } + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/FileAccessServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/FileAccessServiceImpl.java new file mode 100644 index 0000000..a557c75 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/FileAccessServiceImpl.java @@ -0,0 +1,196 @@ +package xyz.lingview.dimstack.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Service; +import xyz.lingview.dimstack.domain.UploadAttachment; +import xyz.lingview.dimstack.service.FileAccessService; +import xyz.lingview.dimstack.service.FileStorage; +import xyz.lingview.dimstack.service.ImageCompressionService; +import xyz.lingview.dimstack.service.SiteConfigService; +import xyz.lingview.dimstack.service.StorageFacadeService; +import xyz.lingview.dimstack.service.UploadService; +import xyz.lingview.dimstack.util.MimeTypeUtil; + +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; + +@Slf4j +@Service +public class FileAccessServiceImpl implements FileAccessService { + + @Autowired + private UploadService uploadService; + + @Autowired + private ImageCompressionService imageCompressionService; + + @Autowired + private SiteConfigService siteConfigService; + + @Autowired + private StorageFacadeService storageFacadeService; + + @Value("${file.data-root:.}") + private String dataRoot; + + @Override + public FileAccessResult getFile(String accessKey, boolean download) { + UploadAttachment attachment = uploadService.selectByAccessKey(accessKey); + if (attachment == null) { + log.warn("未找到访问键对应的文件: {}", accessKey); + return new FileAccessResult(null, null, null, false, null); + } + + String storageId = attachment.getStorage_id(); + try { + FileStorage storage = storageFacadeService.getStorage(storageId); + if (storage instanceof LocalFileStorageImpl) { + return handleLocalStorage(attachment, download); + } + return handleExternalStorage(attachment, download, storage); + } catch (Exception e) { + log.warn("存储方式不可用,文件无法访问: {}", e.getMessage()); + return new FileAccessResult(null, null, null, false, null); + } + } + + private FileAccessResult handleLocalStorage(UploadAttachment attachment, boolean download) { + Path basePath = Path.of(dataRoot).toAbsolutePath().normalize(); + Path filePath = basePath.resolve(attachment.getAttachment_path()).normalize(); + + if (!Files.exists(filePath)) { + log.warn("文件不存在: {}", filePath); + return new FileAccessResult(null, null, null, false, null); + } + + String contentType = detectContentType(filePath); + Resource resource = resolveResource(filePath, contentType, download); + + String filename = filePath.getFileName().toString(); + String disposition = buildDisposition(filename, download); + + return new FileAccessResult(resource, contentType, disposition, true, null); + } + + private FileAccessResult handleExternalStorage(UploadAttachment attachment, boolean download, FileStorage storage) { + if (storage.supportsPresignedUrl()) { + return handleS3Storage(attachment, download); + } + return handleStreamingStorage(attachment, download); + } + + private FileAccessResult handleS3Storage(UploadAttachment attachment, boolean download) { + try { + String objectKey = attachment.getAttachment_path(); + S3FileStorageImpl s3Storage = (S3FileStorageImpl) storageFacadeService.getStorage(attachment.getStorage_id()); + Duration expiration = download ? Duration.ofMinutes(5) : Duration.ofHours(1); + + String filename = Path.of(attachment.getAttachment_path()).getFileName().toString(); + String disposition = buildDisposition(filename, download); + String presignedUrl = s3Storage.generatePresignedUrl(objectKey, expiration, download ? disposition : null); + + return new FileAccessResult(null, null, disposition, true, presignedUrl); + } catch (Exception e) { + log.error("生成Presigned URL失败,回退到本地存储: {}", attachment.getAttachment_id(), e); + return handleLocalStorage(attachment, download); + } + } + + private FileAccessResult handleStreamingStorage(UploadAttachment attachment, boolean download) { + try { + FileStorage storage = storageFacadeService.getStorage(attachment.getStorage_id()); + String objectKey = attachment.getAttachment_path(); + InputStream data = storage.retrieve(objectKey); + String contentType = resolveContentType(attachment); + String filename = Path.of(objectKey).getFileName().toString(); + String disposition = buildDisposition(filename, download); + Resource resource = new InputStreamResource(data); + return new FileAccessResult(resource, contentType, disposition, true, null); + } catch (Exception e) { + log.error("流式读取文件失败: {}", attachment.getAttachment_id(), e); + return new FileAccessResult(null, null, null, false, null); + } + } + + private String resolveContentType(UploadAttachment attachment) { + if (attachment.getContent_type() != null && !attachment.getContent_type().isEmpty()) { + return attachment.getContent_type(); + } + String filename = attachment.getOriginal_filename(); + if (filename == null || filename.isEmpty()) { + filename = attachment.getAttachment_path(); + } + if (filename != null && filename.contains(".")) { + String extension = filename.substring(filename.lastIndexOf(".")); + return MimeTypeUtil.getByExtension(extension); + } + return "application/octet-stream"; + } + + private String detectContentType(Path filePath) { + try { + String contentType = Files.probeContentType(filePath); + return contentType != null ? contentType : "application/octet-stream"; + } catch (Exception e) { + log.warn("探测文件类型失败: {}", filePath, e); + return "application/octet-stream"; + } + } + + private Resource resolveResource(Path filePath, String contentType, boolean download) { + boolean isImage = contentType.startsWith("image/"); + + if (isImage && !download) { + Integer enableCompression = siteConfigService.getEnableImageCompression(); + boolean shouldCompress = enableCompression != null && enableCompression == 1; + + if (shouldCompress) { + Path compressedPath = getCompressedImagePath(filePath); + if (Files.exists(compressedPath)) { + log.debug("使用压缩图片: {}", compressedPath); + return new FileSystemResource(compressedPath.toFile()); + } else { + log.debug("压缩图片不存在,使用原图并触发异步压缩: {}", filePath); + imageCompressionService.compressImageAsync(filePath.toString()); + } + } + } + + return new FileSystemResource(filePath.toFile()); + } + + private String buildDisposition(String filename, boolean download) { + String encoded = encodeFilename(filename); + if (download) { + return "attachment; filename*=UTF-8''" + encoded; + } else { + return "inline; filename*=UTF-8''" + encoded; + } + } + + private String encodeFilename(String filename) { + try { + return URLEncoder.encode(filename, "UTF-8").replace("+", "%20"); + } catch (UnsupportedEncodingException e) { + log.error("编码文件名失败: {}", filename, e); + return filename; + } + } + + private Path getCompressedImagePath(Path originalPath) { + Path parentDir = originalPath.getParent(); + String fileName = originalPath.getFileName().toString(); + int dotIndex = fileName.lastIndexOf('.'); + String baseName = dotIndex > 0 ? fileName.substring(0, dotIndex) : fileName; + return parentDir.resolve("compressor").resolve(baseName + "_compressed.jpg"); + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/ForgotPasswordServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/ForgotPasswordServiceImpl.java new file mode 100644 index 0000000..cfa1520 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/ForgotPasswordServiceImpl.java @@ -0,0 +1,651 @@ +package xyz.lingview.dimstack.service.impl; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import xyz.lingview.dimstack.service.CacheService; +import org.springframework.stereotype.Service; +import xyz.lingview.dimstack.dto.request.ForgotPasswordDTO; +import xyz.lingview.dimstack.dto.response.ForgotPasswordResponseDTO; +import xyz.lingview.dimstack.mapper.UserInformationMapper; +import xyz.lingview.dimstack.service.ForgotPasswordService; +import xyz.lingview.dimstack.service.MailService; +import xyz.lingview.dimstack.service.NotificationService; +import xyz.lingview.dimstack.util.CaptchaUtil; +import xyz.lingview.dimstack.util.PasswordUtil; +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.domain.UserInformation; +import xyz.lingview.dimstack.util.SiteConfigUtil; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * @Author: lingview + * @Date: 2026/01/13 18:59:30 + * @Description: 忘记密码服务实现类 + * @Version: 2.2 + */ +@Slf4j +@Service +public class ForgotPasswordServiceImpl implements ForgotPasswordService { + + @Autowired + private CacheService cacheService; + + @Autowired + private UserInformationMapper userInformationMapper; + + @Autowired + private MailService mailService; + + @Autowired + private SiteConfigUtil siteConfigUtil; + + @Autowired + private NotificationService notificationService; + + private static final String FORGOT_PASSWORD_CAPTCHA_PREFIX = "dimstack:forgot_password_captcha_"; + private static final String FORGOT_PASSWORD_FAIL_COUNT_PREFIX = "dimstack:forgot_password_fail_"; + private static final String FORGOT_PASSWORD_RATE_LIMIT_PREFIX = "dimstack:forgot_password_rate_limit_"; + private static final String FORGOT_PASSWORD_IP_LIMIT_PREFIX = "dimstack:forgot_password_ip_limit_"; + private static final String FORGOT_PASSWORD_USER_FAIL_PREFIX = "dimstack:forgot_password_user_fail_"; + + private static final String SESSION_FORGOT_PASSWORD_CAPTCHA_KEY_ATTR = "dimstack:forgotPasswordCaptchaKey"; + private static final String SESSION_FORGOT_PASSWORD_VERIFIED_ATTR = "forgotPasswordVerified"; + private static final String SESSION_FORGOT_PASSWORD_VERIFIED_USER = "forgotPasswordVerifiedUser"; + private static final String SESSION_FORGOT_PASSWORD_VERIFIED_EMAIL_ATTR = "forgotPasswordVerifiedEmail"; + + private static final int MAX_VERIFY_ATTEMPTS = 5; // 最大验证尝试次数 + private static final int VERIFY_LOCK_MINUTES = 30; // 验证锁定时间(分钟) + private static final int SEND_CAPTCHA_COOLDOWN_SECONDS = 60; // 发送验证码冷却时间(秒) + private static final int IP_HOURLY_LIMIT = 10; // 每小时每IP最大请求次数 + private static final int USER_FAIL_THRESHOLD = 3; // 用户信息错误阈值 + + + @Override + public ApiResponse> sendForgotPasswordCaptcha( + ForgotPasswordDTO request, + HttpSession session, + HttpServletRequest httpRequest) { + + long startTime = System.currentTimeMillis(); + + try { + String username = request.getUsername(); + String email = request.getEmail(); + + if (username == null || username.trim().isEmpty()) { + return ApiResponse.error(400, "用户名不能为空"); + } + + if (email == null || email.trim().isEmpty()) { + return ApiResponse.error(400, "邮箱不能为空"); + } + + String clientIp = getClientIp(httpRequest); + if (!checkIpRateLimit(clientIp)) { + log.warn("IP {} 请求过于频繁", clientIp); + return ApiResponse.error(429, "请求过于频繁,请稍后再试"); + } + + // 检查发送频率限制 + String rateLimitKey = FORGOT_PASSWORD_RATE_LIMIT_PREFIX + email; + String lastSendTime = cacheService.get(rateLimitKey, String.class); + + if (lastSendTime != null) { + return ApiResponse.error(429, "验证码已发送,请60秒后再试"); + } + + // 检查用户是否存在且邮箱匹配 + UserInformation userInfo = userInformationMapper.getUserByUsernameAndEmail(username, email); + + if (userInfo == null) { + String userFailKey = FORGOT_PASSWORD_USER_FAIL_PREFIX + username; + long failCount = cacheService.incrementAndGet(userFailKey); + cacheService.set(userFailKey, failCount, 1, TimeUnit.HOURS); + + log.warn("用户名 {} 或邮箱 {} 不匹配,失败次数: {}", username, email, failCount); + + if (failCount == 1) { + String correctEmail = userInformationMapper.getEmailByUsername(username); + if (correctEmail != null && !correctEmail.isEmpty()) { + sendFirstAttemptWarning( + correctEmail, + username, + clientIp, + email + ); + log.info("检测到用户 {} 的首次异常密码找回尝试,已向注册邮箱发送警告", username); + } + } + + cacheService.set(rateLimitKey, "1", SEND_CAPTCHA_COOLDOWN_SECONDS, TimeUnit.SECONDS); + + ensureMinimumDelay(startTime, 800); + + if (failCount <= USER_FAIL_THRESHOLD) { + return ApiResponse.error(400, "用户名或邮箱不正确,请检查后重试"); + } else { + log.warn("用户 {} 多次尝试失败,切换为模糊提示模式", username); + Map response = new HashMap<>(); + response.put("message", "如果该账户存在,验证码已发送到您的邮箱"); + return ApiResponse.success(response); + } + } + + String userFailKey = FORGOT_PASSWORD_USER_FAIL_PREFIX + username; + cacheService.delete(userFailKey); + + // 生成验证码 + String captcha = CaptchaUtil.generateCaptcha(6); + String captchaKey = CaptchaUtil.generateCaptchaKey(); + + String oldCaptchaKey = (String) session.getAttribute(SESSION_FORGOT_PASSWORD_CAPTCHA_KEY_ATTR); + if (oldCaptchaKey != null) { + cacheService.delete(FORGOT_PASSWORD_CAPTCHA_PREFIX + oldCaptchaKey); + } + + // 保存新验证码到缓存(有效期10分钟) + cacheService.set( + FORGOT_PASSWORD_CAPTCHA_PREFIX + captchaKey, + captcha.toLowerCase(), + 10, + TimeUnit.MINUTES + ); + + session.setAttribute(SESSION_FORGOT_PASSWORD_CAPTCHA_KEY_ATTR, captchaKey); + + // 发送验证码到邮箱 + String siteName = siteConfigUtil.getSiteName(); + mailService.sendSimpleMail(email, "【" + siteName + "】密码重置验证码", + "您正在重置密码,验证码是:" + captcha + ",有效期10分钟。如果不是本人操作,请忽略。"); + notificationService.sendSystemNotification(username, "系统通知", "用户 " + username + " 尝试重置密码"); + cacheService.set(rateLimitKey, "1", SEND_CAPTCHA_COOLDOWN_SECONDS, TimeUnit.SECONDS); + + log.info("忘记密码验证码已发送至邮箱: {}", maskEmail(email)); + + ensureMinimumDelay(startTime, 800); + + Map response = new HashMap<>(); + response.put("message", "验证码已发送到您的邮箱"); + return ApiResponse.success(response); + + } catch (Exception e) { + log.error("发送忘记密码验证码时发生异常", e); + ensureMinimumDelay(startTime, 800); + return ApiResponse.error(500, "发送验证码失败,请稍后再试"); + } + } + + /** + * 验证验证码 + */ + @Override + public ApiResponse verifyForgotPassword( + ForgotPasswordDTO request, + HttpSession session, + HttpServletRequest httpRequest) { + try { + String username = request.getUsername(); + String email = request.getEmail(); + String captcha = request.getCaptcha(); + + if (username == null || username.trim().isEmpty()) { + return ApiResponse.error(400, "用户名不能为空"); + } + + if (email == null || email.trim().isEmpty()) { + return ApiResponse.error(400, "邮箱不能为空"); + } + + if (captcha == null || captcha.isEmpty()) { + return ApiResponse.error(400, "验证码不能为空"); + } + + String clientIp = getClientIp(httpRequest); + if (!checkIpRateLimit(clientIp)) { + log.warn("IP {} 请求过于频繁", clientIp); + return ApiResponse.error(429, "请求过于频繁,请稍后再试"); + } + + String failKey = FORGOT_PASSWORD_FAIL_COUNT_PREFIX + username; + String failCountStr = cacheService.get(failKey, String.class); + int failCount = failCountStr != null ? Integer.parseInt(failCountStr) : 0; + + if (failCount >= MAX_VERIFY_ATTEMPTS) { + log.warn("用户 {} 验证失败次数过多,已锁定", username); + + // 发送可疑活动通知 + UserInformation userInfo = userInformationMapper.getUserByUsernameAndEmail(username, email); + if (userInfo != null) { + sendSuspiciousActivityNotification( + email, + username, + clientIp, + "验证码尝试次数过多(" + failCount + "次)" + ); + } + + return ApiResponse.error(429, "验证失败次数过多,请" + VERIFY_LOCK_MINUTES + "分钟后再试"); + } + + String sessionCaptchaKey = (String) session.getAttribute(SESSION_FORGOT_PASSWORD_CAPTCHA_KEY_ATTR); + if (sessionCaptchaKey == null) { + return ApiResponse.error(400, "未找到验证码记录,请重新发送验证码"); + } + + String redisCaptchaKey = FORGOT_PASSWORD_CAPTCHA_PREFIX + sessionCaptchaKey; + String redisCaptcha = cacheService.get(redisCaptchaKey, String.class); + + if (redisCaptcha == null) { + log.warn("Redis 中无验证码 - key: {}", redisCaptchaKey); + cleanUpForgotPasswordCaptcha(session, redisCaptchaKey); + return ApiResponse.error(400, "验证码已过期,请重新获取"); + } + + if (!CaptchaUtil.validateCaptcha(redisCaptcha, captcha)) { + + cacheService.incrementAndGet(failKey); + cacheService.set(failKey, String.valueOf(cacheService.get(failKey, Long.class)), VERIFY_LOCK_MINUTES, TimeUnit.MINUTES); + + int remainingAttempts = MAX_VERIFY_ATTEMPTS - failCount - 1; + log.warn("用户 {} 验证码错误,剩余尝试次数: {}", username, remainingAttempts); + + if (remainingAttempts <= 0) { + cleanUpForgotPasswordCaptcha(session, redisCaptchaKey); + return ApiResponse.error(429, "验证失败次数过多,请" + VERIFY_LOCK_MINUTES + "分钟后再试"); + } + + return ApiResponse.error(400, "验证码错误,剩余尝试次数: " + remainingAttempts); + } + + UserInformation userInfo = userInformationMapper.getUserByUsernameAndEmail(username, email); + if (userInfo == null) { + cacheService.incrementAndGet(failKey); + cacheService.set(failKey, String.valueOf(cacheService.get(failKey, Long.class)), VERIFY_LOCK_MINUTES, TimeUnit.MINUTES); + cleanUpForgotPasswordCaptcha(session, redisCaptchaKey); + return ApiResponse.error(400, "用户名或邮箱不匹配"); + } + + cacheService.delete(failKey); + + session.setAttribute(SESSION_FORGOT_PASSWORD_VERIFIED_ATTR, true); + session.setAttribute(SESSION_FORGOT_PASSWORD_VERIFIED_USER, username); + session.setAttribute(SESSION_FORGOT_PASSWORD_VERIFIED_EMAIL_ATTR, email); + session.removeAttribute(SESSION_FORGOT_PASSWORD_CAPTCHA_KEY_ATTR); + + cacheService.delete(redisCaptchaKey); + + log.info("用户 {} 验证码验证成功", username); + + ForgotPasswordResponseDTO response = new ForgotPasswordResponseDTO(); + response.setSuccess(true); + response.setMessage("验证成功,请设置新密码"); + return ApiResponse.success(response); + + } catch (Exception e) { + log.error("验证验证码时发生异常", e); + return ApiResponse.error(500, "验证失败,请稍后再试"); + } + } + + + /** + * 重置密码 + */ + @Override + public ApiResponse resetPassword( + ForgotPasswordDTO request, + HttpSession session, + HttpServletRequest httpRequest) { + try { + String username = request.getUsername(); + String email = request.getEmail(); + String newPassword = request.getNewPassword(); + String confirmNewPassword = request.getConfirmNewPassword(); + + if (username == null || username.trim().isEmpty()) { + return ApiResponse.error(400, "用户名不能为空"); + } + + if (email == null || email.trim().isEmpty()) { + return ApiResponse.error(400, "邮箱不能为空"); + } + + if (newPassword == null || newPassword.trim().isEmpty()) { + return ApiResponse.error(400, "新密码不能为空"); + } + + if (confirmNewPassword == null || confirmNewPassword.trim().isEmpty()) { + return ApiResponse.error(400, "确认密码不能为空"); + } + + if (!newPassword.equals(confirmNewPassword)) { + return ApiResponse.error(400, "两次输入的密码不一致"); + } + + if (newPassword.length() < 6) { + return ApiResponse.error(400, "密码长度不能少于6位"); + } + + String clientIp = getClientIp(httpRequest); + if (!checkIpRateLimit(clientIp)) { + log.warn("IP {} 请求过于频繁", clientIp); + return ApiResponse.error(429, "请求过于频繁,请稍后再试"); + } + + Boolean verified = (Boolean) session.getAttribute(SESSION_FORGOT_PASSWORD_VERIFIED_ATTR); + String verifiedUser = (String) session.getAttribute(SESSION_FORGOT_PASSWORD_VERIFIED_USER); + + if (verified == null || !verified || verifiedUser == null || !verifiedUser.equals(username)) { + return ApiResponse.error(400, "请先完成验证"); + } + + UserInformation userInfo = userInformationMapper.getUserByUsernameAndEmail(username, email); + if (userInfo == null) { + return ApiResponse.error(400, "用户名或邮箱不匹配"); + } + + String hashedPassword = PasswordUtil.hashPassword(newPassword); + int result = userInformationMapper.updatePasswordByUsername(username, hashedPassword); + + if (result <= 0) { + return ApiResponse.error(500, "密码更新失败"); + } + + sendPasswordChangeNotification(email, username, clientIp); + + session.removeAttribute(SESSION_FORGOT_PASSWORD_VERIFIED_ATTR); + session.removeAttribute(SESSION_FORGOT_PASSWORD_VERIFIED_USER); + + String userFailKey = FORGOT_PASSWORD_USER_FAIL_PREFIX + username; + cacheService.delete(userFailKey); + + session.invalidate(); + + log.info("用户 {} 密码重置成功,已发送通知邮件", username); + + ForgotPasswordResponseDTO response = new ForgotPasswordResponseDTO(); + response.setSuccess(true); + response.setMessage("密码重置成功"); + return ApiResponse.success(response); + + } catch (Exception e) { + log.error("重置密码时发生异常", e); + return ApiResponse.error(500, "密码重置失败,请稍后再试"); + } + } + + /** + * 发送首次异常尝试警告 + */ + private void sendFirstAttemptWarning(String correctEmail, String username, String clientIp, String wrongEmail) { + try { + String siteName = siteConfigUtil.getSiteName(); + String currentTime = java.time.LocalDateTime.now() + .format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + + String emailContent = """ + 尊敬的用户 %s,您好: + + 我们检测到有人正在尝试使用您的用户名重置密码,但提供的邮箱地址与您的注册邮箱不符。 + + 尝试详情: + - 尝试时间:%s + - 来源IP:%s + - 使用的用户名:%s + - 提供的邮箱:%s(错误) + - 您的注册邮箱:%s + + 安全提醒: + 如果这不是您本人的操作,说明有人可能知道了您的用户名,正在尝试重置您的密码。 + + 建议您立即采取以下措施: + 1. 确认您的密码足够强壮且未在其他网站使用 + 2. 如果使用了相同密码,请立即修改密码 + 3. 检查账户近期登录记录是否有异常 + 如果是您本人操作但输错了邮箱,请使用正确的邮箱地址重试。 + + %s 安全团队 + %s""".formatted( + username, + currentTime, + clientIp, + username, + maskEmailForUser(wrongEmail), + maskEmailForUser(correctEmail), + siteName, + currentTime + ); + + mailService.sendSimpleMail( + correctEmail, + "【" + siteName + "】安全提醒:检测到密码重置尝试", + emailContent + ); + notificationService.sendSystemNotification(username, "系统通知", emailContent); + + log.info("首次异常尝试警告邮件已发送至用户 {} 的注册邮箱", username); + + } catch (Exception e) { + log.error("发送首次异常尝试警告邮件失败,用户: {}", username, e); + } + } + + /** + * 发送密码修改通知邮件 + */ + private void sendPasswordChangeNotification(String email, String username, String clientIp) { + try { + String siteName = siteConfigUtil.getSiteName(); + String currentTime = java.time.LocalDateTime.now() + .format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + + String emailContent = """ + 尊敬的用户 %s,您好: + + 您的账户密码已于 %s 成功修改。 + + 修改详情: + - 操作时间:%s + - 操作IP:%s + - 操作方式:密码找回 + + 如果这不是您本人的操作,请立即: + 1. 重新修改您的密码 + 2. 检查账户安全设置 + + 为了您的账户安全,建议: + - 使用强密码(包含大小写字母、数字和特殊字符) + - 不要在多个网站使用相同密码 + - 定期更换密码 + + 如果是您本人操作,请忽略此邮件。 + + %s 团队 + %s""".formatted( + username, + currentTime, + currentTime, + clientIp, + siteName, + currentTime + ); + + mailService.sendSimpleMail( + email, + "【" + siteName + "】重要:您的密码已修改", + emailContent + ); + notificationService.sendSystemNotification(username, "系统通知", emailContent); + + log.info("密码修改通知邮件已发送至: {}", maskEmail(email)); + + } catch (Exception e) { + log.error("发送密码修改通知邮件失败,用户: {}, 邮箱: {}", username, maskEmail(email), e); + } + } + + /** + * 发送可疑密码修改尝试通知 + */ + private void sendSuspiciousActivityNotification(String email, String username, String clientIp, String reason) { + try { + String siteName = siteConfigUtil.getSiteName(); + String currentTime = java.time.LocalDateTime.now() + .format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + + String emailContent = """ + 尊敬的用户 %s,您好: + + 我们检测到您的账户存在异常的密码修改尝试。 + + 尝试详情: + - 尝试时间:%s + - 来源IP:%s + - 原因:%s + + 如果这不是您本人的操作,您的账户可能面临安全风险。 + + 请立即采取以下措施: + 1. 修改您的密码 + 2. 检查账户绑定的邮箱是否正确 + 如果是您本人操作,请忽略此邮件。 + + %s 安全团队 + %s""".formatted( + username, + currentTime, + clientIp, + reason, + siteName, + currentTime + ); + + mailService.sendSimpleMail( + email, + "【" + siteName + "】安全警告:检测到可疑的密码修改尝试", + emailContent + ); + notificationService.sendSystemNotification(username, "系统通知", emailContent); + + log.info("可疑活动通知邮件已发送至: {}", maskEmail(email)); + + } catch (Exception e) { + log.error("发送可疑活动通知邮件失败,用户: {}, 邮箱: {}", username, maskEmail(email), e); + } + } + + private void cleanUpForgotPasswordCaptcha(HttpSession session, String redisCaptchaKey) { + try { + cacheService.delete(redisCaptchaKey); + session.removeAttribute(SESSION_FORGOT_PASSWORD_CAPTCHA_KEY_ATTR); + session.removeAttribute(SESSION_FORGOT_PASSWORD_VERIFIED_USER); + log.debug("已清理忘记密码验证码 - Cache Key: {}", redisCaptchaKey); + } catch (Exception e) { + log.warn("清理忘记密码验证码时发生异常", e); + } + } + + /** + * 检查IP速率限制 + */ + private boolean checkIpRateLimit(String ip) { + try { + String ipLimitKey = FORGOT_PASSWORD_IP_LIMIT_PREFIX + ip; + long requestCount = cacheService.incrementAndGet(ipLimitKey); + + if (requestCount == 1) { + cacheService.set(ipLimitKey, requestCount, 1, TimeUnit.HOURS); + } + + return requestCount <= IP_HOURLY_LIMIT; + } catch (Exception e) { + log.error("检查IP速率限制时发生异常", e); + return true; + } + } + + + private String getClientIp(HttpServletRequest request) { + String ip = request.getHeader("X-Forwarded-For"); + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("X-Real-IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getRemoteAddr(); + } + if (ip != null && ip.contains(",")) { + ip = ip.split(",")[0].trim(); + } + return ip; + } + + private String maskEmail(String email) { + if (email == null || email.length() < 3) { + return "***"; + } + int atIndex = email.indexOf('@'); + if (atIndex <= 0) { + return "***"; + } + String localPart = email.substring(0, atIndex); + String domain = email.substring(atIndex); + + if (localPart.length() <= 2) { + return localPart.charAt(0) + "***" + domain; + } + return localPart.substring(0, 2) + "***" + domain; + } + + + private String maskEmailForUser(String email) { + if (email == null || email.length() < 3) { + return "***@***"; + } + int atIndex = email.indexOf('@'); + if (atIndex <= 0) { + return "***@***"; + } + String localPart = email.substring(0, atIndex); + String domain = email.substring(atIndex + 1); + + String maskedLocal; + if (localPart.length() <= 3) { + maskedLocal = localPart.charAt(0) + "***"; + } else { + maskedLocal = localPart.substring(0, 2) + "***" + localPart.charAt(localPart.length() - 1); + } + + String maskedDomain; + int dotIndex = domain.indexOf('.'); + if (dotIndex > 0) { + String domainName = domain.substring(0, dotIndex); + String topLevel = domain.substring(dotIndex); + if (domainName.length() <= 2) { + maskedDomain = domainName.charAt(0) + "***" + topLevel; + } else { + maskedDomain = domainName.substring(0, 1) + "***" + topLevel; + } + } else { + maskedDomain = domain.charAt(0) + "***"; + } + + return maskedLocal + "@" + maskedDomain; + } + + private void ensureMinimumDelay(long startTime, long minimumDelayMs) { + try { + long elapsedTime = System.currentTimeMillis() - startTime; + if (elapsedTime < minimumDelayMs) { + Thread.sleep(minimumDelayMs - elapsedTime); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("延迟等待被中断", e); + } + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/FriendLinksConfigServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/FriendLinksConfigServiceImpl.java new file mode 100644 index 0000000..73d710f --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/FriendLinksConfigServiceImpl.java @@ -0,0 +1,43 @@ +package xyz.lingview.dimstack.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import xyz.lingview.dimstack.domain.FriendLinksConfig; +import xyz.lingview.dimstack.mapper.FriendLinksConfigMapper; +import xyz.lingview.dimstack.service.FriendLinksConfigService; + +@Service +@Slf4j +public class FriendLinksConfigServiceImpl implements FriendLinksConfigService { + + @Autowired + private FriendLinksConfigMapper friendLinksConfigMapper; + + @Override + public FriendLinksConfig getActiveConfig() { + try { + return friendLinksConfigMapper.selectActiveConfig(); + } catch (Exception e) { + log.error("获取本站友链配置失败", e); + return null; + } + } + + @Override + public boolean updateConfig(FriendLinksConfig config) { + try { + FriendLinksConfig existing = friendLinksConfigMapper.selectActiveConfig(); + if (existing != null) { + config.setId(existing.getId()); + return friendLinksConfigMapper.updateConfig(config) > 0; + } else { + config.setStatus(1); + return friendLinksConfigMapper.insertConfig(config) > 0; + } + } catch (Exception e) { + log.error("更新本站友链配置失败", e); + return false; + } + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/FriendLinksServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/FriendLinksServiceImpl.java new file mode 100644 index 0000000..9b9cd54 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/FriendLinksServiceImpl.java @@ -0,0 +1,221 @@ +package xyz.lingview.dimstack.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import xyz.lingview.dimstack.domain.FriendLinks; +import xyz.lingview.dimstack.dto.request.FriendLinksRequestDTO; +import xyz.lingview.dimstack.mapper.FriendLinksMapper; +import xyz.lingview.dimstack.mapper.UserInformationMapper; +import xyz.lingview.dimstack.service.FriendLinksService; +import xyz.lingview.dimstack.service.MailService; +import xyz.lingview.dimstack.service.NotificationService; +import xyz.lingview.dimstack.util.RandomUtil; +import xyz.lingview.dimstack.util.SiteConfigUtil; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * @Author: lingview + * @Date: 2025/12/04 19:53:58 + * @Description: 友链控制服务 + * @Version: 1.0 + */ +@Service +@Slf4j +public class FriendLinksServiceImpl implements FriendLinksService { + + @Autowired + private FriendLinksMapper friendLinksMapper; + + @Autowired + private MailService mailService; + + @Autowired + private SiteConfigUtil siteConfigUtil; + + @Autowired + private UserInformationMapper userInformationMapper; + + @Autowired + private NotificationService notificationService; + + @Override + public boolean applyFriendLink(FriendLinksRequestDTO requestDTO) { + try { + FriendLinks friendLink = new FriendLinks(); + friendLink.setUuid(RandomUtil.generateUUID()); + friendLink.setSiteName(requestDTO.getSiteName()); + friendLink.setSiteUrl(requestDTO.getSiteUrl()); + friendLink.setSiteIcon(requestDTO.getSiteIcon() != null ? requestDTO.getSiteIcon() : ""); + friendLink.setSiteDescription(requestDTO.getSiteDescription()); + friendLink.setWebmasterName(requestDTO.getWebmasterName()); + friendLink.setContact(requestDTO.getContact()); + friendLink.setStatus(2); + + int result = friendLinksMapper.insert(friendLink); + + if (result > 0) { + sendFriendLinkNotification(requestDTO); + } else { + log.warn("友链申请保存到数据库失败"); + } + + return result > 0; + } catch (Exception e) { + log.error("申请友链失败", e); + return false; + } + } + + @Override + public boolean addFriendLink(FriendLinksRequestDTO requestDTO) { + try { + FriendLinks friendLink = new FriendLinks(); + friendLink.setUuid(RandomUtil.generateUUID()); + friendLink.setSiteName(requestDTO.getSiteName()); + friendLink.setSiteUrl(requestDTO.getSiteUrl()); + friendLink.setSiteIcon(requestDTO.getSiteIcon() != null ? requestDTO.getSiteIcon() : ""); + friendLink.setSiteDescription(requestDTO.getSiteDescription()); + friendLink.setWebmasterName(requestDTO.getWebmasterName()); + friendLink.setContact(requestDTO.getContact()); + friendLink.setStatus(1); + + int result = friendLinksMapper.insert(friendLink); + return result > 0; + } catch (Exception e) { + log.error("后台添加友链失败", e); + return false; + } + } + + @Async + public void sendFriendLinkNotification(FriendLinksRequestDTO requestDTO) { + try { + String siteName = siteConfigUtil.getSiteName(); + List emails = userInformationMapper.getEmailsByPermissionCode("system:friendlinks:management"); + + if (emails == null || emails.isEmpty()) { + log.warn("未找到拥有 'system:friendlinks:management' 权限的用户,跳过发送审核通知邮件"); + return; + } + + String emailContent = "站点名称:" + requestDTO.getSiteName() + "\n" + + "站点地址:" + requestDTO.getSiteUrl() + "\n" + + "站长姓名:" + requestDTO.getWebmasterName() + "\n" + + "联系方式:" + requestDTO.getContact() + "\n" + + "站点描述:" + requestDTO.getSiteDescription() + "\n\n" + + "请尽快审核该友链申请"; + + for (String email : emails) { + try { + mailService.sendSimpleMail(email, siteName + " 友链申请", emailContent); + notificationService.sendSystemNotification(userInformationMapper.getUsernameByEmail(email), "系统通知", "您有一个新的友链申请待审核"); + log.info("已发送审核通知邮件至: {}", email); + } catch (Exception e) { + log.error("发送审核通知邮件失败,目标邮箱: {}", email, e); + } + } + } catch (Exception e) { + log.error("发送友链申请通知邮件时发生异常", e); + } + } + + + + + @Override + public List getApprovedFriendLinks() { + return friendLinksMapper.selectByStatus(1); + } + + @Override + public Map getFriendLinksByPage(Integer page, Integer size, Integer status) { + int offset = (page - 1) * size; + List friendLinks = friendLinksMapper.selectByPage(offset, size, status); + int total = friendLinksMapper.count(status); + int totalPages = (int) Math.ceil((double) total / size); + + Map result = new HashMap<>(); + result.put("data", friendLinks); + result.put("total", total); + result.put("page", page); + result.put("size", size); + result.put("total_pages", totalPages); + + return result; + } + + @Override + public boolean updateFriendLinkStatus(Integer id, Integer status) { + try { + int result = friendLinksMapper.updateStatusById(id, status); + return result > 0; + } catch (Exception e) { + log.error("更新友链状态失败", e); + return false; + } + } + + @Override + public boolean deleteFriendLink(Integer id) { + try { + FriendLinks friendLink = friendLinksMapper.selectById(id); + if (friendLink == null) { + log.warn("尝试删除不存在的友链,ID: {}", id); + return false; + } + + if (friendLink.getStatus() == 0) { + log.info("友链已处于删除状态,ID: {}", id); + return true; + } + + int result = friendLinksMapper.deleteById(id); + return result > 0; + } catch (Exception e) { + log.error("删除友链失败,ID: {}", id, e); + return false; + } + } + + + @Override + public boolean deleteFriendLinkPermanent(Integer id) { + try { + int result = friendLinksMapper.deleteByIdPermanent(id); + return result > 0; + } catch (Exception e) { + log.error("彻底删除友链失败", e); + return false; + } + } + + + @Override + public boolean updateFriendLink(Integer id, FriendLinksRequestDTO requestDTO) { + try { + FriendLinks existingLink = friendLinksMapper.selectById(id); + if (existingLink == null) { + log.warn("尝试更新不存在的友链,ID: {}", id); + return false; + } + + existingLink.setSiteName(requestDTO.getSiteName()); + existingLink.setSiteUrl(requestDTO.getSiteUrl()); + existingLink.setSiteIcon(requestDTO.getSiteIcon() != null ? requestDTO.getSiteIcon() : ""); + existingLink.setSiteDescription(requestDTO.getSiteDescription()); + existingLink.setWebmasterName(requestDTO.getWebmasterName()); + existingLink.setContact(requestDTO.getContact()); + + int result = friendLinksMapper.updateById(existingLink); + return result > 0; + } catch (Exception e) { + log.error("更新友链失败", e); + return false; + } + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/HotArticleServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/HotArticleServiceImpl.java new file mode 100644 index 0000000..b33f56f --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/HotArticleServiceImpl.java @@ -0,0 +1,42 @@ +package xyz.lingview.dimstack.service.impl; + +import xyz.lingview.dimstack.dto.request.HotArticleDTO; +import xyz.lingview.dimstack.mapper.HotArticleMapper; +import xyz.lingview.dimstack.service.CacheService; +import xyz.lingview.dimstack.service.HotArticleService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; +import java.util.List; +import java.util.concurrent.TimeUnit; + +@Service +public class HotArticleServiceImpl implements HotArticleService { + + @Autowired + private HotArticleMapper hotArticleMapper; + + @Autowired + private CacheService cacheService; + + private static final String HOT_ARTICLES_KEY = "dimstack:hot_articles"; + + @Override + public List getHotArticles() { + + @SuppressWarnings("unchecked") + List articles = (List) cacheService.get(HOT_ARTICLES_KEY, List.class); + + if (articles == null || articles.isEmpty()) { + articles = hotArticleMapper.selectHotArticles(); + cacheService.set(HOT_ARTICLES_KEY, articles, 1, TimeUnit.HOURS); + } + return articles; + } + + @Override + public void refreshHotArticlesCache() { + List articles = hotArticleMapper.selectHotArticles(); + cacheService.set(HOT_ARTICLES_KEY, articles, 1, TimeUnit.HOURS); + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/ImageCompressionServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/ImageCompressionServiceImpl.java new file mode 100644 index 0000000..1f13378 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/ImageCompressionServiceImpl.java @@ -0,0 +1,248 @@ +package xyz.lingview.dimstack.service.impl; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import xyz.lingview.dimstack.service.CacheService; +import xyz.lingview.dimstack.service.ImageCompressionService; +import xyz.lingview.dimstack.service.SiteConfigService; +import xyz.lingview.dimstack.util.ImageCompressorUtil; +import xyz.lingview.dimstack.util.SiteConfigUtil; + + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.atomic.AtomicInteger; + +@Slf4j +@Service +public class ImageCompressionServiceImpl implements ImageCompressionService { + + @Autowired + private CacheService cacheService; + + @Autowired + private SiteConfigUtil siteConfigUtil; + + @Autowired + private SiteConfigService siteConfigService; + + private static final String COMPRESSION_QUEUE_KEY = "dimstack:image_compression:queue"; + private static final String COMPRESSION_PROCESSING_KEY = "dimstack:image_compression:processing"; + + private BlockingQueue compressionQueue; + private Thread[] workerThreads; + private volatile boolean running = true; + private AtomicInteger activeWorkers = new AtomicInteger(0); + private volatile int currentMaxWorkers = 5; + + @PostConstruct + public void init() { + Integer threads = siteConfigService.getImageCompressionThreads(); + currentMaxWorkers = (threads != null && threads > 0) ? threads : 5; + + compressionQueue = new LinkedBlockingQueue<>(1000); + workerThreads = new Thread[currentMaxWorkers]; + + for (int i = 0; i < currentMaxWorkers; i++) { + workerThreads[i] = new Thread(new CompressionWorker(), "ImageCompression-Worker-" + i); + workerThreads[i].setDaemon(true); + workerThreads[i].start(); + } + + log.info("图片压缩服务已启动,工作线程数: {}", currentMaxWorkers); + } + + @PreDestroy + public void shutdown() { + running = false; + for (Thread thread : workerThreads) { + if (thread != null) { + thread.interrupt(); + } + } + log.info("图片压缩服务已关闭"); + } + + public void updateWorkerThreads() { + int newThreadCount = siteConfigService.getImageCompressionThreads(); + if (newThreadCount == currentMaxWorkers) { + log.debug("线程数未变化,无需调整: {}", newThreadCount); + return; + } + + log.info("检测到线程数配置变化: {} -> {}", currentMaxWorkers, newThreadCount); + + if (newThreadCount > currentMaxWorkers) { + Thread[] newWorkers = new Thread[newThreadCount]; + System.arraycopy(workerThreads, 0, newWorkers, 0, workerThreads.length); + + for (int i = currentMaxWorkers; i < newThreadCount; i++) { + newWorkers[i] = new Thread(new CompressionWorker(), "ImageCompression-Worker-" + i); + newWorkers[i].setDaemon(true); + newWorkers[i].start(); + log.info("启动新工作线程: ImageCompression-Worker-{}", i); + } + + workerThreads = newWorkers; + } else if (newThreadCount < currentMaxWorkers) { + running = false; + + for (int i = 0; i < currentMaxWorkers; i++) { + if (workerThreads[i] != null) { + workerThreads[i].interrupt(); + } + } + + for (int i = 0; i < currentMaxWorkers; i++) { + if (workerThreads[i] != null) { + try { + workerThreads[i].join(3000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + + running = true; + + Thread[] newWorkers = new Thread[newThreadCount]; + for (int i = 0; i < newThreadCount; i++) { + newWorkers[i] = new Thread(new CompressionWorker(), "ImageCompression-Worker-" + i); + newWorkers[i].setDaemon(true); + newWorkers[i].start(); + log.info("启动新工作线程: ImageCompression-Worker-{}", i); + } + + workerThreads = newWorkers; + } + + currentMaxWorkers = newThreadCount; + log.info("工作线程数已更新为: {}", currentMaxWorkers); + } + + @Override + public void compressImageAsync(String originalImagePath) { + try { + Path originalPath = Path.of(originalImagePath); + if (!Files.exists(originalPath)) { + log.warn("原图不存在,跳过压缩: {}", originalImagePath); + return; + } + + Path compressedPath = getCompressedImagePath(originalPath); + if (Files.exists(compressedPath)) { + log.debug("压缩图片已存在,跳过压缩: {}", compressedPath); + return; + } + + if (cacheService.isMemberOfSet(COMPRESSION_PROCESSING_KEY, originalImagePath)) { + log.debug("图片正在压缩中,跳过重复任务: {}", originalImagePath); + return; + } + + if (compressionQueue.offer(originalImagePath)) { + cacheService.addToSet(COMPRESSION_PROCESSING_KEY, originalImagePath); + log.debug("图片已加入压缩队列: {}, 队列大小: {}", originalImagePath, compressionQueue.size()); + } else { + log.warn("压缩队列已满,丢弃任务: {}", originalImagePath); + } + } catch (Exception e) { + log.error("添加压缩任务失败: {}", originalImagePath, e); + } + } + + private class CompressionWorker implements Runnable { + @Override + public void run() { + log.debug("压缩工作线程启动: {}", Thread.currentThread().getName()); + + while (running) { + try { + String imagePath = compressionQueue.take(); + activeWorkers.incrementAndGet(); + + try { + processCompression(imagePath); + } finally { + activeWorkers.decrementAndGet(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } catch (Exception e) { + log.error("压缩工作线程异常", e); + } + } + + log.debug("压缩工作线程退出: {}", Thread.currentThread().getName()); + } + + private void processCompression(String originalImagePath) { + try { + Path originalPath = Path.of(originalImagePath); + if (!Files.exists(originalPath)) { + log.warn("原图不存在,跳过压缩: {}", originalImagePath); + return; + } + + Path compressedPath = getCompressedImagePath(originalPath); + + Path compressedDir = compressedPath.getParent(); + if (!Files.exists(compressedDir)) { + Files.createDirectories(compressedDir); + log.debug("创建压缩目录: {}", compressedDir); + } + + if (Files.exists(compressedPath)) { + log.debug("压缩图片已存在,跳过压缩: {}", compressedPath); + cacheService.removeFromSet(COMPRESSION_PROCESSING_KEY, originalImagePath); + return; + } + + try { + String outputPath = compressedPath.toString(); + ImageCompressorUtil.compress(originalImagePath, outputPath); + + long originalSize = Files.size(originalPath); + long compressedSize = Files.size(compressedPath); + double ratio = (1.0 - (double) compressedSize / originalSize) * 100; + + log.info("图片压缩完成 [{}] - 原图: {} bytes, 压缩后: {} bytes, 压缩率: {}%", + Thread.currentThread().getName(), + originalSize, compressedSize, String.format("%.2f", ratio)); + } finally { + cacheService.removeFromSet(COMPRESSION_PROCESSING_KEY, originalImagePath); + } + + } catch (IOException e) { + log.error("图片压缩失败: {}", originalImagePath, e); + cacheService.removeFromSet(COMPRESSION_PROCESSING_KEY, originalImagePath); + } catch (Exception e) { + log.error("图片压缩过程中发生未知错误: {}", originalImagePath, e); + cacheService.removeFromSet(COMPRESSION_PROCESSING_KEY, originalImagePath); + } + } + } + + private Path getCompressedImagePath(Path originalPath) { + Path parentDir = originalPath.getParent(); + String fileName = originalPath.getFileName().toString(); + int dotIndex = fileName.lastIndexOf('.'); + + String baseName; + if (dotIndex > 0) { + baseName = fileName.substring(0, dotIndex); + } else { + baseName = fileName; + } + + String compressedFileName = baseName + "_compressed.jpg"; + return parentDir.resolve("compressor").resolve(compressedFileName); + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/LLMServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/LLMServiceImpl.java new file mode 100644 index 0000000..e1e9fad --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/LLMServiceImpl.java @@ -0,0 +1,207 @@ +package xyz.lingview.dimstack.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import xyz.lingview.dimstack.domain.LlmConfig; +import xyz.lingview.dimstack.domain.LlmPromptConfig; +import xyz.lingview.dimstack.enums.AiReviewResult; +import xyz.lingview.dimstack.service.CacheService; +import xyz.lingview.dimstack.service.LLMService; +import xyz.lingview.dimstack.service.LlmConfigService; +import xyz.lingview.dimstack.service.LlmPromptConfigService; +import xyz.lingview.dimstack.service.SiteConfigService; +import xyz.lingview.dimstack.util.LargeLanguageModelsUtil; + +/** + * @Author: lingview + * @Date: 2026/04/08 17:18:31 + * @Description: 大模型调用服务实现 + * @Version: 1.0 + */ +@Service +@Slf4j +public class LLMServiceImpl implements LLMService { + + @Autowired + private LlmConfigService llmConfigService; + + @Autowired + private LlmPromptConfigService llmPromptConfigService; + + @Autowired + private CacheService cacheService; + + @Autowired + private SiteConfigService siteConfigService; + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public AiReviewResult reviewArticle(String articleContent) { + try { + LlmConfig llmConfig = llmConfigService.getLlmConfig(); + if (llmConfig == null || llmConfig.getApi_key() == null || llmConfig.getApi_key().trim().isEmpty()) { + log.warn("LLM配置不存在或API密钥未配置,跳过审核"); + return AiReviewResult.ERROR; + } + + LlmPromptConfig promptConfig = llmPromptConfigService.getPromptByName("article_review"); + if (promptConfig == null || promptConfig.getPrompt_content() == null || promptConfig.getPrompt_content().trim().isEmpty()) { + log.warn("文章审核提示词不存在,跳过审核"); + return AiReviewResult.ERROR; + } + + String systemContent = promptConfig.getPrompt_content(); + + String response = LargeLanguageModelsUtil.callOpenAICompatibleAPI( + llmConfig.getApi_key(), + llmConfig.getApi_url(), + llmConfig.getModel(), + systemContent, + articleContent + ); + + Boolean result = parseReviewResult(response); + if (result == null) { + log.warn("AI审核结果不明确,转为人工审核"); + return AiReviewResult.ERROR; + } + return result ? AiReviewResult.PASS : AiReviewResult.REJECT; + + } catch (Exception e) { + log.error("文章审核异常,转为人工审核", e); + return AiReviewResult.ERROR; + } + } + + @Override + public String reviewComment(String commentContent) { + try { + LlmConfig llmConfig = llmConfigService.getLlmConfig(); + if (llmConfig == null || llmConfig.getApi_key() == null || llmConfig.getApi_key().trim().isEmpty()) { + return "ERROR"; + } + + LlmPromptConfig promptConfig = llmPromptConfigService.getPromptByName("comment_review"); + if (promptConfig == null || promptConfig.getPrompt_content() == null || promptConfig.getPrompt_content().trim().isEmpty()) { + return "ERROR"; + } + + String response = LargeLanguageModelsUtil.callOpenAICompatibleAPI( + llmConfig.getApi_key(), + llmConfig.getApi_url(), + llmConfig.getModel(), + promptConfig.getPrompt_content(), + commentContent + ); + + Boolean result = parseReviewResult(response); + if (result == null) return "ERROR"; + return result ? "PASS" : "REJECT"; + } catch (Exception e) { + log.error("评论审核异常", e); + return "ERROR"; + } + } + private Boolean parseReviewResult(String response) { + try { + JsonNode rootNode = objectMapper.readTree(response); + + JsonNode choices = rootNode.get("choices"); + if (choices != null && choices.isArray() && choices.size() > 0) { + JsonNode message = choices.get(0).get("message"); + if (message != null) { + String content = message.get("content").asString(); + + try { + JsonNode resultNode = objectMapper.readTree(content); + if (resultNode.has("compliant")) { + return resultNode.get("compliant").asBoolean(); + } + } catch (Exception e) { + log.debug("响应内容不是JSON格式,尝试直接解析: {}", content); + } + + String lowerContent = content.toLowerCase().trim(); + if (lowerContent.contains("true") || lowerContent.contains("合规") || lowerContent.contains("通过")) { + return true; + } else if (lowerContent.contains("false") || lowerContent.contains("违规") || lowerContent.contains("不通过")) { + return false; + } + } + } + + log.warn("无法解析审核结果,需要人工审核: {}", response); + return null; + + } catch (Exception e) { + log.error("解析审核结果失败,需要人工审核", e); + return null; + } + } + + @Override + public String generateArticle(String description) { + try { + Integer enableLlm = siteConfigService.getEnableLlm(); + Integer enableLlmCreateArticle = siteConfigService.getEnableLlmCreateArticle(); + + if (enableLlm == null || enableLlm != 1 || enableLlmCreateArticle == null || enableLlmCreateArticle != 1) { + log.warn("管理员未开启大模型生成功能"); + return null; + } + + LlmConfig llmConfig = llmConfigService.getLlmConfig(); + if (llmConfig == null || llmConfig.getApi_key() == null || llmConfig.getApi_key().trim().isEmpty()) { + log.warn("LLM配置不存在或API密钥未配置,无法生成文章"); + return null; + } + + LlmPromptConfig promptConfig = llmPromptConfigService.getPromptByName("article_create"); + if (promptConfig == null || promptConfig.getPrompt_content() == null || promptConfig.getPrompt_content().trim().isEmpty()) { + log.warn("文章生成提示词不存在,无法生成文章"); + return null; + } + + String systemContent = promptConfig.getPrompt_content(); + + String response = LargeLanguageModelsUtil.callOpenAICompatibleAPI( + llmConfig.getApi_key(), + llmConfig.getApi_url(), + llmConfig.getModel(), + systemContent, + description + ); + + return parseGeneratedContent(response); + + } catch (Exception e) { + log.error("文章生成失败", e); + return null; + } + } + + private String parseGeneratedContent(String response) { + try { + JsonNode rootNode = objectMapper.readTree(response); + + JsonNode choices = rootNode.get("choices"); + if (choices != null && choices.isArray() && choices.size() > 0) { + JsonNode message = choices.get(0).get("message"); + if (message != null) { + return message.get("content").asString(); + } + } + + log.warn("无法解析生成的文章内容: {}", response); + return null; + + } catch (Exception e) { + log.error("解析生成的文章内容失败", e); + return null; + } + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/LlmConfigServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/LlmConfigServiceImpl.java new file mode 100644 index 0000000..1e29a00 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/LlmConfigServiceImpl.java @@ -0,0 +1,56 @@ +package xyz.lingview.dimstack.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import xyz.lingview.dimstack.domain.LlmConfig; +import xyz.lingview.dimstack.mapper.LlmConfigMapper; +import xyz.lingview.dimstack.service.CacheService; +import xyz.lingview.dimstack.service.LlmConfigService; + +@Service +@Slf4j +public class LlmConfigServiceImpl implements LlmConfigService { + + @Autowired + private LlmConfigMapper llmConfigMapper; + + @Autowired + private CacheService cacheService; + + @Override + public LlmConfig getLlmConfig() { + try { + LlmConfig llmConfig = cacheService.get("dimstack:llm_config", LlmConfig.class); + if (llmConfig != null) { + return llmConfig; + } + } catch (Exception e) { + log.warn("从缓存读取LLM配置失败,回退到数据库查询", e); + } + return llmConfigMapper.getLlmConfig(); + } + + @Override + public boolean updateLlmConfig(LlmConfig llmConfig) { + try { + LlmConfig existingConfig = llmConfigMapper.getLlmConfig(); + int result; + + if (existingConfig != null) { + result = llmConfigMapper.updateLlmConfig(llmConfig); + } else { + result = llmConfigMapper.insertLlmConfig(llmConfig); + } + + if (result > 0) { + cacheService.set("dimstack:llm_config", llmConfig); + return true; + } + return false; + } catch (Exception e) { + log.error("更新LLM配置失败", e); + return false; + } + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/LlmPromptConfigServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/LlmPromptConfigServiceImpl.java new file mode 100644 index 0000000..e518cdb --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/LlmPromptConfigServiceImpl.java @@ -0,0 +1,37 @@ +package xyz.lingview.dimstack.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import xyz.lingview.dimstack.domain.LlmPromptConfig; +import xyz.lingview.dimstack.mapper.LlmPromptConfigMapper; +import xyz.lingview.dimstack.service.LlmPromptConfigService; + +@Service +@Slf4j +public class LlmPromptConfigServiceImpl implements LlmPromptConfigService { + + @Autowired + private LlmPromptConfigMapper llmPromptConfigMapper; + + @Override + public LlmPromptConfig getPromptByName(String promptName) { + try { + return llmPromptConfigMapper.getPromptByName(promptName); + } catch (Exception e) { + log.error("获取提示词配置失败: {}", promptName, e); + return null; + } + } + + @Override + public boolean updatePrompt(LlmPromptConfig config) { + try { + int result = llmPromptConfigMapper.updatePrompt(config); + return result > 0; + } catch (Exception e) { + log.error("更新提示词配置失败: {}", config.getPrompt_name(), e); + return false; + } + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/LocalFileStorageImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/LocalFileStorageImpl.java new file mode 100644 index 0000000..4a6480a --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/LocalFileStorageImpl.java @@ -0,0 +1,94 @@ +package xyz.lingview.dimstack.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import xyz.lingview.dimstack.service.FileStorage; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; + +/** + * @Author: lingview + * @Date: 2026/07/17 20:17:33 + * @Description: 本地文件系统存储实现 + * @Version: 1.0 + */ +@Slf4j +@Service +public class LocalFileStorageImpl implements FileStorage { + + @Value("${file.data-root:.}") + private String dataRoot; + + private Path resolvePath(String objectKey) { + return Path.of(dataRoot).toAbsolutePath().normalize().resolve(objectKey).normalize(); + } + + @Override + public void store(String objectKey, InputStream data, long contentLength, String contentType) { + Path filePath = resolvePath(objectKey); + try { + Files.createDirectories(filePath.getParent()); + try (OutputStream out = Files.newOutputStream(filePath, StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) { + data.transferTo(out); + } + log.debug("本地存储写入成功: {}", filePath); + } catch (IOException e) { + log.error("本地存储写入失败: {}", filePath, e); + throw new RuntimeException("文件写入失败", e); + } + } + + @Override + public InputStream retrieve(String objectKey) { + Path filePath = resolvePath(objectKey); + try { + return Files.newInputStream(filePath, StandardOpenOption.READ); + } catch (IOException e) { + log.error("本地存储读取失败: {}", filePath, e); + throw new RuntimeException("文件读取失败", e); + } + } + + @Override + public void delete(String objectKey) { + Path filePath = resolvePath(objectKey); + try { + Files.deleteIfExists(filePath); + log.debug("本地存储删除成功: {}", filePath); + } catch (IOException e) { + log.error("本地存储删除失败: {}", filePath, e); + throw new RuntimeException("文件删除失败", e); + } + } + + @Override + public boolean exists(String objectKey) { + return Files.exists(resolvePath(objectKey)); + } + + @Override + public void copy(String sourceKey, String destKey) { + Path sourcePath = resolvePath(sourceKey); + Path destPath = resolvePath(destKey); + try { + Files.createDirectories(destPath.getParent()); + Files.copy(sourcePath, destPath, StandardCopyOption.REPLACE_EXISTING); + log.debug("本地存储拷贝成功: {} -> {}", sourcePath, destPath); + } catch (IOException e) { + log.error("本地存储拷贝失败: {} -> {}", sourcePath, destPath, e); + throw new RuntimeException("文件拷贝失败", e); + } + } + + @Override + public String getType() { + return "local"; + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/LoginServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/LoginServiceImpl.java new file mode 100644 index 0000000..83d7cd3 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/LoginServiceImpl.java @@ -0,0 +1,186 @@ +package xyz.lingview.dimstack.service.impl; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import xyz.lingview.dimstack.domain.Login; +import xyz.lingview.dimstack.dto.request.LoginDTO; +import xyz.lingview.dimstack.dto.response.LoginResponseDTO; +import xyz.lingview.dimstack.mapper.LoginMapper; +import xyz.lingview.dimstack.mapper.UserInformationMapper; +import xyz.lingview.dimstack.service.CacheService; +import xyz.lingview.dimstack.service.LoginService; +import xyz.lingview.dimstack.service.MailService; +import xyz.lingview.dimstack.service.NotificationService; +import xyz.lingview.dimstack.util.CaptchaUtil; +import xyz.lingview.dimstack.util.PasswordUtil; +import xyz.lingview.dimstack.util.SiteConfigUtil; + +import java.text.SimpleDateFormat; +import java.util.Date; + +@Service +@Slf4j +public class LoginServiceImpl implements LoginService { + + @Autowired + private LoginMapper loginMapper; + + @Autowired + private CacheService cacheService; + + @Autowired + private MailService mailService; + + @Autowired + private UserInformationMapper userInformationMapper; + + @Autowired + private SiteConfigUtil siteConfigUtil; + + @Autowired + private NotificationService notificationService; + + private static final String CAPTCHA_PREFIX = "captcha_"; + private static final String SESSION_CAPTCHA_KEY_ATTR = "captchaKey"; + + @Override + public LoginResponseDTO login(LoginDTO loginDTO, HttpServletRequest request) { + HttpSession session = request.getSession(); + + String username = loginDTO.getUsername(); + String password = loginDTO.getPassword(); + String captcha = loginDTO.getCaptcha(); + String captchaKey = loginDTO.getCaptchaKey(); + + if (username == null || username.trim().isEmpty()) { + LoginResponseDTO dto = new LoginResponseDTO(); + dto.setSuccess(false); + dto.setMessage("用户名不能为空"); + dto.setCode(400); + return dto; + } + if (password == null || password.isEmpty()) { + LoginResponseDTO dto = new LoginResponseDTO(); + dto.setSuccess(false); + dto.setMessage("密码不能为空"); + dto.setCode(400); + return dto; + } + if (captcha == null || captcha.isEmpty()) { + LoginResponseDTO dto = new LoginResponseDTO(); + dto.setSuccess(false); + dto.setMessage("验证码不能为空"); + dto.setCode(400); + return dto; + } + if (captchaKey == null || captchaKey.isEmpty()) { + LoginResponseDTO dto = new LoginResponseDTO(); + dto.setSuccess(false); + dto.setMessage("验证码无效"); + dto.setCode(400); + return dto; + } + + String sessionCaptchaKey = (String) session.getAttribute(SESSION_CAPTCHA_KEY_ATTR); + String currentSessionId = session.getId(); + + log.info("登录请求 - Session ID: {}, 请求 captchaKey: {}, Session captchaKey: {}", + currentSessionId, captchaKey, sessionCaptchaKey); + + if (sessionCaptchaKey == null || !sessionCaptchaKey.equals(captchaKey)) { + log.warn("验证码 key 不匹配 - Session: {}, 请求: {}", sessionCaptchaKey, captchaKey); + cleanUpCaptcha(session, CAPTCHA_PREFIX + captchaKey); + LoginResponseDTO dto = new LoginResponseDTO(); + dto.setSuccess(false); + dto.setMessage("验证码无效或已过期,请重新获取"); + dto.setCode(400); + return dto; + } + + String redisCaptchaKey = CAPTCHA_PREFIX + captchaKey; + String redisCaptcha = cacheService.get(redisCaptchaKey, String.class); + + if (redisCaptcha == null) { + log.warn("Redis 中无验证码 - key: {}", redisCaptchaKey); + cleanUpCaptcha(session, redisCaptchaKey); + LoginResponseDTO dto = new LoginResponseDTO(); + dto.setSuccess(false); + dto.setMessage("验证码已过期,请重新获取"); + dto.setCode(400); + return dto; + } + + if (!CaptchaUtil.validateCaptcha(redisCaptcha, captcha)) { + log.warn("验证码错误 - 输入: {}, 正确: {}", captcha, redisCaptcha); + cleanUpCaptcha(session, redisCaptchaKey); + LoginResponseDTO dto = new LoginResponseDTO(); + dto.setSuccess(false); + dto.setMessage("验证码错误"); + dto.setCode(400); + return dto; + } + + cleanUpCaptcha(session, redisCaptchaKey); + log.info("验证码校验通过,已立即删除 - key: {}", redisCaptchaKey); + + var login = new Login(); + login.setUsername(username); + login.setPassword(password); + + var result = loginMapper.loginUser(login); + if (result == null || !PasswordUtil.checkPassword(password, result.getPassword())) { + LoginResponseDTO dto = new LoginResponseDTO(); + dto.setSuccess(false); + dto.setMessage("用户名或密码错误"); + dto.setCode(401); + return dto; + } + + session.invalidate(); + session = request.getSession(true); + + session.setAttribute("username", username); + session.setAttribute("isLoggedIn", true); + session.setAttribute("loginTime", System.currentTimeMillis()); + session.setAttribute("userAgent", request.getHeader("User-Agent")); + + log.info("用户 {} 登录成功,Session ID: {}", username, session.getId()); + + if (siteConfigUtil.isNotificationEnabled()) { + String email = userInformationMapper.getEmailByUsername(username); + String formattedDate = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()); + if (email != null && !email.trim().isEmpty()) { + String siteName = siteConfigUtil.getSiteName(); + mailService.sendSimpleMail(email, siteName + " 登录成功", + "用户:" + username + " 于 " + formattedDate + " 登录成功"); + } + notificationService.sendSystemNotification(username, "系统通知", "用户:" + username + " 于 " + formattedDate + " 登录成功"); + } + + LoginResponseDTO dto = new LoginResponseDTO(); + dto.setSuccess(true); + dto.setMessage("登录成功"); + dto.setCode(200); + return dto; + } + + @Override + public void logout(HttpSession session) { + String username = (String) session.getAttribute("username"); + session.invalidate(); + log.info("用户 {} 登出成功", username != null ? username : "Unknown"); + } + + private void cleanUpCaptcha(HttpSession session, String redisCaptchaKey) { + try { + cacheService.delete(redisCaptchaKey); + session.removeAttribute(SESSION_CAPTCHA_KEY_ATTR); + log.debug("已清理验证码 - Cache Key: {}, Session Attribute: {}", redisCaptchaKey, SESSION_CAPTCHA_KEY_ATTR); + } catch (Exception e) { + log.warn("清理验证码时发生异常", e); + } + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/MailServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/MailServiceImpl.java new file mode 100644 index 0000000..fecaa12 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/MailServiceImpl.java @@ -0,0 +1,186 @@ +package xyz.lingview.dimstack.service.impl; + +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import xyz.lingview.dimstack.domain.SiteConfig; +import xyz.lingview.dimstack.mapper.SiteConfigMapper; +import xyz.lingview.dimstack.service.MailService; +import xyz.lingview.dimstack.service.SiteConfigService; + +import java.io.File; +import java.util.Map; +import java.util.Properties; + +@Slf4j +@Service +public class MailServiceImpl implements MailService { + + @Autowired + private SiteConfigService siteConfigService; + + @Autowired + private SiteConfigMapper siteConfigMapper; + /** + * 获取邮件发送器 + * @return JavaMailSender + */ + private JavaMailSender getMailSender() { + SiteConfig config = siteConfigService.getSiteConfig(); + // 从数据库查询邮件密码(因为内存模式进入邮箱配置页面会导致邮箱密码被污染) + String emailPassword = siteConfigMapper.getSiteConfig().getMail_password(); + if (config == null || + !StringUtils.hasText(config.getMail_username()) || + !StringUtils.hasText(emailPassword) || + !StringUtils.hasText(config.getSmtp_host())) { + log.warn("邮件配置不完整,无法发送邮件"); + return null; + } + + JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); + mailSender.setHost(config.getSmtp_host()); + mailSender.setPort(config.getSmtp_port() != null ? config.getSmtp_port() : 25); + mailSender.setUsername(config.getMail_username()); + + mailSender.setPassword(emailPassword); + + Properties props = mailSender.getJavaMailProperties(); + props.put("mail.transport.protocol", config.getMail_protocol() != null ? config.getMail_protocol() : "smtp"); + props.put("mail.smtp.auth", "true"); + + if (config.getMail_enable_tls() != null && config.getMail_enable_tls()) { + props.put("mail.smtp.starttls.enable", "true"); + } else if (config.getMail_enable_ssl() != null && config.getMail_enable_ssl()) { + props.put("mail.smtp.ssl.enable", "true"); + } + + props.put("mail.debug", "false"); + + return mailSender; + } + + @Override + public void sendSimpleMail(String to, String subject, String content) { + JavaMailSender mailSender = getMailSender(); + if (mailSender == null) { + return; + } + + SiteConfig config = siteConfigService.getSiteConfig(); + + try { + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(config.getMail_sender_email()); + message.setTo(to); + message.setSubject(subject); + message.setText(content); + + mailSender.send(message); + log.info("简单邮件发送成功: to={}, subject={}", to, subject); + } catch (Exception e) { + log.error("发送简单邮件时发生异常: to={}, subject={}", to, subject, e); + } + } + + @Override + public void sendHtmlMail(String to, String subject, String content) throws MessagingException { + JavaMailSender mailSender = getMailSender(); + if (mailSender == null) { + return; + } + + SiteConfig config = siteConfigService.getSiteConfig(); + + try { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true); + + helper.setFrom(config.getMail_sender_email()); + helper.setTo(to); + helper.setSubject(subject); + helper.setText(content, true); + + mailSender.send(message); + log.info("HTML邮件发送成功: to={}, subject={}", to, subject); + } catch (MessagingException e) { + log.error("发送HTML邮件时发生异常: to={}, subject={}", to, subject, e); + throw e; + } + } + + @Override + public void sendAttachmentsMail(String to, String subject, String content, String filePath) throws MessagingException { + JavaMailSender mailSender = getMailSender(); + if (mailSender == null) { + return; + } + + SiteConfig config = siteConfigService.getSiteConfig(); + + try { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true); + + helper.setFrom(config.getMail_sender_email()); + helper.setTo(to); + helper.setSubject(subject); + helper.setText(content, true); + + File file = new File(filePath); + if (file.exists()) { + helper.addAttachment(file.getName(), file); + } + + mailSender.send(message); + log.info("带附件邮件发送成功: to={}, subject={}", to, subject); + } catch (MessagingException e) { + log.error("发送带附件邮件时发生异常: to={}, subject={}", to, subject, e); + throw e; + } + } + + @Override + public void sendInlineResourceMail(String to, String subject, String content, String rscPath, String rscId) throws MessagingException { + JavaMailSender mailSender = getMailSender(); + if (mailSender == null) { + return; + } + + SiteConfig config = siteConfigService.getSiteConfig(); + + try { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true); + + helper.setFrom(config.getMail_sender_email()); + helper.setTo(to); + helper.setSubject(subject); + helper.setText(content, true); + + File file = new File(rscPath); + if (file.exists()) { + helper.addInline(rscId, file); + } + + mailSender.send(message); + log.info("带静态资源邮件发送成功: to={}, subject={}", to, subject); + } catch (MessagingException e) { + log.error("发送带静态资源邮件时发生异常: to={}, subject={}", to, subject, e); + throw e; + } + } + + @Override + public void sendTemplateMail(String to, String subject, String templateName, Map model) throws MessagingException { + // 暂时用不到先不开发( + String content = "模板邮件内容"; + sendHtmlMail(to, subject, content); + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/MemoryCacheServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/MemoryCacheServiceImpl.java new file mode 100644 index 0000000..1d29238 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/MemoryCacheServiceImpl.java @@ -0,0 +1,147 @@ +package xyz.lingview.dimstack.service.impl; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import org.springframework.stereotype.Service; +import xyz.lingview.dimstack.service.CacheService; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +/** + * @Author: lingview + * @Date: 2026/02/15 20:57:04 + * @Description: 内存缓存实现 + * @Version: 1.0 + */ +//@Service +public class MemoryCacheServiceImpl implements CacheService { + private final Cache cache; + private final Cache atomicCounters; + + public MemoryCacheServiceImpl() { + this.cache = Caffeine.newBuilder() + .maximumSize(1000) + .expireAfterWrite(1, TimeUnit.HOURS) + .build(); + + this.atomicCounters = Caffeine.newBuilder() + .maximumSize(1000) + .expireAfterWrite(1, TimeUnit.HOURS) + .build(); + } + + @Override + public void set(String key, Object value) { + cache.put(key, value); + } + + @Override + public void set(String key, Object value, long timeout, TimeUnit unit) { + cache.put(key, value); + if (value instanceof Number) { + atomicCounters.get(key, k -> new AtomicLong()).set(((Number) value).longValue()); + } + } + + @SuppressWarnings("unchecked") + @Override + public T get(String key, Class type) { + Object value = cache.getIfPresent(key); + if (type == Long.class && atomicCounters.getIfPresent(key) != null) { + return (T) Long.valueOf(atomicCounters.getIfPresent(key).get()); + } + return (T) value; + } + + @Override + public void delete(String key) { + cache.invalidate(key); + atomicCounters.invalidate(key); + } + + @Override + public void deleteByPrefix(String prefix) { + try { + cache.asMap().keySet().stream() + .filter(key -> key.startsWith(prefix)) + .forEach(key -> { + cache.invalidate(key); + atomicCounters.invalidate(key); + }); + } catch (Exception e) { + + } + } + + @Override + public void addToSet(String key, Object value) { + @SuppressWarnings("unchecked") + Set set = (Set) cache.get(key, k -> ConcurrentHashMap.newKeySet()); + set.add(value); + } + + @Override + public void removeFromSet(String key, Object value) { + @SuppressWarnings("unchecked") + Set set = (Set) cache.getIfPresent(key); + if (set != null) { + set.remove(value); + } + } + + @Override + public boolean isMemberOfSet(String key, Object value) { + @SuppressWarnings("unchecked") + Set set = (Set) cache.getIfPresent(key); + return set != null && set.contains(value); + } + + @Override + public void deleteSet(String key) { + cache.invalidate(key); + } + + @Override + public Set getSetMembers(String key, Class type) { + @SuppressWarnings("unchecked") + Set set = (Set) cache.getIfPresent(key); + if (set == null) return new java.util.HashSet<>(); + + Set result = new java.util.HashSet<>(); + for (Object member : set) { + if (member != null) { + result.add(type.cast(member)); + } + } + return result; + } + + @Override + public long incrementAndGet(String key) { + return atomicCounters.get(key, k -> new AtomicLong()).incrementAndGet(); + } + + @Override + public long incrementAndGet(String key, long delta) { + return atomicCounters.get(key, k -> new AtomicLong()).addAndGet(delta); + } + + @Override + public long decrementAndGet(String key) { + return atomicCounters.get(key, k -> new AtomicLong()).decrementAndGet(); + } + + @Override + public long getAndIncrement(String key) { + return atomicCounters.get(key, k -> new AtomicLong()).getAndIncrement(); + } + + @Override + public long getAndDecrement(String key) { + return atomicCounters.get(key, k -> new AtomicLong()).getAndDecrement(); + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/MenusServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/MenusServiceImpl.java new file mode 100644 index 0000000..00f9075 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/MenusServiceImpl.java @@ -0,0 +1,86 @@ +package xyz.lingview.dimstack.service.impl; + +import org.apache.ibatis.session.ExecutorType; +import org.apache.ibatis.session.SqlSession; +import org.apache.ibatis.session.SqlSessionFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import xyz.lingview.dimstack.domain.Menus; +import xyz.lingview.dimstack.dto.request.MenusDTO; +import xyz.lingview.dimstack.mapper.MenusMapper; +import xyz.lingview.dimstack.mapper.UserInformationMapper; +import xyz.lingview.dimstack.service.MenusService; + +import java.util.List; +import java.util.UUID; + +@Service +public class MenusServiceImpl implements MenusService { + + @Autowired + private MenusMapper menusMapper; + + @Autowired + private UserInformationMapper userInformationMapper; + + @Autowired + private SqlSessionFactory sqlSessionFactory; + + @Override + public List getAllMenus() { + return menusMapper.selectAllMenus(); + } + + @Override + public void addMenus(Menus menus, String username) { + menus.setMenus_id("menu_" + UUID.randomUUID().toString().replace("-", "")); + + String userUuid = userInformationMapper.selectUserUUID(username); + if (userUuid != null) { + menus.setUser_id(userUuid); + } + + if (menus.getSort_order() == null) { + List allMenus = menusMapper.selectAllMenus(); + int maxOrder = allMenus.stream() + .mapToInt(menu -> menu.getSort_order() != null ? menu.getSort_order() : 0) + .max() + .orElse(0); + menus.setSort_order(maxOrder + 1); + } + + menus.setStatus(1); + + menusMapper.insertMenus(menus); + } + + @Override + public void updateMenus(Menus menus, String username) { + String userUuid = userInformationMapper.selectUserUUID(username); + if (userUuid != null) { + menus.setUser_id(userUuid); + } + + menusMapper.updateMenus(menus); + } + + @Override + public void deleteMenus(String menusId) { + menusMapper.deleteMenus(menusId); + } + + @Override + public void updateSortOrder(List menusList) { + try (SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH)) { + MenusMapper mapper = sqlSession.getMapper(MenusMapper.class); + + for (Menus menu : menusList) { + mapper.updateSortOrder(menu); + } + + sqlSession.commit(); + } catch (Exception e) { + throw new RuntimeException("批次更新排序失敗", e); + } + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/MusicServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/MusicServiceImpl.java new file mode 100644 index 0000000..e27058a --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/MusicServiceImpl.java @@ -0,0 +1,88 @@ +package xyz.lingview.dimstack.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import xyz.lingview.dimstack.domain.Music; +import xyz.lingview.dimstack.mapper.MusicMapper; +import xyz.lingview.dimstack.service.MusicService; + +import java.util.List; + +/** + * @Author: lingview + * @Date: 2026/01/24 00:44:15 + * @Description: 站点音乐服务实现 + * @Version: 1.0 + */ + +@Service +@Slf4j +public class MusicServiceImpl implements MusicService { + + @Autowired + private MusicMapper musicMapper; + + @Override + public boolean addMusic(Music music) { + try { + int result = musicMapper.insertMusic(music); + return result > 0; + } catch (Exception e) { + log.error("添加音乐失败", e); + return false; + } + } + + @Override + public boolean updateMusic(Music music) { + try { + int result = musicMapper.updateMusicById(music); + return result > 0; + } catch (Exception e) { + log.error("更新音乐失败", e); + return false; + } + } + + @Override + public boolean deleteMusic(Integer id) { + try { + int result = musicMapper.deleteMusicById(id); + return result > 0; + } catch (Exception e) { + log.error("删除音乐失败", e); + return false; + } + } + + @Override + public List getAllEnabledMusics() { + try { + return musicMapper.selectAllEnabledMusics(); + } catch (Exception e) { + log.error("获取启用的音乐列表失败", e); + return null; + } + } + + @Override + public List getAllMusics() { + try { + return musicMapper.selectAllMusics(); + } catch (Exception e) { + log.error("获取所有音乐列表失败", e); + return null; + } + } + + @Override + public Music getMusicById(Integer id) { + try { + return musicMapper.selectMusicById(id); + } catch (Exception e) { + log.error("获取音乐详情失败", e); + return null; + } + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/NotificationServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/NotificationServiceImpl.java new file mode 100644 index 0000000..52cda50 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/NotificationServiceImpl.java @@ -0,0 +1,132 @@ +package xyz.lingview.dimstack.service.impl; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import xyz.lingview.dimstack.domain.SystematicNotification; +import xyz.lingview.dimstack.dto.request.PageResult; +import xyz.lingview.dimstack.mapper.SystematicNotificationMapper; +import xyz.lingview.dimstack.mapper.UserInformationMapper; +import xyz.lingview.dimstack.service.NotificationService; + +import java.util.List; + +/** + * @Author: lingview + * @Date: 2026/01/31 16:07:35 + * @Description: 系统通知服务实现 + * @Version: 1.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class NotificationServiceImpl implements NotificationService { + + @Autowired + private SystematicNotificationMapper systematicNotificationMapper; + + @Autowired + private UserInformationMapper userInformationMapper; + + @Async + @Override + public void sendSystemNotification(String username, String type, String content) { + String uuid = userInformationMapper.selectUserUUID(username); + if (uuid == null) { + log.warn("用户 {} 不存在,无法发送通知", username); + return; + } + + var notification = new SystematicNotification(); + notification.setUuid(uuid); + notification.setType(type); + notification.setContent(content); + notification.setStatus(2); + + int result = systematicNotificationMapper.insertSystematicNotification(notification); + if (result > 0) { + log.info("已向用户 {} 发送【{}】: {}", username, type, content); + } else { + log.warn("发送【{}】失败,用户: {}, 内容: {}", type, username, content); + } + } + + @Override + public PageResult getSystematicNotifications(String uuid, int pageNum, int pageSize) { + int total = systematicNotificationMapper.getCountByUuid(uuid); + + int offset = (pageNum - 1) * pageSize; + + List notifications = systematicNotificationMapper.getSystematicNotificationsWithPagination(uuid, offset, pageSize); + + PageResult result = new PageResult<>(); + result.setData(notifications); + result.setTotal(total); + result.setPage(pageNum); + result.setSize(pageSize); + result.setTotal_pages((int) Math.ceil((double) total / pageSize)); + + return result; + } + + @Override + public PageResult getReadNotifications(String uuid, int pageNum, int pageSize) { + + int total = systematicNotificationMapper.getReadCountByUuid(uuid); + + int offset = (pageNum - 1) * pageSize; + + List notifications = systematicNotificationMapper.getReadNotificationsWithPagination(uuid, offset, pageSize); + + PageResult result = new PageResult<>(); + result.setData(notifications); + result.setTotal(total); + result.setPage(pageNum); + result.setSize(pageSize); + result.setTotal_pages((int) Math.ceil((double) total / pageSize)); + + return result; + } + + @Override + public PageResult getUnreadNotifications(String uuid, int pageNum, int pageSize) { + int total = systematicNotificationMapper.getUnreadCountByUuid(uuid); + int offset = (pageNum - 1) * pageSize; + + List notifications = systematicNotificationMapper.getUnreadNotificationsWithPagination(uuid, offset, pageSize); + + PageResult result = new PageResult<>(); + result.setData(notifications); + result.setTotal(total); + result.setPage(pageNum); + result.setSize(pageSize); + result.setTotal_pages((int) Math.ceil((double) total / pageSize)); + + return result; + } + + @Override + public boolean updateNotificationStatus(Integer id, String uuid) { + + SystematicNotification notification = systematicNotificationMapper.selectByIdAndUuid(id, uuid); + if (notification == null) { + return false; + } + + int result = systematicNotificationMapper.updateNotificationStatus(id, 1); + return result > 0; + } + + @Override + public boolean deleteSystematicNotification(Integer id, String uuid) { + SystematicNotification notification = systematicNotificationMapper.selectByIdAndUuid(id, uuid); + if (notification == null) { + return false; + } + + int result = systematicNotificationMapper.deleteSystematicNotification(id); + return result > 0; + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/PageViewCounterServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/PageViewCounterServiceImpl.java new file mode 100644 index 0000000..70cb6c9 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/PageViewCounterServiceImpl.java @@ -0,0 +1,125 @@ +package xyz.lingview.dimstack.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import xyz.lingview.dimstack.domain.ReadArticle; +import xyz.lingview.dimstack.mapper.ReadArticleMapper; +import xyz.lingview.dimstack.service.PageViewCounterService; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +public class PageViewCounterServiceImpl implements PageViewCounterService { + + @Autowired + private ReadArticleMapper readArticleMapper; + + private final Map pageViewCounters = new ConcurrentHashMap<>(); + + private ScheduledExecutorService scheduler; + + private static final long SYNC_INTERVAL_SECONDS = 10; + + @PostConstruct + public void init() { + scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread thread = new Thread(r, "page-view-sync"); + thread.setDaemon(true); + return thread; + }); + + scheduler.scheduleAtFixedRate( + this::syncPageViewsToDatabase, + SYNC_INTERVAL_SECONDS, + SYNC_INTERVAL_SECONDS, + TimeUnit.SECONDS + ); + + log.info("浏览量同步定时器已启动,每{}秒同步一次", SYNC_INTERVAL_SECONDS); + } + + @PreDestroy + public void destroy() { + if (scheduler != null) { + log.info("正在关闭浏览量同步定时器..."); + scheduler.shutdown(); + try { + if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) { + scheduler.shutdownNow(); + } + } catch (InterruptedException e) { + scheduler.shutdownNow(); + Thread.currentThread().interrupt(); + } + syncPageViewsToDatabase(); + log.info("浏览量同步定时器已关闭"); + } + } + + @Override + public void initializePageView(String alias, Long dbPageViews) { + pageViewCounters.put(alias, dbPageViews); + } + + @Override + public void incrementPageView(String alias) { + pageViewCounters.merge(alias, 1L, Long::sum); + } + + @Override + public Long getPageView(String alias) { + return pageViewCounters.get(alias); + } + + @Override + public void removePageView(String alias) { + pageViewCounters.remove(alias); + log.debug("已清除文章浏览量计数器: {}", alias); + } + + private void syncPageViewsToDatabase() { + if (pageViewCounters.isEmpty()) { + return; + } + + Map snapshot = new ConcurrentHashMap<>(pageViewCounters); + + int successCount = 0; + int failCount = 0; + + for (Map.Entry entry : snapshot.entrySet()) { + String alias = entry.getKey(); + Long currentValue = entry.getValue(); + + try { + ReadArticle article = readArticleMapper.selectByAlias(alias); + if (article != null) { + Long dbValue = article.getPage_views(); + Long increment = currentValue - dbValue; + + if (increment > 0) { + readArticleMapper.batchUpdatePageViews(alias, increment); + pageViewCounters.put(alias, currentValue); + successCount++; + } + } + } catch (Exception e) { + failCount++; + log.error("同步文章浏览量失败: alias={}, currentValue={}", alias, currentValue, e); + } + } + + if (successCount > 0 || failCount > 0) { + log.debug("浏览量同步完成: 成功={}, 失败={}, 总计数器={}", + successCount, failCount, pageViewCounters.size()); + } + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/PluginServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/PluginServiceImpl.java new file mode 100644 index 0000000..9a6bfb1 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/PluginServiceImpl.java @@ -0,0 +1,741 @@ +package xyz.lingview.dimstack.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.pf4j.PluginRuntimeException; +import org.pf4j.PluginState; +import org.pf4j.PluginWrapper; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import tools.jackson.databind.ObjectMapper; +import xyz.lingview.dimstack.domain.PluginInfo; +import xyz.lingview.dimstack.domain.PluginSqlAudit; +import xyz.lingview.dimstack.mapper.PluginConfigMapper; +import xyz.lingview.dimstack.mapper.PluginMapper; +import xyz.lingview.dimstack.mapper.PluginSqlAuditMapper; +import xyz.lingview.dimstack.plugin.DimStackPluginManager; +import xyz.lingview.dimstack.plugin.PluginAuditLogger; +import xyz.lingview.dimstack.plugin.PluginExtensionLoader; +import xyz.lingview.dimstack.plugin.PluginLifecycleGuard; +import xyz.lingview.dimstack.plugin.PluginManifest; +import xyz.lingview.dimstack.plugin.PluginPermissionRegistrar; +import xyz.lingview.dimstack.plugin.PluginYamlLoader; +import xyz.lingview.dimstack.plugin.YamlPluginDescriptorFinder; +import xyz.lingview.dimstack.service.PluginService; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.InetAddress; +import java.net.URI; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Supplier; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +/** + * @Author: lingview + * @Date: 2026/08/03 20:23:41 + * @Description: 插件管理服务实现 + * @Version: 1.0 + */ +@Slf4j +@Service +public class PluginServiceImpl implements PluginService { + + private static final String CONFIG_KEY = "config"; + private static final long MAX_PLUGIN_SIZE = 50L * 1024 * 1024; + + private final DimStackPluginManager pluginManager; + private final PluginMapper pluginMapper; + private final YamlPluginDescriptorFinder descriptorFinder; + private final PluginExtensionLoader extensionLoader; + private final PluginConfigMapper configMapper; + private final PluginAuditLogger auditLogger; + private final PluginSqlAuditMapper sqlAuditMapper; + private final PluginPermissionRegistrar permissionRegistrar; + private final ObjectMapper objectMapper = new ObjectMapper(); + + private final Path pluginDir = Path.of(System.getProperty("user.dir"), "plugins"); + + private final ConcurrentHashMap pluginLocks = new ConcurrentHashMap<>(); + + private final PluginLifecycleGuard lifecycleGuard; + + public PluginServiceImpl(DimStackPluginManager pluginManager, + PluginMapper pluginMapper, + YamlPluginDescriptorFinder descriptorFinder, + PluginExtensionLoader extensionLoader, + PluginConfigMapper configMapper, + PluginAuditLogger auditLogger, + PluginSqlAuditMapper sqlAuditMapper, + PluginPermissionRegistrar permissionRegistrar, + PluginLifecycleGuard lifecycleGuard) { + this.pluginManager = pluginManager; + this.pluginMapper = pluginMapper; + this.descriptorFinder = descriptorFinder; + this.extensionLoader = extensionLoader; + this.configMapper = configMapper; + this.auditLogger = auditLogger; + this.sqlAuditMapper = sqlAuditMapper; + this.permissionRegistrar = permissionRegistrar; + this.lifecycleGuard = lifecycleGuard; + } + + private Path resolvePluginJar(String fileName) { + Path dest = pluginDir.resolve(fileName).normalize(); + if (!dest.startsWith(pluginDir.normalize())) { + throw new PluginRuntimeException("插件文件名越出插件目录: " + fileName); + } + return dest; + } + + private T withLock(String key, Supplier action) { + ReentrantLock lock = pluginLocks.computeIfAbsent(key, k -> new ReentrantLock()); + lock.lock(); + try { + return action.get(); + } finally { + lock.unlock(); + } + } + + @Override + public List list() { + List infos = pluginMapper.selectAll(); + for (PluginInfo info : infos) { + PluginWrapper wrapper = pluginManager.getPlugin(info.getName()); + if (wrapper != null) { + info.setState(wrapper.getPluginState().name()); + if (wrapper.getPluginState() == PluginState.FAILED && wrapper.getFailedException() != null) { + info.setLast_error(wrapper.getFailedException().getMessage()); + } + } else { + info.setState(PluginState.UNLOADED.name()); + } + } + return infos; + } + + @Override + public PluginInfo install(MultipartFile file) { + return lifecycleGuard.withWriteLock(() -> doInstall(file)); + } + + private PluginInfo doInstall(MultipartFile file) { + validateJar(file); + Path temp = null; + try { + temp = Files.createTempFile("plugin-upload-", ".jar"); + file.transferTo(temp.toFile()); + return installJar(temp, "本地上传"); + } catch (PluginRuntimeException e) { + throw e; + } catch (Exception e) { + throw new PluginRuntimeException("插件安装失败", e); + } finally { + if (temp != null) { + try { + Files.deleteIfExists(temp); + } catch (IOException ignored) { + } + } + } + } + + + private PluginInfo installJar(Path jar, String source) { + try { + validateJarPath(jar); + PluginManifest manifest = parseManifest(jar); + if (pluginMapper.selectByName(manifest.getId()) != null) { + throw new PluginRuntimeException("插件已存在: " + manifest.getId()); + } + String fileName = manifest.getId() + "-" + manifest.getVersion() + ".jar"; + Path dest = resolvePluginJar(fileName); + if (Files.exists(dest)) { + throw new PluginRuntimeException("同名插件文件已存在(可能是上次卸载残留): " + fileName + ", 请先清理 " + pluginDir + " 后重试"); + } + Files.copy(jar, dest, StandardCopyOption.REPLACE_EXISTING); + + PluginInfo info = new PluginInfo(); + info.setName(manifest.getId()); + info.setVersion(manifest.getVersion()); + info.setDisplay_name(manifest.getDisplayName()); + info.setDescription(manifest.getDescription()); + info.setAuthor(manifest.getAuthor()); + info.setRequires(manifest.getRequires()); + info.setJar_file(fileName); + info.setSha256(sha256Hex(jar)); + info.setEnabled(false); + info.setSetting_name(manifest.getSettingName()); + info.setConfig_map_name(manifest.getConfigMapName()); + try { + pluginMapper.insert(info); + } catch (DuplicateKeyException e) { + log.warn("插件记录已存在, 回滚刚落盘的 jar: {}", fileName); + Files.deleteIfExists(dest); + throw new PluginRuntimeException("插件已存在: " + manifest.getId()); + } + info.setState(PluginState.UNLOADED.name()); + auditLogger.log("install", info.getName(), info.getVersion(), true, source); + log.info("插件安装成功: {}@{} ({})", info.getName(), info.getVersion(), source); + return info; + } catch (PluginRuntimeException e) { + auditLogger.log("install", "?", null, false, e.getMessage()); + throw e; + } catch (Exception e) { + auditLogger.log("install", "?", null, false, e.getMessage()); + log.error("插件安装发生非业务异常", e); + throw new PluginRuntimeException("插件安装失败, 请查看服务端日志"); + } + } + + @Override + public boolean start(String name) { + return lifecycleGuard.withWriteLock(() -> doStart(name)); + } + + private boolean doStart(String name) { + PluginWrapper wrapper = pluginManager.getPlugin(name); + if (wrapper == null) { + PluginInfo info = pluginMapper.selectByName(name); + if (info == null || info.getJar_file() == null) { + throw new PluginRuntimeException("插件不存在: " + name); + } + try { + pluginManager.loadPlugin(resolvePluginJar(info.getJar_file())); + } catch (Exception e) { + throw new PluginRuntimeException("插件加载失败: " + name, e); + } + } + PluginState state = pluginManager.startPlugin(name); + if (state == PluginState.STARTED) { + pluginMapper.updateEnabled(name, true); + PluginWrapper started = pluginManager.getPlugin(name); + if (started != null) { + extensionLoader.loadPluginExtensions(name, started); + } + log.info("插件已启用: {}", name); + return true; + } + PluginWrapper failed = pluginManager.getPlugin(name); + String reason = failed != null && failed.getFailedException() != null + ? failed.getFailedException().getMessage() : "未知原因"; + throw new PluginRuntimeException("插件启动失败: " + reason); + } + + @Override + public boolean stop(String name) { + return lifecycleGuard.withWriteLock(() -> doStop(name)); + } + + private boolean doStop(String name) { + PluginWrapper wrapper = pluginManager.getPlugin(name); + if (wrapper == null) { + throw new PluginRuntimeException("插件不存在: " + name); + } + if (wrapper.getPluginState() != PluginState.STARTED) { + pluginMapper.updateEnabled(name, false); + return true; + } + PluginState state = pluginManager.stopPlugin(name); + if (state == PluginState.STOPPED) { + pluginMapper.updateEnabled(name, false); + log.info("插件已停用: {}", name); + return true; + } + throw new PluginRuntimeException("插件停用失败: " + name); + } + + @Override + public boolean uninstall(String name) { + return lifecycleGuard.withWriteLock(() -> doUninstall(name)); + } + + private boolean doUninstall(String name) { + + PluginManifest manifest = pluginManager.getManifest(name); + PluginWrapper wrapper = pluginManager.getPlugin(name); + if (wrapper != null) { + if (wrapper.getPluginState() == PluginState.STARTED) { + pluginManager.stopPlugin(name); + } + pluginManager.unloadAndClose(name); + } + + PluginInfo info = pluginMapper.selectByName(name); + pluginMapper.deleteByName(name); + + configMapper.deleteByPluginName(name); + extensionLoader.cleanupPluginExtensions(name); + permissionRegistrar.unregisterOnUninstall(name); + + String managedNote = cleanupManagedPaths(manifest); + String fileNote = deletePluginJar(info); + auditLogger.log("uninstall", name, info != null ? info.getVersion() : null, true, "卸载完成" + managedNote + fileNote); + log.info("插件已卸载: {}{}{}", name, managedNote, fileNote); + return true; + } + + private String deletePluginJar(PluginInfo info) { + if (info == null || info.getJar_file() == null) { + return ""; + } + Path jarPath = resolvePluginJar(info.getJar_file()); + try { + Files.deleteIfExists(jarPath); + return ""; + } catch (IOException e) { + // Windows/JVM下插件类加载器关闭后jar句柄可能延迟释放, 触发GC后重试 + System.gc(); + try { + Thread.sleep(500); + } catch (InterruptedException ignored) { + } + try { + Files.deleteIfExists(jarPath); + return ""; + } catch (IOException e2) { + log.warn("插件文件删除失败, 待重启后清理: {} ({})", jarPath, e2.getMessage()); + return "(jar 文件被占用, 待重启后清理: " + info.getJar_file() + ")"; + } + } + } + + public static final String MANAGED_MARKER_FILE = ".managed-by"; + + private String cleanupManagedPaths(PluginManifest manifest) { + if (manifest == null || manifest.getManagedPaths() == null) { + return ""; + } + Path workDir = Path.of(System.getProperty("user.dir")).toAbsolutePath().normalize(); + List failures = new ArrayList<>(); + for (String managedPath : manifest.getManagedPaths()) { + try { + if (managedPath == null || managedPath.isBlank() + || managedPath.startsWith("/") || managedPath.contains("..") || managedPath.contains(":")) { + log.warn("忽略非法的托管资源路径: {}", managedPath); + continue; + } + Path target = workDir.resolve(managedPath).normalize(); + if (!target.startsWith(workDir) || !Files.exists(target)) { + continue; + } + + Path ownerMarker = target.resolve(MANAGED_MARKER_FILE); + if (!Files.isRegularFile(ownerMarker)) { + log.warn("托管资源缺少署名标记 {}, 跳过清理: {}", MANAGED_MARKER_FILE, target); + failures.add(managedPath + "(缺少署名标记)"); + continue; + } + String owner = Files.readString(ownerMarker, StandardCharsets.UTF_8).trim(); + if (!manifest.getId().equals(owner)) { + log.warn("托管资源署名不匹配(声明者={}, 标记={}), 跳过清理: {}", manifest.getId(), owner, target); + failures.add(managedPath + "(署名不匹配)"); + continue; + } + deleteRecursively(target); + log.info("已清理插件托管资源: {}", target); + } catch (Exception e) { + log.warn("清理插件托管资源失败: {}", managedPath, e); + failures.add(managedPath); + } + } + return failures.isEmpty() ? "" : "(托管资源未完全清理: " + String.join(", ", failures) + ")"; + } + + private void deleteRecursively(Path path) throws IOException { + if (Files.isDirectory(path)) { + try (var entries = Files.list(path)) { + for (Path entry : entries.toList()) { + deleteRecursively(entry); + } + } + } + Files.deleteIfExists(path); + } + + @Override + public PluginInfo upgrade(String name, MultipartFile file) { + return lifecycleGuard.withWriteLock(() -> doUpgrade(name, file)); + } + + private PluginInfo doUpgrade(String name, MultipartFile file) { + validateJar(file); + PluginInfo info = pluginMapper.selectByName(name); + if (info == null) { + throw new PluginRuntimeException("插件不存在: " + name); + } + Path temp = null; + try { + temp = Files.createTempFile("plugin-upgrade-", ".jar"); + file.transferTo(temp.toFile()); + PluginManifest manifest = parseManifest(temp); + if (!name.equals(manifest.getId())) { + throw new PluginRuntimeException("上传插件的 ID 与目标不一致"); + } + if (manifest.getVersion().equals(info.getVersion())) { + throw new PluginRuntimeException("上传版本与当前版本相同(" + manifest.getVersion() + "), 请修改版本号后再升级"); + } + + String oldJarFile = info.getJar_file(); + boolean wasEnabled = Boolean.TRUE.equals(info.getEnabled()); + String newFileName = manifest.getId() + "-" + manifest.getVersion() + ".jar"; + Path dest = resolvePluginJar(newFileName); + + try { + PluginWrapper wrapper = pluginManager.getPlugin(name); + if (wrapper != null) { + if (wrapper.getPluginState() == PluginState.STARTED) { + pluginManager.stopPlugin(name); + } + pluginManager.unloadAndClose(name); + } + + Files.copy(temp, dest, StandardCopyOption.REPLACE_EXISTING); + + String loadedId = pluginManager.loadPlugin(dest); + if (loadedId == null) { + throw new PluginRuntimeException("新版本加载失败: " + name); + } + if (wasEnabled) { + PluginState startState = pluginManager.startPlugin(name); + if (startState != PluginState.STARTED) { + throw new PluginRuntimeException("新版本启动失败: " + name); + } + } + } catch (Exception e) { + auditLogger.log("upgrade", name, manifest.getVersion(), false, "升级失败, 已回滚: " + e.getMessage()); + throw rollbackUpgrade(name, dest, oldJarFile, wasEnabled, e); + } + + info.setVersion(manifest.getVersion()); + info.setJar_file(newFileName); + info.setSha256(sha256Hex(temp)); + info.setDisplay_name(manifest.getDisplayName()); + info.setDescription(manifest.getDescription()); + info.setAuthor(manifest.getAuthor()); + info.setRequires(manifest.getRequires()); + info.setSetting_name(manifest.getSettingName()); + info.setConfig_map_name(manifest.getConfigMapName()); + pluginMapper.updateInfo(info); + + if (oldJarFile != null && !oldJarFile.equals(newFileName) && !deleteWithRetry(resolvePluginJar(oldJarFile))) { + log.warn("旧版本插件jar删除失败, 请重启后手动清理: {}", oldJarFile); + } + + auditLogger.log("upgrade", name, manifest.getVersion(), true, "升级成功"); + log.info("插件升级成功: {}@{}", name, manifest.getVersion()); + return info; + } catch (PluginRuntimeException e) { + throw e; + } catch (Exception e) { + throw new PluginRuntimeException("插件升级失败", e); + } finally { + if (temp != null) { + try { + Files.deleteIfExists(temp); + } catch (IOException ignored) { + } + } + } + } + + private PluginRuntimeException rollbackUpgrade(String name, Path newJar, String oldJarFile, boolean wasEnabled, Exception cause) { + log.warn("插件升级失败, 开始回滚: {} ({})", name, cause.getMessage()); + try { + pluginManager.unloadAndClose(name); + } catch (Exception e) { + log.warn("回滚时卸载新版本失败: {} ({})", name, e.getMessage()); + } + if (!deleteWithRetry(newJar)) { + log.warn("回滚时新版本jar删除失败, 请重启后手动清理: {}", newJar.getFileName()); + } + if (oldJarFile != null) { + Path oldJar = resolvePluginJar(oldJarFile); + if (Files.exists(oldJar)) { + try { + pluginManager.loadPlugin(oldJar); + if (wasEnabled) { + pluginManager.startPlugin(name); + } + } catch (Exception e) { + log.error("回滚后旧版本重新加载失败: {} ({})", name, e.getMessage()); + } + } + } + return new PluginRuntimeException("插件升级失败, 已回滚到旧版本: " + name + " (" + cause.getMessage() + ")", cause); + } + + private boolean deleteWithRetry(Path path) { + try { + Files.deleteIfExists(path); + return true; + } catch (IOException e) { + System.gc(); + try { + Thread.sleep(500); + } catch (InterruptedException ignored) { + } + try { + Files.deleteIfExists(path); + return true; + } catch (IOException e2) { + log.warn("文件删除失败: {} ({})", path, e2.getMessage()); + return false; + } + } + } + + @Override + public boolean reload(String name) { + return lifecycleGuard.withWriteLock(() -> doReload(name)); + } + + private boolean doReload(String name) { + PluginInfo info = pluginMapper.selectByName(name); + if (info == null || info.getJar_file() == null) { + throw new PluginRuntimeException("插件不存在: " + name); + } + try { + PluginWrapper wrapper = pluginManager.getPlugin(name); + if (wrapper != null) { + if (wrapper.getPluginState() == PluginState.STARTED) { + pluginManager.stopPlugin(name); + } + pluginManager.unloadAndClose(name); + } + String loadedId = pluginManager.loadPlugin(resolvePluginJar(info.getJar_file())); + if (loadedId == null) { + throw new PluginRuntimeException("插件加载失败: " + name); + } + if (Boolean.TRUE.equals(info.getEnabled())) { + PluginState startState = pluginManager.startPlugin(name); + if (startState != PluginState.STARTED) { + pluginMapper.updateEnabled(name, false); + throw new PluginRuntimeException("插件启动失败, 已回写为停用状态: " + name); + } + } + return true; + } catch (Exception e) { + throw new PluginRuntimeException("插件重载失败: " + name, e); + } + } + + @Override + public void syncLoadedPlugins() { + lifecycleGuard.withWriteLock(() -> { + List wrappers = pluginManager.getPlugins(); + int synced = 0; + for (PluginWrapper wrapper : wrappers) { + String name = wrapper.getPluginId(); + if (pluginMapper.selectByName(name) == null) { + PluginManifest manifest = pluginManager.getManifest(name); + PluginInfo info = new PluginInfo(); + info.setName(name); + info.setVersion(wrapper.getDescriptor().getVersion()); + info.setDisplay_name(manifest != null ? manifest.getDisplayName() : name); + info.setDescription(manifest != null ? manifest.getDescription() : null); + info.setAuthor(manifest != null ? manifest.getAuthor() : null); + info.setRequires(wrapper.getDescriptor().getRequires()); + info.setJar_file(wrapper.getPluginPath() != null ? wrapper.getPluginPath().getFileName().toString() : name + ".jar"); + // 手动放置的jar统一补录为待启用, 避免绕过安装校验直接对外服务 + info.setEnabled(false); + info.setSetting_name(manifest != null ? manifest.getSettingName() : null); + info.setConfig_map_name(manifest != null ? manifest.getConfigMapName() : null); + try { + pluginMapper.insert(info); + synced++; + } catch (Exception e) { + log.warn("同步插件记录失败: {}", name, e); + } + } + + if (wrapper.getPluginState() == PluginState.STARTED) { + extensionLoader.loadPluginExtensions(name, wrapper); + } + } + if (synced > 0) { + log.info("插件启动同步完成: 补录 {} 个插件", synced); + } + }); + } + + @Override + public void startEnabledPlugins() { + lifecycleGuard.withWriteLock(() -> { + for (PluginInfo info : pluginMapper.selectAll()) { + if (!Boolean.TRUE.equals(info.getEnabled())) { + continue; + } + try { + start(info.getName()); + } catch (Exception e) { + log.error("插件启动失败, 已跳过(不影响宿主): {} ({})", info.getName(), e.getMessage()); + } + } + }); + } + + @Override + public Map getConfig(String name) { + Map config = new HashMap<>(); + + PluginWrapper wrapper = pluginManager.getPlugin(name); + if (wrapper != null) { + try (InputStream in = wrapper.getPluginClassLoader().getResourceAsStream("config.yaml")) { + if (in != null) { + Map loaded = PluginYamlLoader.load(in); + if (!loaded.isEmpty()) { + flatten("", loaded, config); + } + } + } catch (Exception e) { + log.warn("读取插件默认配置失败: {}", name, e); + } + } + + String json = configMapper.selectValue(name, configKey(pluginMapper.selectByName(name))); + if (json != null && !json.isBlank()) { + try { + Map userConfig = objectMapper.readValue(json, Map.class); + config.putAll(userConfig); + } catch (Exception e) { + log.warn("读取插件用户配置失败: {}", name, e); + } + } + return config; + } + + @Override + public boolean saveConfig(String name, Map config) { + if (config == null) { + throw new PluginRuntimeException("配置内容为空"); + } + return withLock(name, () -> { + PluginInfo info = pluginMapper.selectByName(name); + if (info == null) { + throw new PluginRuntimeException("插件不存在: " + name); + } + try { + String json = objectMapper.writeValueAsString(config); + return configMapper.upsert(name, configKey(info), json) > 0; + } catch (PluginRuntimeException e) { + throw e; + } catch (Exception e) { + log.error("插件配置保存失败: {}", name, e); + throw new PluginRuntimeException("插件配置保存失败, 请查看服务端日志"); + } + }); + } + + private String configKey(PluginInfo info) { + String configKey = info != null ? info.getConfig_map_name() : null; + return configKey != null && !configKey.isBlank() ? configKey : CONFIG_KEY; + } + + @Override + public List listSqlAudit(String name, int page, int size) { + return sqlAuditMapper.selectPage(name, (page - 1) * size, size); + } + + @Override + public long countSqlAudit(String name) { + return sqlAuditMapper.countByPlugin(name); + } + + @SuppressWarnings("unchecked") + private void flatten(String prefix, Map source, Map target) { + source.forEach((key, value) -> { + String fullKey = prefix.isEmpty() ? key : prefix + "." + key; + if (value instanceof Map nested) { + flatten(fullKey, (Map) nested, target); + } else { + target.put(fullKey, value); + } + }); + } + + private void validateJar(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new PluginRuntimeException("上传文件为空"); + } + String name = file.getOriginalFilename(); + if (name == null || !name.toLowerCase().endsWith(".jar")) { + throw new PluginRuntimeException("仅支持 jar 格式的插件文件"); + } + if (file.getSize() > MAX_PLUGIN_SIZE) { + throw new PluginRuntimeException("插件文件超过 50MB 限制"); + } + } + + private void validateJarPath(Path path) { + try { + if (!Files.exists(path) || Files.size(path) == 0) { + throw new PluginRuntimeException("插件文件为空"); + } + if (Files.size(path) > MAX_PLUGIN_SIZE) { + throw new PluginRuntimeException("插件文件超过 50MB 限制"); + } + try (InputStream in = Files.newInputStream(path)) { + byte[] magic = in.readNBytes(4); + if (magic.length != 4 || magic[0] != 0x50 || magic[1] != 0x4B + || magic[2] != 0x03 || magic[3] != 0x04) { + throw new PluginRuntimeException("文件不是有效的 jar(zip) 格式"); + } + } + try (ZipFile zip = new ZipFile(path.toFile())) { + Enumeration entries = zip.entries(); + while (entries.hasMoreElements()) { + String entryName = entries.nextElement().getName(); + if (entryName.contains("..") || entryName.startsWith("/") || entryName.contains("\\")) { + throw new PluginRuntimeException("插件包内含非法路径条目: " + entryName); + } + } + } + } catch (PluginRuntimeException e) { + throw e; + } catch (Exception e) { + throw new PluginRuntimeException("插件文件校验失败: " + e.getMessage(), e); + } + } + + private String sha256Hex(Path path) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + try (InputStream in = Files.newInputStream(path)) { + byte[] buffer = new byte[8192]; + int read; + while ((read = in.read(buffer)) > 0) { + digest.update(buffer, 0, read); + } + } + return HexFormat.of().formatHex(digest.digest()); + } catch (Exception e) { + throw new PluginRuntimeException("插件完整性校验失败: " + e.getMessage(), e); + } + } + + private PluginManifest parseManifest(Path pluginPath) { + try { + return descriptorFinder.getManifest( + descriptorFinder.find(pluginPath).getPluginId()); + } catch (Exception e) { + throw new PluginRuntimeException("插件描述文件(plugin.yaml)解析失败: " + e.getMessage(), e); + } + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/ReadArticleServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/ReadArticleServiceImpl.java new file mode 100644 index 0000000..a645fe6 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/ReadArticleServiceImpl.java @@ -0,0 +1,270 @@ +package xyz.lingview.dimstack.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.ValueOperations; +import xyz.lingview.dimstack.domain.Article; +import xyz.lingview.dimstack.domain.ArticleLike; +import xyz.lingview.dimstack.domain.ReadArticle; +import xyz.lingview.dimstack.domain.UserInformation; +import xyz.lingview.dimstack.mapper.ArticleLikeMapper; +import xyz.lingview.dimstack.mapper.ArticleMapper; +import xyz.lingview.dimstack.mapper.ReadArticleMapper; +import xyz.lingview.dimstack.mapper.UserInformationMapper; +import xyz.lingview.dimstack.service.CacheService; +import xyz.lingview.dimstack.service.MailService; +import xyz.lingview.dimstack.service.NotificationService; +import xyz.lingview.dimstack.service.ReadArticleService; +import xyz.lingview.dimstack.service.PageViewCounterService; +import xyz.lingview.dimstack.util.SiteConfigUtil; +import org.mindrot.jbcrypt.BCrypt; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +public class ReadArticleServiceImpl implements ReadArticleService { + + @Autowired + private ReadArticleMapper readArticleMapper; + + + @Autowired + private CacheService cacheService; + + @Autowired + private ArticleMapper articleMapper; + + @Autowired + private ArticleLikeMapper articleLikeMapper; + + @Autowired + private UserInformationMapper userInformationMapper; + + @Autowired + private SiteConfigUtil siteConfigUtil; + + @Autowired + private NotificationService notificationService; + + @Autowired + private MailService mailService; + + @Autowired + private PageViewCounterService pageViewCounterService; + + @Override + public boolean isArticleNeedPassword(String alias) { + return readArticleMapper.isArticleNeedPassword(alias); + } + + @Override + public ReadArticle getRandomArticle() { + return readArticleMapper.selectRandomArticle(); + } + + @Override + public ReadArticle getArticleByAlias(String alias, String password) throws Exception { + String cacheKey = "dimstack:article:" + alias; + ReadArticle article = cacheService.get(cacheKey, ReadArticle.class); + + if (article != null) { + log.debug("文章缓存命中: {}", alias); + if (article.getPassword() != null && !article.getPassword().isEmpty()) { + if (password == null) { + throw new Exception("文章密码错误"); + } + if (!BCrypt.checkpw(password, article.getPassword())) { + throw new Exception("文章密码错误"); + } + } + + Long currentPageViews = pageViewCounterService.getPageView(alias); + if (currentPageViews == null) { + pageViewCounterService.initializePageView(alias, article.getPage_views()); + } + pageViewCounterService.incrementPageView(alias); + + ReadArticle result = new ReadArticle(); + org.springframework.beans.BeanUtils.copyProperties(article, result); + Long pageViews = pageViewCounterService.getPageView(alias); + if (pageViews != null) { + result.setPage_views(pageViews); + } + return result; + } + + log.info("文章缓存未命中,从数据库加载: {}", alias); + article = readArticleMapper.selectByAlias(alias); + if (article == null) { + throw new Exception("文章不存在"); + } + + if (article.getPassword() != null && !article.getPassword().isEmpty()) { + if (password == null) { + throw new Exception("文章密码错误"); + } + if (!BCrypt.checkpw(password, article.getPassword())) { + throw new Exception("文章密码错误"); + } + } + + pageViewCounterService.initializePageView(alias, article.getPage_views()); + pageViewCounterService.incrementPageView(alias); + + ReadArticle result = new ReadArticle(); + org.springframework.beans.BeanUtils.copyProperties(article, result); + Long pageViews = pageViewCounterService.getPageView(alias); + if (pageViews != null) { + result.setPage_views(pageViews); + } + + cacheService.set(cacheKey, article, 30, TimeUnit.MINUTES); + log.info("文章已缓存: {}, 键: {}", alias, cacheKey); + + return result; + } + + @Override + public void updatePageViews(String alias) { + pageViewCounterService.incrementPageView(alias); + } + + + @Override + public List listAllArticles() { + String key = "dimstack:sitemap:articles"; + + @SuppressWarnings("unchecked") + List articles = (List) cacheService.get(key, List.class); + + if (articles == null || articles.isEmpty()) { + log.info("文章sitemap缓存未命中,从数据库加载"); + articles = readArticleMapper.listAllArticles(); + + if (articles == null) { + articles = Collections.emptyList(); + } + + cacheService.set(key, articles, 1, TimeUnit.HOURS); + log.info("已将{}篇文章缓存,键为: {}", articles.size(), key); + } else { + log.info("文章sitemap缓存命中,从缓存返回{}篇文章", articles.size()); + } + return articles; + } + + @Override + public List listAllCategories() { + String key = "dimstack:sitemap:categories"; + + @SuppressWarnings("unchecked") + List categories = (List) cacheService.get(key, List.class); + + if (categories == null || categories.isEmpty()) { + log.info("分类sitemap缓存未命中,从数据库加载"); + categories = readArticleMapper.listAllCategories(); + if (categories == null) { + categories = Collections.emptyList(); + } + cacheService.set(key, categories, 1, TimeUnit.HOURS); + log.info("已将{}个分类缓存,键为: {}", categories.size(), key); + } else { + log.info("分类sitemap缓存命中,从缓存返回{}个分类", categories.size()); + } + return categories; + } + + @Override + public List listAllTags() { + String key = "dimstack:sitemap:tags"; + + @SuppressWarnings("unchecked") + List tags = (List) cacheService.get(key, List.class); + + if (tags == null || tags.isEmpty()) { + log.info("标签sitemap缓存未命中,从数据库加载"); + tags = readArticleMapper.listAllTags(); + if (tags == null) { + tags = Collections.emptyList(); + } + cacheService.set(key, tags, 1, TimeUnit.HOURS); + log.info("已将{}个标签缓存,键为: {}", tags.size(), key); + } else { + log.info("标签sitemap缓存命中,从缓存返回{}个标签", tags.size()); + } + return tags; + } + + @Override + public void likeArticle(String username, String articleAlias) { + String userId = userInformationMapper.selectUserUUID(username); + + Article article = articleMapper.selectArticleByAlias(articleAlias); + if (article == null) { + throw new RuntimeException("文章不存在"); + } + + if (articleLikeMapper.existsLike(userId, article.getArticle_id())) { + articleLikeMapper.deleteLike(userId, article.getArticle_id()); + Long newLikeCount = article.getLike_count() - 1; + articleMapper.updateArticleLikeCount(article.getArticle_id(), newLikeCount); + } else { + ArticleLike like = new ArticleLike(); + like.setUser_id(userId); + like.setArticle_id(article.getArticle_id()); + like.setCreate_time(LocalDateTime.now()); + articleLikeMapper.insertLike(like); + + Long newLikeCount = article.getLike_count() + 1; + articleMapper.updateArticleLikeCount(article.getArticle_id(), newLikeCount); + sendLikeNotification(article, username); + } + + String cacheKey = "dimstack:article:" + articleAlias; + cacheService.delete(cacheKey); + pageViewCounterService.removePageView(articleAlias); + log.info("点赞操作已清除文章缓存和浏览量计数器: {}", cacheKey); + } + + @Override + public boolean isUserLikedArticle(String username, String articleAlias) { + String userId = userInformationMapper.selectUserUUID(username); + + Article article = articleMapper.selectArticleByAlias(articleAlias); + if (article == null) { + return false; + } + + return articleLikeMapper.existsLike(userId, article.getArticle_id()); + } + + + private void sendLikeNotification(Article article, String likerUsername) { + try { + if (!siteConfigUtil.isNotificationEnabled()) { + return; + } + + UserInformation articleAuthor = userInformationMapper.selectUserByUUID(article.getUuid()); + String likerUserId = userInformationMapper.selectUserUUID(likerUsername); + + if (articleAuthor != null && articleAuthor.getEmail() != null && + likerUserId != null && !articleAuthor.getUuid().equals(likerUserId)) { + String siteName = siteConfigUtil.getSiteName(); + String subject = siteName + " 点赞通知"; + String content = "用户 " + likerUsername + " 点赞了您的文章《" + article.getArticle_name() + "》"; + + mailService.sendSimpleMail(articleAuthor.getEmail(), subject, content); + notificationService.sendSystemNotification(articleAuthor.getUsername(), "系统通知", content); + } + } catch (Exception e) { + log.warn("文章点赞通知邮件发送失败{}", String.valueOf(e)); + } + } + +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/RedisCacheServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/RedisCacheServiceImpl.java new file mode 100644 index 0000000..020019e --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/RedisCacheServiceImpl.java @@ -0,0 +1,192 @@ +package xyz.lingview.dimstack.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; +import xyz.lingview.dimstack.service.CacheService; + +import java.util.concurrent.TimeUnit; + +/** + * @Author: lingview + * @Date: 2026/02/15 20:56:16 + * @Description: Redis缓存实现 + * @Version: 1.0 + */ +//@Service +@Slf4j +public class RedisCacheServiceImpl implements CacheService { + + private final RedisTemplate redisTemplate; + private final boolean redisAvailable; + + public RedisCacheServiceImpl(RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + + boolean available; + try { + redisTemplate.getConnectionFactory().getConnection().ping(); + available = true; + } catch (Exception e) { + log.info("Redis 不可用"); + available = false; + } + this.redisAvailable = available; + } + + @Override + public void set(String key, Object value, long timeout, TimeUnit unit) { + if (redisAvailable) { + redisTemplate.opsForValue().set(key, value, timeout, unit); + } + } + + @Override + public void set(String key, Object value) { + if (redisAvailable) { + redisTemplate.opsForValue().set(key, value); + } + } + + @Override + public T get(String key, Class type) { + if (!redisAvailable) return null; + try { + Object obj = redisTemplate.opsForValue().get(key); + return obj != null ? type.cast(obj) : null; + } catch (Exception e) { + return null; + } + } + + @Override + public void delete(String key) { + if (redisAvailable) { + redisTemplate.delete(key); + } + } + + @Override + public void deleteByPrefix(String prefix) { + if (!redisAvailable) return; + try { + java.util.Set keys = redisTemplate.keys(prefix + "*"); + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + log.info("已清除 {} 个以 {} 开头的缓存键", keys.size(), prefix); + } + } catch (Exception e) { + log.error("按前缀清除缓存失败: {}", prefix, e); + } + } + + @Override + public void addToSet(String key, Object value) { + if (redisAvailable) { + redisTemplate.opsForSet().add(key, value); + } + } + + @Override + public void removeFromSet(String key, Object value) { + if (redisAvailable) { + redisTemplate.opsForSet().remove(key, value); + } + } + + @Override + public boolean isMemberOfSet(String key, Object value) { + if (!redisAvailable) return false; + try { + return Boolean.TRUE.equals(redisTemplate.opsForSet().isMember(key, value)); + } catch (Exception e) { + return false; + } + } + + @Override + public void deleteSet(String key) { + if (redisAvailable) { + redisTemplate.delete(key); + } + } + + @Override + public java.util.Set getSetMembers(String key, Class type) { + if (!redisAvailable) return new java.util.HashSet<>(); + try { + java.util.Set members = redisTemplate.opsForSet().members(key); + if (members == null) return new java.util.HashSet<>(); + + java.util.Set result = new java.util.HashSet<>(); + for (Object member : members) { + if (member != null) { + result.add(type.cast(member)); + } + } + return result; + } catch (Exception e) { + return new java.util.HashSet<>(); + } + } + + public boolean isRedisAvailable() { + return redisAvailable; + } + + @Override + public long incrementAndGet(String key) { + if (!redisAvailable) return 0L; + try { + return redisTemplate.opsForValue().increment(key, 1L); + } catch (Exception e) { + return 0L; + } + } + + @Override + public long incrementAndGet(String key, long delta) { + if (!redisAvailable) return 0L; + try { + return redisTemplate.opsForValue().increment(key, delta); + } catch (Exception e) { + return 0L; + } + } + + @Override + public long decrementAndGet(String key) { + if (!redisAvailable) return 0L; + try { + return redisTemplate.opsForValue().increment(key, -1L); + } catch (Exception e) { + return 0L; + } + } + + @Override + public long getAndIncrement(String key) { + if (!redisAvailable) return 0L; + try { + Long currentValue = (Long) redisTemplate.opsForValue().get(key); + long result = currentValue != null ? currentValue : 0L; + redisTemplate.opsForValue().increment(key, 1L); + return result; + } catch (Exception e) { + return 0L; + } + } + + @Override + public long getAndDecrement(String key) { + if (!redisAvailable) return 0L; + try { + Long currentValue = (Long) redisTemplate.opsForValue().get(key); + long result = currentValue != null ? currentValue : 0L; + redisTemplate.opsForValue().increment(key, -1L); + return result; + } catch (Exception e) { + return 0L; + } + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/RegisterServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/RegisterServiceImpl.java new file mode 100644 index 0000000..0fb3feb --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/RegisterServiceImpl.java @@ -0,0 +1,212 @@ +package xyz.lingview.dimstack.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import xyz.lingview.dimstack.domain.Register; +import xyz.lingview.dimstack.domain.UserInformation; +import xyz.lingview.dimstack.dto.request.RegisterDTO; +import xyz.lingview.dimstack.dto.response.RegisterResponseDTO; +import xyz.lingview.dimstack.mapper.RegisterMapper; +import xyz.lingview.dimstack.mapper.SiteConfigMapper; +import xyz.lingview.dimstack.mapper.UserInformationMapper; +import xyz.lingview.dimstack.mapper.UserRoleMapper; +import xyz.lingview.dimstack.service.CacheService; +import xyz.lingview.dimstack.service.MailService; +import xyz.lingview.dimstack.service.NotificationService; +import xyz.lingview.dimstack.service.RegisterService; +import xyz.lingview.dimstack.util.CaptchaUtil; +import xyz.lingview.dimstack.util.PasswordUtil; +import xyz.lingview.dimstack.util.RandomUtil; +import xyz.lingview.dimstack.util.SiteConfigUtil; + +import java.text.SimpleDateFormat; +import java.util.Date; + +@Service +@Slf4j +public class RegisterServiceImpl implements RegisterService { + + @Autowired + private RegisterMapper registerMapper; + + @Autowired + private CacheService cacheService; + + @Autowired + private SiteConfigMapper siteConfigMapper; + + @Autowired + private SiteConfigUtil siteConfigUtil; + + @Autowired + private MailService mailService; + + @Autowired + private NotificationService notificationService; + + @Autowired + private UserInformationMapper userInformationMapper; + + @Autowired + private UserRoleMapper userRoleMapper; + + @Override + public RegisterResponseDTO register(RegisterDTO requestDTO) { + try { + Integer enableRegister = siteConfigMapper.getEnableRegister(); + if (enableRegister == null || enableRegister != 1) { + RegisterResponseDTO dto = new RegisterResponseDTO(); + dto.setSuccess(false); + dto.setMessage("管理员未启用注册功能"); + dto.setCode(400); + return dto; + } + + String username = requestDTO.getUsername(); + String email = requestDTO.getEmail(); + String phone = requestDTO.getPhone(); + String password = requestDTO.getPassword(); + String captcha = requestDTO.getCaptcha(); + String captchaKey = requestDTO.getCaptchaKey(); + + if (isBlank(username)) { + RegisterResponseDTO dto = new RegisterResponseDTO(); + dto.setSuccess(false); + dto.setMessage("用户名不能为空"); + dto.setCode(400); + return dto; + } + if (isBlank(password)) { + RegisterResponseDTO dto = new RegisterResponseDTO(); + dto.setSuccess(false); + dto.setMessage("密码不能为空"); + dto.setCode(400); + return dto; + } + if (isBlank(captcha)) { + RegisterResponseDTO dto = new RegisterResponseDTO(); + dto.setSuccess(false); + dto.setMessage("验证码不能为空"); + dto.setCode(400); + return dto; + } + if (isBlank(captchaKey)) { + RegisterResponseDTO dto = new RegisterResponseDTO(); + dto.setSuccess(false); + dto.setMessage("验证码无效"); + dto.setCode(400); + return dto; + } + + String redisCaptcha = cacheService.get("captcha_" + captchaKey, String.class); + if (redisCaptcha == null) { + RegisterResponseDTO dto = new RegisterResponseDTO(); + dto.setSuccess(false); + dto.setMessage("验证码已过期,请重新获取"); + dto.setCode(400); + return dto; + } + + if (!CaptchaUtil.validateCaptcha(redisCaptcha, captcha)) { + clearCaptcha(captchaKey); + RegisterResponseDTO dto = new RegisterResponseDTO(); + dto.setSuccess(false); + dto.setMessage("验证码错误"); + dto.setCode(400); + return dto; + } + + clearCaptcha(captchaKey); + + log.info("注册用户名:{} 手机号:{} 邮箱:{}", username, phone, email); + + int userExists = registerMapper.selectUser(username); + if (userExists > 0) { + RegisterResponseDTO dto = new RegisterResponseDTO(); + dto.setSuccess(false); + dto.setMessage("账号已存在!"); + dto.setCode(400); + return dto; + } + + if (phone != null && !phone.trim().isEmpty()) { + int phoneExists = userInformationMapper.selectUserByPhone(phone); + if (phoneExists > 0) { + RegisterResponseDTO dto = new RegisterResponseDTO(); + dto.setSuccess(false); + dto.setMessage("手机号已存在!"); + dto.setCode(400); + return dto; + } + } + + if (email != null && !email.trim().isEmpty()) { + int emailExists = userInformationMapper.selectUserByEmail(email); + if (emailExists > 0) { + RegisterResponseDTO dto = new RegisterResponseDTO(); + dto.setSuccess(false); + dto.setMessage("邮箱已存在!"); + dto.setCode(400); + return dto; + } + } + + Register register = new Register(); + register.setUsername(username); + register.setEmail(email); + register.setPhone(phone); + register.setUuid(RandomUtil.generateUUID()); + register.setPassword(PasswordUtil.hashPassword(password)); + + int insertResult = registerMapper.insertUser(register); + if (insertResult > 0) { + UserInformation newUser = userInformationMapper.getUserByUsername(username); + Integer newUserId = newUser != null ? newUser.getId() : null; + + // 为用户分配默认角色 + Integer defaultRoleId = siteConfigMapper.getRegisterUserPermission(); + if (defaultRoleId != null && newUserId != null) { + userRoleMapper.insertUserRole(newUserId, defaultRoleId); + } + if (siteConfigUtil.isNotificationEnabled()) { + Date date = new Date(); + SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + String formattedDate = formatter.format(date); + String siteName = siteConfigUtil.getSiteName(); + mailService.sendSimpleMail(email, siteName + " 注册成功", "用户:" + username + " 于 " + formattedDate + " 注册成功"); + notificationService.sendSystemNotification(username, "系统通知", "用户:" + username + " 于 " + formattedDate + " 注册成功"); + } + + RegisterResponseDTO dto = new RegisterResponseDTO(); + dto.setSuccess(true); + dto.setMessage("注册成功!"); + dto.setCode(200); + return dto; + } else { + RegisterResponseDTO dto = new RegisterResponseDTO(); + dto.setSuccess(false); + dto.setMessage("注册失败,请稍后再试"); + dto.setCode(500); + return dto; + } + + } catch (Exception e) { + log.error("注册过程中发生错误", e); + RegisterResponseDTO dto = new RegisterResponseDTO(); + dto.setSuccess(false); + dto.setMessage("注册失败,请检查输入或稍后再试"); + dto.setCode(500); + return dto; + } + } + + private boolean isBlank(String s) { + return s == null || s.trim().isEmpty(); + } + + private void clearCaptcha(String captchaKey) { + cacheService.delete("captcha_" + captchaKey); + log.info("已清理验证码: {}", captchaKey); + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/RoleServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/RoleServiceImpl.java new file mode 100644 index 0000000..11b1026 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/RoleServiceImpl.java @@ -0,0 +1,169 @@ +package xyz.lingview.dimstack.service.impl; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.domain.Permission; +import xyz.lingview.dimstack.domain.Role; +import xyz.lingview.dimstack.mapper.RoleMapper; +import xyz.lingview.dimstack.service.RoleService; + +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.List; + +@Service +public class RoleServiceImpl implements RoleService { + + @Autowired + private RoleMapper roleMapper; + + @Override + public List getAllRoles() { + return roleMapper.selectAllRoles(); + } + + @Override + public Role getRoleById(Integer id) { + return roleMapper.selectRoleById(id); + } + + @Override + public ApiResponse createRole(Role role) { + Role existingRole = roleMapper.selectRoleByCode(role.getCode()); + if (existingRole != null) { + return ApiResponse.error(400, "角色编码已存在"); + } + + if (role.getStatus() == null) { + role.setStatus((byte) 1); + } + + int result = roleMapper.insertRole(role); + if (result <= 0) { + return ApiResponse.error(500, "创建角色失败"); + } + + return ApiResponse.success(role); + } + + @Override + public ApiResponse updateRole(Role role) { + Role existingRole = roleMapper.selectRoleById(role.getId()); + if (existingRole == null) { + return ApiResponse.error(404, "角色不存在"); + } + + if (role.getCode() != null && !role.getCode().equals(existingRole.getCode())) { + Role duplicateRole = roleMapper.selectRoleByCode(role.getCode()); + if (duplicateRole != null) { + return ApiResponse.error(400, "角色编码已存在"); + } + } + + int result = roleMapper.updateRole(role); + if (result <= 0) { + return ApiResponse.error(500, "更新角色失败"); + } + + return ApiResponse.success("更新成功"); + } + + @Override + public ApiResponse deleteRole(Integer id) { + Role existingRole = roleMapper.selectRoleById(id); + if (existingRole == null) { + return ApiResponse.error(404, "角色不存在"); + } + + int result = roleMapper.deleteRole(id); + if (result <= 0) { + return ApiResponse.error(500, "删除角色失败"); + } + + return ApiResponse.success("删除成功"); + } + + @Override + public ApiResponse addPermissionToRole(Integer roleId, String permissionCode) { + Role role = roleMapper.selectRoleById(roleId); + if (role == null) { + return ApiResponse.error(404, "角色不存在"); + } + + Integer permissionId = roleMapper.selectPermissionIdByCode(permissionCode); + + if (permissionId == null) { + Permission permission = new Permission(); + permission.setCode(permissionCode); + permission.setName(permissionCode); + permission.setModule("custom"); + permission.setCreate_time(new Timestamp(System.currentTimeMillis())); + + int insertResult = roleMapper.insertPermission(permission); + if (insertResult <= 0) { + return ApiResponse.error(500, "创建权限失败"); + } + permissionId = permission.getId(); + } + + List existingPermissionIds = roleMapper.selectPermissionIdsByRoleId(roleId); + if (existingPermissionIds.contains(permissionId)) { + return ApiResponse.error(400, "权限已存在"); + } + + int result = roleMapper.insertRolePermission(roleId, permissionId); + if (result <= 0) { + return ApiResponse.error(500, "添加权限失败"); + } + + return ApiResponse.success("添加权限成功"); + } + + @Override + public ApiResponse removePermissionFromRole(Integer roleId, String permissionCode) { + Role role = roleMapper.selectRoleById(roleId); + if (role == null) { + return ApiResponse.error(404, "角色不存在"); + } + + Integer permissionId = roleMapper.selectPermissionIdByCode(permissionCode); + if (permissionId == null) { + return ApiResponse.error(404, "权限不存在"); + } + + int result = roleMapper.deleteRolePermission(roleId, permissionId); + if (result <= 0) { + return ApiResponse.error(500, "移除权限失败"); + } + + return ApiResponse.success("移除权限成功"); + } + + @Override + public List getPermissionCodesByRoleId(Integer roleId) { + return roleMapper.selectPermissionCodesByRoleId(roleId); + } + + @Override + public List selectAllPermissions() { + return roleMapper.selectAllPermissions(); + } + + @Override + public ApiResponse createPermission(Permission permission) { + Integer existingId = roleMapper.selectPermissionIdByCode(permission.getCode()); + if (existingId != null) { + return ApiResponse.error(400, "权限编码已存在"); + } + + permission.setCreate_time(new Timestamp(System.currentTimeMillis())); + + int result = roleMapper.insertPermission(permission); + if (result <= 0) { + return ApiResponse.error(500, "创建权限失败"); + } + + return ApiResponse.success(permission); + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/S3FileStorageImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/S3FileStorageImpl.java new file mode 100644 index 0000000..78f177b --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/S3FileStorageImpl.java @@ -0,0 +1,177 @@ +package xyz.lingview.dimstack.service.impl; + +import lombok.extern.slf4j.Slf4j; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.*; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; +import xyz.lingview.dimstack.service.FileStorage; + +import java.io.InputStream; +import java.net.URI; +import java.time.Duration; + +/** + * @Author: lingview + * @Date: 2026/07/17 20:25:47 + * @Description: S3协议存储实现(兼容 AWS S3、阿里云OSS、腾讯云COS、MinIO 等) + * @Version: 1.0 + */ +@Slf4j +public class S3FileStorageImpl implements FileStorage { + + private final S3Client s3Client; + private final S3Presigner s3Presigner; + private final String bucket; + + public S3FileStorageImpl(String endpoint, String region, String bucket, + String accessKey, String secretKey, boolean pathStyleAccess) { + this.bucket = bucket; + + URI endpointUri = URI.create(endpoint); + Region awsRegion = parseRegion(region); + AwsBasicCredentials credentials = AwsBasicCredentials.create(accessKey, secretKey); + + this.s3Client = S3Client.builder() + .endpointOverride(endpointUri) + .region(awsRegion) + .credentialsProvider(StaticCredentialsProvider.create(credentials)) + .forcePathStyle(pathStyleAccess) + .build(); + + this.s3Presigner = S3Presigner.builder() + .endpointOverride(endpointUri) + .region(awsRegion) + .credentialsProvider(StaticCredentialsProvider.create(credentials)) + .build(); + + ensureBucketExists(); + } + + + private static Region parseRegion(String region) { + return Region.US_EAST_1; + } + + private void ensureBucketExists() { + try { + s3Client.headBucket(HeadBucketRequest.builder().bucket(bucket).build()); + } catch (NoSuchBucketException e) { + s3Client.createBucket(CreateBucketRequest.builder().bucket(bucket).build()); + log.info("S3桶已创建: {}", bucket); + } catch (S3Exception e) { + if (e.statusCode() == 404) { + s3Client.createBucket(CreateBucketRequest.builder().bucket(bucket).build()); + log.info("S3桶已创建: {}", bucket); + } else { + log.warn("检查S3桶时发生异常(可能无权限,不影响后续操作): {}", e.getMessage()); + } + } + } + + @Override + public void store(String objectKey, InputStream data, long contentLength, String contentType) { + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucket) + .key(objectKey) + .contentType(contentType) + .contentLength(contentLength) + .build(); + + s3Client.putObject(request, RequestBody.fromInputStream(data, contentLength)); + log.debug("S3存储写入成功: {}/{}", bucket, objectKey); + } + + @Override + public InputStream retrieve(String objectKey) { + GetObjectRequest request = GetObjectRequest.builder() + .bucket(bucket) + .key(objectKey) + .build(); + + return s3Client.getObject(request); + } + + @Override + public void delete(String objectKey) { + DeleteObjectRequest request = DeleteObjectRequest.builder() + .bucket(bucket) + .key(objectKey) + .build(); + + s3Client.deleteObject(request); + log.debug("S3存储删除成功: {}/{}", bucket, objectKey); + } + + @Override + public boolean exists(String objectKey) { + try { + HeadObjectRequest request = HeadObjectRequest.builder() + .bucket(bucket) + .key(objectKey) + .build(); + s3Client.headObject(request); + return true; + } catch (NoSuchKeyException e) { + return false; + } + } + + @Override + public void copy(String sourceKey, String destKey) { + CopyObjectRequest request = CopyObjectRequest.builder() + .sourceBucket(bucket) + .sourceKey(sourceKey) + .destinationBucket(bucket) + .destinationKey(destKey) + .build(); + + s3Client.copyObject(request); + log.debug("S3存储拷贝成功: {}/{} -> {}/{}", bucket, sourceKey, bucket, destKey); + } + + @Override + public String getType() { + return "s3"; + } + + @Override + public boolean supportsPresignedUrl() { + return true; + } + + /** + * 生成预签名URL(用于文件访问) + */ + public String generatePresignedUrl(String objectKey, Duration expiration, String responseContentDisposition) { + GetObjectRequest.Builder requestBuilder = GetObjectRequest.builder() + .bucket(bucket) + .key(objectKey); + if (responseContentDisposition != null && !responseContentDisposition.isBlank()) { + requestBuilder.responseContentDisposition(responseContentDisposition); + } + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(expiration) + .getObjectRequest(requestBuilder.build()) + .build(); + + PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject(presignRequest); + return presignedRequest.url().toString(); + } + + public void shutdown() { + s3Client.close(); + s3Presigner.close(); + } + + @Override + public void close() { + shutdown(); + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/SiteConfigServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/SiteConfigServiceImpl.java new file mode 100644 index 0000000..0ac2ab7 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/SiteConfigServiceImpl.java @@ -0,0 +1,263 @@ +package xyz.lingview.dimstack.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import xyz.lingview.dimstack.config.ThemeProperties; +import xyz.lingview.dimstack.domain.SiteConfig; +import xyz.lingview.dimstack.dto.request.HeroDTO; +import xyz.lingview.dimstack.mapper.SiteConfigMapper; +import xyz.lingview.dimstack.service.CacheService; +import xyz.lingview.dimstack.service.SiteConfigService; + +@Service +@Slf4j +public class SiteConfigServiceImpl implements SiteConfigService { + + @Autowired + private SiteConfigMapper siteConfigMapper; + + @Autowired + private CacheService cacheService; + + /** @Lazy 避免与 ThemeProperties(依赖 SiteConfigService) 循环依赖 */ + @Autowired + @Lazy + private ThemeProperties themeProperties; + + @Override + public SiteConfig getSiteConfig() { + try { + SiteConfig siteConfig = cacheService.get("dimstack:site_config", SiteConfig.class); + if (siteConfig != null) { + return siteConfig; + } + } catch (Exception e) { + log.warn("从缓存读取站点配置失败,回退到数据库查询", e); + } + return siteConfigMapper.getSiteConfig(); + } + + @Override + public HeroDTO getHeroConfig() { + SiteConfig config = getSiteConfig(); + if (config != null) { + HeroDTO heroDTO = new HeroDTO(); + heroDTO.setTitle(config.getHero_title()); + heroDTO.setSubtitle(config.getHero_subtitle()); + heroDTO.setImage(config.getHero_image()); + return heroDTO; + } + return null; + } + + @Override + public String getCopyright() { + SiteConfig config = getSiteConfig(); + if (config != null) { + return config.getCopyright(); + } + return null; + } + + @Override + public String getSiteName() { + SiteConfig config = getSiteConfig(); + if (config != null) { + return config.getSite_name(); + } + return null; + } + + @Override + public String getSiteIcon() { + SiteConfig config = getSiteConfig(); + if (config != null) { + return config.getSite_icon(); + } + return null; + } + + @Override + public String getSiteTheme() { + SiteConfig config = getSiteConfig(); + if (config != null) { + return config.getSite_theme(); + } + return null; + } + + @Override + public String getExpansionServer() { + SiteConfig config = getSiteConfig(); + if (config != null) { + return config.getExpansion_server(); + } + return null; + } + + @Override + public String getIcpRecordNumber() { + SiteConfig config = getSiteConfig(); + if (config != null) { + return config.getIcp_record_number(); + } + return null; + } + + @Override + public String getMpsRecordNumber() { + SiteConfig config = getSiteConfig(); + if (config != null) { + return config.getMps_record_number(); + } + return null; + } + + @Override + public boolean updateSiteTheme(String themeName) { + try { + SiteConfig config = getSiteConfig(); + if (config != null) { + config.setSite_theme(themeName); + int result = siteConfigMapper.updateSiteConfig(config); + if (result > 0) { + cacheService.set("dimstack:site_config", config); + // 同步刷新运行时主题, 无需重启即生效 + if (themeProperties != null) { + themeProperties.setActiveTheme(themeName); + } + return true; + } + } + return false; + } catch (Exception e) { + log.error("更新站点主题失败", e); + return false; + } + } + + @Override + public boolean updateSiteConfig(SiteConfig siteConfig) { + try { + int result = siteConfigMapper.updateSiteConfig(siteConfig); + if (result > 0) { + cacheService.set("dimstack:site_config", siteConfig); + return true; + } + return false; + } catch (Exception e) { + log.error("更新站点配置失败", e); + return false; + } + } + + @Override + public Integer getEnableRegister() { + SiteConfig config = getSiteConfig(); + if (config != null) { + return config.getEnable_register(); + } + return null; + } + + @Override + public Integer getAdminPostNoReview() { + SiteConfig config = getSiteConfig(); + if (config != null) { + return config.getAdmin_post_no_review(); + } + return null; + } + + @Override + public Integer getEnableLlm() { + SiteConfig config = getSiteConfig(); + if (config != null) { + return config.getEnable_llm(); + } + return null; + } + + @Override + public Integer getEnableLlmArticleReview() { + SiteConfig config = getSiteConfig(); + if (config != null) { + return config.getEnable_llm_article_review(); + } + return null; + } + + @Override + public Integer getEnableLlmCreateArticle() { + SiteConfig config = getSiteConfig(); + if (config != null) { + return config.getEnable_llm_create_article(); + } + return null; + } + + @Override + public String getGlobalHeadCode() { + SiteConfig config = getSiteConfig(); + if (config != null) { + return config.getGlobal_head_code(); + } + return null; + } + + @Override + public String getContentHeadCode() { + SiteConfig config = getSiteConfig(); + if (config != null) { + return config.getContent_head_code(); + } + return null; + } + + @Override + public String getFooterCode() { + SiteConfig config = getSiteConfig(); + if (config != null) { + return config.getFooter_code(); + } + return null; + } + + @Override + public Integer getEnableImageCompression() { + SiteConfig config = getSiteConfig(); + if (config != null) { + return config.getEnable_image_compression(); + } + return null; + } + + @Override + public Integer getImageCompressionThreads() { + SiteConfig config = getSiteConfig(); + if (config != null) { + return config.getImage_compression_threads(); + } + return 5; + } + + @Override + public Integer getProxyResourceDownload() { + SiteConfig config = getSiteConfig(); + if (config != null) { + return config.getProxy_resource_download(); + } + return 0; + } + + @Override + public Integer getEnableComment() { + SiteConfig config = getSiteConfig(); + if (config != null) { + return config.getEnable_comment(); + } + return 1; + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/StatisticsServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/StatisticsServiceImpl.java new file mode 100644 index 0000000..8a16e57 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/StatisticsServiceImpl.java @@ -0,0 +1,37 @@ +package xyz.lingview.dimstack.service.impl; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import xyz.lingview.dimstack.mapper.StatisticsMapper; +import xyz.lingview.dimstack.service.StatisticsService; + +@Service +public class StatisticsServiceImpl implements StatisticsService { + + @Autowired + private StatisticsMapper statisticsMapper; + + @Override + public long getTotalArticles() { + Long count = statisticsMapper.countArticles(); + return count != null ? count : 0L; + } + + @Override + public long getTotalComments() { + Long count = statisticsMapper.countComments(); + return count != null ? count : 0L; + } + + @Override + public long getTotalPageViews() { + Long sum = statisticsMapper.sumPageViews(); + return sum != null ? sum : 0L; + } + + @Override + public long getTotalUsers() { + Long count = statisticsMapper.countUsers(); + return count != null ? count : 0L; + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/StorageFacadeServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/StorageFacadeServiceImpl.java new file mode 100644 index 0000000..e5a4826 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/StorageFacadeServiceImpl.java @@ -0,0 +1,160 @@ +package xyz.lingview.dimstack.service.impl; + +import tools.jackson.databind.ObjectMapper; +import jakarta.annotation.PreDestroy; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; +import xyz.lingview.dimstack.domain.StorageMethod; +import xyz.lingview.dimstack.mapper.StorageMethodMapper; +import xyz.lingview.dimstack.service.FileStorage; +import xyz.lingview.dimstack.service.SiteConfigService; +import xyz.lingview.dimstack.service.StorageFacadeService; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * @Author: lingview + * @Date: 2026/07/17 20:34:12 + * @Description: 存储选择服务实现 + * @Version: 1.0 + */ +@Slf4j +@Service +public class StorageFacadeServiceImpl implements StorageFacadeService { + + @Autowired + private LocalFileStorageImpl localFileStorage; + + @Autowired + private StorageMethodMapper storageMethodMapper; + + @Autowired + private SiteConfigService siteConfigService; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private final Map storageCache = new ConcurrentHashMap<>(); + + @EventListener(ApplicationReadyEvent.class) + public void onApplicationReady() { + try { + String defaultStorageUuid = siteConfigService.getSiteConfig().getDefault_storage(); + if (defaultStorageUuid != null) { + StorageMethod method = storageMethodMapper.selectByUuid(defaultStorageUuid); + if (method != null && method.getStatus() == 1 && !"local".equals(method.getType())) { + getDefaultStorage(defaultStorageUuid); + log.info("默认存储已预热: {}", method.getName()); + } + } + } catch (Exception e) { + log.info("默认存储预热跳过(数据库可能尚未就绪或无外部存储配置): {}", e.getMessage()); + } + } + + public FileStorage getDefaultStorage(String defaultStorageUuid) { + if (defaultStorageUuid == null) { + return localFileStorage; + } + return getStorage(defaultStorageUuid); + } + + public FileStorage getStorage(String storageId) { + if (storageId == null) { + return localFileStorage; + } + + FileStorage cached = storageCache.get(storageId); + if (cached != null) { + return cached; + } + + // 缓存未命中,同步初始化(防止并发重复创建) + synchronized (this) { + cached = storageCache.get(storageId); + if (cached != null) { + return cached; + } + + StorageMethod method = storageMethodMapper.selectByUuid(storageId); + if (method == null) { + throw new RuntimeException("存储方式不存在: " + storageId); + } + if (method.getStatus() == 0) { + throw new RuntimeException("存储方式已禁用: " + method.getName()); + } + + String type = method.getType(); + if ("local".equals(type)) { + return localFileStorage; + } + + FileStorage newStorage = "webdav".equals(type) + ? initWebDavStorage(method) + : initS3Storage(method); + storageCache.put(storageId, newStorage); + return newStorage; + } + } + + private FileStorage initS3Storage(StorageMethod method) { + String uuid = method.getUuid(); + try { + @SuppressWarnings("unchecked") + Map config = objectMapper.readValue(method.getConfig(), Map.class); + + String endpoint = (String) config.get("endpoint"); + String region = (String) config.get("region"); + String bucket = (String) config.get("bucket"); + String accessKey = (String) config.get("accessKey"); + String secretKey = (String) config.get("secretKey"); + boolean pathStyleAccess = config.get("pathStyleAccess") != null && (boolean) config.get("pathStyleAccess"); + + S3FileStorageImpl newStorage = new S3FileStorageImpl(endpoint, region, bucket, accessKey, secretKey, pathStyleAccess); + log.info("S3存储初始化完成: {} ({})", method.getName(), uuid); + + return newStorage; + } catch (Exception e) { + log.error("初始化S3存储失败: {}", uuid, e); + throw new RuntimeException("S3存储初始化失败: " + method.getName(), e); + } + } + + private FileStorage initWebDavStorage(StorageMethod method) { + String uuid = method.getUuid(); + try { + @SuppressWarnings("unchecked") + Map config = objectMapper.readValue(method.getConfig(), Map.class); + + String url = (String) config.get("url"); + String username = (String) config.get("username"); + String password = (String) config.get("password"); + + WebDavFileStorageImpl newStorage = new WebDavFileStorageImpl(url, username, password); + log.info("WebDAV存储初始化完成: {} ({})", method.getName(), uuid); + + return newStorage; + } catch (Exception e) { + log.error("初始化WebDAV存储失败: {}", uuid, e); + throw new RuntimeException("WebDAV存储初始化失败: " + method.getName(), e); + } + } + + public void invalidateCache(String uuid) { + FileStorage removed = storageCache.remove(uuid); + if (removed != null) { + removed.close(); + log.debug("存储缓存已清除: {}", uuid); + } + } + + @PreDestroy + public void shutdown() { + storageCache.values().forEach(FileStorage::close); + storageCache.clear(); + log.info("所有外置存储连接已关闭"); + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/StorageMethodServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/StorageMethodServiceImpl.java new file mode 100644 index 0000000..a149ee0 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/StorageMethodServiceImpl.java @@ -0,0 +1,216 @@ +package xyz.lingview.dimstack.service.impl; + +import tools.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import xyz.lingview.dimstack.domain.SiteConfig; +import xyz.lingview.dimstack.domain.StorageMethod; +import xyz.lingview.dimstack.mapper.StorageMethodMapper; +import xyz.lingview.dimstack.service.SiteConfigService; +import xyz.lingview.dimstack.service.StorageFacadeService; +import xyz.lingview.dimstack.service.StorageMethodService; +import xyz.lingview.dimstack.util.RandomUtil; + +import java.util.List; +import java.util.Map; + +/** + * @Author: lingview + * @Date: 2026/07/17 20:41:55 + * @Description: 存储方式管理服务实现 + * @Version: 1.0 + */ +@Slf4j +@Service +public class StorageMethodServiceImpl implements StorageMethodService { + + @Autowired + private StorageMethodMapper storageMethodMapper; + + @Autowired + private SiteConfigService siteConfigService; + + @Autowired + private StorageFacadeService storageFacadeService; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static final List SENSITIVE_KEYS = List.of("accessKey", "secretKey", "password"); + + @Override + public List list() { + List list = storageMethodMapper.selectAll(); + // 密钥脱敏 + for (StorageMethod method : list) { + if (method.getConfig() != null) { + try { + @SuppressWarnings("unchecked") + Map config = objectMapper.readValue(method.getConfig(), Map.class); + for (String key : SENSITIVE_KEYS) { + if (config.containsKey(key)) { + config.put(key, ""); + } + } + method.setConfig(objectMapper.writeValueAsString(config)); + } catch (Exception e) { + method.setConfig(null); + } + } + } + return list; + } + + @Override + public StorageMethod getByUuid(String uuid) { + return storageMethodMapper.selectByUuid(uuid); + } + + @Override + public Map add(StorageMethod storageMethod, String userUuid) { + String uuid = RandomUtil.generateUUID(); + storageMethod.setUuid(uuid); + storageMethod.setUser_uuid(userUuid); + storageMethod.setStatus(1); + + int result = storageMethodMapper.insert(storageMethod); + if (result != 1) { + log.error("新增存储方式失败: {}", storageMethod.getName()); + return Map.of("error", "新增存储方式失败"); + } + + log.info("新增存储方式成功: {} ({})", storageMethod.getName(), uuid); + return Map.of("message", "新增成功", "uuid", uuid); + } + + @Override + public Map edit(StorageMethod storageMethod) { + StorageMethod existing = storageMethodMapper.selectByUuid(storageMethod.getUuid()); + if (existing == null) { + return Map.of("error", "存储方式不存在"); + } + + // 保留原有密钥 + if (storageMethod.getConfig() != null) { + try { + @SuppressWarnings("unchecked") + Map newConfig = objectMapper.readValue(storageMethod.getConfig(), Map.class); + @SuppressWarnings("unchecked") + Map oldConfig = objectMapper.readValue(existing.getConfig(), Map.class); + + for (String key : SENSITIVE_KEYS) { + Object newValue = newConfig.get(key); + boolean isEmpty = newValue == null || (newValue instanceof String s && s.isEmpty()); + if (isEmpty && oldConfig.containsKey(key)) { + newConfig.put(key, oldConfig.get(key)); + } + } + + storageMethod.setConfig(objectMapper.writeValueAsString(newConfig)); + } catch (Exception e) { + log.warn("解析存储配置JSON失败,使用原配置", e); + storageMethod.setConfig(existing.getConfig()); + } + } + + storageMethod.setStatus(existing.getStatus()); + + int result = storageMethodMapper.update(storageMethod); + if (result != 1) { + log.error("编辑存储方式失败: {}", storageMethod.getUuid()); + return Map.of("error", "编辑存储方式失败"); + } + + storageFacadeService.invalidateCache(storageMethod.getUuid()); + + log.info("编辑存储方式成功: {}", storageMethod.getUuid()); + return Map.of("message", "编辑成功"); + } + + @Override + public Map disable(String uuid) { + StorageMethod method = storageMethodMapper.selectByUuid(uuid); + if (method == null) { + return Map.of("error", "存储方式不存在"); + } + if ("local".equals(method.getType())) { + return Map.of("error", "不允许禁用本地存储"); + } + if (method.getStatus() == 0) { + return Map.of("error", "存储方式已禁用"); + } + + String currentDefault = siteConfigService.getSiteConfig().getDefault_storage(); + if (uuid.equals(currentDefault)) { + return Map.of("error", "默认存储方式不可禁用,请先切换默认存储"); + } + + int result = storageMethodMapper.disable(uuid); + if (result != 1) { + return Map.of("error", "禁用存储方式失败"); + } + + storageFacadeService.invalidateCache(uuid); + log.info("存储方式已禁用: {}", uuid); + return Map.of("message", "已禁用"); + } + + @Override + public Map enable(String uuid) { + StorageMethod method = storageMethodMapper.selectByUuid(uuid); + if (method == null) { + return Map.of("error", "存储方式不存在"); + } + + int result = storageMethodMapper.enable(uuid); + if (result != 1) { + return Map.of("error", "启用存储方式失败"); + } + + log.info("存储方式已启用: {}", uuid); + return Map.of("message", "已启用"); + } + + @Override + public Map deletePhysical(String uuid) { + StorageMethod method = storageMethodMapper.selectByUuid(uuid); + if (method == null) { + return Map.of("error", "存储方式不存在"); + } + if ("local".equals(method.getType())) { + return Map.of("error", "不允许删除本地存储"); + } + + String currentDefault = siteConfigService.getSiteConfig().getDefault_storage(); + if (uuid.equals(currentDefault)) { + return Map.of("error", "默认存储方式不可删除,请先切换默认存储"); + } + + int result = storageMethodMapper.deletePhysical(uuid); + if (result != 1) { + return Map.of("error", "删除存储方式失败"); + } + + storageFacadeService.invalidateCache(uuid); + log.info("存储方式已物理删除: {}", uuid); + return Map.of("message", "已删除"); + } + + @Override + public Map setDefault(String uuid) { + StorageMethod method = storageMethodMapper.selectByUuid(uuid); + if (method == null) { + return Map.of("error", "存储方式不存在"); + } + if (method.getStatus() == 0) { + return Map.of("error", "存储方式已禁用,无法设为默认"); + } + + SiteConfig siteConfig = siteConfigService.getSiteConfig(); + siteConfig.setDefault_storage(uuid); + siteConfigService.updateSiteConfig(siteConfig); + + log.info("默认存储方式已更新为: {} ({})", method.getName(), uuid); + return Map.of("message", "默认存储方式已更新"); + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/UpdateServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/UpdateServiceImpl.java new file mode 100644 index 0000000..95af180 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/UpdateServiceImpl.java @@ -0,0 +1,433 @@ +package xyz.lingview.dimstack.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import xyz.lingview.dimstack.service.UpdateService; + +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.Optional; + +@Service +@Slf4j +public class UpdateServiceImpl implements UpdateService { + private static final String VERSION_FILE_PATH = "/system_version.txt"; + + @Override + public String getCurrentVersion() { + try (InputStream inputStream = getClass().getResourceAsStream(VERSION_FILE_PATH)) { + if (inputStream != null) { + // 资源文件与代码均为 UTF-8,显式指定避免 Windows 默认字符集(GBK)误读 + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8)); + return reader.readLine(); + } else { + log.error("无法找到版本文件: {}", VERSION_FILE_PATH); + return "unknown"; + } + } catch (IOException e) { + log.error("读取版本文件失败", e); + return "unknown"; + } + } + + @Override + public String fetchUpdateInfo(String updateUrl) { + try { + URL url = new URL(updateUrl); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(10000); + connection.setReadTimeout(30000); + + int responseCode = connection.getResponseCode(); + if (responseCode == HttpURLConnection.HTTP_OK) { + // 更新信息响应为 UTF-8,显式指定避免 Windows 默认字符集(GBK)误读 + BufferedReader in = new BufferedReader(new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8)); + StringBuilder response = new StringBuilder(); + String inputLine; + + while ((inputLine = in.readLine()) != null) { + response.append(inputLine).append("\n"); + } + in.close(); + + return response.toString().trim(); + } else { + log.error("获取更新信息失败,响应码: {}", responseCode); + return null; + } + } catch (Exception e) { + log.error("获取更新信息时发生错误", e); + return null; + } + } + + @Override + public boolean downloadNewJar(String downloadUrl, String targetPath) { + try { + URL url = new URL(downloadUrl); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(10000); + connection.setReadTimeout(120000); + + int responseCode = connection.getResponseCode(); + if (responseCode == HttpURLConnection.HTTP_OK) { + Path targetFilePath = Path.of(targetPath); + Files.createDirectories(targetFilePath.getParent()); + + try (InputStream inputStream = connection.getInputStream(); + FileOutputStream outputStream = new FileOutputStream(targetPath)) { + + byte[] buffer = new byte[4096]; + int bytesRead; + long totalBytesRead = 0; + + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + totalBytesRead += bytesRead; + log.debug("已下载字节: {}", totalBytesRead); + } + + log.info("成功下载新版本JAR到: {}", targetPath); + return true; + } + } else { + log.error("下载JAR失败,响应码: {}", responseCode); + return false; + } + } catch (Exception e) { + log.error("下载JAR文件时发生错误", e); + return false; + } + } + + @Override + public boolean verifyChecksum(String filePath, String expectedChecksum) { + try (InputStream in = Files.newInputStream(Path.of(filePath))) { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] buffer = new byte[8192]; + int len; + while ((len = in.read(buffer)) != -1) { + digest.update(buffer, 0, len); + } + + byte[] hash = digest.digest(); + StringBuilder hex = new StringBuilder(); + for (byte b : hash) { + hex.append("%02x".formatted(b)); + } + return hex.toString().equalsIgnoreCase(expectedChecksum); + } catch (Exception e) { + log.error("验证校验和失败", e); + return false; + } + } + + @Override + public String getExpectedChecksum() throws IOException { + Path checksumPath = Path.of( + System.getProperty("user.dir"), + ".update", + "dimstack_update.sha256" + ); + if (!Files.exists(checksumPath)) { + return null; + } + return Files.readString(checksumPath).trim(); + } + + @Override + public boolean performUpdate(String newJarPath) { + try { + String currentJarPath = getCurrentJarPath(); + if (currentJarPath == null) { + log.error("无法获取当前JAR路径"); + return false; + } + + File newFile = new File(newJarPath); + if (!newFile.exists()) { + log.error("新JAR文件不存在: {}", newJarPath); + return false; + } + + Path targetPath = Path.of(currentJarPath); + Files.createDirectories(targetPath.getParent()); + + if (Files.exists(targetPath)) { + String backupPath = currentJarPath + ".update_backup"; + Path backupPathObj = Path.of(backupPath); + Files.copy(targetPath, backupPathObj, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + log.info("当前JAR已备份到: {}", backupPath); + } + + Files.copy(newFile.toPath(), targetPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + log.info("更新完成,新JAR已替换到: {}", currentJarPath); + + return true; + } catch (Exception e) { + log.error("执行更新时发生错误", e); + return false; + } + } + + /** + * 仅执行更新,不重启 + */ + @Override + public boolean performUpdateOnly(String newJarPath) { + log.info("执行仅更新操作,不重启应用"); + return performUpdate(newJarPath); + } + + /** + * 执行更新并重启 + */ + @Override + public boolean performUpdateAndRestart(String newJarPath) { + try { + boolean updateSuccess = performUpdate(newJarPath); + if (!updateSuccess) { + log.error("更新失败,无法重启"); + return false; + } + + + + long currentPid = ProcessHandle.current().pid(); + String jarPath = getCurrentJarPath(); + + if (jarPath == null) { + log.error("无法获取当前JAR路径"); + return false; + } + + log.info("当前进程PID: {}", currentPid); + log.info("JAR路径: {}", jarPath); + + String scriptPath = createRestartScript(jarPath, currentPid); + if (scriptPath == null) { + log.error("创建重启脚本失败"); + return false; + } + + log.info("重启脚本已创建: {}", scriptPath); + + new Thread(() -> { + try { + log.info("等待1秒让HTTP响应返回..."); + Thread.sleep(1000); + + log.info("启动重启脚本..."); + + Runtime.getRuntime().exec(new String[]{ + "bash", "-c", + "nohup bash " + scriptPath + " > /tmp/restart_script.log 2>&1 &" + }); + + log.info("重启脚本已启动"); + + Thread.sleep(2000); + + log.info("当前进程即将退出"); + System.exit(0); + + } catch (Exception e) { + log.error("启动重启脚本失败", e); + } + }, "restart-script-launcher").start(); + + return true; + + } catch (Exception e) { + log.error("执行更新并重启时发生错误", e); + return false; + } + } + + /** + * 创建重启脚本 + */ + private String createRestartScript(String jarPath, long currentPid) { + try { + String javaExe = getJavaExecutable(); + + String scriptContent = """ + #!/bin/bash + echo '[Restart Script] Waiting for application to exit...' + sleep 1 + echo '[Restart Script] Ensuring old process is terminated...' + kill -9 %d 2>/dev/null || true + sleep 1 + echo '[Restart Script] Starting new application...' + cd '%s' + nohup '%s' -jar '%s' > app.log 2>&1 & + echo '[Restart Script] Application restarted, PID: '$! + exit 0 + """.formatted( + currentPid, + System.getProperty("user.dir"), + javaExe, + jarPath + ); + + String tempDir = System.getProperty("user.dir") + "/.update"; + Path tempDirPath = Path.of(tempDir); + Files.createDirectories(tempDirPath); + + String scriptFileName = "dimstack_restart_" + System.currentTimeMillis() + ".sh"; + String scriptPath = tempDir + "/" + scriptFileName; + + Path scriptFile = Path.of(scriptPath); + Files.write(scriptFile, scriptContent.getBytes()); + scriptFile.toFile().setExecutable(true); + + log.info("重启脚本已创建:"); + log.info("路径: {}", scriptPath); + log.info("内容:\n{}", scriptContent); + + return scriptPath; + + } catch (Exception e) { + log.error("创建重启脚本失败", e); + return null; + } + } + + /** + * 获取Java可执行文件路径 + */ + private String getJavaExecutable() { + String javaHome = System.getProperty("java.home"); + String javaExe = javaHome + "/bin/java"; + + File javaFile = new File(javaExe); + if (javaFile.exists()) { + log.info("使用Java路径: {}", javaExe); + return javaExe; + } + + log.warn("未找到Java可执行文件,使用PATH中的java"); + return "java"; + } + + /** + * 获取当前运行的JAR文件路径 + */ + private String getCurrentJarPath() { + try { + Optional jarFromCmd = ProcessHandle.current() + .info() + .commandLine() + .flatMap(cmd -> { + String[] parts = cmd.split("\\s+"); + for (int i = 0; i < parts.length; i++) { + if ("-jar".equals(parts[i])) { + for (int j = i + 1; j < parts.length; j++) { + if (parts[j].endsWith(".jar")) { + return Optional.of(parts[j]); + } + } + } + } + return Optional.empty(); + }); + + if (jarFromCmd.isPresent()) { + File jar = new File(jarFromCmd.get()).getAbsoluteFile(); + if (jar.exists() && jar.getName().endsWith(".jar")) { + log.info("从 JVM 启动参数解析到主 JAR: {}", jar); + return jar.getAbsolutePath(); + } + } + + URL location = getClass() + .getProtectionDomain() + .getCodeSource() + .getLocation(); + + if (location != null) { + Path path = Path.of(location.toURI()).toAbsolutePath(); + + if (Files.isRegularFile(path) && path.toString().endsWith(".jar")) { + log.info("从保护域获取 JAR: {}", path); + return path.toString(); + } + } + + log.error("当前不是以 jar 方式运行,禁止执行自更新"); + return null; + + } catch (Exception e) { + log.error("获取当前 JAR 路径失败", e); + return null; + } + } + + @Override + public boolean isNewerVersion(String currentVersion, String newVersion) { + try { + return compareVersions(newVersion, currentVersion) > 0; + } catch (Exception e) { + log.error("版本比较失败", e); + return true; + } + } + + @Override + public int compareVersions(String v1, String v2) { + if (v1 == null && v2 == null) return 0; + if (v1 == null) return -1; + if (v2 == null) return 1; + + try { + java.math.BigDecimal a = new java.math.BigDecimal(v1.trim().replaceFirst("^[vV]", "")); + java.math.BigDecimal b = new java.math.BigDecimal(v2.trim().replaceFirst("^[vV]", "")); + return a.compareTo(b); + } catch (NumberFormatException e) { + log.warn("版本号不是合法的十进制数值,无法比较: {} / {}", v1, v2); + return 0; + } + } + + @Override + public boolean isCoreVersionInRange(String currentVersion, String minVersion, String maxVersion) { + if (currentVersion == null || currentVersion.isBlank() || "unknown".equals(currentVersion)) { + return true; + } + try { + if (minVersion != null && !minVersion.isBlank() + && compareVersions(currentVersion, minVersion) < 0) { + return false; + } + if (maxVersion != null && !maxVersion.isBlank() + && compareVersions(currentVersion, maxVersion) > 0) { + return false; + } + return true; + } catch (Exception e) { + log.error("核心版本范围检查失败", e); + return true; + } + } + + @Override + public boolean isVersionCompatible(String currentVersion, String minCompatibleVersion) { + if (minCompatibleVersion == null || minCompatibleVersion.isEmpty()) { + return true; + } + + try { + return compareVersions(currentVersion, minCompatibleVersion) >= 0; + } catch (Exception e) { + log.error("版本兼容性检查失败", e); + return false; + } + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/UploadServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/UploadServiceImpl.java new file mode 100644 index 0000000..c8fad85 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/UploadServiceImpl.java @@ -0,0 +1,1257 @@ +package xyz.lingview.dimstack.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.mindrot.jbcrypt.BCrypt; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import xyz.lingview.dimstack.domain.UploadArticle; +import xyz.lingview.dimstack.domain.UploadAttachment; +import xyz.lingview.dimstack.domain.UserInformation; +import xyz.lingview.dimstack.mapper.ArticleCategoryMapper; +import xyz.lingview.dimstack.mapper.UploadMapper; +import xyz.lingview.dimstack.mapper.UserInformationMapper; +import xyz.lingview.dimstack.service.CacheService; +import xyz.lingview.dimstack.service.CurrentUserService; +import xyz.lingview.dimstack.service.FileStorage; +import xyz.lingview.dimstack.service.SiteConfigService; +import xyz.lingview.dimstack.service.StorageFacadeService; +import xyz.lingview.dimstack.service.UploadService; +import xyz.lingview.dimstack.mapper.StorageMethodMapper; +import xyz.lingview.dimstack.util.CategoryPathUtil; +import xyz.lingview.dimstack.util.RandomUtil; + +import jakarta.servlet.http.HttpServletRequest; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.InetAddress; +import java.net.URI; +import java.net.URL; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.Instant; +import java.util.*; +import java.util.stream.Collectors; + +import static java.net.InetAddress.getByName; + +/** + * @Author: lingview + * @Date: 2025/11/12 16:09:36 + * @Description: 文件上传服务 + * @Version: 1.0 + */ +@Slf4j +@Service +public class UploadServiceImpl implements UploadService { + + private static final int MAX_EXTERNAL_SIZE = 100 * 1024 * 1024; + @Autowired + private UploadMapper uploadMapper; + + @Autowired + private UserInformationMapper userInformationMapper; + + @Autowired + private CurrentUserService currentUserService; + + @Autowired + private ArticleCategoryMapper articleCategoryMapper; + + @Autowired + private CategoryPathUtil categoryPathUtil; + + @Autowired + private SiteConfigService siteConfigService; + + @Autowired + private CacheService cacheService; + + @Autowired + private StorageFacadeService storageFacadeService; + + @Autowired + private StorageMethodMapper storageMethodMapper; + + // 注入配置属性 + @Value("${file.data-root:.}") + private String dataRoot; + + @Value("${file.upload-dir:upload}") + private String uploadDir; + + private static final long MAX_FILE_SIZE = 100 * 1024 * 1024; + + private static final Map> SUPPORTED_FILE_TYPES = Map.of( + "image", List.of( + "image/jpeg", "image/jpg", "image/png", "image/gif", + "image/webp", "image/bmp" + ), + "video", List.of( + "video/mp4", "video/webm", "video/ogg", + "video/avi", "video/mov", "video/mkv" + ), + "audio", List.of( + "audio/mp3", "audio/mpeg", "audio/wav", + "audio/ogg", "audio/m4a", "audio/aac", + "audio/flac", "audio/x-flac" + ), + "archive", List.of( + "application/zip", "application/x-rar-compressed", + "application/x-tar", "application/gzip", + "application/x-xz", "application/x-7z-compressed", + "application/x-zip-compressed","application/x-compressed", + "application/x-gzip" + ), + "document", List.of( + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ) + ); + + + private static final Set SUPPORTED_EXTENSIONS = Set.of( + ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", + ".mp4", ".webm", ".ogg", ".avi", ".mov", ".mkv", + ".mp3", ".wav", ".flac", ".aac", ".m4a", + ".zip", ".rar", ".tar", ".gz", ".tgz", ".xz", ".7z", + ".pdf", ".doc", ".docx" + ); + + private String getUsername(HttpServletRequest request) { + String username = currentUserService.getCurrentUsername(); + if (username == null) { + log.debug("未找到用户会话或用户未登录"); + return null; + } + if (!username.matches("^[\\p{L}\\p{N}_]+$")) { + log.debug("当前用户名无效: {}", username); + return null; + } + return username; + } + + private String getUserUUID(String username) { + String uuid = userInformationMapper.selectUserUUID(username); + if (uuid == null) { + log.warn("未找到用户名 {} 的UUID", username); + } + return uuid; + } + + private boolean isMimeAllowed(String mimeType) { + boolean allowed = SUPPORTED_FILE_TYPES.values().stream() + .flatMap(List::stream) + .anyMatch(type -> type.equalsIgnoreCase(mimeType)); + if (!allowed) { + log.debug("MIME类型不被允许: {}", mimeType); + } + return allowed; + } + + private boolean isExtensionAllowed(String extension) { + boolean allowed = SUPPORTED_EXTENSIONS.contains(extension.toLowerCase()); + if (!allowed) { + log.debug("文件扩展名不被允许: {}", extension); + } + return allowed; + } + + private String getFolderByMime(String mimeType) { + for (Map.Entry> entry : SUPPORTED_FILE_TYPES.entrySet()) { + if (entry.getValue().contains(mimeType)) { + return entry.getKey(); + } + } + return "attachment"; + } + + private String getMimeTypeByExtension(String extension) { + Map extensionToMime = new HashMap<>(); + extensionToMime.put(".jpg", "image/jpeg"); + extensionToMime.put(".jpeg", "image/jpeg"); + extensionToMime.put(".png", "image/png"); + extensionToMime.put(".gif", "image/gif"); + extensionToMime.put(".webp", "image/webp"); + extensionToMime.put(".svg", "image/svg+xml"); + extensionToMime.put(".bmp", "image/bmp"); + extensionToMime.put(".mp4", "video/mp4"); + extensionToMime.put(".webm", "video/webm"); + extensionToMime.put(".ogg", "video/ogg"); + extensionToMime.put(".avi", "video/avi"); + extensionToMime.put(".mov", "video/mov"); + extensionToMime.put(".mkv", "video/mkv"); + extensionToMime.put(".mp3", "audio/mp3"); + extensionToMime.put(".wav", "audio/wav"); + extensionToMime.put(".flac", "audio/flac"); + extensionToMime.put(".aac", "audio/aac"); + extensionToMime.put(".m4a", "audio/m4a"); + extensionToMime.put(".zip", "application/zip"); + extensionToMime.put(".rar", "application/x-rar-compressed"); + extensionToMime.put(".tar", "application/x-tar"); + extensionToMime.put(".gz", "application/gzip"); + extensionToMime.put(".tgz", "application/gzip"); + extensionToMime.put(".xz", "application/x-xz"); + extensionToMime.put(".7z", "application/x-7z-compressed"); + extensionToMime.put(".pdf", "application/pdf"); + extensionToMime.put(".doc", "application/msword"); + extensionToMime.put(".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"); + return extensionToMime.getOrDefault(extension.toLowerCase(), "application/octet-stream"); + } + + private String detectMimeTypeFromFile(Path filePath) { + try { + String mimeType = Files.probeContentType(filePath); + if (mimeType == null || mimeType.equals("application/octet-stream")) { + String fileName = filePath.getFileName().toString(); + if (fileName.contains(".")) { + String extension = fileName.substring(fileName.lastIndexOf(".")); + mimeType = getMimeTypeByExtension(extension); + } + } + log.debug("检测到文件 {} 的MIME类型: {}", filePath.getFileName(), mimeType); + return mimeType != null ? mimeType : "application/octet-stream"; + } catch (IOException e) { + log.warn("检测文件 {} 的MIME类型时出错", filePath.getFileName(), e); + String fileName = filePath.getFileName().toString(); + if (fileName.contains(".")) { + String extension = fileName.substring(fileName.lastIndexOf(".")); + return getMimeTypeByExtension(extension); + } + return "application/octet-stream"; + } + } + + private Path getBasePath() { + return Path.of(dataRoot).toAbsolutePath().normalize().resolve(uploadDir); + } + + private Path buildFileSystemPath(String... paths) { + Path basePath = getBasePath(); + Path relativePath = Path.of(paths[0], Arrays.copyOfRange(paths, 1, paths.length)); + return basePath.resolve(relativePath).normalize(); + } + + private String buildDatabasePath(String... paths) { + return Path.of(uploadDir, paths).toString().replace('\\', '/'); + } + + @Override + public UploadAttachment selectByAccessKey(String accessKey) { + return uploadMapper.selectByAccessKey(accessKey); + } + + @Override + public Map uploadAttachment(HttpServletRequest request, MultipartFile file) { + log.info("开始附件上传流程"); + + if (file.isEmpty()) { + log.warn("尝试上传空文件"); + return Map.of("error", "文件为空"); + } + + if (file.getSize() > MAX_FILE_SIZE) { + log.warn("尝试上传过大文件: {} 字节", file.getSize()); + return Map.of("error", "文件过大"); + } + + String username = getUsername(request); + if (username == null) { + log.warn("未授权的上传尝试"); + return Map.of("error", "用户未登录或用户名无效"); + } + + String userUUID = getUserUUID(username); + if (userUUID == null) { + log.error("未找到用户名 {} 的UUID", username); + return Map.of("error", "未找到用户"); + } + + String contentType = file.getContentType(); + String originalFilename = file.getOriginalFilename(); + String extension = ""; + + if (originalFilename != null && originalFilename.contains(".")) { + extension = originalFilename.substring(originalFilename.lastIndexOf(".")).toLowerCase(); + } + + if (!isMimeAllowed(contentType) || !isExtensionAllowed(extension)) { + log.warn("尝试上传被禁止的文件类型。MIME: {}, 扩展名: {}", contentType, extension); + return Map.of("error", "文件类型不被允许"); + } + + String subFolder = getFolderByMime(contentType); + String fileUUID = RandomUtil.generateUUID(); + String fileName = fileUUID + extension; + String accessKey = UUID.randomUUID().toString().replace("-", ""); + String databasePath = buildDatabasePath(username, "attachment", subFolder, fileName); + String objectKey = databasePath; + + // 获取当前默认存储方式 + String defaultStorageUuid = siteConfigService.getSiteConfig().getDefault_storage(); + String storageId = defaultStorageUuid != null ? defaultStorageUuid : storageMethodMapper.selectByType("local").getUuid(); + FileStorage storage; + try { + storage = storageFacadeService.getDefaultStorage(defaultStorageUuid); + } catch (Exception e) { + log.error("默认存储方式不可用: {}", e.getMessage()); + return Map.of("error", "默认存储方式不可用,请检查存储配置"); + } + + UploadAttachment uploadFile = new UploadAttachment(); + uploadFile.setUuid(userUUID); + uploadFile.setAttachment_id(fileUUID); + uploadFile.setOriginal_filename(originalFilename); + uploadFile.setAttachment_path(databasePath); + uploadFile.setAccess_key(accessKey); + uploadFile.setStorage_id(storageId); + uploadFile.setContent_type(contentType); + int insertResult = uploadMapper.insertUploadAttachment(uploadFile); + if (insertResult != 1) { + log.error("插入附件记录到数据库失败。文件: {}", fileName); + return Map.of("error", "插入数据库失败"); + } + + try { + storage.store(objectKey, file.getInputStream(), file.getSize(), contentType); + log.info("文件上传成功 Key: {}", objectKey); + } catch (Exception e) { + log.error("保存文件失败: {}", objectKey, e); + return Map.of("error", "保存文件失败"); + } + + String fileUrl = "/file/" + accessKey; + log.info("附件上传完成 URL: {}", fileUrl); + return Map.of("fileUrl", fileUrl); + } + + @Override + public Map initMultipartUpload(HttpServletRequest request, Map payload) { + log.info("初始化分片上传"); + + String username = getUsername(request); + if (username == null) { + log.warn("未授权的分片上传初始化尝试"); + return Map.of("error", "用户未登录或用户名无效"); + } + + String filename = payload.get("filename"); + if (filename == null || filename.isEmpty()) { + log.warn("分片上传初始化时未提供文件名"); + return Map.of("error", "文件名是必需的"); + } + + String extension = ""; + if (filename.contains(".")) { + extension = filename.substring(filename.lastIndexOf(".")).toLowerCase(); + } + + if (!isExtensionAllowed(extension)) { + log.warn("分片上传初始化时使用了被禁止的扩展名: {}", extension); + return Map.of("error", "文件扩展名不被允许"); + } + + String uploadId = UUID.randomUUID().toString(); + log.info("分片上传初始化成功。上传ID: {}", uploadId); + return Map.of("uploadId", uploadId); + } + + @Override + public Map uploadChunk(HttpServletRequest request, String uploadId, int chunkIndex, byte[] chunkData) { + log.debug("上传分片 {}/{},上传ID: {}", chunkIndex, uploadId); + + String username = getUsername(request); + if (username == null) { + log.warn("未授权的分片上传尝试"); + return Map.of("error", "用户未登录或用户名无效"); + } + + Path uploadPath = buildFileSystemPath(username, "temp", uploadId); + Path allowedRoot = getBasePath(); + + if (!uploadPath.startsWith(allowedRoot)) { + log.error("分片的上传路径无效: {}", uploadPath); + return Map.of("error", "上传路径无效"); + } + + try { + Files.createDirectories(uploadPath); + Path chunkFile = uploadPath.resolve("%05d.part".formatted(chunkIndex)); + Files.write(chunkFile, chunkData, StandardOpenOption.CREATE, StandardOpenOption.WRITE); + log.debug("分片 {}/{} 上传成功", chunkIndex, uploadId); + } catch (IOException e) { + log.error("保存分片 {}/{} 失败", chunkIndex, uploadId, e); + return Map.of("error", "保存分片失败"); + } + + return Map.of("message", "分片上传成功"); + } + + @Override + public Map completeUpload(HttpServletRequest request, Map payload) { + log.info("完成分片上传"); + + String uploadId = payload.get("uploadId"); + String filename = payload.get("filename"); + + if (uploadId == null || uploadId.isEmpty() || filename == null || filename.isEmpty()) { + log.warn("完成上传请求缺少必要参数"); + return Map.of("error", "uploadId和文件名是必需的"); + } + + String username = getUsername(request); + if (username == null) { + log.warn("未授权的分片上传完成尝试"); + return Map.of("error", "用户未登录或用户名无效"); + } + + String userUUID = getUserUUID(username); + if (userUUID == null) { + log.error("未找到用户名 {} 的UUID", username); + return Map.of("error", "未找到用户"); + } + + String extension = ""; + if (filename.contains(".")) { + extension = filename.substring(filename.lastIndexOf(".")).toLowerCase(); + } + + if (!isExtensionAllowed(extension)) { + log.warn("分片上传完成时使用了被禁止的扩展名: {}", extension); + return Map.of("error", "文件扩展名不被允许"); + } + + Path tempDir = buildFileSystemPath(username, "temp", uploadId); + Path allowedRoot = getBasePath(); + if (!tempDir.startsWith(allowedRoot)) { + log.error("完成操作的上传路径无效: {}", tempDir); + return Map.of("error", "上传路径无效"); + } + + if (!Files.exists(tempDir)) { + log.error("临时目录不存在: {}", tempDir); + return Map.of("error", "临时目录不存在"); + } + + String fileUUID = RandomUtil.generateUUID(); + String newFileName = fileUUID + extension; + + String mimeType = getMimeTypeByExtension(extension); + String subFolder = getFolderByMime(mimeType); + + Path finalDir = buildFileSystemPath(username, "attachment", subFolder); + Path finalFilePath = finalDir.resolve(newFileName); + + try { + Files.createDirectories(finalDir); + log.debug("为最终文件创建目录: {}", finalDir); + + List chunks = Files.list(tempDir) + .filter(p -> p.getFileName().toString().endsWith(".part")) + .sorted(Comparator.comparing(Path::getFileName)) + .toList(); + + if (chunks.isEmpty()) { + log.warn("未找到任何分片文件在目录: {}", tempDir); + return Map.of("error", "未找到任何分片文件"); + } + + log.debug("找到 {} 个分片文件准备合并", chunks.size()); + + try (FileChannel outChannel = FileChannel.open(finalFilePath, + StandardOpenOption.CREATE, + StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING)) { + + for (Path chunk : chunks) { + log.debug("正在处理分片: {}", chunk.getFileName()); + try (FileChannel inChannel = FileChannel.open(chunk, StandardOpenOption.READ)) { + inChannel.transferTo(0, inChannel.size(), outChannel); + } catch (IOException e) { + log.error("处理分片文件 {} 时出错", chunk, e); + throw e; + } + } + } + + log.debug("分片合并完成,开始清理临时文件"); + + for (Path chunk : chunks) { + try { + Files.deleteIfExists(chunk); + log.debug("已删除分片文件: {}", chunk.getFileName()); + } catch (IOException e) { + log.warn("删除分片文件 {} 失败", chunk, e); + } + } + + try { + Files.deleteIfExists(tempDir); + log.debug("已删除临时目录: {}", tempDir.getFileName()); + } catch (IOException e) { + log.warn("删除临时目录 {} 失败", tempDir, e); + } + + log.info("分片合并成功。最终文件: {}", finalFilePath); + + } catch (IOException e) { + log.error("合并上传ID为 {} 的分片失败,临时目录: {}", uploadId, tempDir, e); + + try { + Files.deleteIfExists(finalFilePath); + } catch (IOException cleanupEx) { + log.warn("清理不完整的文件 {} 失败", finalFilePath, cleanupEx); + } + + return Map.of("error", "合并分片失败: " + e.getMessage()); + } catch (Exception e) { + log.error("合并上传ID为 {} 的分片时发生未预期错误", uploadId, e); + + try { + Files.deleteIfExists(finalFilePath); + } catch (IOException cleanupEx) { + log.warn("清理不完整的文件 {} 失败", finalFilePath, cleanupEx); + } + + return Map.of("error", "合并分片时发生错误: " + e.getMessage()); + } + + String detectedMimeType = detectMimeTypeFromFile(finalFilePath); + + if (!isMimeAllowed(detectedMimeType)) { + try { + Files.deleteIfExists(finalFilePath); + } catch (IOException e) { + log.warn("删除MIME类型被禁止的文件失败: {}", finalFilePath, e); + } + + log.warn("检测后发现文件类型不被允许: {}", detectedMimeType); + return Map.of("error", "文件类型不被允许: " + detectedMimeType); + } + + // 通过存储抽象层写入目标存储 + String databasePath = buildDatabasePath(username, "attachment", subFolder, newFileName); + String objectKey = databasePath; + String defaultStorageUuid = siteConfigService.getSiteConfig().getDefault_storage(); + String storageId = defaultStorageUuid != null ? defaultStorageUuid : storageMethodMapper.selectByType("local").getUuid(); + FileStorage storage; + try { + storage = storageFacadeService.getDefaultStorage(defaultStorageUuid); + } catch (Exception e) { + log.error("默认存储方式不可用: {}", e.getMessage()); + try { + Files.deleteIfExists(finalFilePath); + } catch (IOException ignored) {} + return Map.of("error", "默认存储方式不可用,请检查存储配置"); + } + + if (storage instanceof LocalFileStorageImpl) { + log.info("本地存储合并完成,文件路径: {}", finalFilePath); + } else { + try (InputStream fileIn = Files.newInputStream(finalFilePath, StandardOpenOption.READ)) { + storage.store(objectKey, fileIn, Files.size(finalFilePath), detectedMimeType); + log.info("分片合并文件已上传到存储。Key: {}", objectKey); + } catch (Exception e) { + log.error("分片合并文件上传到存储失败: {}", objectKey, e); + return Map.of("error", "保存文件到存储失败"); + } finally { + try { + Files.deleteIfExists(finalFilePath); + } catch (IOException e) { + log.warn("清理本地临时文件失败: {}", finalFilePath, e); + } + } + } + + String accessKey = UUID.randomUUID().toString().replace("-", ""); + + UploadAttachment uploadFile = new UploadAttachment(); + uploadFile.setUuid(userUUID); + uploadFile.setAttachment_id(fileUUID); + uploadFile.setOriginal_filename(filename); + uploadFile.setAttachment_path(databasePath); + uploadFile.setAccess_key(accessKey); + uploadFile.setStorage_id(storageId); + uploadFile.setContent_type(detectedMimeType); + + int insertResult = uploadMapper.insertUploadAttachment(uploadFile); + if (insertResult != 1) { + log.error("保存文件信息到数据库失败。文件: {}", newFileName); + try { + Files.deleteIfExists(finalFilePath); + } catch (IOException e) { + log.warn("清理文件 {} 失败", finalFilePath, e); + } + return Map.of("error", "保存文件信息失败"); + } + + String fileUrl = "/file/" + accessKey; + log.info("分片上传完成。URL: {}", fileUrl); + return Map.of("fileUrl", fileUrl); + } + + private boolean isAliasExists(String alias, String uuid) { + return uploadMapper.selectArticleCountByAliasAndUuid(alias, uuid) > 0; + } + + @Override + public Map uploadArticle(HttpServletRequest request, UploadArticle uploadArticle) { + log.info("开始文章上传流程"); + + String username = getUsername(request); + if (username == null) { + log.warn("未授权的文章上传尝试"); + return Map.of("error", "用户未登录或用户名无效"); + } + + String userUUID = getUserUUID(username); + if (userUUID == null) { + log.error("未找到用户名 {} 的UUID", username); + return Map.of("error", "未找到用户"); + } + + if (uploadArticle.getArticle_name() == null || uploadArticle.getArticle_name().trim().isEmpty()) { + log.warn("文章标题为空"); + return Map.of("error", "文章标题不能为空"); + } + + if (uploadArticle.getArticle_content() == null || uploadArticle.getArticle_content().trim().isEmpty()) { + log.warn("文章内容为空"); + return Map.of("error", "文章内容不能为空"); + } + + if (uploadArticle.getCategory() == null || uploadArticle.getCategory().trim().isEmpty()) { + log.warn("文章分类为空"); + return Map.of("error", "文章分类不能为空"); + } + + CategoryPathUtil.CategoryPath categoryPath = categoryPathUtil.parsePath(uploadArticle.getCategory()); + + uploadArticle.setParentCategory(categoryPath.getParentCategory()); + uploadArticle.setChildCategory(categoryPath.getChildCategory()); + + String alias = uploadArticle.getAlias(); + if (alias == null || alias.trim().isEmpty()) { + alias = UUID.randomUUID().toString(); + uploadArticle.setAlias(alias); + } else { + alias = alias.trim(); + uploadArticle.setAlias(alias); + + if (isAliasExists(alias, userUUID)) { + log.warn("文章别名已存在: {}", alias); + return Map.of("error", "文章别名不能重复"); + } + } + + if (uploadArticle.getPassword() != null && !uploadArticle.getPassword().trim().isEmpty()) { + String hashedPassword = BCrypt.hashpw(uploadArticle.getPassword(), BCrypt.gensalt()); + uploadArticle.setPassword(hashedPassword); + log.debug("文章密码已使用BCrypt加密"); + } else { + uploadArticle.setPassword(null); + } + + uploadArticle.setUuid(userUUID); + + if (uploadArticle.getArticle_id() == null || uploadArticle.getArticle_id().trim().isEmpty()) { + String articleId = UUID.randomUUID().toString(); + uploadArticle.setArticle_id(articleId); + } + +// 文章刚创建统一设置为未发布,需要用户手动点击后调用publish接口再读取文章默认状态(该进审核进审核,该发布发布) +// if (uploadArticle.getStatus() != 1 && uploadArticle.getStatus() != 3) { +// uploadArticle.setStatus(2); +// } + if (uploadArticle.getStatus() != 2){ + uploadArticle.setStatus(2); + log.warn("检测到非法攻击,文章状态被篡改已恢复正确值"); + } + + if (uploadArticle.getEnable_comment() == null) { + uploadArticle.setEnable_comment(1); + } + + try { + int result = uploadMapper.insertUploadArticleWithCategory(uploadArticle); + if (result == 1) { + String[] tags = uploadArticle.getTag().split(","); + for (String tag : tags) { + if (!tag.trim().isEmpty()) { + uploadMapper.insertArticleTagRelation(uploadArticle.getArticle_id(), tag.trim()); + } + } + + CategoryPathUtil.CategoryPath parsedPath = categoryPathUtil.parsePath(uploadArticle.getCategory()); + String fullPath = parsedPath.getParentCategory() != null + ? parsedPath.getParentCategory() + "/" + parsedPath.getChildCategory() + : parsedPath.getChildCategory(); + articleCategoryMapper.incrementCount(fullPath); + log.info("文章上传成功,文章ID: {}", uploadArticle.getArticle_id()); + return Map.of( + "message", "文章保存成功", + "articleId", uploadArticle.getArticle_id() + ); + } else { + log.error("插入文章记录到数据库失败"); + return Map.of("error", "保存文章失败"); + } + } catch (Exception e) { + log.error("保存文章时发生错误", e); + return Map.of("error", "保存文章时发生内部错误"); + } + } + + @Override + public Map uploadAvatar(HttpServletRequest request, MultipartFile file) { + log.info("开始头像上传流程"); + + if (file.isEmpty()) { + log.warn("尝试上传空文件"); + return Map.of("error", "文件为空"); + } + + // 头像文件大小限制为50MB + long MAX_AVATAR_SIZE = 50 * 1024 * 1024; + if (file.getSize() > MAX_AVATAR_SIZE) { + log.warn("尝试上传过大的头像文件: {} 字节", file.getSize()); + return Map.of("error", "头像文件过大,最大支持50MB"); + } + + String username = getUsername(request); + if (username == null) { + log.warn("未授权的头像上传尝试"); + return Map.of("error", "用户未登录或用户名无效"); + } + + String userUUID = getUserUUID(username); + if (userUUID == null) { + log.error("未找到用户名 {} 的UUID", username); + return Map.of("error", "未找到用户"); + } + + String contentType = file.getContentType(); + String originalFilename = file.getOriginalFilename(); + String extension = ""; + + if (originalFilename != null && originalFilename.contains(".")) { + extension = originalFilename.substring(originalFilename.lastIndexOf(".")).toLowerCase(); + } + + List allowedImageTypes = SUPPORTED_FILE_TYPES.get("image"); + if (contentType == null || !allowedImageTypes.contains(contentType) || !isExtensionAllowed(extension)) { + log.warn("尝试上传非图片类型作为头像。MIME: {}, 扩展名: {}", contentType, extension); + return Map.of("error", "头像文件必须是图片格式"); + } + + Path uploadPath = buildFileSystemPath(username, "avatar"); + Path allowedRoot = getBasePath(); + + if (!uploadPath.startsWith(allowedRoot)) { + log.error("检测到无效的头像上传路径: {}", uploadPath); + return Map.of("error", "上传路径无效"); + } + + try { + Files.createDirectories(uploadPath); + log.debug("创建头像目录: {}", uploadPath); + } catch (IOException e) { + log.error("创建头像目录失败: {}", uploadPath, e); + return Map.of("error", "创建目录失败"); + } + + String timestamp = String.valueOf(Instant.now().getEpochSecond()); + UUID uuid = UUID.randomUUID(); + String fileUUID = "avatar-" + uuid + "-" + timestamp; + String fileName = fileUUID + extension; + + String accessKey = UUID.randomUUID().toString().replace("-", ""); + + String databasePath = buildDatabasePath(username, "avatar", fileName); + String objectKey = databasePath; + + // 获取当前默认存储方式 + String defaultStorageUuid = siteConfigService.getSiteConfig().getDefault_storage(); + String storageId = defaultStorageUuid != null ? defaultStorageUuid : storageMethodMapper.selectByType("local").getUuid(); + FileStorage storage; + try { + storage = storageFacadeService.getDefaultStorage(defaultStorageUuid); + } catch (Exception e) { + log.error("默认存储方式不可用: {}", e.getMessage()); + return Map.of("error", "默认存储方式不可用,请检查存储配置"); + } + + try { + storage.store(objectKey, file.getInputStream(), file.getSize(), contentType); + log.info("头像上传成功。Key: {}", objectKey); + } catch (Exception e) { + log.error("保存头像文件失败: {}", objectKey, e); + return Map.of("error", "保存头像文件失败"); + } + + try { + UploadAttachment uploadFile = new UploadAttachment(); + uploadFile.setUuid(userUUID); + uploadFile.setAttachment_id(fileUUID); + uploadFile.setOriginal_filename(originalFilename); + uploadFile.setAttachment_path(buildDatabasePath(username, "avatar", fileName)); + uploadFile.setAccess_key(accessKey); + uploadFile.setStorage_id(storageId); + uploadFile.setContent_type(contentType); + + uploadMapper.insertUploadAttachment(uploadFile); + } catch (Exception e) { + log.warn("保存头像访问键到数据库失败", e); + } + + String fileUrl = "/file/" + accessKey; + log.info("头像上传完成。URL: {}", fileUrl); + + try { + UserInformation user = new UserInformation(); + user.setUuid(userUUID); + user.setAvatar(fileUrl); + userInformationMapper.updateUserByUUID(user); + log.debug("用户头像信息更新成功"); + } catch (Exception e) { + log.error("更新用户头像信息失败", e); + } + + cacheService.deleteByPrefix("article:home:"); + log.info("用户头像修改,已自动清除文章列表分页缓存"); + + return Map.of("fileUrl", fileUrl); + } + + // 用户管理模块头像上传 + @Override + public Map adminUploadAvatar(HttpServletRequest request, MultipartFile file) { + log.info("管理员修改用户头像流程开始"); + + if (file.isEmpty()) { + log.warn("尝试上传空文件"); + return Map.of("error", "文件为空"); + } + + // 头像文件大小限制为50MB + long MAX_AVATAR_SIZE = 50 * 1024 * 1024; + if (file.getSize() > MAX_AVATAR_SIZE) { + log.warn("尝试上传过大的头像文件: {} 字节", file.getSize()); + return Map.of("error", "头像文件过大,最大支持50MB"); + } + + String username = getUsername(request); + if (username == null) { + log.warn("未授权的头像上传尝试"); + return Map.of("error", "用户未登录或用户名无效"); + } + + String callerUUID = getUserUUID(username); + if (callerUUID == null) { + log.error("未找到调用者用户名 {} 的UUID", username); + return Map.of("error", "未找到调用者用户"); + } + + String contentType = file.getContentType(); + String originalFilename = file.getOriginalFilename(); + String extension = ""; + + if (originalFilename != null && originalFilename.contains(".")) { + extension = originalFilename.substring(originalFilename.lastIndexOf(".")).toLowerCase(); + } + + List allowedImageTypes = SUPPORTED_FILE_TYPES.get("image"); + if (contentType == null || !allowedImageTypes.contains(contentType) || !isExtensionAllowed(extension)) { + log.warn("尝试上传非图片类型作为头像。MIME: {}, 扩展名: {}", contentType, extension); + return Map.of("error", "头像文件必须是图片格式"); + } + + String targetUserUUID = request.getParameter("targetUserUUID"); + if (targetUserUUID == null || targetUserUUID.isEmpty()) { + log.warn("未提供目标用户UUID"); + return Map.of("error", "目标用户UUID不能为空"); + } + + String targetUsername = userInformationMapper.selectUsernameByUUID(targetUserUUID); + if (targetUsername == null) { + log.error("未找到目标用户UUID {} 对应的用户", targetUserUUID); + return Map.of("error", "目标用户不存在"); + } + + Path uploadPath = buildFileSystemPath(targetUsername, "avatar"); + Path allowedRoot = getBasePath(); + + if (!uploadPath.startsWith(allowedRoot)) { + log.error("检测到无效的头像上传路径: {}", uploadPath); + return Map.of("error", "上传路径无效"); + } + + try { + Files.createDirectories(uploadPath); + log.debug("创建头像目录: {}", uploadPath); + } catch (IOException e) { + log.error("创建头像目录失败: {}", uploadPath, e); + return Map.of("error", "创建目录失败"); + } + + String timestamp = String.valueOf(Instant.now().getEpochSecond()); + UUID uuid = UUID.randomUUID(); + String fileUUID = "avatar-" + uuid + "-" + timestamp; + String fileName = fileUUID + extension; + + String accessKey = UUID.randomUUID().toString().replace("-", ""); + + String databasePath = buildDatabasePath(targetUsername, "avatar", fileName); + String objectKey = databasePath; + + // 获取当前默认存储方式 + String defaultStorageUuid = siteConfigService.getSiteConfig().getDefault_storage(); + String storageId = defaultStorageUuid != null ? defaultStorageUuid : storageMethodMapper.selectByType("local").getUuid(); + FileStorage storage; + try { + storage = storageFacadeService.getDefaultStorage(defaultStorageUuid); + } catch (Exception e) { + log.error("默认存储方式不可用: {}", e.getMessage()); + return Map.of("error", "默认存储方式不可用,请检查存储配置"); + } + + try { + storage.store(objectKey, file.getInputStream(), file.getSize(), contentType); + log.info("头像上传成功。Key: {}", objectKey); + } catch (Exception e) { + log.error("保存头像文件失败: {}", objectKey, e); + return Map.of("error", "保存头像文件失败"); + } + + try { + UploadAttachment uploadFile = new UploadAttachment(); + uploadFile.setUuid(targetUserUUID); + uploadFile.setAttachment_id(fileUUID); + uploadFile.setOriginal_filename(originalFilename); + uploadFile.setAttachment_path(buildDatabasePath(targetUsername, "avatar", fileName)); + uploadFile.setAccess_key(accessKey); + uploadFile.setStorage_id(storageId); + uploadFile.setContent_type(contentType); + + uploadMapper.insertUploadAttachment(uploadFile); + } catch (Exception e) { + log.warn("保存头像访问键到数据库失败", e); + } + + String fileUrl = "/file/" + accessKey; + log.info("头像上传完成。URL: {}", fileUrl); + + try { + UserInformation user = new UserInformation(); + user.setUuid(targetUserUUID); + user.setAvatar(fileUrl); + userInformationMapper.updateUserByUUID(user); + log.debug("用户头像信息更新成功,用户UUID: {}", targetUserUUID); + } catch (Exception e) { + log.error("更新用户头像信息失败", e); + return Map.of("error", "更新用户头像信息失败"); + } + + cacheService.deleteByPrefix("article:home:"); + log.info("用户头像修改,已自动清除文章列表分页缓存"); + + return Map.of("fileUrl", fileUrl, "message", "头像上传成功"); + } + + @Override + public Map downloadAndUploadExternalResource(HttpServletRequest request, String url) { + try { + log.info("开始下载并上传外部资源: {}", url); + + // 检查是否启用外部资源代理下载功能 + Integer proxyEnabled = siteConfigService.getProxyResourceDownload(); + if (proxyEnabled == null || proxyEnabled != 1) { + log.warn("外部资源代理下载功能未启用"); + return Map.of("error", "外部资源代理下载功能未启用"); + } + + if (url == null || url.trim().isEmpty()) { + return Map.of("error", "URL不能为空"); + } + + if (!url.startsWith("http://") && !url.startsWith("https://")) { + log.warn("不支持的URL协议: {}", url); + return Map.of("error", "只支持HTTP/HTTPS协议"); + } + + String clientIp = request.getRemoteAddr(); + boolean isLocalhostRequest = isLocalhost(clientIp); + + URI resourceUri = new URI(url); + String host = resourceUri.getHost(); + + if (host == null || host.trim().isEmpty()) { + return Map.of("error", "无效的URL"); + } + + try { + java.net.InetAddress resolvedAddress = getByName(host); + String resolvedIp = resolvedAddress.getHostAddress(); + + if (!isLocalhostRequest) { + if (resolvedAddress.isLoopbackAddress()) { + log.warn("禁止访问本地地址: {} -> {}", host, resolvedIp); + return Map.of("error", "禁止访问本地地址"); + } + + boolean isClientPublicIP = !isLocalhost(clientIp) && !isPrivateIP(clientIp); + if (isClientPublicIP && (resolvedAddress.isSiteLocalAddress() || resolvedAddress.isLinkLocalAddress())) { + log.warn("公网请求禁止访问内网地址: {} -> {}", host, resolvedIp); + return Map.of("error", "禁止访问内网地址"); + } + } + } catch (Exception e) { + log.error("DNS解析失败: {}", host, e); + return Map.of("error", "DNS解析失败"); + } + + String username = getUsername(request); + if (username == null) { + log.warn("未授权的下载尝试"); + return Map.of("error", "用户未登录或用户名无效"); + } + + String userUUID = getUserUUID(username); + if (userUUID == null) { + log.error("未找到用户名 {} 的UUID", username); + return Map.of("error", "未找到用户"); + } + + HttpURLConnection connection = (HttpURLConnection) resourceUri.toURL().openConnection(); + + connection.setRequestMethod("GET"); + connection.setConnectTimeout(10000); + connection.setReadTimeout(30000); + connection.setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); + connection.setRequestProperty("Accept", "image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"); + connection.setRequestProperty("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8"); + connection.setInstanceFollowRedirects(false); + + int responseCode = connection.getResponseCode(); + int redirectCount = 0; + while ((responseCode == HttpURLConnection.HTTP_MOVED_PERM || + responseCode == HttpURLConnection.HTTP_MOVED_TEMP || + responseCode == HttpURLConnection.HTTP_SEE_OTHER || + responseCode == 307 || responseCode == 308) && redirectCount < 5) { + + String location = connection.getHeaderField("Location"); + connection.disconnect(); + + if (location == null || location.trim().isEmpty()) { + return Map.of("error", "重定向地址为空"); + } + + URI redirectUri = new URI(location); + String redirectHost = redirectUri.getHost(); + if (redirectHost == null || redirectHost.trim().isEmpty()) { + return Map.of("error", "无效的重定向地址"); + } + + String scheme = redirectUri.getScheme(); + if (!"http".equals(scheme) && !"https".equals(scheme)) { + log.warn("不安全的重定向协议: {}", scheme); + return Map.of("error", "不安全的请求"); + } + + try { + java.net.InetAddress redirectAddress = getByName(redirectHost); + if (redirectAddress.isLoopbackAddress() || + redirectAddress.isSiteLocalAddress() || + redirectAddress.isLinkLocalAddress()) { + log.warn("重定向目标为内网地址,已拦截: {} -> {}", location, redirectAddress.getHostAddress()); + return Map.of("error", "禁止访问内网地址"); + } + } catch (Exception e) { + log.error("重定向目标DNS解析失败: {}", redirectHost, e); + return Map.of("error", "DNS解析失败"); + } + + connection = (HttpURLConnection) redirectUri.toURL().openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(10000); + connection.setReadTimeout(30000); + connection.setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); + connection.setRequestProperty("Accept", "image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"); + connection.setRequestProperty("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8"); + connection.setInstanceFollowRedirects(false); + + responseCode = connection.getResponseCode(); + redirectCount++; + } + + if (responseCode != HttpURLConnection.HTTP_OK) { + log.error("下载失败,HTTP状态码: {}, URL: {}", responseCode, url); + connection.disconnect(); + return Map.of("error", "下载失败,HTTP状态码: " + responseCode); + } + + byte[] data = connection.getInputStream().readAllBytes(); + int contentLength = connection.getContentLength(); + if (contentLength > MAX_EXTERNAL_SIZE) { + log.warn("外部资源过大"); + return Map.of("error", "外部资源过大"); + } + String contentType = connection.getContentType(); + connection.disconnect(); + + log.info("外部资源下载成功,大小: {} bytes, Content-Type: {}, URL: {}", data.length, contentType, url); + + String extension = ""; + if (contentType != null) { + if (contentType.contains("jpeg") || contentType.contains("jpg")) { + extension = ".jpg"; + } else if (contentType.contains("png")) { + extension = ".png"; + } else if (contentType.contains("gif")) { + extension = ".gif"; + } else if (contentType.contains("webp")) { + extension = ".webp"; + } else if (contentType.contains("svg")) { + extension = ".svg"; + } else if (contentType.contains("mp4")) { + extension = ".mp4"; + } else if (contentType.contains("webm")) { + extension = ".webm"; + } else if (contentType.contains("ogg")) { + extension = ".ogg"; + } else if (contentType.contains("mp3") || contentType.contains("mpeg")) { + extension = ".mp3"; + } else if (contentType.contains("wav")) { + extension = ".wav"; + } else if (contentType.contains("flac")) { + extension = ".flac"; + } + } + + if (extension.isEmpty()) { + String urlPath = url.split("\\?")[0]; + if (urlPath.contains(".")) { + String urlExt = urlPath.substring(urlPath.lastIndexOf(".")).toLowerCase(); + if (isExtensionAllowed(urlExt)) { + extension = urlExt; + } + } + } + + if (extension.isEmpty()) { + extension = ".jpg"; + } + + String mimeType = getMimeTypeByExtension(extension); + if (!isMimeAllowed(mimeType)) { + log.warn("不支持的文件类型: {}", mimeType); + return Map.of("error", "文件类型不被允许"); + } + + String subFolder = getFolderByMime(mimeType); + String fileUUID = RandomUtil.generateUUID(); + String fileName = fileUUID + extension; + String accessKey = UUID.randomUUID().toString().replace("-", ""); + String databasePath = buildDatabasePath(username, "attachment", subFolder, fileName); + String objectKey = databasePath; + + // 获取当前默认存储方式 + String defaultStorageUuid = siteConfigService.getSiteConfig().getDefault_storage(); + String storageId = defaultStorageUuid != null ? defaultStorageUuid : storageMethodMapper.selectByType("local").getUuid(); + FileStorage storage; + try { + storage = storageFacadeService.getDefaultStorage(defaultStorageUuid); + } catch (Exception e) { + log.error("默认存储方式不可用: {}", e.getMessage()); + return Map.of("error", "默认存储方式不可用,请检查存储配置"); + } + + try (ByteArrayInputStream dataStream = new ByteArrayInputStream(data)) { + storage.store(objectKey, dataStream, data.length, mimeType); + log.info("外部资源保存成功。Key: {}", objectKey); + } catch (Exception e) { + log.error("保存外部资源失败: {}", objectKey, e); + return Map.of("error", "保存文件失败"); + } + + UploadAttachment uploadFile = new UploadAttachment(); + uploadFile.setUuid(userUUID); + uploadFile.setAttachment_id(fileUUID); + uploadFile.setOriginal_filename("external_resource" + extension); + uploadFile.setAttachment_path(buildDatabasePath(username, "attachment", subFolder, fileName)); + uploadFile.setAccess_key(accessKey); + uploadFile.setStorage_id(storageId); + uploadFile.setContent_type(mimeType); + + int insertResult = uploadMapper.insertUploadAttachment(uploadFile); + if (insertResult != 1) { + log.error("插入附件记录到数据库失败。文件: {}", fileName); + return Map.of("error", "插入数据库失败"); + } + + String fileUrl = "/file/" + accessKey; + log.info("外部资源上传完成。URL: {}", fileUrl); + return Map.of("fileUrl", fileUrl); + + } catch (Exception e) { + log.error("下载并上传外部资源失败: {}", url, e); + return Map.of("error", "处理失败: " + e.getMessage()); + } + } + + /** + * 判断是否为localhost地址 + */ + private boolean isLocalhost(String host) { + if (host == null) { + return false; + } + + if ("localhost".equalsIgnoreCase(host) || "127.0.0.1".equals(host) || "::1".equals(host)) { + return true; + } + + if (host.toLowerCase().endsWith(".localhost")) { + return true; + } + + if (host.startsWith("127.")) { + return true; + } + + try { + java.net.InetAddress address = getByName(host); + return address.isLoopbackAddress(); + } catch (Exception e) { + log.debug("无法解析主机: {}", host); + return false; + } + } + + /** + * 判断是否为私有IP地址 + */ + private boolean isPrivateIP(String host) { + if (host == null) { + return false; + } + + try { + InetAddress address = getByName(host); + + return address.isSiteLocalAddress() || + address.isLinkLocalAddress() || + address.isLoopbackAddress(); + } catch (Exception e) { + log.debug("无法解析主机: {}", host); + return false; + } + } + +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/UserBlacklistServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/UserBlacklistServiceImpl.java new file mode 100644 index 0000000..38e18dc --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/UserBlacklistServiceImpl.java @@ -0,0 +1,43 @@ +package xyz.lingview.dimstack.service.impl; + +import org.springframework.stereotype.Service; +import xyz.lingview.dimstack.service.CacheService; +import xyz.lingview.dimstack.service.UserBlacklistService; + +import java.util.Set; + +@Service +public class UserBlacklistServiceImpl implements UserBlacklistService { + + private final CacheService cacheService; + private static final String BLACKLIST_KEY = "dimstack:user:blacklist"; + + public UserBlacklistServiceImpl(CacheService cacheService) { + this.cacheService = cacheService; + } + + @Override + public void addUserToBlacklist(String username) { + cacheService.addToSet(BLACKLIST_KEY, username); + } + + @Override + public void removeUserFromBlacklist(String username) { + cacheService.removeFromSet(BLACKLIST_KEY, username); + } + + @Override + public boolean isUserInBlacklist(String username) { + return cacheService.isMemberOfSet(BLACKLIST_KEY, username); + } + + @Override + public void clearBlacklist() { + cacheService.deleteSet(BLACKLIST_KEY); + } + + @Override + public Set getBlacklistedUsers() { + return cacheService.getSetMembers(BLACKLIST_KEY, String.class); + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/UserPermissionCheckServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/UserPermissionCheckServiceImpl.java new file mode 100644 index 0000000..025b70c --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/UserPermissionCheckServiceImpl.java @@ -0,0 +1,46 @@ +package xyz.lingview.dimstack.service.impl; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import xyz.lingview.dimstack.mapper.UserPermissionMapper; +import xyz.lingview.dimstack.service.UserPermissionCheckService; + +import java.util.Set; + +@Service +public class UserPermissionCheckServiceImpl implements UserPermissionCheckService { + + @Autowired + private UserPermissionMapper userPermissionMapper; + + @Override + // 检查用户是否拥有所有指定权限 + public boolean hasAllPermissions(String username, String[] permissions) { + Set userPermissions = getUserPermissions(username); + for (String permission : permissions) { + if (!userPermissions.contains(permission)) { + return false; + } + } + return true; + } + + @Override + // 检查用户是否拥有任意一个指定权限 + public boolean hasAnyPermission(String username, String[] permissions) { + Set userPermissions = getUserPermissions(username); + for (String permission : permissions) { + if (userPermissions.contains(permission)) { + return true; + } + } + return false; + } + + + private Set getUserPermissions(String username) { + return Set.copyOf(userPermissionMapper.findPermissionCodesByUserName(username)); + } + + +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/UserRoleServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/UserRoleServiceImpl.java new file mode 100644 index 0000000..00e3f66 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/UserRoleServiceImpl.java @@ -0,0 +1,71 @@ +package xyz.lingview.dimstack.service.impl; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.domain.Role; +import xyz.lingview.dimstack.mapper.UserRoleMapper; +import xyz.lingview.dimstack.service.UserRoleService; + +import java.util.List; + +@Service +public class UserRoleServiceImpl implements UserRoleService { + + @Autowired + private UserRoleMapper userRoleMapper; + + @Override + public List getUserRoles(Integer userId) { + return userRoleMapper.selectRolesByUserId(userId); + } + + @Override + @Transactional + public ApiResponse addUserRole(Integer userId, Integer roleId) { + // 检查用户是否已经拥有该角色 + int count = userRoleMapper.countUserRole(userId, roleId); + if (count > 0) { + return ApiResponse.error(400, "用户已拥有该角色"); + } + + int result = userRoleMapper.insertUserRole(userId, roleId); + if (result <= 0) { + return ApiResponse.error(500, "添加角色失败"); + } + + return ApiResponse.success("添加角色成功"); + } + + @Override + @Transactional + public ApiResponse removeUserRole(Integer userId, Integer roleId) { + int result = userRoleMapper.deleteUserRole(userId, roleId); + if (result <= 0) { + return ApiResponse.error(500, "移除角色失败"); + } + + return ApiResponse.success("移除角色成功"); + } + + @Override + @Transactional + public ApiResponse setUserRoles(Integer userId, List roleIds) { + List existingRoleIds = userRoleMapper.selectRoleIdsByUserId(userId); + for (Integer existingRoleId : existingRoleIds) { + userRoleMapper.deleteUserRole(userId, existingRoleId); + } + + if (roleIds != null) { + for (Integer roleId : roleIds) { + int result = userRoleMapper.insertUserRole(userId, roleId); + if (result <= 0) { + throw new RuntimeException("设置角色失败,角色 ID: " + roleId); + } + } + } + + return ApiResponse.success("设置角色成功"); + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/UserServiceImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/UserServiceImpl.java new file mode 100644 index 0000000..9ff0f0f --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/UserServiceImpl.java @@ -0,0 +1,347 @@ +package xyz.lingview.dimstack.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.mindrot.jbcrypt.BCrypt; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import xyz.lingview.dimstack.common.ApiResponse; +import xyz.lingview.dimstack.domain.Role; +import xyz.lingview.dimstack.domain.UserInformation; +import xyz.lingview.dimstack.dto.request.UserDTO; +import xyz.lingview.dimstack.dto.request.UserUpdateDTO; +import xyz.lingview.dimstack.mapper.SiteConfigMapper; +import xyz.lingview.dimstack.mapper.UserInformationMapper; +import xyz.lingview.dimstack.mapper.UserPermissionMapper; +import xyz.lingview.dimstack.mapper.UserRoleMapper; +import xyz.lingview.dimstack.service.UserService; +import xyz.lingview.dimstack.service.UserBlacklistService; +import xyz.lingview.dimstack.util.RandomUtil; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; + +@Service +@Slf4j +public class UserServiceImpl implements UserService { + + @Autowired + private UserInformationMapper userInformationMapper; + + @Autowired + private UserBlacklistService userBlacklistService; + + @Autowired + private SiteConfigMapper siteConfigMapper; + + @Autowired + private UserPermissionMapper userPermissionMapper; + + @Autowired + private UserRoleMapper userRoleMapper; + + @Override + public UserInformation getUserByUUID(String uuid) { + return userInformationMapper.selectUserByUUID(uuid); + } + + @Override + public ApiResponse updateUserInfo(UserUpdateDTO userUpdateDTO, String currentUsername) { + UserInformation user = new UserInformation(); +// 修复一个水平越权漏洞 +// user.setUuid(userUpdateDTO.getUuid()); +// 修改为从session中读取用户信息(最后只能改自己hhh) + String currentUserUuid = userInformationMapper.selectUserUUID(currentUsername); + user.setUuid(currentUserUuid); + + if (userUpdateDTO.getUsername() != null && !userUpdateDTO.getUsername().isEmpty()) { + if (!currentUsername.equals(userUpdateDTO.getUsername())) { + int usernameExists = userInformationMapper.selectUserByUsername(userUpdateDTO.getUsername()); + if (usernameExists > 0) { + return ApiResponse.error(400, "用户名已存在!"); + } + } + user.setUsername(userUpdateDTO.getUsername()); + } + + + if (userUpdateDTO.getAvatar() != null && !userUpdateDTO.getAvatar().isEmpty()) { +// System.out.println("头像不在这里设置"); + } + + UserInformation userInformation = userInformationMapper.selectUserByUUID(currentUserUuid); + String currentUserEmail = userInformation.getEmail(); + String currentUserPhone = userInformation.getPhone(); + + if (userUpdateDTO.getPhone() != null && !userUpdateDTO.getPhone().isEmpty()) { + if (!userUpdateDTO.getPhone().equals(currentUserPhone)) { + int phoneExists = userInformationMapper.selectUserByPhone(userUpdateDTO.getPhone()); + if (phoneExists > 0) { + return ApiResponse.error(400, "手机号已存在!"); + } + } + user.setPhone(userUpdateDTO.getPhone()); + } + if (userUpdateDTO.getEmail() != null && !userUpdateDTO.getEmail().isEmpty()) { + if (!userUpdateDTO.getEmail().equals(currentUserEmail)) { + int emailExists = userInformationMapper.selectUserByEmail(userUpdateDTO.getEmail()); + if (emailExists > 0) { + return ApiResponse.error(400, "邮箱已存在!"); + } + } + user.setEmail(userUpdateDTO.getEmail()); + } + if (userUpdateDTO.getGender() != null && !userUpdateDTO.getGender().isEmpty()) { + user.setGender(userUpdateDTO.getGender()); + } + if (userUpdateDTO.getPassword() != null && !userUpdateDTO.getPassword().isEmpty()) { + String hashedPassword = BCrypt.hashpw(userUpdateDTO.getPassword(), BCrypt.gensalt()); + user.setPassword(hashedPassword); + } + + if (userUpdateDTO.getBirthday() != null && !userUpdateDTO.getBirthday().isEmpty()) { + try { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); + Date birthday = sdf.parse(userUpdateDTO.getBirthday()); + user.setBirthday(birthday); + } catch (ParseException e) { + user.setBirthday(null); + } + } else { + user.setBirthday(null); + } + + int result = userInformationMapper.updateUserByUUID(user); + if (result <= 0) { + return ApiResponse.error(500, "更新失败"); + } + return ApiResponse.success("更新成功"); + } + + @Override + public String getUserUUID(String username) { + return userInformationMapper.selectUserUUID(username); + } + + + // 后台用户管理 + @Override + public List getAllEnableUsers() { + return userInformationMapper.selectAllEnableUsers(); + } + + @Override + public List getAllUsers() { + return userInformationMapper.selectAllUsers(); + } + + @Override + public List getUsersPage(int page, int size, String keyword) { + int safePage = Math.max(page, 1); + int safeSize = Math.min(Math.max(size, 1), 100); + return userInformationMapper.selectUsersPage(keyword, (safePage - 1) * safeSize, safeSize); + } + + @Override + public int countUsers(String keyword) { + return userInformationMapper.countUsers(keyword); + } + + + @Override + public UserDTO getUserById(Integer id) { + return userInformationMapper.selectUserById(id); + } + + @Override + public boolean updateUserRole(Integer userId, Integer roleId) { + try { + int count = userRoleMapper.countUserRole(userId, roleId); + if (count > 0) { + int result = userRoleMapper.deleteUserRole(userId, roleId); + return result > 0; + } else { + int result = userRoleMapper.insertUserRole(userId, roleId); + return result > 0; + } + } catch (Exception e) { + log.error("更新用户角色失败", e); + return false; + } + } + + @Override + public boolean updateUserStatus(Integer userId, Byte status) { + try { + UserDTO user = userInformationMapper.selectUserById(userId); + if (user == null) { + return false; + } + + int result = userInformationMapper.updateUserStatus(userId, status); + + if (status == 0 || status == 2) { + userBlacklistService.addUserToBlacklist(user.getUsername()); + } else if (status == 1) { + userBlacklistService.removeUserFromBlacklist(user.getUsername()); + } + + return result > 0; + } catch (Exception e) { + return false; + } + } + + @Override + public List getAllRoles() { + return userInformationMapper.selectAllRoles(); + } + + @Override + public List getUserPermissions(Integer userId) { + return userInformationMapper.selectPermissionsByUserId(userId); + } + + @Override + public void addUserToBlacklist(String username) { + userBlacklistService.addUserToBlacklist(username); + } + + @Override + public void removeUserFromBlacklist(String username) { + userBlacklistService.removeUserFromBlacklist(username); + } + + public boolean checkPassword(String plaintextPassword, String hashedPassword) { + if (plaintextPassword == null || hashedPassword == null) { + return false; + } + return BCrypt.checkpw(plaintextPassword, hashedPassword); + } + + + @Override + public ApiResponse updateUserByAdmin(UserUpdateDTO userUpdateDTO) { + UserInformation user = userInformationMapper.selectUserByUUID(userUpdateDTO.getUuid()); + if (user == null) { + return ApiResponse.error(404, "用户不存在"); + } + + if (userUpdateDTO.getUsername() != null && !userUpdateDTO.getUsername().isEmpty()) { + if (!user.getUsername().equals(userUpdateDTO.getUsername())) { + int usernameExists = userInformationMapper.selectUserByUsername(userUpdateDTO.getUsername()); + if (usernameExists > 0) { + return ApiResponse.error(400, "用户名已存在!"); + } + } + user.setUsername(userUpdateDTO.getUsername()); + } + + if (userUpdateDTO.getEmail() != null && !userUpdateDTO.getEmail().isEmpty()) { + if (!user.getEmail().equals(userUpdateDTO.getEmail())) { + int emailExists = userInformationMapper.selectUserByEmail(userUpdateDTO.getEmail()); + if (emailExists > 0) { + return ApiResponse.error(400, "邮箱已存在!"); + } + } + user.setEmail(userUpdateDTO.getEmail()); + } + if (userUpdateDTO.getPhone() != null && !userUpdateDTO.getPhone().isEmpty()) { + if (!user.getPhone().equals(userUpdateDTO.getPhone())) { + int phoneExists = userInformationMapper.selectUserByPhone(userUpdateDTO.getPhone()); + if (phoneExists > 0) { + return ApiResponse.error(400, "手机号已存在!"); + } + } + user.setPhone(userUpdateDTO.getPhone()); + } + if (userUpdateDTO.getPassword() != null && !userUpdateDTO.getPassword().isEmpty()) { + String hashedPassword = BCrypt.hashpw(userUpdateDTO.getPassword(), BCrypt.gensalt()); + user.setPassword(hashedPassword); + } + + int result = userInformationMapper.updateUserByUUID(user); + if (result <= 0) { + return ApiResponse.error(500, "更新失败"); + } + return ApiResponse.success("更新成功"); + } + + + @Override + public String getAvatarByUsername(String username) { + return userInformationMapper.selectAvatarByUsername(username); + } + + @Override + public List getPermissionCodesByUsername(String username) { + return userPermissionMapper.findPermissionCodesByUserName(username); + } + + @Override + public ApiResponse addUser(UserUpdateDTO userUpdateDTO) { + String existingUserUUID = userInformationMapper.selectUserUUID(userUpdateDTO.getUsername()); + if (existingUserUUID != null) { + return ApiResponse.error(400, "用户名已存在!"); + } + if (userUpdateDTO.getEmail() != null && !userUpdateDTO.getEmail().isEmpty()) { + int emailExists = userInformationMapper.selectUserByEmail(userUpdateDTO.getEmail()); + if (emailExists > 0) { + return ApiResponse.error(400, "邮箱已存在!"); + } + } + if (userUpdateDTO.getPhone() != null && !userUpdateDTO.getPhone().isEmpty()) { + int phoneExists = userInformationMapper.selectUserByPhone(userUpdateDTO.getPhone()); + if (phoneExists > 0) { + return ApiResponse.error(400, "手机号已存在!"); + } + } + + UserInformation newUser = new UserInformation(); + newUser.setUuid(RandomUtil.generateUUID()); + newUser.setUsername(userUpdateDTO.getUsername()); + newUser.setEmail(userUpdateDTO.getEmail()); + newUser.setPhone(userUpdateDTO.getPhone()); + String rawGender = userUpdateDTO.getGender(); + String dbGender = null; + if (rawGender != null && !rawGender.trim().isEmpty()) { + String trimmed = rawGender.trim(); + if ("male".equals(trimmed) || "female".equals(trimmed) || "other".equals(trimmed)) { + dbGender = trimmed; + } + } + newUser.setGender(dbGender); + + int defaultRoleId = siteConfigMapper.getRegisterUserPermission(); + newUser.setStatus((byte) 1); + + String hashedPassword = BCrypt.hashpw(userUpdateDTO.getPassword(), BCrypt.gensalt()); + newUser.setPassword(hashedPassword); + + if (userUpdateDTO.getBirthday() != null && !userUpdateDTO.getBirthday().isEmpty()) { + try { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); + Date birthday = sdf.parse(userUpdateDTO.getBirthday()); + newUser.setBirthday(birthday); + } catch (ParseException e) { + newUser.setBirthday(null); + } + } else { + newUser.setBirthday(null); + } + + int result = userInformationMapper.insertUser(newUser); + if (result <= 0) { + return ApiResponse.error(500, "添加用户失败"); + } + + Integer newUserId = newUser.getId(); + if (newUserId != null) { + userRoleMapper.insertUserRole(newUserId, defaultRoleId); + } + + return ApiResponse.success("添加用户成功"); + } + +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/WebDavFileStorageImpl.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/WebDavFileStorageImpl.java new file mode 100644 index 0000000..931581e --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/service/impl/WebDavFileStorageImpl.java @@ -0,0 +1,236 @@ +package xyz.lingview.dimstack.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.client.JdkClientHttpRequestFactory; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClient; +import xyz.lingview.dimstack.service.FileStorage; + +import java.io.IOException; +import java.io.InputStream; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Base64; +import java.util.stream.Collectors; + +@Slf4j +public class WebDavFileStorageImpl implements FileStorage { + + private final RestClient restClient; + private final String baseUrl; + + public WebDavFileStorageImpl(String url, String username, String password) { + this.baseUrl = stripTrailingSlash(url); + this.restClient = RestClient.builder() + .requestFactory(new JdkClientHttpRequestFactory()) + .defaultHeader(HttpHeaders.AUTHORIZATION, basicAuth(username, password)) + .build(); + verifyConnection(); + } + + private void verifyConnection() { + try { + restClient.method(HttpMethod.valueOf("PROPFIND")) + .uri(baseUrl) + .header("Depth", "0") + .retrieve() + .toBodilessEntity(); + } catch (HttpClientErrorException.Unauthorized e) { + throw new RuntimeException("WebDAV认证失败,请检查用户名和密码", e); + } catch (ResourceAccessException e) { + throw new RuntimeException("WebDAV服务器无法连接: " + e.getMessage(), e); + } catch (Exception e) { + log.debug("WebDAV初始化PROPFIND返回异常: {}", e.getMessage()); + } + } + + @Override + public void store(String objectKey, InputStream data, long contentLength, String contentType) { + ensureParentCollections(objectKey); + String url = resolveUrl(objectKey); + Resource body = new InputStreamResource(data) { + @Override + public long contentLength() { + return contentLength; + } + }; + try { + restClient.put() + .uri(url) + .header(HttpHeaders.CONTENT_TYPE, + (contentType != null && !contentType.isEmpty()) ? contentType : "application/octet-stream") + .body(body) + .retrieve() + .toBodilessEntity(); + log.debug("WebDAV写入成功: {}", url); + } catch (Exception e) { + log.error("WebDAV写入失败: {}", url, e); + throw new RuntimeException("WebDAV文件写入失败", e); + } + } + + @Override + public InputStream retrieve(String objectKey) { + String url = resolveUrl(objectKey); + try { + if (!exists(objectKey)) { + throw new RuntimeException("WebDAV文件不存在: " + objectKey); + } + + PipedInputStream in = new PipedInputStream(); + PipedOutputStream out = new PipedOutputStream(in); + + Thread runner = new Thread(() -> { + try { + restClient.get() + .uri(url) + .exchange((request, response) -> { + // exchange 不抛 4xx/5xx,由我们手动检查 + if (response.getStatusCode().isError()) { + throw new HttpClientErrorException( + response.getStatusCode(), + response.getStatusText()); + } + response.getBody().transferTo(out); + return null; + }); + } catch (Exception e) { + log.error("WebDAV流式读取失败: {}", url, e); + } finally { + try { out.close(); } catch (IOException ignored) { } + } + }); + runner.setDaemon(true); + runner.start(); + + return in; + } catch (Exception e) { + log.error("WebDAV读取失败: {}", url, e); + throw new RuntimeException("WebDAV文件读取失败", e); + } + } + + @Override + public void delete(String objectKey) { + String url = resolveUrl(objectKey); + try { + restClient.delete() + .uri(url) + .retrieve() + .toBodilessEntity(); + log.debug("WebDAV删除成功: {}", url); + } catch (HttpClientErrorException.NotFound e) { + log.debug("WebDAV删除目标不存在,视为已删除: {}", url); + } catch (Exception e) { + log.error("WebDAV删除失败: {}", url, e); + throw new RuntimeException("WebDAV文件删除失败", e); + } + } + + @Override + public boolean exists(String objectKey) { + String url = resolveUrl(objectKey); + try { + restClient.method(HttpMethod.valueOf("PROPFIND")) + .uri(url) + .header("Depth", "0") + .retrieve() + .toBodilessEntity(); + return true; + } catch (HttpClientErrorException.NotFound e) { + return false; + } catch (Exception e) { + log.warn("WebDAV exists检查失败: {}", url, e); + return false; + } + } + + @Override + public void copy(String sourceKey, String destKey) { + String sourceUrl = resolveUrl(sourceKey); + String destUrl = resolveUrl(destKey); + ensureParentCollections(destKey); + try { + restClient.method(HttpMethod.valueOf("COPY")) + .uri(sourceUrl) + .header("Destination", destUrl) + .header("Overwrite", "T") + .retrieve() + .toBodilessEntity(); + log.debug("WebDAV拷贝成功: {} -> {}", sourceUrl, destUrl); + } catch (Exception e) { + log.error("WebDAV拷贝失败: {} -> {}", sourceUrl, destUrl, e); + throw new RuntimeException("WebDAV文件拷贝失败", e); + } + } + + @Override + public String getType() { + return "webdav"; + } + + + private void ensureParentCollections(String objectKey) { + String[] segments = objectKey.split("/"); + StringBuilder path = new StringBuilder(); + for (int i = 0; i < segments.length - 1; i++) { + if (segments[i].isEmpty()) { + continue; + } + if (path.length() > 0) { + path.append("/"); + } + path.append(segments[i]); + mkcol(baseUrl + "/" + encodePath(path.toString())); + } + } + + private void mkcol(String dirUrl) { + try { + restClient.method(HttpMethod.valueOf("MKCOL")) + .uri(dirUrl) + .retrieve() + .toBodilessEntity(); + } catch (HttpClientErrorException e) { + if (e.getStatusCode().value() != 405) { + log.debug("MKCOL {} 返回状态 {}", dirUrl, e.getStatusCode()); + } + } catch (Exception e) { + log.debug("MKCOL {} 失败: {}", dirUrl, e.getMessage()); + } + } + + private String resolveUrl(String objectKey) { + return baseUrl + "/" + encodePath(objectKey); + } + + private String encodePath(String path) { + return Arrays.stream(path.split("/")) + .map(segment -> URLEncoder.encode(segment, StandardCharsets.UTF_8).replace("+", "%20")) + .collect(Collectors.joining("/")); + } + + private static String stripTrailingSlash(String s) { + if (s == null) { + return ""; + } + String result = s.trim(); + while (result.endsWith("/")) { + result = result.substring(0, result.length() - 1); + } + return result; + } + + private static String basicAuth(String username, String password) { + String credentials = (username == null ? "" : username) + ":" + (password == null ? "" : password); + return "Basic " + Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/task/ExpiredAttachmentCleanupTask.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/task/ExpiredAttachmentCleanupTask.java new file mode 100644 index 0000000..7e512d4 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/task/ExpiredAttachmentCleanupTask.java @@ -0,0 +1,35 @@ +package xyz.lingview.dimstack.task; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import xyz.lingview.dimstack.service.AttachmentManagementService; + +/** + * @Author: lingview + * @Date: 2026/02/09 + * @Description: 过期附件清理定时任务 + * @Version: 1.0 + */ +@Component +@Slf4j +public class ExpiredAttachmentCleanupTask { + + @Autowired + private AttachmentManagementService attachmentManagementService; + + @Scheduled(fixedRate = 3 * 60 * 60 * 1000) + public void cleanupExpiredAttachments() { + try { + log.info("开始执行过期附件清理任务..."); + + int cleanedCount = attachmentManagementService.cleanupExpiredDeletedAttachments(); + + log.info("过期附件清理任务完成,共清理 {} 个附件", cleanedCount); + } catch (Exception e) { + log.error("执行过期附件清理任务时发生错误", e); + } + } + +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/task/HotArticleScheduledTask.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/task/HotArticleScheduledTask.java new file mode 100644 index 0000000..b1c25a0 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/task/HotArticleScheduledTask.java @@ -0,0 +1,22 @@ +package xyz.lingview.dimstack.task; + +import lombok.extern.slf4j.Slf4j; +import xyz.lingview.dimstack.service.HotArticleService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +public class HotArticleScheduledTask { + + @Autowired + private HotArticleService hotArticleService; + + @Scheduled(fixedRate = 3600000) + public void refreshHotArticlesCache() { + log.info("开始刷新热门文章缓存..."); + hotArticleService.refreshHotArticlesCache(); + log.info("完成刷新热门文章缓存!"); + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/task/TempUploadCleanupService.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/task/TempUploadCleanupService.java new file mode 100644 index 0000000..a53abd1 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/task/TempUploadCleanupService.java @@ -0,0 +1,104 @@ +package xyz.lingview.dimstack.task; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.stream.Stream; + +/** + * @Author: lingview + * @Date: 2025/12/18 15:43:44 + * @Description: 定时清理用户过期的分片上传目录 + * @Version: 1.0 + */ +@Slf4j +@Service +public class TempUploadCleanupService { + + @Value("${file.data-root:.}") + private String dataRoot; + + @Value("${file.upload-dir:upload}") + private String uploadDir; + + + @Scheduled(cron = "0 0 */6 * * ?") + public void cleanupExpiredTempUploads() { + log.info("开始清理超过24小时的临时上传目录..."); + + Path uploadBasePath = Path.of(dataRoot).toAbsolutePath().normalize().resolve(uploadDir); + + if (!Files.exists(uploadBasePath) || !Files.isDirectory(uploadBasePath)) { + log.warn("上传根目录不存在或无效: {}", uploadBasePath); + return; + } + + try (Stream userDirs = Files.list(uploadBasePath)) { + userDirs + .filter(Files::isDirectory) + .forEach(this::cleanupUserTempDirs); + } catch (IOException e) { + log.error("无法读取上传根目录: {}", uploadBasePath, e); + } + + log.info("临时上传目录清理完成。"); + } + + private void cleanupUserTempDirs(Path userDir) { + String username = userDir.getFileName().toString(); + Path userTempDir = userDir.resolve("temp"); + + if (!Files.exists(userTempDir)) { + return; + } + + if (!Files.isDirectory(userTempDir)) { + log.warn("用户 {} 的 temp 路径不是目录,跳过: {}", username, userTempDir); + return; + } + + try (Stream uploadIdDirs = Files.list(userTempDir)) { + uploadIdDirs + .filter(Files::isDirectory) + .forEach(uploadIdDir -> { + try { + FileTime lastModified = Files.getLastModifiedTime(uploadIdDir); + long hoursOld = ChronoUnit.HOURS.between(lastModified.toInstant(), Instant.now()); + + if (hoursOld >= 24) { + deleteRecursively(uploadIdDir); + log.info("已清理用户 {} 的过期临时上传目录({} 小时前): {}", + username, hoursOld, uploadIdDir.getFileName()); + } + } catch (IOException e) { + log.warn("检查或删除用户 {} 的临时目录失败: {}", username, uploadIdDir, e); + } + }); + } catch (IOException e) { + log.warn("无法读取用户 {} 的 temp 目录: {}", username, userTempDir, e); + } + } + + private void deleteRecursively(Path dir) { + try (Stream walk = Files.walk(dir)) { + walk.sorted((a, b) -> -a.compareTo(b)) + .forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + log.warn("无法删除临时文件: {}", path, e); + } + }); + } catch (IOException e) { + log.warn("遍历临时目录失败: {}", dir, e); + } + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/ApiKeyUtil.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/ApiKeyUtil.java new file mode 100644 index 0000000..2d76b1a --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/ApiKeyUtil.java @@ -0,0 +1,35 @@ +package xyz.lingview.dimstack.util; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; + +public class ApiKeyUtil { + + public static final String KEY_PREFIX = "dim_"; + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private static final Base64.Encoder URL_ENCODER = Base64.getUrlEncoder().withoutPadding(); + + public static String generateApiKey() { + byte[] randomBytes = new byte[32]; + SECURE_RANDOM.nextBytes(randomBytes); + return KEY_PREFIX + URL_ENCODER.encodeToString(randomBytes); + } + + public static String sha256Hex(String apiKey) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashBytes = digest.digest(apiKey.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(hashBytes.length * 2); + for (byte b : hashBytes) { + sb.append("%02x".formatted(b)); + } + return sb.toString(); + } catch (Exception e) { + throw new IllegalStateException("SHA-256 算法不可用", e); + } + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/BotUtil.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/BotUtil.java new file mode 100644 index 0000000..5c32616 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/BotUtil.java @@ -0,0 +1,20 @@ +package xyz.lingview.dimstack.util; + +import java.util.List; + +public final class BotUtil { + + private BotUtil() { + } + + private static final List BOT_UA = List.of( + "Googlebot", "Bingbot", "Baiduspider", "DuckDuckBot", "Sogou", "360Spider", + "YisouSpider", "ByteSpider" + ); + + public static boolean isBot(String ua) { + if (ua == null) return false; + String uaLower = ua.toLowerCase(); + return BOT_UA.stream().anyMatch(bot -> uaLower.contains(bot.toLowerCase())); + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/CaptchaUtil.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/CaptchaUtil.java new file mode 100644 index 0000000..c0f431c --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/CaptchaUtil.java @@ -0,0 +1,79 @@ +package xyz.lingview.dimstack.util; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.util.Base64; +import java.util.Random; +import java.util.UUID; + +public class CaptchaUtil { + + private static final String CHARACTERS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + private static final Random RANDOM = new Random(); + + public static String generateCaptcha(int length) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < length; i++) { + sb.append(CHARACTERS.charAt(RANDOM.nextInt(CHARACTERS.length()))); + } + return sb.toString(); + } + + // 生成验证码key + public static String generateCaptchaKey() { + return UUID.randomUUID().toString().replace("-", "").substring(0, 16); + } + + // 生成验证码图片 + public static String generateCaptchaImage(String captcha) { + int width = 120; + int height = 40; + + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + Graphics2D g = image.createGraphics(); + + g.setColor(Color.WHITE); + g.fillRect(0, 0, width, height); + + g.setColor(Color.LIGHT_GRAY); + for (int i = 0; i < 5; i++) { + int x1 = RANDOM.nextInt(width); + int y1 = RANDOM.nextInt(height); + int x2 = RANDOM.nextInt(width); + int y2 = RANDOM.nextInt(height); + g.drawLine(x1, y1, x2, y2); + } + + g.setFont(new Font("Arial", Font.BOLD, 24)); + + for (int i = 0; i < captcha.length(); i++) { + g.setColor(new Color(RANDOM.nextInt(150), RANDOM.nextInt(150), RANDOM.nextInt(150))); + int x = 20 + i * 20; + int y = 30; + g.drawString(String.valueOf(captcha.charAt(i)), x, y); + } + + g.dispose(); + + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(image, "png", baos); + byte[] bytes = baos.toByteArray(); + return Base64.getEncoder().encodeToString(bytes); + } catch (Exception e) { + e.printStackTrace(); + return ""; + } + } + + // 检查验证码是否正确 + public static boolean validateCaptcha(String sessionCaptcha, String inputCaptcha) { + if (sessionCaptcha == null || inputCaptcha == null) { + return false; + } + return sessionCaptcha.equalsIgnoreCase(inputCaptcha.trim().toLowerCase()); + } + +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/CategoryPathUtil.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/CategoryPathUtil.java new file mode 100644 index 0000000..08ac8ff --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/CategoryPathUtil.java @@ -0,0 +1,47 @@ +package xyz.lingview.dimstack.util; + +import lombok.Data; +import org.springframework.stereotype.Component; + +/** + * @Author: lingview + * @Date: 2026/02/08 01:03:10 + * @Description: 多级分类解析 + * @Version: 1.0 + */ +@Component +public class CategoryPathUtil { + + public CategoryPath parsePath(String fullPath) { + if (fullPath == null || fullPath.isEmpty()) { + return new CategoryPath(null, null); + } + + String[] parts = fullPath.split("/"); + if (parts.length == 1) { + return new CategoryPath(null, parts[0].trim()); + } else if (parts.length >= 2) { + return new CategoryPath(parts[0].trim(), parts[parts.length - 1].trim()); + } + + return new CategoryPath(null, null); + } + + public String buildPath(String parent, String child) { + if (parent == null || parent.isEmpty()) { + return child; + } + return parent + "/" + child; + } + + @Data + public static class CategoryPath { + private String parentCategory; + private String childCategory; + + public CategoryPath(String parent, String child) { + this.parentCategory = parent; + this.childCategory = child; + } + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/DomainUtil.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/DomainUtil.java new file mode 100644 index 0000000..c6395d2 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/DomainUtil.java @@ -0,0 +1,33 @@ +package xyz.lingview.dimstack.util; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * @Author: lingview + * @Date: 2025/11/16 10:56:45 + * @Description: 动态获取域名 + * @Version: 1.0 + */ +public class DomainUtil { + public static String getFullDomain(HttpServletRequest request) { + String scheme = request.getHeader("X-Forwarded-Proto"); + if (scheme == null) scheme = request.getScheme(); + + String host = request.getHeader("X-Forwarded-Host"); + if (host == null) host = request.getServerName(); + + String portHeader = request.getHeader("X-Forwarded-Port"); + int port; + if (portHeader != null) { + port = Integer.parseInt(portHeader); + } else { + port = request.getServerPort(); + } + + boolean defaultPort = + (scheme.equals("http") && port == 80) || + (scheme.equals("https") && port == 443); + + return defaultPort ? (scheme + "://" + host) : (scheme + "://" + host + ":" + port); + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/FileSuffix.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/FileSuffix.java new file mode 100644 index 0000000..79ca646 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/FileSuffix.java @@ -0,0 +1,23 @@ +//package xyz.lingview.dimstack.util; +// +//import java.nio.file.Path; +// +//// 文件扩展名获取类 +//public class FileSuffix { +// +// public static String getFileExtension(String filePath) { +// if (filePath == null || filePath.isEmpty()) { +// return ""; +// } +// +// int dotIndex = filePath.lastIndexOf("."); +// if (dotIndex > 0 && dotIndex < filePath.length() - 1) { +// return filePath.substring(dotIndex).toLowerCase(); +// } +// return ""; +// } +// +// public static String getFileExtension(Path file) { +// return getFileExtension(file.getFileName().toString()); +// } +//} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/ImageCompressorUtil.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/ImageCompressorUtil.java new file mode 100644 index 0000000..9f041be --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/ImageCompressorUtil.java @@ -0,0 +1,96 @@ +package xyz.lingview.dimstack.util; + +import net.coobird.thumbnailator.Thumbnails; + +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; + +/** + * @Author: lingview + * @Date: 2026/04/17 22:25:12 + * @Description: 图片压缩工具 + * @Version: 1.0 + */ +public class ImageCompressorUtil { + + public static void compress(String inputPath, String outputPath) throws IOException { + File inputFile = new File(inputPath); + if (!inputFile.exists()) { + throw new IllegalArgumentException("原图不存在: " + inputPath); + } + + long originalSize = inputFile.length(); + + String fileName = inputFile.getName().toLowerCase(); + boolean isJpeg = fileName.endsWith(".jpg") || fileName.endsWith(".jpeg"); + boolean isWebp = fileName.endsWith(".webp"); + + if (isWebp) { + Files.copy(inputFile.toPath(), new File(outputPath).toPath()); + return; + } + + if (originalSize <= 1_000_000) { + Files.copy(inputFile.toPath(), new File(outputPath).toPath()); + return; + } + + float quality; + if (isJpeg) { + quality = calculateQualityForJpeg(originalSize); + } else { + quality = calculateQualityForOther(originalSize); + } + + Thumbnails.of(inputFile) + .scale(1.0) + .outputQuality(quality) + .outputFormat("JPEG") + .toFile(new File(outputPath)); + + File outputFile = new File(outputPath); + long compressedSize = outputFile.length(); + + if (compressedSize >= originalSize) { + outputFile.delete(); + javax.imageio.ImageIO.read(inputFile); + Thumbnails.of(inputFile) + .scale(0.8) + .outputQuality(0.7f) + .outputFormat("JPEG") + .toFile(new File(outputPath)); + + compressedSize = new File(outputPath).length(); + if (compressedSize >= originalSize) { + outputFile.delete(); + java.nio.file.Files.copy(inputFile.toPath(), outputFile.toPath()); + } + } + } + + + private static float calculateQualityForJpeg(long fileSizeBytes) { + if (fileSizeBytes <= 2_000_000) { + return 0.55f; + } else if (fileSizeBytes <= 5_000_000) { + return 0.45f; + } else { + return 0.35f; + } + } + + private static float calculateQualityForOther(long fileSizeBytes) { + if (fileSizeBytes <= 500_000) { + return 0.7f; + } else if (fileSizeBytes <= 2_000_000) { + return 0.6f; + } else if (fileSizeBytes <= 5_000_000) { + return 0.5f; + } else { + return 0.4f; + } + } + +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/LargeLanguageModelsUtil.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/LargeLanguageModelsUtil.java new file mode 100644 index 0000000..fa8403f --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/LargeLanguageModelsUtil.java @@ -0,0 +1,118 @@ +package xyz.lingview.dimstack.util; + +import lombok.extern.slf4j.Slf4j; +import tools.jackson.databind.ObjectMapper; + +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Map; + +/** + * @Author: lingview + * @Date: 2026/04/08 17:06:58 + * @Description: 大模型调用工具 + * @Version: 1.0 + */ +@Slf4j +public class LargeLanguageModelsUtil { + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + public static String callOpenAICompatibleAPI(String apiKey, String apiUrl, String model, String systemContent, String userQuestion) + throws IOException, InterruptedException { + int retryCount = 0; + int maxRetries = 3; + long delayBetweenRetries = 2000; + + while (retryCount <= maxRetries) { + try { + if (retryCount > 0) { + Thread.sleep(delayBetweenRetries); + } + + URL url = new URL(apiUrl); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + + connection.setRequestMethod("POST"); + connection.setRequestProperty("Authorization", "Bearer " + apiKey); + connection.setRequestProperty("Content-Type", "application/json"); + connection.setRequestProperty("Accept", "application/json"); + connection.setConnectTimeout(10_000); + connection.setReadTimeout(30_000); + connection.setDoOutput(true); + + Map payload = Map.of( + "model", model, + "stream", false, + "messages", Arrays.asList( + Map.of("role", "system", "content", systemContent), + Map.of("role", "user", "content", userQuestion) + ) + ); + + String jsonInputString = objectMapper.writeValueAsString(payload); + + try (OutputStream os = connection.getOutputStream()) { + byte[] input = jsonInputString.getBytes(StandardCharsets.UTF_8); + os.write(input, 0, input.length); + } + + int responseCode = connection.getResponseCode(); + + if (responseCode == HttpURLConnection.HTTP_OK) { + try (BufferedReader in = new BufferedReader( + new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) { + StringBuilder response = new StringBuilder(); + String line; + while ((line = in.readLine()) != null) { + response.append(line); + } + return response.toString(); + } + } else { + InputStream errorStream = connection.getErrorStream(); + String errorContent = ""; + if (errorStream != null) { + try (BufferedReader errorReader = new BufferedReader( + new InputStreamReader(errorStream, StandardCharsets.UTF_8))) { + StringBuilder error = new StringBuilder(); + String line; + while ((line = errorReader.readLine()) != null) { + error.append(line).append("\n"); + } + errorContent = error.toString(); + } + } + + log.warn("请求失败,HTTP状态码: {}, 错误详情:\n{}", responseCode, errorContent); + + if (responseCode == 429 || responseCode == 503 || responseCode == 500) { + retryCount++; + log.warn("触发限流或服务不可用,正在进行第 {} 次重试...", retryCount); + continue; + } else { + throw new IOException("请求失败,状态码:" + responseCode + ", 响应内容:" + errorContent); + } + } + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("线程被中断: {}", e.getMessage()); + throw new IOException("请求被中断", e); + } catch (Exception e) { + log.warn("请求调用异常: {}", e.getMessage(), e); + retryCount++; + if (retryCount > maxRetries) { + throw new IOException("请求失败,已达到最大重试次数: " + maxRetries, e); + } + log.warn("正在进行第 {} 次重试...", retryCount); + Thread.sleep(delayBetweenRetries); + } + } + + throw new IOException("请求失败,已达到最大重试次数: " + maxRetries); + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/MimeTypeUtil.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/MimeTypeUtil.java new file mode 100644 index 0000000..ae22d3e --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/MimeTypeUtil.java @@ -0,0 +1,52 @@ +package xyz.lingview.dimstack.util; + +import java.util.Map; + +/** + * @Author: lingview + * @Description: 文件扩展名到MIME类型的映射工具 + * @Version: 1.0 + */ +public final class MimeTypeUtil { + + private static final Map EXTENSION_TO_MIME = Map.ofEntries( + Map.entry(".jpg", "image/jpeg"), + Map.entry(".jpeg", "image/jpeg"), + Map.entry(".png", "image/png"), + Map.entry(".gif", "image/gif"), + Map.entry(".webp", "image/webp"), + Map.entry(".svg", "image/svg+xml"), + Map.entry(".bmp", "image/bmp"), + Map.entry(".mp4", "video/mp4"), + Map.entry(".webm", "video/webm"), + Map.entry(".ogg", "video/ogg"), + Map.entry(".avi", "video/avi"), + Map.entry(".mov", "video/mov"), + Map.entry(".mkv", "video/mkv"), + Map.entry(".mp3", "audio/mp3"), + Map.entry(".wav", "audio/wav"), + Map.entry(".flac", "audio/flac"), + Map.entry(".aac", "audio/aac"), + Map.entry(".m4a", "audio/m4a"), + Map.entry(".zip", "application/zip"), + Map.entry(".rar", "application/x-rar-compressed"), + Map.entry(".tar", "application/x-tar"), + Map.entry(".gz", "application/gzip"), + Map.entry(".tgz", "application/gzip"), + Map.entry(".xz", "application/x-xz"), + Map.entry(".7z", "application/x-7z-compressed"), + Map.entry(".pdf", "application/pdf"), + Map.entry(".doc", "application/msword"), + Map.entry(".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document") + ); + + private MimeTypeUtil() { + } + + public static String getByExtension(String extension) { + if (extension == null) { + return "application/octet-stream"; + } + return EXTENSION_TO_MIME.getOrDefault(extension.toLowerCase(), "application/octet-stream"); + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/PasswordUtil.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/PasswordUtil.java new file mode 100644 index 0000000..a999de1 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/PasswordUtil.java @@ -0,0 +1,16 @@ +package xyz.lingview.dimstack.util; + +import org.mindrot.jbcrypt.BCrypt; + +public class PasswordUtil { + + // 加密密码 + public static String hashPassword(String password) { + return BCrypt.hashpw(password, BCrypt.gensalt()); + } + + // 检查密码是否相同 + public static boolean checkPassword(String password, String hashedPassword) { + return BCrypt.checkpw(password, hashedPassword); + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/RandomUtil.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/RandomUtil.java new file mode 100644 index 0000000..8626ce7 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/RandomUtil.java @@ -0,0 +1,123 @@ +package xyz.lingview.dimstack.util; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.security.SecureRandom; +import java.util.UUID; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +public class RandomUtil { + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private static final Path SALT_FILE = Path.of(".random_salt"); + + private static final String LOADED_SALT = loadOrCreateSalt(); + + private static final String CACHED_NODE_ID = initHashedNodeId(); + + private static String loadOrCreateSalt() { + try { + if (Files.exists(SALT_FILE)) { + String salt = Files.readString(SALT_FILE).trim(); + if (salt.length() == 64 && salt.matches("[a-f0-9]{64}")) { + return salt; + } + } + + byte[] saltBytes = new byte[32]; + SECURE_RANDOM.nextBytes(saltBytes); + StringBuilder sb = new StringBuilder(64); + for (byte b : saltBytes) { + sb.append("%02x".formatted(b)); + } + String newSalt = sb.toString(); + + Files.write(SALT_FILE, newSalt.getBytes(), + StandardOpenOption.CREATE, + StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING); + + try { + SALT_FILE.toFile().setReadable(false, false); + SALT_FILE.toFile().setReadable(true, true); + SALT_FILE.toFile().setWritable(true, true); + } catch (Exception ignored) {} + + return newSalt; + + } catch (Exception e) { + byte[] fallback = new byte[32]; + SECURE_RANDOM.nextBytes(fallback); + StringBuilder sb = new StringBuilder(64); + for (byte b : fallback) { + sb.append("%02x".formatted(b)); + } + return sb.toString(); + } + } + + + private static String getLocalIpAddress() { + try { + java.util.Enumeration interfaces = + java.net.NetworkInterface.getNetworkInterfaces(); + while (interfaces.hasMoreElements()) { + java.net.NetworkInterface nic = interfaces.nextElement(); + if (nic.isLoopback() || !nic.isUp()) continue; + java.util.Enumeration addresses = nic.getInetAddresses(); + while (addresses.hasMoreElements()) { + java.net.InetAddress addr = addresses.nextElement(); + if (addr instanceof java.net.Inet4Address && !addr.isLoopbackAddress()) { + return addr.getHostAddress(); + } + } + } + return java.net.InetAddress.getLocalHost().getHostAddress(); + } catch (Exception e) { + return "127.0.0.1"; + } + } + + + private static String initHashedNodeId() { + String ip = getLocalIpAddress(); + String hash = hmacSha256(ip, LOADED_SALT); + return hash.substring(0, Math.min(hash.length(), 32)); + } + + + private static String hmacSha256(String data, String key) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key.getBytes(), "HmacSHA256")); + byte[] rawHmac = mac.doFinal(data.getBytes()); + StringBuilder sb = new StringBuilder(rawHmac.length * 2); + for (byte b : rawHmac) { + sb.append("%02x".formatted(b)); + } + return sb.toString(); + } catch (Exception e) { + return "fallback_hmac_" + Math.abs(SECURE_RANDOM.nextInt(1000000)); + } + } + + + private static String generate8DigitRandom() { + int num = 10_000_000 + SECURE_RANDOM.nextInt(90_000_000); + return String.valueOf(num); + } + + public static String generateUUID() { + String uuid = UUID.randomUUID().toString().replace("-", ""); + String timestamp = String.valueOf(System.currentTimeMillis()); + return CACHED_NODE_ID + uuid + timestamp + generate8DigitRandom(); + } + + public static String generateStandardUUID() { + String uuid = UUID.randomUUID().toString(); + String timestamp = String.valueOf(System.currentTimeMillis()); + return CACHED_NODE_ID + "-" + uuid + "-" + timestamp + "-" + generate8DigitRandom(); + } +} \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/SiteConfigUtil.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/SiteConfigUtil.java new file mode 100644 index 0000000..f33ea90 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/SiteConfigUtil.java @@ -0,0 +1,278 @@ +package xyz.lingview.dimstack.util; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import xyz.lingview.dimstack.service.CacheService; +import xyz.lingview.dimstack.domain.SiteConfig; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class SiteConfigUtil { + + @Autowired + private CacheService cacheService; + + /** + * 从缓存中的dimstack:site_config获取指定字段的值 + * @param fieldKey 字段名 + * @return 字段值,如果不存在或出错则返回null + */ + public String getSiteConfigField(String fieldKey) { + try { + SiteConfig siteConfig = cacheService.get("dimstack:site_config", SiteConfig.class); + if (siteConfig != null) { + switch (fieldKey) { + case "site_name": + return siteConfig.getSite_name(); + case "copyright": + return siteConfig.getCopyright(); + case "site_icon": + return siteConfig.getSite_icon(); + default: + return null; + } + } + } catch (Exception e) { + log.warn("获取站点配置字段 {} 时发生异常", fieldKey, e); + } + return null; + } + + /** + * 从缓存中的dimstack:site_config获取站点名称 + * @return 站点名称,如果不存在或出错则返回null + */ + public String getSiteName() { + try { + SiteConfig siteConfig = cacheService.get("dimstack:site_config", SiteConfig.class); + return siteConfig != null ? siteConfig.getSite_name() : null; + } catch (Exception e) { + log.warn("获取站点名称时发生异常", e); + return null; + } + } + + /** + * 从缓存中的dimstack:site_config获取版权信息 + * @return 版权信息,如果不存在或出错则返回null + */ + public String getCopyright() { + try { + SiteConfig siteConfig = cacheService.get("dimstack:site_config", SiteConfig.class); + return siteConfig != null ? siteConfig.getCopyright() : null; + } catch (Exception e) { + log.warn("获取版权信息时发生异常", e); + return null; + } + } + + /** + * 从缓存中的dimstack:site_config获取站点图标 + * @return 站点图标URL,如果不存在或出错则返回null + */ + public String getSiteIcon() { + try { + SiteConfig siteConfig = cacheService.get("dimstack:site_config", SiteConfig.class); + return siteConfig != null ? siteConfig.getSite_icon() : null; + } catch (Exception e) { + log.warn("获取站点图标时发生异常", e); + return null; + } + } + + /** + * 从缓存中的dimstack:site_config获取是否启用通知 + * @return boolean值,如果不存在或出错则返回false + */ + public boolean isNotificationEnabled() { + try { + SiteConfig siteConfig = cacheService.get("dimstack:site_config", SiteConfig.class); + if (siteConfig != null) { + Boolean enableNotification = siteConfig.getEnable_notification(); + return enableNotification != null && enableNotification; + } + } catch (Exception e) { + log.warn("检查通知设置时发生异常", e); + } + return false; + } + + + /** + * 从缓存中的dimstack:site_config获取是否启用用户注册 + * @return boolean值,如果不存在或出错则返回true(默认启用) + */ + public boolean isRegisterEnabled() { + try { + SiteConfig siteConfig = cacheService.get("dimstack:site_config", SiteConfig.class); + if (siteConfig != null) { + Integer enableRegister = siteConfig.getEnable_register(); + return enableRegister == null || enableRegister == 1; + } + } catch (Exception e) { + log.warn("检查注册设置时发生异常", e); + } + return true; + } + + + /** + * 从缓存中的dimstack:site_config获取是否启用音乐播放器 + * @return boolean值,如果不存在或出错则返回false(默认不启用) + */ + public boolean isMusicEnabled() { + try { + SiteConfig siteConfig = cacheService.get("dimstack:site_config", SiteConfig.class); + if (siteConfig != null) { + Integer enableMusic = siteConfig.getEnable_music(); + return enableMusic != null && enableMusic == 1; + } + } catch (Exception e) { + log.warn("检查音乐设置时发生异常", e); + } + return false; + } + + + /** + * 从缓存中的dimstack:site_config获取是否启用评论区 + * @return boolean值,如果不存在或出错则返回true(默认启用) + */ + public boolean isCommentEnabled() { + try { + SiteConfig siteConfig = cacheService.get("dimstack:site_config", SiteConfig.class); + if (siteConfig != null) { + Integer enableComment = siteConfig.getEnable_comment(); + return enableComment == null || enableComment == 1; + } + } catch (Exception e) { + log.warn("检查评论区设置时发生异常", e); + } + return true; + } + + + public boolean adminPostNoReview() { + try { + SiteConfig siteConfig = cacheService.get("dimstack:site_config", SiteConfig.class); + if (siteConfig != null) { + Integer adminPostNoReview = siteConfig.getAdmin_post_no_review(); + return adminPostNoReview != null && adminPostNoReview == 1; + } + } catch (Exception e) { + log.warn("检查 admin_post_no_review 设置时发生异常", e); + } + return false; + } + + public String getGlobalHeadCode() { + try { + SiteConfig siteConfig = cacheService.get("dimstack:site_config", SiteConfig.class); + return siteConfig != null ? siteConfig.getGlobal_head_code() : null; + } catch (Exception e) { + log.warn("获取全局Head代码时发生异常", e); + return null; + } + } + + public String getContentHeadCode() { + try { + SiteConfig siteConfig = cacheService.get("dimstack:site_config", SiteConfig.class); + return siteConfig != null ? siteConfig.getContent_head_code() : null; + } catch (Exception e) { + log.warn("获取内容页Head代码时发生异常", e); + return null; + } + } + + public String getFooterCode() { + try { + SiteConfig siteConfig = cacheService.get("dimstack:site_config", SiteConfig.class); + return siteConfig != null ? siteConfig.getFooter_code() : null; + } catch (Exception e) { + log.warn("获取页脚代码时发生异常", e); + return null; + } + } + + public Integer getEnableImageCompression() { + try { + SiteConfig siteConfig = cacheService.get("dimstack:site_config", SiteConfig.class); + if (siteConfig != null) { + Integer enableCompression = siteConfig.getEnable_image_compression(); + return enableCompression != null ? enableCompression : 1; + } + } catch (Exception e) { + log.warn("获取图片压缩设置时发生异常", e); + } + return null; + } + + public Integer getImageCompressionThreads() { + try { + SiteConfig siteConfig = cacheService.get("dimstack:site_config", SiteConfig.class); + if (siteConfig != null) { + Integer threads = siteConfig.getImage_compression_threads(); + return threads != null && threads > 0 ? threads : 5; + } + } catch (Exception e) { + log.warn("获取图片压缩线程数时发生异常", e); + } + return 5; + } + + public Integer getProxyResourceDownload() { + try { + SiteConfig siteConfig = cacheService.get("dimstack:site_config", SiteConfig.class); + if (siteConfig != null) { + Integer proxyDownload = siteConfig.getProxy_resource_download(); + return proxyDownload != null ? proxyDownload : 0; + } + } catch (Exception e) { + log.warn("获取外部资源代理设置时发生异常", e); + } + return 0; + } + + public int getCommentStatus() { + try { + SiteConfig siteConfig = cacheService.get("dimstack:site_config", SiteConfig.class); + if (siteConfig != null) { + Integer status = siteConfig.getComment_status(); + return status != null ? status : 1; + } + } catch (Exception e) { + log.warn("获取评论默认状态时发生异常", e); + } + return 1; + } + + public boolean isLlmCommentReviewEnabled() { + try { + SiteConfig siteConfig = cacheService.get("dimstack:site_config", SiteConfig.class); + if (siteConfig != null) { + Integer enableLlm = siteConfig.getEnable_llm(); + Integer enableCommentReview = siteConfig.getEnable_llm_comment_review(); + return enableLlm != null && enableLlm == 1 + && enableCommentReview != null && enableCommentReview == 1; + } + } catch (Exception e) { + log.warn("检查LLM评论审核设置时发生异常", e); + } + return false; + } + + public boolean adminCommentNoReview() { + try { + SiteConfig siteConfig = cacheService.get("dimstack:site_config", SiteConfig.class); + if (siteConfig != null) { + Integer noReview = siteConfig.getAdmin_comment_no_review(); + return noReview == null || noReview == 1; + } + } catch (Exception e) { + log.warn("检查管理员评论免审核设置时发生异常", e); + } + return true; + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/UrlUtil.java b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/UrlUtil.java new file mode 100644 index 0000000..d4b157f --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/java/xyz/lingview/dimstack/util/UrlUtil.java @@ -0,0 +1,26 @@ +package xyz.lingview.dimstack.util; + +/** + * @Author: lingview + * @Date: 2026/01/02 00:15:34 + * @Description: url处理工具 + * @Version: 1.0 + */ +public class UrlUtil { + + public static String getFullUrl(String domain, String path) { + if (path == null || path.isEmpty()) { + return domain + "/favicon.ico"; + } + + if (path.startsWith("http://") || path.startsWith("https://")) { + return path; + } + + if (path.startsWith("/")) { + return domain + path; + } else { + return domain + "/" + path; + } + } +} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/application-init.yml b/demo/dim_stack/backend/dimstack-app/src/main/resources/application-init.yml new file mode 100644 index 0000000..8d59c5d --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/application-init.yml @@ -0,0 +1,18 @@ +server: + port: 0 + +spring: + autoconfigure: + exclude: + - org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration +app: + mode: init +logging: + level: + root: INFO + xyz.lingview.dimstack: DEBUG + +management: + health: + redis: + enabled: false \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/banner.txt b/demo/dim_stack/backend/dimstack-app/src/main/resources/banner.txt new file mode 100644 index 0000000..2138e51 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/banner.txt @@ -0,0 +1,7 @@ + ____ _ _____ __ __ + / __ \(_)___ ___ / ___// /_____ ______/ /__ + / / / / / __ `__ \ \__ \/ __/ __ `/ ___/ //_/ + / /_/ / / / / / / / ___/ / /_/ /_/ / /__/ ,< +/_____/_/_/ /_/ /_/ /____/\__/\__,_/\___/_/|_| + +:: Spring Boot :: ${spring-boot.version} diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V10__add_image_compression_threads.sql b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V10__add_image_compression_threads.sql new file mode 100644 index 0000000..ca45a84 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V10__add_image_compression_threads.sql @@ -0,0 +1,3 @@ +ALTER TABLE `site_config` + ADD COLUMN `image_compression_threads` int NULL DEFAULT 5 COMMENT '图片压缩处理线程数' + AFTER `enable_image_compression`; diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V11__add_proxy_resource_download.sql b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V11__add_proxy_resource_download.sql new file mode 100644 index 0000000..3efb897 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V11__add_proxy_resource_download.sql @@ -0,0 +1,2 @@ +-- 添加外部资源代理下载开关字段 +ALTER TABLE site_config ADD COLUMN proxy_resource_download INT DEFAULT 0 COMMENT '是否启用外部资源代理下载:0-禁用,1-启用'; diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V12__add_friend_links_config.sql b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V12__add_friend_links_config.sql new file mode 100644 index 0000000..f752178 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V12__add_friend_links_config.sql @@ -0,0 +1,12 @@ +DROP TABLE IF EXISTS `friend_links_config`; +CREATE TABLE `friend_links_config` ( + `id` int NOT NULL AUTO_INCREMENT, + `site_name` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '站点名称', + `site_url` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '站点url', + `site_logo` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '站点图标', + `description` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '站点描述', + `apply_rules` text CHARACTER SET utf8mb4 NULL COMMENT '友链申请规则说明', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `status` int NULL DEFAULT NULL COMMENT '站点状态1为启用,0为删除', + PRIMARY KEY (`id`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 ROW_FORMAT = Dynamic; \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V13__update_llm_prompt.sql b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V13__update_llm_prompt.sql new file mode 100644 index 0000000..f262482 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V13__update_llm_prompt.sql @@ -0,0 +1,5 @@ +UPDATE `llm_prompt_config` +SET `prompt_content` = '你是一位经验丰富的通用内容作者,擅长撰写结构清晰、语言流畅、富有逻辑与人文关怀的文章。你的文风平实而不平淡,兼具可读性与思想性,能用具体例子或生动比喻解释抽象概念。请避免使用术语堆砌或空洞口号,注重真实感、节奏感和读者共鸣。字数如用户没有特别要求请不要超过2000字。\n下面是本文的主题和内容要求:', + `create_time` = CURRENT_TIMESTAMP +WHERE `prompt_name` = 'article_create' + AND `prompt_content` = '你是一位经验丰富的通用内容作者,擅长撰写结构清晰、语言流畅、富有逻辑与人文关怀的文章。你的文风平实而不平淡,兼具可读性与思想性,能用具体例子或生动比喻解释抽象概念。请避免使用术语堆砌或空洞口号,注重真实感、节奏感和读者共鸣。\n下面是本文的主题和内容要求:'; \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V14__clear_custom_page_status_delete.sql b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V14__clear_custom_page_status_delete.sql new file mode 100644 index 0000000..278e325 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V14__clear_custom_page_status_delete.sql @@ -0,0 +1,2 @@ +DELETE FROM custom_page +WHERE status = 0; \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V15__add_comment_toggle.sql b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V15__add_comment_toggle.sql new file mode 100644 index 0000000..42ec9a8 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V15__add_comment_toggle.sql @@ -0,0 +1,2 @@ +ALTER TABLE `site_config` ADD COLUMN `enable_comment` INT NULL DEFAULT 1 COMMENT '是否启用评论区(1:启用,0:禁用)'; +ALTER TABLE `article` ADD COLUMN `enable_comment` TINYINT NOT NULL DEFAULT 1 COMMENT '文章是否开启评论区(1:开启,0:关闭)'; diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V16__add_api_key.sql b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V16__add_api_key.sql new file mode 100644 index 0000000..c9f24eb --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V16__add_api_key.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS `api_key` ( + `id` int UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '所属用户UUID', + `key_hash` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT 'SHA-256哈希,替代原api_key明文', + `description` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '备注描述', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `status` int UNSIGNED NOT NULL DEFAULT 1 COMMENT '状态: 1-启用, 0-禁用', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_key_hash`(`key_hash` ASC) USING BTREE, + INDEX `idx_user_status`(`user_id` ASC, `status` ASC) USING BTREE, + CONSTRAINT `api_key_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `user_information` (`uuid`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COMMENT = 'API Key表' ROW_FORMAT = Dynamic; diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V17__add_announcement.sql b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V17__add_announcement.sql new file mode 100644 index 0000000..b5cdca8 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V17__add_announcement.sql @@ -0,0 +1,16 @@ +DROP TABLE IF EXISTS `announcement`; +CREATE TABLE `announcement` ( + `id` int NOT NULL AUTO_INCREMENT, + `content` text CHARACTER SET utf8mb4 NOT NULL COMMENT '公告内容(HTML)', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COMMENT = '公告表' ROW_FORMAT = Dynamic; + +INSERT INTO `permission` VALUES (60, 'system:announcement:management', '公告管理', 'announcement', NOW()); +INSERT INTO `permission` VALUES (61, 'announcement:menus', '公告管理菜单', 'menus', NOW()); + +INSERT INTO `dashboard_menu` VALUES (111, '公告管理', 'announcement', '/dashboard/announcement', 5, 'announcement:menus', 30, NOW(), 'sidebar'); + +INSERT INTO `role_permission` VALUES (1, 60); +INSERT INTO `role_permission` VALUES (1, 61); diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V18__add_comment_review.sql b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V18__add_comment_review.sql new file mode 100644 index 0000000..e3c9530 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V18__add_comment_review.sql @@ -0,0 +1,14 @@ +ALTER TABLE `site_config` + ADD COLUMN `comment_status` INT NOT NULL DEFAULT 1 COMMENT '评论默认状态(1直接发布,3待审核)', + ADD COLUMN `enable_llm_comment_review` INT NOT NULL DEFAULT 0 COMMENT '是否启用大模型自动审核评论(1启用,0禁用)', + ADD COLUMN `admin_comment_no_review` INT NOT NULL DEFAULT 1 COMMENT '管理员用户评论免审核(1启用,0禁用)'; + +INSERT INTO `permission` VALUES (62, 'system:comments:review', '审核评论', 'comments', NOW()); +INSERT INTO `permission` VALUES (63, 'commentreview:menus', '评论审核菜单', 'menus', NOW()); + +INSERT INTO `dashboard_menu` VALUES (112, '评论审核', 'comment-review', '/dashboard/commentsreview', 3, 'commentreview:menus', 35, NOW(), 'sidebar'); + +INSERT INTO `role_permission` VALUES (1, 62); +INSERT INTO `role_permission` VALUES (1, 63); + +INSERT INTO `llm_prompt_config` VALUES (3, 'comment_review', '你是一名专业的评论内容审核员,需依据以下标准对用户提交的评论内容进行快速评估与风险研判:\n一、违规内容识别\n是否包含色情、低俗、性暗示等不当内容;\n是否包含人身攻击、辱骂、歧视性言论、地域黑等不友善内容;\n是否包含广告、垃圾营销、引流、联系方式等信息;\n是否包含政治敏感、违法信息、谣言、欺诈等内容;\n是否包含恶意代码、SQL注入、XSS攻击等技术性威胁。\n二、评论质量判断\n是否为无意义灌水(如纯表情、重复字符、随机字母等);\n是否与主题完全无关的恶意刷屏内容。\n你的输出必须且只能是如下两种 JSON 格式:\n{"compliant": true}\n{"compliant": false}\n下面是需要你审核的评论:', NOW(), 1); diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V19__allow_null_tag_and_category_explain.sql b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V19__allow_null_tag_and_category_explain.sql new file mode 100644 index 0000000..6801f44 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V19__allow_null_tag_and_category_explain.sql @@ -0,0 +1,2 @@ +ALTER TABLE `article_tag` MODIFY COLUMN `tag_explain` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '标签说明'; +ALTER TABLE `article_categories` MODIFY COLUMN `categories_explain` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '分类说明'; \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V1__init_tables.sql b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V1__init_tables.sql new file mode 100644 index 0000000..1150e14 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V1__init_tables.sql @@ -0,0 +1,7 @@ +-- V1__init_tables.sql +-- +-- 注意: +-- 1. 此脚本为空是正常行为 +-- 2. 因为初始化数据库在系统配置阶段已经完成 +-- Flyway 会因配置了 baselineOnMigrate=true 而跳过本脚本 +-- 并将当前状态标记为 "baseline version = 1" diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V20__add_storage_support.sql b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V20__add_storage_support.sql new file mode 100644 index 0000000..1bb32db --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V20__add_storage_support.sql @@ -0,0 +1,36 @@ +CREATE TABLE `storage_method` ( + `uuid` varchar(255) NOT NULL COMMENT '存储方式唯一标识', + `user_uuid` varchar(255) DEFAULT NULL COMMENT '创建人UUID,关联管理员用户表', + `name` varchar(255) DEFAULT NULL COMMENT '存储方式名称,如:本地存储、阿里云OSS', + `type` varchar(255) DEFAULT NULL COMMENT '存储类型:local、s3、oss、cos等', + `config` text COMMENT '存储配置信息,JSON字符串格式', + `status` int NOT NULL DEFAULT 1 COMMENT '状态:0-禁用,1-启用', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`uuid`), + INDEX `idx_type`(`type`), + INDEX `idx_status`(`status`), + INDEX `user_uuid`(`user_uuid`), + CONSTRAINT `storage_method_ibfk_1` FOREIGN KEY (`user_uuid`) REFERENCES `user_information` (`uuid`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='存储方式管理表'; + +INSERT INTO `storage_method` (`uuid`, `user_uuid`, `name`, `type`, `config`, `status`) +SELECT 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4f47ac10b48bf511eb9a5eb9b5a8f1c2d172121853800042356789', `uuid`, 'local', 'local', NULL, 1 +FROM `user_information` +ORDER BY `create_time` ASC +LIMIT 1; + +ALTER TABLE `site_config` + ADD COLUMN `default_storage` varchar(255) DEFAULT NULL COMMENT '默认存储方式UUID' + AFTER `admin_comment_no_review`; + +UPDATE `site_config` s +SET s.`default_storage` = (SELECT `uuid` FROM `storage_method` WHERE `name` = 'local') +WHERE s.`id` = 1; + +ALTER TABLE `attachment` + ADD COLUMN `storage_id` varchar(255) DEFAULT NULL COMMENT '存储方式UUID' + AFTER `access_key`; + +UPDATE `attachment` a +SET a.`storage_id` = (SELECT `uuid` FROM `storage_method` WHERE `name` = 'local'); \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V21__storage_migration_log.sql b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V21__storage_migration_log.sql new file mode 100644 index 0000000..0dfd712 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V21__storage_migration_log.sql @@ -0,0 +1,27 @@ +CREATE TABLE `storage_migration_log` ( + `id` int NOT NULL AUTO_INCREMENT COMMENT '自增主键', + `source_storage_id` varchar(255) NOT NULL COMMENT '源存储UUID', + `target_storage_id` varchar(255) NOT NULL COMMENT '目标存储UUID', + `total` int NOT NULL DEFAULT 0 COMMENT '总附件数', + `success` int NOT NULL DEFAULT 0 COMMENT '成功数', + `failed` int NOT NULL DEFAULT 0 COMMENT '失败数', + `status` int NOT NULL DEFAULT 0 COMMENT '状态:0-进行中,1-已完成,2-失败', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + INDEX `idx_source`(`source_storage_id`), + INDEX `idx_target`(`target_storage_id`), + INDEX `idx_created_at`(`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='存储迁移记录'; + +CREATE TABLE `storage_migration_failed_item` ( + `id` int NOT NULL AUTO_INCREMENT COMMENT '自增主键', + `migration_id` int NOT NULL COMMENT '关联迁移记录ID', + `attachment_id` varchar(255) NOT NULL COMMENT '附件ID', + `file_path` varchar(255) NOT NULL COMMENT '文件路径', + `error_msg` text COMMENT '错误信息', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + INDEX `idx_migration_id`(`migration_id`), + CONSTRAINT `fk_migration_item` FOREIGN KEY (`migration_id`) REFERENCES `storage_migration_log`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='迁移失败明细'; \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V22__add_attachment_content_type.sql b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V22__add_attachment_content_type.sql new file mode 100644 index 0000000..289f112 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V22__add_attachment_content_type.sql @@ -0,0 +1,3 @@ +ALTER TABLE `attachment` + ADD COLUMN `content_type` varchar(255) DEFAULT NULL COMMENT '文件MIME类型' + AFTER `storage_id`; diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V23__add_plugin_tables.sql b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V23__add_plugin_tables.sql new file mode 100644 index 0000000..cecbf23 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V23__add_plugin_tables.sql @@ -0,0 +1,38 @@ +-- 插件系统完整迁移(注册表/配置表/管理权限/后台菜单/完整性校验字段) +-- 说明: 原 V23/V24/V25 三个脚本在开发阶段合并为一个; 脚本保持幂等 +-- (CREATE TABLE IF NOT EXISTS + INSERT IGNORE), 已执行过的库清理 Flyway 历史后重放安全。 +CREATE TABLE IF NOT EXISTS `plugin` ( + `id` BIGINT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(64) NOT NULL COMMENT '插件ID(唯一)', + `version` VARCHAR(32) NOT NULL COMMENT '插件版本(semver)', + `display_name` VARCHAR(128) NOT NULL COMMENT '插件中文名', + `description` VARCHAR(512) DEFAULT NULL COMMENT '插件描述', + `author` VARCHAR(128) DEFAULT NULL COMMENT '插件作者', + `requires` VARCHAR(32) DEFAULT NULL COMMENT '宿主版本约束', + `jar_file` VARCHAR(255) NOT NULL COMMENT '插件文件相对路径', + `sha256` VARCHAR(64) DEFAULT NULL COMMENT '插件jar完整性校验值(SHA-256 hex)', + `enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '启用状态: 0-停用, 1-启用', + `setting_name` VARCHAR(64) DEFAULT NULL COMMENT '设置表单扩展名(阶段5)', + `config_map_name` VARCHAR(64) DEFAULT NULL COMMENT '配置存储名(阶段5)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY `uk_plugin_name` (`name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件注册表'; + +CREATE TABLE IF NOT EXISTS `plugin_config` ( + `id` BIGINT AUTO_INCREMENT PRIMARY KEY, + `plugin_name` VARCHAR(64) NOT NULL COMMENT '插件ID', + `config_key` VARCHAR(64) NOT NULL COMMENT '配置键', + `config_value` TEXT COMMENT '配置值(JSON字符串)', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY `uk_plugin_config` (`plugin_name`, `config_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件配置表'; + +-- 插件管理权限与后台菜单 +INSERT IGNORE INTO `permission` VALUES (64, 'plugin:management', '插件管理', 'plugin', NOW()); +INSERT IGNORE INTO `permission` VALUES (65, 'plugin:menus', '插件管理菜单', 'menus', NOW()); + +INSERT IGNORE INTO `dashboard_menu` VALUES (113, '插件管理', 'plugin', '/dashboard/plugins', 5, 'plugin:menus', 30, NOW(), 'sidebar'); + +INSERT IGNORE INTO `role_permission` VALUES (1, 64); +INSERT IGNORE INTO `role_permission` VALUES (1, 65); diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V24__add_plugin_sql_audit.sql b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V24__add_plugin_sql_audit.sql new file mode 100644 index 0000000..ba9ec7a --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V24__add_plugin_sql_audit.sql @@ -0,0 +1,10 @@ +-- 插件SQL审计表: 只记录DDL/写宿主核心表/慢SQL, 供管理员核查插件行为 +CREATE TABLE IF NOT EXISTS `plugin_sql_audit` ( + `id` BIGINT AUTO_INCREMENT PRIMARY KEY, + `plugin_name` VARCHAR(64) NOT NULL COMMENT '插件ID', + `category` VARCHAR(32) NOT NULL COMMENT 'ddl-结构变更, core_write-写核心表, slow-慢SQL', + `sql_text` VARCHAR(2048) DEFAULT NULL COMMENT 'SQL文本(超长截断)', + `cost_millis` BIGINT DEFAULT NULL COMMENT '执行耗时(毫秒)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录时间', + KEY `idx_plugin_name_id` (`plugin_name`, `id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件SQL审计'; diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V25__menu_auto_increment.sql b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V25__menu_auto_increment.sql new file mode 100644 index 0000000..f5344fd --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V25__menu_auto_increment.sql @@ -0,0 +1,3 @@ +ALTER TABLE `dashboard_menu` DROP FOREIGN KEY `fk_menu_parent_id`; +ALTER TABLE `dashboard_menu` MODIFY COLUMN `id` INT NOT NULL AUTO_INCREMENT; +ALTER TABLE `dashboard_menu` ADD CONSTRAINT `fk_menu_parent_id` FOREIGN KEY (`parent_id`) REFERENCES `dashboard_menu` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT; diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V26__widen_permission_code.sql b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V26__widen_permission_code.sql new file mode 100644 index 0000000..4c1b53a --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V26__widen_permission_code.sql @@ -0,0 +1,4 @@ +ALTER TABLE `dashboard_menu` DROP FOREIGN KEY `fk_menu_permission_code`; +ALTER TABLE `permission` MODIFY COLUMN `code` varchar(100) CHARACTER SET utf8mb4 NOT NULL COMMENT '权限码,如 post:view'; +ALTER TABLE `dashboard_menu` MODIFY COLUMN `permission_code` varchar(100) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '访问此菜单所需的权限码,如 system:edit'; +ALTER TABLE `dashboard_menu` ADD CONSTRAINT `fk_menu_permission_code` FOREIGN KEY (`permission_code`) REFERENCES `permission` (`code`) ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V2__admin_post_no_review.sql b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V2__admin_post_no_review.sql new file mode 100644 index 0000000..9209195 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V2__admin_post_no_review.sql @@ -0,0 +1,2 @@ +ALTER TABLE `site_config` + ADD COLUMN `admin_post_no_review` INT NULL DEFAULT 0 COMMENT '管理员文章是否无需审核(1:启用,0:禁用)'; \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V3__multi_level_categories.sql b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V3__multi_level_categories.sql new file mode 100644 index 0000000..a8e931b --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V3__multi_level_categories.sql @@ -0,0 +1,71 @@ +-- 删除 article 表的旧外键(依赖 category -> article_categories.article_categories) +ALTER TABLE `article` DROP FOREIGN KEY `categories`; + + +-- 升级 article_categories 表,支持多级分类 + +-- 创建新分类表结构 +CREATE TABLE `article_categories_new` ( + `id` int NOT NULL AUTO_INCREMENT, + `parent_id` int NULL DEFAULT NULL COMMENT '父分类ID,NULL 表示顶级分类', + `article_categories` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '分类名称', + `categories_explain` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '分类说明', + `founder` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '创建人', + `article_count` int NOT NULL DEFAULT 0 COMMENT '文章数量', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '创建时间', + `status` int NOT NULL COMMENT '分类状态:0=禁用, 1=启用', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_category_name_parent` (`article_categories`, `parent_id`), + INDEX `idx_parent_id` (`parent_id`), + INDEX `categories_founder` (`founder`), + CONSTRAINT `categories_founder_new` + FOREIGN KEY (`founder`) REFERENCES `user_information` (`uuid`) + ON DELETE SET NULL ON UPDATE SET NULL, + CONSTRAINT `fk_parent_category` + FOREIGN KEY (`parent_id`) REFERENCES `article_categories_new` (`id`) + ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 ROW_FORMAT = DYNAMIC; + +-- 迁移数据(parent_id = NULL) +INSERT INTO `article_categories_new` +(`id`, `parent_id`, `article_categories`, `categories_explain`, `founder`, `article_count`, `create_time`, `status`) +SELECT + `id`, NULL, `article_categories`, `categories_explain`, `founder`, `article_count`, `create_time`, `status` +FROM `article_categories`; + +-- 动态设置自增值 +SET @max_id = (SELECT COALESCE(MAX(id), 0) + 1 FROM `article_categories_new`); +SET @sql = CONCAT('ALTER TABLE `article_categories_new` AUTO_INCREMENT = ', @max_id); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 切换表 +RENAME TABLE + `article_categories` TO `article_categories_backup`, + `article_categories_new` TO `article_categories`; + + +-- 升级 article 表 引入 category_id 并删除 category 字段 +-- 添加 category_id 字段 +ALTER TABLE `article` + ADD COLUMN `category_id` int NULL DEFAULT NULL COMMENT '文章分类ID(关联 article_categories.id)' + AFTER `password`; + +-- 填充数据 +UPDATE `article` a + JOIN `article_categories` c ON a.`category` = c.`article_categories` +SET a.`category_id` = c.`id`; + +-- 设为非空 +ALTER TABLE `article` MODIFY `category_id` int NOT NULL; + +-- 删除旧 category 字段 +ALTER TABLE `article` DROP COLUMN `category`; + +-- 添加外键 +ALTER TABLE `article` + ADD CONSTRAINT `fk_article_category_id` + FOREIGN KEY (`category_id`) REFERENCES `article_categories` (`id`) + ON DELETE RESTRICT + ON UPDATE CASCADE; diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V4__attachment_management.sql b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V4__attachment_management.sql new file mode 100644 index 0000000..9d0d7f3 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V4__attachment_management.sql @@ -0,0 +1,18 @@ +ALTER TABLE `attachment` + ADD COLUMN `deleted_time` datetime NULL COMMENT '删除时间' AFTER `create_time`; + +ALTER TABLE `attachment` + MODIFY COLUMN `create_time` + datetime NOT NULL + DEFAULT CURRENT_TIMESTAMP + COMMENT '上传时间'; + +UPDATE `attachment` +SET `deleted_time` = `create_time` +WHERE `status` = 0 + AND `deleted_time` IS NULL; + + +INSERT INTO `dashboard_menu` VALUES (108, '全局附件管理', 'attachment', '/dashboard/global-attachments', 3, 'system:edit', 46, '2026-02-08 20:50:15', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (109, '我的附件', 'upload', '/dashboard/my-attachments', 3, NULL, 47, '2026-02-08 20:50:19', 'sidebar'); + diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V5__add_original_filename.sql b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V5__add_original_filename.sql new file mode 100644 index 0000000..d560b28 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V5__add_original_filename.sql @@ -0,0 +1,3 @@ +ALTER TABLE `attachment` + ADD COLUMN `original_filename` VARCHAR(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '原始文件名' + AFTER `attachment_id`; \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V6__rbac_to_complete_rbac.sql b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V6__rbac_to_complete_rbac.sql new file mode 100644 index 0000000..326733c --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V6__rbac_to_complete_rbac.sql @@ -0,0 +1,301 @@ +DROP TABLE IF EXISTS `dashboard_menu`; +DROP TABLE IF EXISTS `role_permission`; +DROP TABLE IF EXISTS `permission`; + + + +CREATE TABLE `permission` ( + `id` int NOT NULL AUTO_INCREMENT COMMENT '权限ID', + `code` varchar(50) CHARACTER SET utf8mb4 NOT NULL COMMENT '权限码,如 post:view', + `name` varchar(100) CHARACTER SET utf8mb4 NOT NULL COMMENT '权限名称,如 查看文章', + `module` varchar(50) CHARACTER SET utf8mb4 NOT NULL COMMENT '所属模块,如 post, user, system', + `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `idx_code`(`code` ASC) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 60 CHARACTER SET = utf8mb4 COMMENT = '权限表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of permission +-- ---------------------------- +INSERT INTO `permission` VALUES (1, 'system:attachment:view', '查看任意附件', 'attachment', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (2, 'system:attachment:delete', '删除任意附件', 'attachment', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (3, 'system:attachment:undelete', '撤销删除任意附件', 'attachment', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (4, 'system:attachment:management', '系统附件管理完整操作', 'attachment', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (5, 'attachment:add', '添加附件', 'attachment', '2026-03-19 22:16:25'); +INSERT INTO `permission` VALUES (6, 'attachment:view', '查看自己附件', 'attachment', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (7, 'attachment:delete', '删除自己附件', 'attachment', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (8, 'attachment:undelete', '撤销删除自己附件', 'attachment', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (9, 'attachment:edit', '管理自己附件(完整操作)', 'attachment', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (10, 'system:comments:view', '查看任意评论', 'comments', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (11, 'system:comments:edit', '编辑任意评论', 'comments', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (12, 'system:comments:delete', '删除任意评论', 'comments', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (13, 'system:comments:management', '评论管理(完整操作)', 'comments', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (14, 'comments:add', '用户评论', 'comments', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (15, 'comments:delete', '删除自己评论', 'comments', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (16, 'comments:like', '点赞评论', 'comments', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (17, 'comments:edit', '评论完整操作', 'comments', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (18, 'system:post:review', '审核文章', 'post', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (19, 'system:custompage:add', '添加自定义页面', 'custompage', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (20, 'system:custompage:update', '更新自定义页面', 'custompage', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (21, 'system:custompage:delete', '删除自定义页面', 'custompage', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (22, 'system:custompage:view', '获取自己的自定义页面', 'custompage', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (23, 'system:custompage:management', '自定义页面完整操作', 'custompage', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (24, 'post:view', '获取文章列表', 'post', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (25, 'post:add', '用户创建文章', 'post', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (26, 'post:update', '用户更新文章', 'post', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (27, 'post:details', '获取文章详情', 'post', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (28, 'post:delete', '删除自己文章', 'post', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (29, 'post:unpublish', '取消发布文章', 'post', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (30, 'post:publish', '发布文章', 'post', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (31, 'post:removepassword', '移除文章密码', 'post', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (32, 'post:edit', '编辑文章(对自己文章完整操作)', 'post', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (33, 'system:theme:management', '系统主题管理', 'theme', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (34, 'system:friendlinks:management', '系统友链管理', 'friendlinks', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (35, 'system:menus:management', '系统菜单管理', 'menu', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (36, 'system:music:management', '系统音乐管理', 'config', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (37, 'system:role:management', '系统角色管理', 'role', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (38, 'system:config:management', '系统配置管理', 'config', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (39, 'system:tags:management', '系统标签管理', 'tags', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (40, 'system:categories:management', '系统分类管理', 'categories', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (41, 'system:update:management', '系统更新管理', 'update', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (42, 'system:user:management', '管理用户信息', 'user', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (43, 'content:menus', '内容管理菜单', 'menus', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (44, 'user:menus', '用户管理菜单', 'menus', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (45, 'custompages:menus', '自定义页面菜单', 'menus', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (46, 'article:menus', '文章管理菜单', 'menus', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (47, 'articlereview:menus', '文章审核菜单', 'menus', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (48, 'comments:menus', '评论管理菜单', 'menus', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (49, 'tags:menus', '标签管理菜单', 'menus', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (50, 'categories:menus', '分类管理菜单', 'menus', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (51, 'friendlinks:menus', '友链管理菜单', 'menus', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (52, 'globalattachments:menus', '全局附件菜单', 'menus', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (53, 'attachments:menus', '我的附件菜单', 'menus', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (54, 'menus:menus', '菜单管理菜单', 'menus', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (55, 'settings:menus', '系统设置菜单', 'menus', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (56, 'config:menus', '配置管理菜单', 'menus', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (57, 'update:menus', '系统更新菜单', 'menus', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (58, 'theme:menus', '主题管理菜单', 'menus', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (59, 'role:menus', '角色管理菜单', 'menus', '2026-03-19 20:11:55'); + + +CREATE TABLE `dashboard_menu` ( + `id` int NOT NULL COMMENT '菜单ID', + `title` varchar(100) CHARACTER SET utf8mb4 NOT NULL COMMENT '菜单标题,如“站点设置”', + `icon` varchar(50) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '图标名称', + `link` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '路由链接,父菜单可为空', + `parent_id` int NULL DEFAULT NULL COMMENT '父级菜单ID,NULL表示顶级菜单', + `permission_code` varchar(50) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '访问此菜单所需的权限码,如 system:edit', + `sort_order` int NULL DEFAULT 0 COMMENT '排序权重,值越小越靠前', + `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP, + `type` enum('sidebar','quick_action') CHARACTER SET utf8mb4 NULL DEFAULT 'sidebar' COMMENT '菜单类型', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_parent_id`(`parent_id` ASC) USING BTREE, + INDEX `idx_link`(`link` ASC) USING BTREE, + INDEX `idx_permission_code`(`permission_code` ASC) USING BTREE, + INDEX `idx_sort_order`(`sort_order` ASC) USING BTREE, + CONSTRAINT `fk_menu_parent_id` FOREIGN KEY (`parent_id`) REFERENCES `dashboard_menu` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT, + CONSTRAINT `fk_menu_permission_code` FOREIGN KEY (`permission_code`) REFERENCES `permission` (`code`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COMMENT = '仪表盘菜单表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of dashboard_menu +-- ---------------------------- +INSERT INTO `dashboard_menu` VALUES (1, '仪表盘', 'dashboard', '/dashboard', NULL, NULL, 10, '2025-09-13 11:12:42', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (2, '个人中心', 'user', '/dashboard/profile', NULL, NULL, 20, '2025-09-13 11:12:42', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (3, '内容', 'content', NULL, NULL, NULL, 30, '2025-09-13 11:12:42', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (5, '设置', 'settings', NULL, NULL, 'settings:menus', 50, '2025-09-13 11:12:42', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (21, '文章', 'article', '/dashboard/articles', 3, 'article:menus', 20, '2025-09-13 11:12:42', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (22, '文章审核', 'review', '/dashboard/articlesreview', 3, 'articlereview:menus', 30, '2025-09-13 11:12:42', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (24, '评论', 'comment', '/dashboard/comments', 3, 'comments:menus', 40, '2025-09-13 11:12:42', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (25, '菜单', 'menus', '/dashboard/menus', 3, 'menus:menus', 50, '2025-09-13 11:12:42', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (42, '用户', 'users', '/dashboard/users', 3, 'user:menus', 10, '2025-09-13 11:12:42', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (43, '站点信息', 'info', '/dashboard/settings', 5, 'config:menus', 10, '2025-09-13 11:12:42', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (44, '主题管理', 'theme', '/dashboard/themes', 5, 'theme:menus', 20, '2025-09-24 16:52:43', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (45, '自定义页面', 'page', '/dashboard/custom-pages', 3, 'custompages:menus', 20, '2025-12-04 21:42:15', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (46, '更新', 'update', '/dashboard/update', 5, 'update:menus', 20, '2025-12-04 21:42:15', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (101, '个人中心', 'user', '/dashboard/profile', NULL, NULL, 10, '2025-09-13 11:12:42', 'quick_action'); +INSERT INTO `dashboard_menu` VALUES (103, '创建文章', 'edit', '/dashboard/articles/create', NULL, 'article:menus', 20, '2025-09-13 11:12:42', 'quick_action'); +INSERT INTO `dashboard_menu` VALUES (104, '用户', 'users', '/dashboard/users', NULL, 'user:menus', 30, '2025-09-13 11:12:42', 'quick_action'); +INSERT INTO `dashboard_menu` VALUES (105, '标签管理', 'tag', '/dashboard/tags', 3, 'tags:menus', 40, '2025-09-13 11:12:42', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (106, '分类管理', 'category', '/dashboard/categories', 3, 'categories:menus', 40, '2025-09-13 11:12:42', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (107, '友链管理', 'link', '/dashboard/friendlinks', 3, 'friendlinks:menus', 45, '2025-12-04 21:42:15', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (108, '全局附件管理', 'attachment', '/dashboard/global-attachments', 3, 'globalattachments:menus', 46, '2026-02-08 20:50:15', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (109, '我的附件', 'upload', '/dashboard/my-attachments', 3, 'attachments:menus', 47, '2026-02-08 20:50:19', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (110, '角色编辑', 'permission', '/dashboard/rbac-editor', 3, 'role:menus', 9, '2026-03-19 20:10:22', 'sidebar'); + + +-- 禁用外键检查 +SET FOREIGN_KEY_CHECKS = 0; + +CREATE TEMPORARY TABLE temp_old_role_mapping AS +SELECT id AS old_role_id, code +FROM role; + +-- 重建 role 表 +DROP TABLE IF EXISTS `role`; +CREATE TABLE `role` ( + `id` int NOT NULL AUTO_INCREMENT, + `code` varchar(50) CHARACTER SET utf8mb4 NOT NULL COMMENT '角色编码,如 AUTHOR', + `name` varchar(100) CHARACTER SET utf8mb4 NOT NULL COMMENT '角色名称,如 作者', + `description` text CHARACTER SET utf8mb4 NULL COMMENT '描述', + `status` tinyint NULL DEFAULT 1 COMMENT '状态:1启用,0禁用', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `idx_code`(`code` ASC) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 6 CHARACTER SET = utf8mb4 COMMENT = '角色表' ROW_FORMAT = DYNAMIC; + +INSERT INTO `role` VALUES (1, 'ADMIN', '管理员', '系统超级管理员,拥有最高权限,可管理用户、角色、权限、站点配置等所有功能,负责平台整体运行与安全。', 1); +INSERT INTO `role` VALUES (2, 'OPERATOR', '操作员', '负责平台运营,拥有部分系统配置权限', 1); +INSERT INTO `role` VALUES (3, 'POST_MANAGER', '文章管理员', '负责内容运营管理的角色,可以审核、发布、修改和删除任何文章,管理评论,维护内容质量和平台秩序。', 1); +INSERT INTO `role` VALUES (4, 'AUTHOR', '作者', '内容创作者角色,可以创建、编辑、删除自己的文章,并提交文章进入审核流程,等待管理员发布。', 1); +INSERT INTO `role` VALUES (5, 'READER', '阅读者', '可以浏览和阅读已发布的文章内容,参与评论互动,但无法创建或编辑文章。', 1); + + + +CREATE TABLE `role_permission` ( + `role_id` int NOT NULL COMMENT '角色ID', + `permission_id` int NOT NULL COMMENT '权限ID', + PRIMARY KEY (`role_id`, `permission_id`) USING BTREE, + INDEX `idx_permission_id`(`permission_id` ASC) USING BTREE, + CONSTRAINT `fk_rp_permission` FOREIGN KEY (`permission_id`) REFERENCES `permission` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT, + CONSTRAINT `fk_rp_role` FOREIGN KEY (`role_id`) REFERENCES `role` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COMMENT = '角色权限关联表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of role_permission +-- ---------------------------- +INSERT INTO `role_permission` VALUES (1, 4); +INSERT INTO `role_permission` VALUES (2, 4); +INSERT INTO `role_permission` VALUES (1, 5); +INSERT INTO `role_permission` VALUES (4, 5); +INSERT INTO `role_permission` VALUES (1, 9); +INSERT INTO `role_permission` VALUES (4, 9); +INSERT INTO `role_permission` VALUES (1, 13); +INSERT INTO `role_permission` VALUES (2, 13); +INSERT INTO `role_permission` VALUES (3, 13); +INSERT INTO `role_permission` VALUES (5, 16); +INSERT INTO `role_permission` VALUES (1, 17); +INSERT INTO `role_permission` VALUES (4, 17); +INSERT INTO `role_permission` VALUES (5, 17); +INSERT INTO `role_permission` VALUES (1, 18); +INSERT INTO `role_permission` VALUES (3, 18); +INSERT INTO `role_permission` VALUES (1, 23); +INSERT INTO `role_permission` VALUES (1, 32); +INSERT INTO `role_permission` VALUES (4, 32); +INSERT INTO `role_permission` VALUES (1, 33); +INSERT INTO `role_permission` VALUES (2, 33); +INSERT INTO `role_permission` VALUES (1, 34); +INSERT INTO `role_permission` VALUES (2, 34); +INSERT INTO `role_permission` VALUES (1, 35); +INSERT INTO `role_permission` VALUES (2, 35); +INSERT INTO `role_permission` VALUES (1, 36); +INSERT INTO `role_permission` VALUES (1, 37); +INSERT INTO `role_permission` VALUES (1, 38); +INSERT INTO `role_permission` VALUES (1, 39); +INSERT INTO `role_permission` VALUES (2, 39); +INSERT INTO `role_permission` VALUES (1, 40); +INSERT INTO `role_permission` VALUES (2, 40); +INSERT INTO `role_permission` VALUES (1, 41); +INSERT INTO `role_permission` VALUES (2, 41); +INSERT INTO `role_permission` VALUES (1, 42); +INSERT INTO `role_permission` VALUES (1, 43); +INSERT INTO `role_permission` VALUES (2, 43); +INSERT INTO `role_permission` VALUES (3, 43); +INSERT INTO `role_permission` VALUES (4, 43); +INSERT INTO `role_permission` VALUES (1, 44); +INSERT INTO `role_permission` VALUES (1, 45); +INSERT INTO `role_permission` VALUES (2, 45); +INSERT INTO `role_permission` VALUES (1, 46); +INSERT INTO `role_permission` VALUES (4, 46); +INSERT INTO `role_permission` VALUES (1, 47); +INSERT INTO `role_permission` VALUES (3, 47); +INSERT INTO `role_permission` VALUES (1, 48); +INSERT INTO `role_permission` VALUES (2, 48); +INSERT INTO `role_permission` VALUES (3, 48); +INSERT INTO `role_permission` VALUES (1, 49); +INSERT INTO `role_permission` VALUES (2, 49); +INSERT INTO `role_permission` VALUES (1, 50); +INSERT INTO `role_permission` VALUES (2, 50); +INSERT INTO `role_permission` VALUES (1, 51); +INSERT INTO `role_permission` VALUES (2, 51); +INSERT INTO `role_permission` VALUES (1, 52); +INSERT INTO `role_permission` VALUES (2, 52); +INSERT INTO `role_permission` VALUES (1, 53); +INSERT INTO `role_permission` VALUES (4, 53); +INSERT INTO `role_permission` VALUES (1, 54); +INSERT INTO `role_permission` VALUES (2, 54); +INSERT INTO `role_permission` VALUES (1, 55); +INSERT INTO `role_permission` VALUES (2, 55); +INSERT INTO `role_permission` VALUES (1, 56); +INSERT INTO `role_permission` VALUES (1, 57); +INSERT INTO `role_permission` VALUES (2, 57); +INSERT INTO `role_permission` VALUES (1, 58); +INSERT INTO `role_permission` VALUES (2, 58); +INSERT INTO `role_permission` VALUES (1, 59); + +-- 创建user_role表 +DROP TABLE IF EXISTS `user_role`; +CREATE TABLE `user_role` ( + `user_id` int NOT NULL COMMENT '用户 ID', + `role_id` int NOT NULL COMMENT '角色 ID', + PRIMARY KEY (`user_id`, `role_id`) USING BTREE, + INDEX `idx_role_id`(`role_id` ASC) USING BTREE, + CONSTRAINT `fk_ur_role` FOREIGN KEY (`role_id`) REFERENCES `role` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT, + CONSTRAINT `fk_ur_user` FOREIGN KEY (`user_id`) REFERENCES `user_information` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COMMENT = '用户角色关联表' ROW_FORMAT = DYNAMIC; + +-- 备份用户表 +CREATE TABLE IF NOT EXISTS `user_information_backup` AS +SELECT * FROM `user_information`; + +-- 清空user_role +TRUNCATE TABLE `user_role`; + +-- 通过 code 映射到新 role.id +INSERT INTO `user_role` (`user_id`, `role_id`) +SELECT + ui.id AS user_id, + r_new.id AS role_id +FROM `user_information` ui + JOIN temp_old_role_mapping old_map ON ui.role_id = old_map.old_role_id + JOIN `role` r_new ON old_map.code = r_new.code +WHERE ui.role_id IS NOT NULL AND ui.role_id > 0; + +-- 统计信息 +SELECT + '迁移完成统计' AS info, + (SELECT COUNT(*) FROM `user_information`) AS total_users, + (SELECT COUNT(*) FROM `user_role`) AS user_role_mappings; + +-- 数据验证 +SELECT + '数据验证' AS verification, + COUNT(DISTINCT ui.id) AS users_with_roles, + COUNT(DISTINCT ur.user_id) AS mapped_users +FROM `user_information` ui + LEFT JOIN `user_role` ur ON ui.id = ur.user_id +WHERE ui.role_id IS NOT NULL; + +-- 删除 user_information 中的 role_id 字段 +ALTER TABLE `user_information` DROP FOREIGN KEY `fk_user_role`; +ALTER TABLE `user_information` DROP INDEX `idx_role_id`; +ALTER TABLE `user_information` DROP COLUMN `role_id`; + +-- 更新 site_config 表中的 register_user_permission 字段,映射到新角色 id +UPDATE `site_config` sc +JOIN temp_old_role_mapping old_map ON sc.register_user_permission = old_map.old_role_id +JOIN `role` r_new ON old_map.code = r_new.code +SET sc.register_user_permission = r_new.id +WHERE sc.register_user_permission IS NOT NULL; + +-- 最终状态 +SELECT + '迁移后状态' AS final_status, + (SELECT COUNT(*) FROM `user_information`) AS total_users, + (SELECT COUNT(*) FROM `user_role`) AS total_role_mappings, + (SELECT COUNT(*) FROM `role`) AS total_roles, + (SELECT COUNT(*) FROM `permission`) AS total_permissions, + (SELECT COUNT(*) FROM `role_permission`) AS total_role_permission_mappings; + +DROP TABLE IF EXISTS `user_information_backup`; +-- 启用外键检查 +SET FOREIGN_KEY_CHECKS = 1; \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V7__add_llm_model.sql b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V7__add_llm_model.sql new file mode 100644 index 0000000..b8aced1 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V7__add_llm_model.sql @@ -0,0 +1,25 @@ +ALTER TABLE `site_config` + ADD COLUMN `enable_llm` int NULL DEFAULT 0 COMMENT '是否启动系统大模型接入(1:启用,0:禁用)', + ADD COLUMN `enable_llm_article_review` int NULL DEFAULT 0 COMMENT '是否启用大模型自动审核文章(1:启用,0:禁用)', + ADD COLUMN `enable_llm_create_article` int NULL DEFAULT 0 COMMENT '是否启用大模型生成文章(1:启用,0:禁用)'; + +DROP TABLE IF EXISTS `llm_config`; +CREATE TABLE `llm_config` ( + `api_key` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '密钥', + `api_url` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '模型地址', + `model` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '模型名称' +) ENGINE = InnoDB CHARACTER SET = utf8mb4 ROW_FORMAT = DYNAMIC; + +DROP TABLE IF EXISTS `llm_prompt_config`; +CREATE TABLE `llm_prompt_config` ( + `id` int NOT NULL AUTO_INCREMENT, + `prompt_name` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '提示词模板名称', + `prompt_content` mediumtext CHARACTER SET utf8mb4 NOT NULL COMMENT '提示词内容', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `status` int NOT NULL COMMENT '提示词状态', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `prompt_name`(`prompt_name` ASC) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 ROW_FORMAT = Dynamic; + +INSERT INTO `llm_prompt_config` VALUES (1, 'article_review', '你是一名专业的文章内容审核员,需依据以下多维度标准对用户提交的文章内容进行综合评估与风险研判:\n一、价值导向与合规性\n是否符合社会主义核心价值观:包括富强、民主、文明、和谐,自由、平等、公正、法治,爱国、敬业、诚信、友善等;\n是否符合人类基本道德准则:如尊重生命、诚实守信、反对暴力歧视、维护人格尊严等;\n是否契合社会基本运行秩序:不得破坏社会稳定、煽动群体对立、传播反科学或封建迷信内容;\n是否遵守中国法律法规及主流意识形态:严禁涉及危害国家安全、分裂国家、颠覆政权、泄露国家秘密、破坏宗教政策等内容。\n二、违规词与敏感信息分析\n广告法违禁词识别:检测是否存在“最”“第一”“唯一”“国家级”“顶级”“全网最低价”等极限化、绝对化用语;\n政治与敏感词筛查:识别涉政不当表述、领导人姓名职务错误、涉疆涉藏涉台不当用语、不规范地图引用等高危内容;\n低俗、暴力、谣言类词汇:包括色情暗示、人身攻击、伪科学、已被官方辟谣的虚假信息等;\n变异规避词检测:识别使用谐音、符号替代(如“薇❤”“电①③⑧”)、生僻字、火星文等方式试图绕过审核的敏感词。\n你的输出必须且只能是如下两种 JSON 格式:\n{\"compliant\": true}\n{\"compliant\": false}\n下面是需要你审核的文章:', '2026-04-08 17:43:27', 1); +INSERT INTO `llm_prompt_config` VALUES (2, 'article_create', '你是一位经验丰富的通用内容作者,擅长撰写结构清晰、语言流畅、富有逻辑与人文关怀的文章。你的文风平实而不平淡,兼具可读性与思想性,能用具体例子或生动比喻解释抽象概念。请避免使用术语堆砌或空洞口号,注重真实感、节奏感和读者共鸣。\n下面是本文的主题和内容要求:', '2026-04-08 17:49:55', 1); diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V8__add_code_Injection.sql b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V8__add_code_Injection.sql new file mode 100644 index 0000000..29e4309 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V8__add_code_Injection.sql @@ -0,0 +1,11 @@ +ALTER TABLE `site_config` + ADD COLUMN `global_head_code` mediumtext CHARACTER SET utf8mb4 NULL COMMENT '全局Head标签代码注入' + AFTER `enable_llm_create_article`; + +ALTER TABLE `site_config` + ADD COLUMN `content_head_code` mediumtext CHARACTER SET utf8mb4 NULL COMMENT '内容页Head标签代码注入' + AFTER `global_head_code`; + +ALTER TABLE `site_config` + ADD COLUMN `footer_code` mediumtext CHARACTER SET utf8mb4 NULL COMMENT '页脚代码注入' + AFTER `content_head_code`; \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V9__add_image_compression.sql b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V9__add_image_compression.sql new file mode 100644 index 0000000..4187e9c --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/V9__add_image_compression.sql @@ -0,0 +1,3 @@ +ALTER TABLE `site_config` + ADD COLUMN `enable_image_compression` int NULL DEFAULT 0 COMMENT '是否启用图片压缩(1:启用,0:禁用)' + AFTER `footer_code`; diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/database_version.txt b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/database_version.txt new file mode 100644 index 0000000..978b4e8 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/db/migration/database_version.txt @@ -0,0 +1 @@ +26 \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/init.sql b/demo/dim_stack/backend/dimstack-app/src/main/resources/init.sql new file mode 100644 index 0000000..7104e15 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/init.sql @@ -0,0 +1,920 @@ +/* + Navicat Premium Dump SQL + + Source Server : localhost + Source Server Type : MySQL + Source Server Version : 80405 (8.4.5) + Source Host : localhost:3306 + Source Schema : dim_stack + + Target Server Type : MySQL + Target Server Version : 80405 (8.4.5) + File Encoding : 65001 + + Date: 22/07/2026 01:05:56 +*/ + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +-- ---------------------------- +-- Table structure for announcement +-- ---------------------------- +DROP TABLE IF EXISTS `announcement`; +CREATE TABLE `announcement` ( + `id` int NOT NULL AUTO_INCREMENT, + `content` text CHARACTER SET utf8mb4 NOT NULL COMMENT '公告内容(HTML)', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COMMENT = '公告表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of announcement +-- ---------------------------- + +-- ---------------------------- +-- Table structure for api_key +-- ---------------------------- +DROP TABLE IF EXISTS `api_key`; +CREATE TABLE `api_key` ( + `id` int UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '所属用户UUID', + `key_hash` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT 'SHA-256哈希,替代原api_key明文', + `description` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '备注描述', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `status` int UNSIGNED NOT NULL DEFAULT 1 COMMENT '状态: 1-启用, 0-禁用', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_key_hash`(`key_hash` ASC) USING BTREE, + INDEX `idx_user_status`(`user_id` ASC, `status` ASC) USING BTREE, + CONSTRAINT `api_key_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `user_information` (`uuid`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COMMENT = 'API Key表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of api_key +-- ---------------------------- + +-- ---------------------------- +-- Table structure for article +-- ---------------------------- +DROP TABLE IF EXISTS `article`; +CREATE TABLE `article` ( + `id` int NOT NULL AUTO_INCREMENT COMMENT '创建顺序', + `uuid` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '用户uuid', + `article_id` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '文章id(唯一)', + `article_name` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '文章名字', + `article_cover` text CHARACTER SET utf8mb4 NULL COMMENT '文章封面', + `excerpt` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '文章摘要', + `article_content` mediumtext CHARACTER SET utf8mb4 NOT NULL COMMENT '文章正文', + `page_views` bigint NOT NULL DEFAULT 0 COMMENT '文章阅读量', + `like_count` bigint NOT NULL DEFAULT 0 COMMENT '文章点赞数', + `favorite_count` bigint NOT NULL DEFAULT 0 COMMENT '文章收藏数', + `password` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '文章阅读密码', + `category_id` int NOT NULL, + `alias` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '文章访问链接', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '文章创建时间', + `status` tinyint NOT NULL COMMENT '文章状态:0=删除, 1=正常, 2=未发布, 3=待审核,4违规', + `enable_comment` tinyint NOT NULL DEFAULT 1 COMMENT '文章是否开启评论区(1:开启,0:关闭)', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `article_id`(`article_id` ASC) USING BTREE, + UNIQUE INDEX `alias`(`alias` ASC) USING BTREE, + INDEX `user_article_uuid`(`uuid` ASC) USING BTREE, + INDEX `idx_article_status_ctime`(`status` ASC, `create_time` DESC) USING BTREE, + INDEX `idx_article_status_category_ctime`(`status` ASC, `create_time` DESC) USING BTREE, + INDEX `idx_article_status_category_pageviews`(`status` ASC, `page_views` DESC) USING BTREE, + INDEX `fk_article_category_id`(`category_id` ASC) USING BTREE, + FULLTEXT INDEX `idx_fulltext_cn`(`article_name`, `excerpt`) WITH PARSER `ngram`, + FULLTEXT INDEX `idx_fulltext_en`(`article_name`, `excerpt`), + CONSTRAINT `fk_article_category_id` FOREIGN KEY (`category_id`) REFERENCES `article_categories` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT `fk_article_user` FOREIGN KEY (`uuid`) REFERENCES `user_information` (`uuid`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COMMENT = '文章上传记录表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of article +-- ---------------------------- +INSERT INTO `article` VALUES (1, '075eb86f721743e3940f35869154a140175689381296899805858', 'a1d3112d-fd8e-4484-9c3c-bad24a9e2019', '关于', 'https://pan.lingview.xyz/d/%E9%9B%A8%E4%BA%91%E8%8A%82%E7%82%B9/%E5%9B%BE%E5%BA%93/%E5%A4%A9%E4%BE%9D/Image_2756849649102.jpg?sign=nkverrmGB28h2DgiXlGIZBlzD2WMVByK7hNn9zwAJI4=:0', '关于本项目', '# 关于次元栈\n## 🌟 项目简介\n**次元栈** 基于SpringBoot的的现代化博客系统\n\n平台核心功能:\n\n+ 📝 文章发布与内容管理(CMS)\n+ 💬 用户互动:评论、点赞、收藏\n+ 🔖 标签分类:支持跨圈层内容组织\n+ 👥 用户系统:注册、登录、个人主页、权限管理、文章发布管理系统......\n+ 🔍 内容搜索与推荐\n+ 📱 响应式前端,支持移动端浏览\n......\n---\n\n## 🛠 技术栈\n\n| 层级 | 技术选型 |\n|------------|--------------------------------------------------------------|\n| **后端** | Java 17+, Spring Boot 4, Mybatis, MySQL, Redis, Cookie |\n| **前端** | React 19, JavaScript, Vite, Axios, Tailwind CSS |\n| **构建** | Maven (后端), npm/pnpm (前端) |\n| **部署** | Docker, Nginx, Linux, Windows |\n---\n\n', 0, 0, 0, '', 1, 'about', '2025-09-13 12:42:47', 1, 1); +INSERT INTO `article` VALUES (2, '075eb86f721743e3940f35869154a140175689381296899805858', '3cb9d2eb-bd24-486e-bb56-c6dc9332b4f1', '致谢', 'https://pan.lingview.xyz/d/%E9%9B%A8%E4%BA%91%E8%8A%82%E7%82%B9/%E5%9B%BE%E5%BA%93/%E5%A4%A9%E4%BE%9D/e5f2f1fe4bfceeb32e88217577732c04.jpg?sign=IA5DnGzWtBhEhYM5e9je4Xx3CqZOMngUd_D4TdLt2X4=:0', '感谢对本项目提供帮助的个人、组织与项目', '# 致谢\n\n在此,我们衷心感谢以下为本项目提供帮助、支持或灵感的个人和组织:\n\n## 贡献者\n感谢所有参与本项目的贡献者(按字母顺序排列):\n- [@bytegeek](https://github.com/xrb114) - 渗透测试\n- [@Denghls](https://github.com/Denghls) - 需求分析\n- [@hanbingniao](https://github.com/hanbingniao) - 系统测试\n- [@kongcangyimama](https://github.com/kongcangyimama) - 主题设计\n- [@lingview](https://github.com/lingview) - 系统开发\n- [@q1uf3ng](https://github.com/q1uf3ng) - 渗透测试\n- [@YeFeng0712](https://github.com/YeFeng0712) - 需求分析\n- [@yukifia](https://github.com/yukifia) - 需求分析\n\n---\n\n## 💡 特别感谢\n- 感谢所有为本项目贡献代码、提出问题和提供反馈的开发者。 \n- 感谢开源社区持续的支持与贡献。 \n\n---\n\n注:如果你在本项目中做出了贡献,请提交 PR 将你的名字加入到致谢名单中! \n', 0, 0, 0, '', 1, 'thanks', '2025-09-14 11:26:52', 1, 1); + +-- ---------------------------- +-- Table structure for article_categories +-- ---------------------------- +DROP TABLE IF EXISTS `article_categories`; +CREATE TABLE `article_categories` ( + `id` int NOT NULL AUTO_INCREMENT, + `parent_id` int NULL DEFAULT NULL COMMENT '父分类ID,NULL 表示顶级分类', + `article_categories` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '分类名称', + `categories_explain` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '分类说明', + `founder` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '创建人', + `article_count` int NOT NULL DEFAULT 0 COMMENT '文章数量', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '创建时间', + `status` int NOT NULL COMMENT '分类状态:0=禁用, 1=启用', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_category_name_parent`(`article_categories` ASC, `parent_id` ASC) USING BTREE, + INDEX `idx_parent_id`(`parent_id` ASC) USING BTREE, + INDEX `categories_founder`(`founder` ASC) USING BTREE, + CONSTRAINT `categories_founder_new` FOREIGN KEY (`founder`) REFERENCES `user_information` (`uuid`) ON DELETE SET NULL ON UPDATE SET NULL, + CONSTRAINT `fk_parent_category` FOREIGN KEY (`parent_id`) REFERENCES `article_categories` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of article_categories +-- ---------------------------- +INSERT INTO `article_categories` VALUES (1, NULL, '默认分类', '默认分类', '075eb86f721743e3940f35869154a140175689381296899805858', 2, '2025-09-13 13:35:57', 1); +INSERT INTO `article_categories` VALUES (2, NULL, '接口文档', '接口文档', '075eb86f721743e3940f35869154a140175689381296899805858', 0, '2026-01-31 11:35:39', 0); + +-- ---------------------------- +-- Table structure for article_like +-- ---------------------------- +DROP TABLE IF EXISTS `article_like`; +CREATE TABLE `article_like` ( + `id` int NOT NULL AUTO_INCREMENT, + `article_id` varchar(255) CHARACTER SET utf8mb4 NOT NULL, + `user_id` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL, + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) USING BTREE, + INDEX `fk_like_article`(`article_id` ASC) USING BTREE, + INDEX `fk_user_id`(`user_id` ASC) USING BTREE, + CONSTRAINT `fk_like_article` FOREIGN KEY (`article_id`) REFERENCES `article` (`article_id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `fk_user_id` FOREIGN KEY (`user_id`) REFERENCES `user_information` (`uuid`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of article_like +-- ---------------------------- + +-- ---------------------------- +-- Table structure for article_tag +-- ---------------------------- +DROP TABLE IF EXISTS `article_tag`; +CREATE TABLE `article_tag` ( + `id` int NOT NULL AUTO_INCREMENT, + `tag_name` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '标签名称', + `tag_explain` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '标签说明', + `founder` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '创建此标签的用户', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '创建时间', + `status` int NOT NULL COMMENT '标签状态:0=禁用, 1=启用', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `tag_name`(`tag_name` ASC) USING BTREE, + INDEX `tag_founder`(`founder` ASC) USING BTREE, + CONSTRAINT `tag_founder` FOREIGN KEY (`founder`) REFERENCES `user_information` (`uuid`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of article_tag +-- ---------------------------- +INSERT INTO `article_tag` VALUES (1, '默认标签', '默认标签', '075eb86f721743e3940f35869154a140175689381296899805858', '2025-09-13 13:33:24', 1); +INSERT INTO `article_tag` VALUES (2, '接口文档', '接口文档', '075eb86f721743e3940f35869154a140175689381296899805858', '2026-01-31 11:36:15', 0); + +-- ---------------------------- +-- Table structure for article_tag_relation +-- ---------------------------- +DROP TABLE IF EXISTS `article_tag_relation`; +CREATE TABLE `article_tag_relation` ( + `article_id` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '文章id', + `article_tag` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '文章标签', + INDEX `article_id`(`article_id` ASC) USING BTREE, + INDEX `article_tag`(`article_tag` ASC) USING BTREE, + CONSTRAINT `article_id` FOREIGN KEY (`article_id`) REFERENCES `article` (`article_id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `article_tag` FOREIGN KEY (`article_tag`) REFERENCES `article_tag` (`tag_name`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of article_tag_relation +-- ---------------------------- +INSERT INTO `article_tag_relation` VALUES ('52bffedc-5f09-48f4-a6cf-06a906bc73f4', '默认标签'); +INSERT INTO `article_tag_relation` VALUES ('52bffedc-5f09-48f4-a6cf-06a906bc73f4', '接口文档'); +INSERT INTO `article_tag_relation` VALUES ('3cb9d2eb-bd24-486e-bb56-c6dc9332b4f1', '默认标签'); +INSERT INTO `article_tag_relation` VALUES ('a1d3112d-fd8e-4484-9c3c-bad24a9e2019', '默认标签'); + +-- ---------------------------- +-- Table structure for attachment +-- ---------------------------- +DROP TABLE IF EXISTS `attachment`; +CREATE TABLE `attachment` ( + `id` int NOT NULL AUTO_INCREMENT COMMENT '上传顺序', + `uuid` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '用户唯一id', + `attachment_id` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '附件唯一id', + `original_filename` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '原始文件名', + `attachment_path` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '附件路径', + `access_key` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '文件访问键', + `storage_id` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '存储方式UUID', + `content_type` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '文件MIME类型', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间', + `deleted_time` datetime NULL DEFAULT NULL COMMENT '删除时间', + `status` tinyint NOT NULL COMMENT '附件状态:0=软删除, 1=正常, 2=物理文件已删除', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `attachment_id`(`attachment_id` ASC) USING BTREE, + UNIQUE INDEX `attachment_path`(`attachment_path` ASC) USING BTREE, + UNIQUE INDEX `access_key`(`access_key` ASC) USING BTREE, + INDEX `idx_uuid`(`uuid` ASC) USING BTREE, + CONSTRAINT `fk_attachment_user` FOREIGN KEY (`uuid`) REFERENCES `user_information` (`uuid`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COMMENT = '附件上传记录表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of attachment +-- ---------------------------- + +-- ---------------------------- +-- Table structure for comment +-- ---------------------------- +DROP TABLE IF EXISTS `comment`; +CREATE TABLE `comment` ( + `id` int NOT NULL AUTO_INCREMENT, + `user_id` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '评论用户id', + `comment_id` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '评论id', + `article_id` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '所属文章id', + `root_comment_id` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '如果为空则为顶级评论,如果不为空则为顶级评论的id', + `to_comment_id` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '如果为null则为顶级评论,否则为目标评论id', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '评论创建时间', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间', + `content` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '内容', + `comment_like_count` bigint NOT NULL COMMENT '点赞数', + `status` int NOT NULL COMMENT '0为删除,1为正常', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `comment_id`(`comment_id` ASC) USING BTREE, + INDEX `comment_article_id`(`article_id` ASC) USING BTREE, + INDEX `comment_user_id`(`user_id` ASC) USING BTREE, + CONSTRAINT `comment_article_id` FOREIGN KEY (`article_id`) REFERENCES `article` (`article_id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `comment_user_id` FOREIGN KEY (`user_id`) REFERENCES `user_information` (`uuid`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of comment +-- ---------------------------- + +-- ---------------------------- +-- Table structure for comment_like +-- ---------------------------- +DROP TABLE IF EXISTS `comment_like`; +CREATE TABLE `comment_like` ( + `id` int NOT NULL AUTO_INCREMENT, + `user_id` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '点赞用户ID', + `comment_id` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '被点赞的评论ID', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '点赞时间', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_user_comment`(`user_id` ASC, `comment_id` ASC) USING BTREE, + INDEX `fk_like_comment`(`comment_id` ASC) USING BTREE, + CONSTRAINT `fk_like_comment` FOREIGN KEY (`comment_id`) REFERENCES `comment` (`comment_id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `fk_like_user` FOREIGN KEY (`user_id`) REFERENCES `user_information` (`uuid`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COMMENT = '评论点赞记录表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of comment_like +-- ---------------------------- + +-- ---------------------------- +-- Table structure for custom_page +-- ---------------------------- +DROP TABLE IF EXISTS `custom_page`; +CREATE TABLE `custom_page` ( + `id` int NOT NULL AUTO_INCREMENT, + `uuid` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL, + `page_name` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '页面名', + `page_code` mediumtext CHARACTER SET utf8mb4 NOT NULL COMMENT '页面代码', + `alias` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '访问地址', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '页面创建时间', + `status` int NOT NULL COMMENT '页面状态0为删除1为正常', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `alias`(`alias` ASC) USING BTREE COMMENT '页面访问地址', + INDEX `uuid`(`uuid` ASC) USING BTREE, + CONSTRAINT `fk_custom_page_user_uuid` FOREIGN KEY (`uuid`) REFERENCES `user_information` (`uuid`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of custom_page +-- ---------------------------- + +-- ---------------------------- +-- Table structure for dashboard_menu +-- ---------------------------- +DROP TABLE IF EXISTS `dashboard_menu`; +CREATE TABLE `dashboard_menu` ( + `id` int NOT NULL AUTO_INCREMENT COMMENT '菜单ID', + `title` varchar(100) CHARACTER SET utf8mb4 NOT NULL COMMENT '菜单标题,如“站点设置”', + `icon` varchar(50) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '图标名称', + `link` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '路由链接,父菜单可为空', + `parent_id` int NULL DEFAULT NULL COMMENT '父级菜单ID,NULL表示顶级菜单', + `permission_code` varchar(100) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '访问此菜单所需的权限码,如 system:edit', + `sort_order` int NULL DEFAULT 0 COMMENT '排序权重,值越小越靠前', + `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP, + `type` enum('sidebar','quick_action') CHARACTER SET utf8mb4 NULL DEFAULT 'sidebar' COMMENT '菜单类型', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_parent_id`(`parent_id` ASC) USING BTREE, + INDEX `idx_link`(`link` ASC) USING BTREE, + INDEX `idx_permission_code`(`permission_code` ASC) USING BTREE, + INDEX `idx_sort_order`(`sort_order` ASC) USING BTREE, + CONSTRAINT `fk_menu_parent_id` FOREIGN KEY (`parent_id`) REFERENCES `dashboard_menu` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT, + CONSTRAINT `fk_menu_permission_code` FOREIGN KEY (`permission_code`) REFERENCES `permission` (`code`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COMMENT = '仪表盘菜单表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of dashboard_menu +-- ---------------------------- +INSERT INTO `dashboard_menu` VALUES (1, '仪表盘', 'dashboard', '/dashboard', NULL, NULL, 10, '2025-09-13 11:12:42', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (2, '个人中心', 'user', '/dashboard/profile', NULL, NULL, 20, '2025-09-13 11:12:42', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (3, '内容', 'content', NULL, NULL, NULL, 30, '2025-09-13 11:12:42', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (5, '设置', 'settings', NULL, NULL, 'settings:menus', 50, '2025-09-13 11:12:42', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (21, '文章', 'article', '/dashboard/articles', 3, 'article:menus', 20, '2025-09-13 11:12:42', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (22, '文章审核', 'review', '/dashboard/articlesreview', 3, 'articlereview:menus', 30, '2025-09-13 11:12:42', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (24, '评论', 'comment', '/dashboard/comments', 3, 'comments:menus', 40, '2025-09-13 11:12:42', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (25, '菜单', 'menus', '/dashboard/menus', 3, 'menus:menus', 50, '2025-09-13 11:12:42', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (42, '用户', 'users', '/dashboard/users', 3, 'user:menus', 10, '2025-09-13 11:12:42', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (43, '站点信息', 'info', '/dashboard/settings', 5, 'config:menus', 10, '2025-09-13 11:12:42', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (44, '主题管理', 'theme', '/dashboard/themes', 5, 'theme:menus', 20, '2025-09-24 16:52:43', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (45, '自定义页面', 'page', '/dashboard/custom-pages', 3, 'custompages:menus', 20, '2025-12-04 21:42:15', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (46, '更新', 'update', '/dashboard/update', 5, 'update:menus', 20, '2025-12-04 21:42:15', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (101, '个人中心', 'user', '/dashboard/profile', NULL, NULL, 10, '2025-09-13 11:12:42', 'quick_action'); +INSERT INTO `dashboard_menu` VALUES (103, '创建文章', 'edit', '/dashboard/articles/create', NULL, 'article:menus', 20, '2025-09-13 11:12:42', 'quick_action'); +INSERT INTO `dashboard_menu` VALUES (104, '用户', 'users', '/dashboard/users', NULL, 'user:menus', 30, '2025-09-13 11:12:42', 'quick_action'); +INSERT INTO `dashboard_menu` VALUES (105, '标签管理', 'tag', '/dashboard/tags', 3, 'tags:menus', 40, '2025-09-13 11:12:42', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (106, '分类管理', 'category', '/dashboard/categories', 3, 'categories:menus', 40, '2025-09-13 11:12:42', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (107, '友链管理', 'link', '/dashboard/friendlinks', 3, 'friendlinks:menus', 45, '2025-12-04 21:42:15', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (108, '全局附件管理', 'attachment', '/dashboard/global-attachments', 3, 'globalattachments:menus', 46, '2026-02-08 20:50:15', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (109, '我的附件', 'upload', '/dashboard/my-attachments', 3, 'attachments:menus', 47, '2026-02-08 20:50:19', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (110, '角色编辑', 'permission', '/dashboard/rbac-editor', 3, 'role:menus', 9, '2026-03-19 20:10:22', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (111, '公告管理', 'announcement', '/dashboard/announcement', 5, 'announcement:menus', 30, '2026-06-20 00:48:10', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (112, '评论审核', 'comment-review', '/dashboard/commentsreview', 3, 'commentreview:menus', 35, '2026-06-20 21:46:01', 'sidebar'); +INSERT INTO `dashboard_menu` VALUES (113, '插件管理', 'plugin', '/dashboard/plugins', 5, 'plugin:menus', 30, '2026-09-03 20:39:07', 'sidebar'); + +-- ---------------------------- +-- Table structure for friend_links +-- ---------------------------- +DROP TABLE IF EXISTS `friend_links`; +CREATE TABLE `friend_links` ( + `id` int NOT NULL AUTO_INCREMENT, + `uuid` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '唯一id', + `site_name` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '站点名称', + `site_url` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '站点url', + `site_icon` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '站点图标url', + `site_description` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '站点介绍', + `webmaster_name` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '站长名称', + `contact` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '联系方式', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `status` int NOT NULL COMMENT '0为删除,1为通过审核,2为待审核', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uuid`(`uuid` ASC) USING BTREE, + UNIQUE INDEX `site_url`(`site_url` ASC) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of friend_links +-- ---------------------------- + +-- ---------------------------- +-- Table structure for friend_links_config +-- ---------------------------- +DROP TABLE IF EXISTS `friend_links_config`; +CREATE TABLE `friend_links_config` ( + `id` int NOT NULL AUTO_INCREMENT, + `site_name` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '站点名称', + `site_url` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '站点url', + `site_logo` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '站点图标', + `description` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '站点描述', + `apply_rules` text CHARACTER SET utf8mb4 NULL COMMENT '友链申请规则说明', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `status` int NULL DEFAULT NULL COMMENT '站点状态1为启用,0为删除', + PRIMARY KEY (`id`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of friend_links_config +-- ---------------------------- + +-- ---------------------------- +-- Table structure for llm_config +-- ---------------------------- +DROP TABLE IF EXISTS `llm_config`; +CREATE TABLE `llm_config` ( + `api_key` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '密钥', + `api_url` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '模型地址', + `model` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '模型名称' +) ENGINE = InnoDB CHARACTER SET = utf8mb4 ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of llm_config +-- ---------------------------- + +-- ---------------------------- +-- Table structure for llm_prompt_config +-- ---------------------------- +DROP TABLE IF EXISTS `llm_prompt_config`; +CREATE TABLE `llm_prompt_config` ( + `id` int NOT NULL AUTO_INCREMENT, + `prompt_name` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '提示词模板名称', + `prompt_content` mediumtext CHARACTER SET utf8mb4 NOT NULL COMMENT '提示词内容', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `status` int NOT NULL COMMENT '提示词状态', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `prompt_name`(`prompt_name` ASC) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8mb4 ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of llm_prompt_config +-- ---------------------------- +INSERT INTO `llm_prompt_config` VALUES (1, 'article_review', '你是一名专业的文章内容审核员,需依据以下多维度标准对用户提交的文章内容进行综合评估与风险研判:\n一、价值导向与合规性\n是否符合社会主义核心价值观:包括富强、民主、文明、和谐,自由、平等、公正、法治,爱国、敬业、诚信、友善等;\n是否符合人类基本道德准则:如尊重生命、诚实守信、反对暴力歧视、维护人格尊严等;\n是否契合社会基本运行秩序:不得破坏社会稳定、煽动群体对立、传播反科学或封建迷信内容;\n是否遵守中国法律法规及主流意识形态:严禁涉及危害国家安全、分裂国家、颠覆政权、泄露国家秘密、破坏宗教政策等内容。\n二、违规词与敏感信息分析\n广告法违禁词识别:检测是否存在“最”“第一”“唯一”“国家级”“顶级”“全网最低价”等极限化、绝对化用语;\n政治与敏感词筛查:识别涉政不当表述、领导人姓名职务错误、涉疆涉藏涉台不当用语、不规范地图引用等高危内容;\n低俗、暴力、谣言类词汇:包括色情暗示、人身攻击、伪科学、已被官方辟谣的虚假信息等;\n变异规避词检测:识别使用谐音、符号替代(如“薇❤”“电①③⑧”)、生僻字、火星文等方式试图绕过审核的敏感词。\n你的输出必须且只能是如下两种 JSON 格式:\n{\"compliant\": true}\n{\"compliant\": false}\n下面是需要你审核的文章:', '2026-04-08 17:43:27', 1); +INSERT INTO `llm_prompt_config` VALUES (2, 'article_create', '你是一位经验丰富的通用内容作者,擅长撰写结构清晰、语言流畅、富有逻辑与人文关怀的文章。你的文风平实而不平淡,兼具可读性与思想性,能用具体例子或生动比喻解释抽象概念。请避免使用术语堆砌或空洞口号,注重真实感、节奏感和读者共鸣。字数如用户没有特别要求请不要超过2000字。\n下面是本文的主题和内容要求:', '2026-04-08 17:49:55', 1); +INSERT INTO `llm_prompt_config` VALUES (3, 'comment_review', '你是一名专业的评论内容审核员,需依据以下标准对用户提交的评论内容进行快速评估与风险研判:\n一、违规内容识别\n是否包含色情、低俗、性暗示等不当内容;\n是否包含人身攻击、辱骂、歧视性言论、地域黑等不友善内容;\n是否包含广告、垃圾营销、引流、联系方式等信息;\n是否包含政治敏感、违法信息、谣言、欺诈等内容;\n是否包含恶意代码、SQL注入、XSS攻击等技术性威胁。\n二、评论质量判断\n是否为无意义灌水(如纯表情、重复字符、随机字母等);\n是否与主题完全无关的恶意刷屏内容。\n你的输出必须且只能是如下两种 JSON 格式:\n{\"compliant\": true}\n{\"compliant\": false}\n下面是需要你审核的评论:', '2026-06-20 21:46:01', 1); + +-- ---------------------------- +-- Table structure for menus +-- ---------------------------- +DROP TABLE IF EXISTS `menus`; +CREATE TABLE `menus` ( + `id` int NOT NULL AUTO_INCREMENT, + `menus_id` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '目录id', + `user_id` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '创建该目录的用户', + `menus_name` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '目录名称', + `menus_url` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '目录url', + `sort_order` int NOT NULL COMMENT '菜单的显示顺序', + `status` int NOT NULL COMMENT '0为删除、1为正常', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `menus_id`(`menus_id` ASC) USING BTREE, + INDEX `menus_user_id`(`user_id` ASC) USING BTREE, + CONSTRAINT `menus_user_id` FOREIGN KEY (`user_id`) REFERENCES `user_information` (`uuid`) ON DELETE SET NULL ON UPDATE SET NULL +) ENGINE = InnoDB AUTO_INCREMENT = 7 CHARACTER SET = utf8mb4 ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of menus +-- ---------------------------- +INSERT INTO `menus` VALUES (1, 'menu_f7f08e6b511848c680c98749cc89b073', '075eb86f721743e3940f35869154a140175689381296899805858', '首页', '/', 0, 1); +INSERT INTO `menus` VALUES (2, 'menu_096205a759934193952aef85dfe382fa', '075eb86f721743e3940f35869154a140175689381296899805858', '作者主页', 'https://github.com/lingview', 1, 0); +INSERT INTO `menus` VALUES (3, 'menu_d7f2c129f1df45c4a606bcd69cf02b51', '075eb86f721743e3940f35869154a140175689381296899805858', '项目地址', 'https://github.com/lingview/dim_stack', 2, 1); +INSERT INTO `menus` VALUES (4, 'menu_16404a4412c0463f9d04e5dea10c19bc', '075eb86f721743e3940f35869154a140175689381296899805858', '致谢', '/article/thanks', 3, 1); +INSERT INTO `menus` VALUES (5, 'menu_8d1022c01adf4b8f87bc9debd86f33a5', '075eb86f721743e3940f35869154a140175689381296899805858', '友链', '/friend-links', 4, 1); +INSERT INTO `menus` VALUES (6, 'menu_f71584ff8f574978bf0e66e52bccf2ac', '075eb86f721743e3940f35869154a140175689381296899805858', '关于', '/article/about', 5, 1); + +-- ---------------------------- +-- Table structure for music +-- ---------------------------- +DROP TABLE IF EXISTS `music`; +CREATE TABLE `music` ( + `id` int NOT NULL AUTO_INCREMENT, + `uuid` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '上传用户uuid', + `music_name` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '曲名', + `music_author` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '曲作者', + `music_url` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '文件地址', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `status` int NOT NULL COMMENT '0为删除1为正常', + PRIMARY KEY (`id`) USING BTREE, + INDEX `fk_music_user_uuid`(`uuid` ASC) USING BTREE, + CONSTRAINT `fk_music_user_uuid` FOREIGN KEY (`uuid`) REFERENCES `user_information` (`uuid`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of music +-- ---------------------------- + +-- ---------------------------- +-- Table structure for permission +-- ---------------------------- +DROP TABLE IF EXISTS `permission`; +CREATE TABLE `permission` ( + `id` int NOT NULL AUTO_INCREMENT COMMENT '权限ID', + `code` varchar(100) CHARACTER SET utf8mb4 NOT NULL COMMENT '权限码,如 post:view', + `name` varchar(100) CHARACTER SET utf8mb4 NOT NULL COMMENT '权限名称,如 查看文章', + `module` varchar(50) CHARACTER SET utf8mb4 NOT NULL COMMENT '所属模块,如 post, user, system', + `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `idx_code`(`code` ASC) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 66 CHARACTER SET = utf8mb4 COMMENT = '权限表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of permission +-- ---------------------------- +INSERT INTO `permission` VALUES (1, 'system:attachment:view', '查看任意附件', 'attachment', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (2, 'system:attachment:delete', '删除任意附件', 'attachment', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (3, 'system:attachment:undelete', '撤销删除任意附件', 'attachment', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (4, 'system:attachment:management', '系统附件管理完整操作', 'attachment', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (5, 'attachment:add', '添加附件', 'attachment', '2026-03-19 22:16:25'); +INSERT INTO `permission` VALUES (6, 'attachment:view', '查看自己附件', 'attachment', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (7, 'attachment:delete', '删除自己附件', 'attachment', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (8, 'attachment:undelete', '撤销删除自己附件', 'attachment', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (9, 'attachment:edit', '管理自己附件(完整操作)', 'attachment', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (10, 'system:comments:view', '查看任意评论', 'comments', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (11, 'system:comments:edit', '编辑任意评论', 'comments', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (12, 'system:comments:delete', '删除任意评论', 'comments', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (13, 'system:comments:management', '评论管理(完整操作)', 'comments', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (14, 'comments:add', '用户评论', 'comments', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (15, 'comments:delete', '删除自己评论', 'comments', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (16, 'comments:like', '点赞评论', 'comments', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (17, 'comments:edit', '评论完整操作', 'comments', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (18, 'system:post:review', '审核文章', 'post', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (19, 'system:custompage:add', '添加自定义页面', 'custompage', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (20, 'system:custompage:update', '更新自定义页面', 'custompage', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (21, 'system:custompage:delete', '删除自定义页面', 'custompage', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (22, 'system:custompage:view', '获取自己的自定义页面', 'custompage', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (23, 'system:custompage:management', '自定义页面完整操作', 'custompage', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (24, 'post:view', '获取文章列表', 'post', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (25, 'post:add', '用户创建文章', 'post', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (26, 'post:update', '用户更新文章', 'post', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (27, 'post:details', '获取文章详情', 'post', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (28, 'post:delete', '删除自己文章', 'post', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (29, 'post:unpublish', '取消发布文章', 'post', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (30, 'post:publish', '发布文章', 'post', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (31, 'post:removepassword', '移除文章密码', 'post', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (32, 'post:edit', '编辑文章(对自己文章完整操作)', 'post', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (33, 'system:theme:management', '系统主题管理', 'theme', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (34, 'system:friendlinks:management', '系统友链管理', 'friendlinks', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (35, 'system:menus:management', '系统菜单管理', 'menu', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (36, 'system:music:management', '系统音乐管理', 'config', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (37, 'system:role:management', '系统角色管理', 'role', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (38, 'system:config:management', '系统配置管理', 'config', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (39, 'system:tags:management', '系统标签管理', 'tags', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (40, 'system:categories:management', '系统分类管理', 'categories', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (41, 'system:update:management', '系统更新管理', 'update', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (42, 'system:user:management', '管理用户信息', 'user', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (43, 'content:menus', '内容管理菜单', 'menus', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (44, 'user:menus', '用户管理菜单', 'menus', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (45, 'custompages:menus', '自定义页面菜单', 'menus', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (46, 'article:menus', '文章管理菜单', 'menus', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (47, 'articlereview:menus', '文章审核菜单', 'menus', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (48, 'comments:menus', '评论管理菜单', 'menus', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (49, 'tags:menus', '标签管理菜单', 'menus', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (50, 'categories:menus', '分类管理菜单', 'menus', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (51, 'friendlinks:menus', '友链管理菜单', 'menus', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (52, 'globalattachments:menus', '全局附件菜单', 'menus', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (53, 'attachments:menus', '我的附件菜单', 'menus', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (54, 'menus:menus', '菜单管理菜单', 'menus', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (55, 'settings:menus', '系统设置菜单', 'menus', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (56, 'config:menus', '配置管理菜单', 'menus', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (57, 'update:menus', '系统更新菜单', 'menus', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (58, 'theme:menus', '主题管理菜单', 'menus', '2025-08-27 09:22:27'); +INSERT INTO `permission` VALUES (59, 'role:menus', '角色管理菜单', 'menus', '2026-03-19 20:11:55'); +INSERT INTO `permission` VALUES (60, 'system:announcement:management', '公告管理', 'announcement', '2026-06-20 00:48:10'); +INSERT INTO `permission` VALUES (61, 'announcement:menus', '公告管理菜单', 'menus', '2026-06-20 00:48:10'); +INSERT INTO `permission` VALUES (62, 'system:comments:review', '审核评论', 'comments', '2026-06-20 21:46:01'); +INSERT INTO `permission` VALUES (63, 'commentreview:menus', '评论审核菜单', 'menus', '2026-06-20 21:46:01'); +INSERT INTO `permission` VALUES (64, 'plugin:management', '插件管理', 'plugin', '2026-09-03 20:39:07'); +INSERT INTO `permission` VALUES (65, 'plugin:menus', '插件管理菜单', 'menus', '2026-09-03 20:39:07'); + +-- ---------------------------- +-- Table structure for plugin +-- ---------------------------- +DROP TABLE IF EXISTS `plugin`; +CREATE TABLE `plugin` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `name` varchar(64) CHARACTER SET utf8mb4 NOT NULL COMMENT '插件ID(唯一)', + `version` varchar(32) CHARACTER SET utf8mb4 NOT NULL COMMENT '插件版本(semver)', + `display_name` varchar(128) CHARACTER SET utf8mb4 NOT NULL COMMENT '插件中文名', + `description` varchar(512) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '插件描述', + `author` varchar(128) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '插件作者', + `requires` varchar(32) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '宿主版本约束', + `jar_file` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '插件文件相对路径', + `sha256` varchar(64) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '插件jar完整性校验值(SHA-256 hex)', + `enabled` tinyint(1) NOT NULL DEFAULT 0 COMMENT '启用状态: 0-停用, 1-启用', + `setting_name` varchar(64) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '设置表单扩展名(阶段5)', + `config_map_name` varchar(64) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '配置存储名(阶段5)', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_plugin_name`(`name` ASC) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COMMENT = '插件注册表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of plugin +-- ---------------------------- + +-- ---------------------------- +-- Table structure for plugin_config +-- ---------------------------- +DROP TABLE IF EXISTS `plugin_config`; +CREATE TABLE `plugin_config` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `plugin_name` varchar(64) CHARACTER SET utf8mb4 NOT NULL COMMENT '插件ID', + `config_key` varchar(64) CHARACTER SET utf8mb4 NOT NULL COMMENT '配置键', + `config_value` text CHARACTER SET utf8mb4 NULL COMMENT '配置值(JSON字符串)', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_plugin_config`(`plugin_name` ASC, `config_key` ASC) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COMMENT = '插件配置表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of plugin_config +-- ---------------------------- + + +-- ---------------------------- +-- Table structure for plugin_sql_audit +-- ---------------------------- +DROP TABLE IF EXISTS `plugin_sql_audit`; +CREATE TABLE `plugin_sql_audit` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `plugin_name` varchar(64) CHARACTER SET utf8mb4 NOT NULL COMMENT '插件ID', + `category` varchar(32) CHARACTER SET utf8mb4 NOT NULL COMMENT 'ddl-结构变更, core_write-写核心表, slow-慢SQL', + `sql_text` varchar(2048) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT 'SQL文本(超长截断)', + `cost_millis` bigint NULL DEFAULT NULL COMMENT '执行耗时(毫秒)', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录时间', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_plugin_name_id`(`plugin_name` ASC, `id` ASC) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COMMENT = '插件SQL审计' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of plugin_sql_audit +-- ---------------------------- + + +-- ---------------------------- +-- Table structure for role +-- ---------------------------- +DROP TABLE IF EXISTS `role`; +CREATE TABLE `role` ( + `id` int NOT NULL AUTO_INCREMENT, + `code` varchar(50) CHARACTER SET utf8mb4 NOT NULL COMMENT '角色编码,如 AUTHOR', + `name` varchar(100) CHARACTER SET utf8mb4 NOT NULL COMMENT '角色名称,如 作者', + `description` text CHARACTER SET utf8mb4 NULL COMMENT '描述', + `status` tinyint NULL DEFAULT 1 COMMENT '状态:1启用,0禁用', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `idx_code`(`code` ASC) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 6 CHARACTER SET = utf8mb4 COMMENT = '角色表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of role +-- ---------------------------- +INSERT INTO `role` VALUES (1, 'ADMIN', '管理员', '系统超级管理员,拥有最高权限,可管理用户、角色、权限、站点配置等所有功能,负责平台整体运行与安全。', 1); +INSERT INTO `role` VALUES (2, 'OPERATOR', '操作员', '负责平台运营,拥有部分系统配置权限', 1); +INSERT INTO `role` VALUES (3, 'POST_MANAGER', '文章管理员', '负责内容运营管理的角色,可以审核、发布、修改和删除任何文章,管理评论,维护内容质量和平台秩序。', 1); +INSERT INTO `role` VALUES (4, 'AUTHOR', '作者', '内容创作者角色,可以创建、编辑、删除自己的文章,并提交文章进入审核流程,等待管理员发布。', 1); +INSERT INTO `role` VALUES (5, 'READER', '阅读者', '可以浏览和阅读已发布的文章内容,参与评论互动,但无法创建或编辑文章。', 1); + +-- ---------------------------- +-- Table structure for role_permission +-- ---------------------------- +DROP TABLE IF EXISTS `role_permission`; +CREATE TABLE `role_permission` ( + `role_id` int NOT NULL COMMENT '角色ID', + `permission_id` int NOT NULL COMMENT '权限ID', + PRIMARY KEY (`role_id`, `permission_id`) USING BTREE, + INDEX `idx_permission_id`(`permission_id` ASC) USING BTREE, + CONSTRAINT `fk_rp_permission` FOREIGN KEY (`permission_id`) REFERENCES `permission` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT, + CONSTRAINT `fk_rp_role` FOREIGN KEY (`role_id`) REFERENCES `role` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COMMENT = '角色权限关联表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of role_permission +-- ---------------------------- +INSERT INTO `role_permission` VALUES (1, 4); +INSERT INTO `role_permission` VALUES (2, 4); +INSERT INTO `role_permission` VALUES (1, 5); +INSERT INTO `role_permission` VALUES (4, 5); +INSERT INTO `role_permission` VALUES (1, 9); +INSERT INTO `role_permission` VALUES (4, 9); +INSERT INTO `role_permission` VALUES (1, 13); +INSERT INTO `role_permission` VALUES (2, 13); +INSERT INTO `role_permission` VALUES (3, 13); +INSERT INTO `role_permission` VALUES (5, 16); +INSERT INTO `role_permission` VALUES (1, 17); +INSERT INTO `role_permission` VALUES (4, 17); +INSERT INTO `role_permission` VALUES (5, 17); +INSERT INTO `role_permission` VALUES (1, 18); +INSERT INTO `role_permission` VALUES (3, 18); +INSERT INTO `role_permission` VALUES (1, 23); +INSERT INTO `role_permission` VALUES (1, 32); +INSERT INTO `role_permission` VALUES (4, 32); +INSERT INTO `role_permission` VALUES (1, 33); +INSERT INTO `role_permission` VALUES (2, 33); +INSERT INTO `role_permission` VALUES (1, 34); +INSERT INTO `role_permission` VALUES (2, 34); +INSERT INTO `role_permission` VALUES (1, 35); +INSERT INTO `role_permission` VALUES (2, 35); +INSERT INTO `role_permission` VALUES (1, 36); +INSERT INTO `role_permission` VALUES (1, 37); +INSERT INTO `role_permission` VALUES (1, 38); +INSERT INTO `role_permission` VALUES (1, 39); +INSERT INTO `role_permission` VALUES (2, 39); +INSERT INTO `role_permission` VALUES (1, 40); +INSERT INTO `role_permission` VALUES (2, 40); +INSERT INTO `role_permission` VALUES (1, 41); +INSERT INTO `role_permission` VALUES (2, 41); +INSERT INTO `role_permission` VALUES (1, 42); +INSERT INTO `role_permission` VALUES (1, 43); +INSERT INTO `role_permission` VALUES (2, 43); +INSERT INTO `role_permission` VALUES (3, 43); +INSERT INTO `role_permission` VALUES (4, 43); +INSERT INTO `role_permission` VALUES (1, 44); +INSERT INTO `role_permission` VALUES (1, 45); +INSERT INTO `role_permission` VALUES (2, 45); +INSERT INTO `role_permission` VALUES (1, 46); +INSERT INTO `role_permission` VALUES (4, 46); +INSERT INTO `role_permission` VALUES (1, 47); +INSERT INTO `role_permission` VALUES (3, 47); +INSERT INTO `role_permission` VALUES (1, 48); +INSERT INTO `role_permission` VALUES (2, 48); +INSERT INTO `role_permission` VALUES (3, 48); +INSERT INTO `role_permission` VALUES (1, 49); +INSERT INTO `role_permission` VALUES (2, 49); +INSERT INTO `role_permission` VALUES (1, 50); +INSERT INTO `role_permission` VALUES (2, 50); +INSERT INTO `role_permission` VALUES (1, 51); +INSERT INTO `role_permission` VALUES (2, 51); +INSERT INTO `role_permission` VALUES (1, 52); +INSERT INTO `role_permission` VALUES (2, 52); +INSERT INTO `role_permission` VALUES (1, 53); +INSERT INTO `role_permission` VALUES (4, 53); +INSERT INTO `role_permission` VALUES (1, 54); +INSERT INTO `role_permission` VALUES (2, 54); +INSERT INTO `role_permission` VALUES (1, 55); +INSERT INTO `role_permission` VALUES (2, 55); +INSERT INTO `role_permission` VALUES (1, 56); +INSERT INTO `role_permission` VALUES (1, 57); +INSERT INTO `role_permission` VALUES (2, 57); +INSERT INTO `role_permission` VALUES (1, 58); +INSERT INTO `role_permission` VALUES (2, 58); +INSERT INTO `role_permission` VALUES (1, 59); +INSERT INTO `role_permission` VALUES (1, 60); +INSERT INTO `role_permission` VALUES (1, 61); +INSERT INTO `role_permission` VALUES (1, 62); +INSERT INTO `role_permission` VALUES (1, 63); +INSERT INTO `role_permission` VALUES (1, 64); +INSERT INTO `role_permission` VALUES (1, 65); + +-- ---------------------------- +-- Table structure for site_config +-- ---------------------------- +DROP TABLE IF EXISTS `site_config`; +CREATE TABLE `site_config` ( + `id` int NOT NULL AUTO_INCREMENT COMMENT '主键', + `site_name` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '站点名称', + `register_user_permission` int NULL DEFAULT NULL COMMENT '注册用户默认角色id', + `copyright` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '版权信息', + `article_status` int NOT NULL COMMENT '用户上传文章默认状态0为不发布1为发布', + `hero_image` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '首页头图', + `hero_title` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '首页标题', + `hero_subtitle` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '首页副标题', + `site_icon` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '站点图标', + `site_theme` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '站点主题', + `expansion_server` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '扩展服务器', + `enable_notification` tinyint(1) NULL DEFAULT 0 COMMENT '是否启用通知系统(1:启用,0:禁用)', + `smtp_host` varchar(100) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT 'SMTP服务器地址', + `smtp_port` int NULL DEFAULT NULL COMMENT 'SMTP端口', + `mail_sender_email` varchar(100) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '发件人邮箱', + `mail_sender_name` varchar(50) CHARACTER SET utf8mb4 NULL DEFAULT '系统通知' COMMENT '发件人名称', + `mail_username` varchar(100) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '邮箱登录账号', + `mail_password` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '邮箱授权码(应加密存储)', + `mail_protocol` varchar(10) CHARACTER SET utf8mb4 NULL DEFAULT 'smtp' COMMENT '协议类型:smtp/smtps', + `mail_enable_tls` tinyint(1) NULL DEFAULT 1 COMMENT '是否启用TLS', + `mail_enable_ssl` tinyint(1) NULL DEFAULT 0 COMMENT '是否启用SSL', + `mail_default_encoding` varchar(10) CHARACTER SET utf8mb4 NULL DEFAULT 'UTF-8' COMMENT '编码格式', + `icp_record_number` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT 'icp备案号', + `mps_record_number` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '公安联网备案号', + `enable_register` int NULL DEFAULT NULL COMMENT '是否启用用户注册(1:启用,0:禁用)', + `enable_music` int NULL DEFAULT NULL COMMENT '是否启用悬浮音乐播放器(1:启用,0:禁用)', + `admin_post_no_review` int NULL DEFAULT 0 COMMENT '管理员文章是否无需审核(1:启用,0:禁用)', + `enable_llm` int NULL DEFAULT NULL COMMENT '是否启动系统大模型接入(1:启用,0:禁用)', + `enable_llm_article_review` int NULL DEFAULT NULL COMMENT '是否启用大模型自动审核文章(1:启用,0:禁用)', + `enable_llm_create_article` int NULL DEFAULT NULL COMMENT '是否启用大模型生成文章(1:启用,0:禁用)', + `global_head_code` mediumtext CHARACTER SET utf8mb4 NULL COMMENT '全局Head标签代码注入', + `content_head_code` mediumtext CHARACTER SET utf8mb4 NULL COMMENT '内容页Head标签代码注入', + `footer_code` mediumtext CHARACTER SET utf8mb4 NULL COMMENT '页脚代码注入', + `enable_image_compression` int NULL DEFAULT 0 COMMENT '是否启用图片压缩(1:启用,0:禁用)', + `image_compression_threads` int NULL DEFAULT 5 COMMENT '图片压缩处理线程数', + `proxy_resource_download` int NULL DEFAULT 0 COMMENT '是否启动代理资源下载(1:启用,0:禁用)', + `enable_comment` int NULL DEFAULT 1 COMMENT '是否启用评论区(1:启用,0:禁用)', + `comment_status` int NOT NULL DEFAULT 1 COMMENT '评论默认状态(1直接发布,3待审核)', + `enable_llm_comment_review` int NOT NULL DEFAULT 0 COMMENT '是否启用大模型自动审核评论(1启用,0禁用)', + `admin_comment_no_review` int NOT NULL DEFAULT 1 COMMENT '管理员用户评论免审核(1启用,0禁用)', + `default_storage` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '默认存储方式UUID', + PRIMARY KEY (`id`) USING BTREE, + INDEX `register_user_permission`(`register_user_permission` ASC) USING BTREE, + CONSTRAINT `register_user_permission` FOREIGN KEY (`register_user_permission`) REFERENCES `role` (`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COMMENT = '站点基础设置表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of site_config +-- ---------------------------- +INSERT INTO `site_config` VALUES (1, '次元栈 - Dim Stack', 4, '© 2025-2026 次元栈 - Dim Stack. All rights reserved.', 3, 'https://pan.lingview.xyz/d/%E9%9B%A8%E4%BA%91%E8%8A%82%E7%82%B9/%E5%9B%BE%E5%BA%93/%E5%A3%81%E7%BA%B8/mmexport1769432250836.jpg?sign=KFQ9u-fY83wR52PdfpesYywMJi_0M4Uc66vz7A9Tal0=:0', '欢迎使用次元栈', '欢迎大家在 GitHub 上提交 Issue 或 Pull Request!', 'https://pan.lingview.xyz/d/%E9%9B%A8%E4%BA%91%E8%8A%82%E7%82%B9/%E5%9B%BE%E5%BA%93/%E5%A4%A9%E4%BE%9D/Image_1721230292906.png?sign=JU30z6z_RsZ3Vv7HB_5D3msYRneiga5NLjhN3EpL-3w=:0', 'default', 'https://dimstackrepo.apilinks.cn/themes.json', 0, '', NULL, '', '系统通知', '', NULL, 'smtp', 0, 1, 'UTF-8', '', '', 1, 0, 1, 0, 0, 0, '', '', '', 0, 5, 0, 1, 1, 0, 1, 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4f47ac10b48bf511eb9a5eb9b5a8f1c2d172121853800042356789'); + +-- ---------------------------- +-- Table structure for storage_method +-- ---------------------------- +DROP TABLE IF EXISTS `storage_method`; +CREATE TABLE `storage_method` ( + `uuid` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '存储方式唯一标识', + `user_uuid` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '创建人UUID,关联管理员用户表', + `name` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '存储方式名称,如:本地存储、阿里云OSS', + `type` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '存储类型:local、s3、oss、cos等', + `config` text CHARACTER SET utf8mb4 NULL COMMENT '存储配置信息,JSON字符串格式', + `status` int NOT NULL DEFAULT 1 COMMENT '状态:0-禁用,1-启用', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`uuid`) USING BTREE, + INDEX `idx_type`(`type` ASC) USING BTREE, + INDEX `idx_status`(`status` ASC) USING BTREE, + INDEX `user_uuid`(`user_uuid` ASC) USING BTREE, + CONSTRAINT `storage_method_ibfk_1` FOREIGN KEY (`user_uuid`) REFERENCES `user_information` (`uuid`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COMMENT = '存储方式管理表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of storage_method +-- ---------------------------- +INSERT INTO `storage_method` VALUES ('a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4f47ac10b48bf511eb9a5eb9b5a8f1c2d172121853800042356789', '075eb86f721743e3940f35869154a140175689381296899805858', 'local', 'local', NULL, 1, '2026-07-17 21:02:55', '2026-07-17 21:02:55'); + +-- ---------------------------- +-- Table structure for storage_migration_failed_item +-- ---------------------------- +DROP TABLE IF EXISTS `storage_migration_failed_item`; +CREATE TABLE `storage_migration_failed_item` ( + `id` int NOT NULL AUTO_INCREMENT COMMENT '自增主键', + `migration_id` int NOT NULL COMMENT '关联迁移记录ID', + `attachment_id` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '附件ID', + `file_path` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '文件路径', + `error_msg` text CHARACTER SET utf8mb4 NULL COMMENT '错误信息', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_migration_id`(`migration_id` ASC) USING BTREE, + CONSTRAINT `fk_migration_item` FOREIGN KEY (`migration_id`) REFERENCES `storage_migration_log` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COMMENT = '迁移失败明细' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of storage_migration_failed_item +-- ---------------------------- + +-- ---------------------------- +-- Table structure for storage_migration_log +-- ---------------------------- +DROP TABLE IF EXISTS `storage_migration_log`; +CREATE TABLE `storage_migration_log` ( + `id` int NOT NULL AUTO_INCREMENT COMMENT '自增主键', + `source_storage_id` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '源存储UUID', + `target_storage_id` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '目标存储UUID', + `total` int NOT NULL DEFAULT 0 COMMENT '总附件数', + `success` int NOT NULL DEFAULT 0 COMMENT '成功数', + `failed` int NOT NULL DEFAULT 0 COMMENT '失败数', + `status` int NOT NULL DEFAULT 0 COMMENT '状态:0-进行中,1-已完成,2-失败', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_source`(`source_storage_id` ASC) USING BTREE, + INDEX `idx_target`(`target_storage_id` ASC) USING BTREE, + INDEX `idx_created_at`(`created_at` ASC) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COMMENT = '存储迁移记录' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of storage_migration_log +-- ---------------------------- + +-- ---------------------------- +-- Table structure for systematic_notification +-- ---------------------------- +DROP TABLE IF EXISTS `systematic_notification`; +CREATE TABLE `systematic_notification` ( + `id` int NOT NULL AUTO_INCREMENT, + `uuid` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '所属用户的uuid', + `content` mediumtext CHARACTER SET utf8mb4 NOT NULL COMMENT '通知内容', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `type` varchar(50) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '通知类型', + `status` int NOT NULL COMMENT '通知状态2为未读,1为已读,0为删除', + PRIMARY KEY (`id`) USING BTREE, + INDEX `fk_systematic_notification_user_uuid`(`uuid` ASC) USING BTREE, + CONSTRAINT `fk_systematic_notification_user_uuid` FOREIGN KEY (`uuid`) REFERENCES `user_information` (`uuid`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of systematic_notification +-- ---------------------------- + +-- ---------------------------- +-- Table structure for user_information +-- ---------------------------- +DROP TABLE IF EXISTS `user_information`; +CREATE TABLE `user_information` ( + `id` int NOT NULL AUTO_INCREMENT COMMENT '用户创建顺序', + `uuid` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '唯一用户id', + `username` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '用户名', + `avatar` text CHARACTER SET utf8mb4 NULL COMMENT '用户头像', + `phone` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '手机号', + `email` varchar(255) CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '邮箱', + `gender` enum('male','female','other') CHARACTER SET utf8mb4 NULL DEFAULT NULL COMMENT '性别', + `password` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '密码', + `birthday` date NULL DEFAULT NULL COMMENT '生日', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '用户创建时间', + `status` tinyint NOT NULL COMMENT '用户状态:0=删除, 1=正常, 2=封禁', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uuid`(`uuid` ASC) USING BTREE, + UNIQUE INDEX `username`(`username` ASC) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COMMENT = '用户信息表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of user_information +-- ---------------------------- +INSERT INTO `user_information` VALUES (1, '075eb86f721743e3940f35869154a140175689381296899805858', 'admin', 'https://pan.lingview.xyz/d/%E9%9B%A8%E4%BA%91%E8%8A%82%E7%82%B9/%E5%9B%BE%E5%BA%93/%E5%A4%A9%E4%BE%9D/-1369486563.jpg?sign=7hdp5soxiEvVYpqvgiWxoJQ2UMJJCcoatm48bajEBkk=:0', NULL, 'official@dimstack.com', NULL, '$2a$10$hNfMxBf3egQkomuMql9LDeMJb2AC9IXkp904GgqX6DAxc8u9i1aAm', NULL, '2025-09-03 18:03:33', 1); + +-- ---------------------------- +-- Table structure for user_role +-- ---------------------------- +DROP TABLE IF EXISTS `user_role`; +CREATE TABLE `user_role` ( + `user_id` int NOT NULL COMMENT '用户 ID', + `role_id` int NOT NULL COMMENT '角色 ID', + PRIMARY KEY (`user_id`, `role_id`) USING BTREE, + INDEX `idx_role_id`(`role_id` ASC) USING BTREE, + CONSTRAINT `fk_ur_role` FOREIGN KEY (`role_id`) REFERENCES `role` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT, + CONSTRAINT `fk_ur_user` FOREIGN KEY (`user_id`) REFERENCES `user_information` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COMMENT = '用户角色关联表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of user_role +-- ---------------------------- +INSERT INTO `user_role` VALUES (1, 1); + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/logback-spring.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..64c9494 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/logback-spring.xml @@ -0,0 +1,41 @@ + + + + + + dim-stack + + + + ${stdout.encoding:-${sun.stdout.encoding:-UTF-8}} + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{40} - %msg%n + + + + + ${LOG_DIR}/dim_stack.log + + UTF-8 + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{40} - %msg%n + + + ${LOG_DIR}/dim_stack-%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 10GB + + + + + 0 + 512 + false + + + + + + + + + \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/AnnouncementMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/AnnouncementMapper.xml new file mode 100644 index 0000000..cd485e6 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/AnnouncementMapper.xml @@ -0,0 +1,16 @@ + + + + + + + + + INSERT INTO announcement (content) + VALUES (#{content}) + + + diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/ApiKeyMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/ApiKeyMapper.xml new file mode 100644 index 0000000..0cde233 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/ApiKeyMapper.xml @@ -0,0 +1,61 @@ + + + + + + + + INSERT INTO api_key ( + user_id, + key_hash, + description, + status + ) VALUES ( + #{user_id}, + #{key_hash}, + #{description}, + #{status} + ) + + + + + + + + + + + + + + UPDATE api_key + SET status = #{status} + WHERE id = #{id} AND user_id = #{userId} + + + + + DELETE FROM api_key + WHERE id = #{id} AND user_id = #{userId} + + + + + + diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/ArticleCategoryMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/ArticleCategoryMapper.xml new file mode 100644 index 0000000..77d8164 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/ArticleCategoryMapper.xml @@ -0,0 +1,319 @@ + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO article_categories (parent_id, article_categories, categories_explain, founder, status, article_count) + VALUES (#{parent_id}, #{article_categories}, #{categories_explain}, #{founder}, #{status}, #{article_count}) + + + + UPDATE article_categories + + article_categories = #{article_categories}, + categories_explain = #{categories_explain}, + founder = #{founder}, + status = #{status}, + create_time = CURRENT_TIMESTAMP + + WHERE id = #{id} + + + + UPDATE article_categories + SET status = #{status} + WHERE id = #{id} + + + + + + + UPDATE article_categories c + LEFT JOIN article_categories p ON c.parent_id = p.id + SET c.article_count = c.article_count + 1 + WHERE ( + (#{category} LIKE '%/%' AND CONCAT(p.article_categories, '/', c.article_categories) = #{category}) + OR + (#{category} NOT LIKE '%/%' AND c.article_categories = #{category} AND p.article_categories IS NULL) + ) + AND c.status = 1 + + + + + UPDATE article_categories c + LEFT JOIN article_categories p ON c.parent_id = p.id + SET c.article_count = c.article_count - 1 + WHERE ( + (#{category} LIKE '%/%' AND CONCAT(p.article_categories, '/', c.article_categories) = #{category}) + OR + (#{category} NOT LIKE '%/%' AND c.article_categories = #{category} AND p.article_categories IS NULL) + ) + AND c.status = 1 + AND c.article_count > 0 + + + + + + + + + + + + + + + + + UPDATE article_categories + SET parent_id = #{parentId} + WHERE id = #{id} + + + + + DELETE FROM article_categories + WHERE id = #{id} OR parent_id = #{id} + + + + \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/ArticleLikeMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/ArticleLikeMapper.xml new file mode 100644 index 0000000..dcd55e3 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/ArticleLikeMapper.xml @@ -0,0 +1,21 @@ + + + + + + INSERT INTO article_like (user_id, article_id, create_time) + VALUES (#{user_id}, #{article_id}, #{create_time}) + + + + DELETE FROM article_like WHERE user_id = #{userId} AND article_id = #{articleId} + + + + + diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/ArticleMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/ArticleMapper.xml new file mode 100644 index 0000000..f512005 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/ArticleMapper.xml @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + UPDATE article + SET like_count = #{likeCount} + WHERE article_id = #{articleId} + + + diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/ArticleReviewMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/ArticleReviewMapper.xml new file mode 100644 index 0000000..d004b16 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/ArticleReviewMapper.xml @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + UPDATE article + SET status = #{status} + WHERE article_id = #{article_id} + + + + + + + + + diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/ArticleSearchMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/ArticleSearchMapper.xml new file mode 100644 index 0000000..d4a8c80 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/ArticleSearchMapper.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/ArticleTagMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/ArticleTagMapper.xml new file mode 100644 index 0000000..3644ab0 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/ArticleTagMapper.xml @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + INSERT INTO article_tag (tag_name, tag_explain, founder, status) + VALUES (#{tag_name}, #{tag_explain}, #{founder}, #{status}) + + + + UPDATE article_tag + + tag_name = #{tag_name}, + tag_explain = #{tag_explain}, + founder = #{founder}, + status = #{status}, + create_time = CURRENT_TIMESTAMP + + WHERE id = #{id} + + + + UPDATE article_tag + SET status = #{status} + WHERE id = #{id} + + + + + + + + + diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/AttachmentManagementMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/AttachmentManagementMapper.xml new file mode 100644 index 0000000..04552b0 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/AttachmentManagementMapper.xml @@ -0,0 +1,233 @@ + + + + + + + + + + + + + + + + + UPDATE attachment + SET status = 0, deleted_time = #{deletedTime} + WHERE attachment_id = #{attachmentId} + + + + + + UPDATE attachment + SET status = 1, deleted_time = NULL + WHERE attachment_id = #{attachmentId} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + UPDATE attachment + SET status = 2 + WHERE attachment_id = #{attachmentId} + + + + UPDATE attachment + SET status = 0, deleted_time = #{deletedTime} + WHERE attachment_id IN + + #{attachmentId} + + + + + UPDATE attachment + SET status = 1, deleted_time = NULL + WHERE attachment_id IN + + #{attachmentId} + + + + + + + UPDATE attachment + SET storage_id = #{storageId} + WHERE attachment_id = #{attachmentId} + + + \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/BackendCommentMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/BackendCommentMapper.xml new file mode 100644 index 0000000..06658ab --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/BackendCommentMapper.xml @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + UPDATE `comment` + SET content = #{content}, update_time = NOW() + WHERE comment_id = #{comment_id} + + + + + UPDATE `comment` + SET status = 0, update_time = NOW() + WHERE comment_id = #{comment_id} + + + + + + + + + + + + UPDATE `comment` + SET create_time = #{create_time}, update_time = NOW() + WHERE comment_id = #{comment_id} + + + + UPDATE `comment` + SET user_id = #{user_id}, update_time = NOW() + WHERE comment_id = #{comment_id} + + + + + + + + + + + + + + UPDATE `comment` + SET status = #{status}, update_time = NOW() + WHERE comment_id = #{comment_id} + + \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/CommentLikeMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/CommentLikeMapper.xml new file mode 100644 index 0000000..72a2166 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/CommentLikeMapper.xml @@ -0,0 +1,21 @@ + + + + + + INSERT INTO comment_like (user_id, comment_id, create_time) + VALUES (#{user_id}, #{comment_id}, #{create_time}) + + + + DELETE FROM comment_like WHERE user_id = #{userId} AND comment_id = #{commentId} + + + + + diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/CommentMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/CommentMapper.xml new file mode 100644 index 0000000..3b75891 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/CommentMapper.xml @@ -0,0 +1,43 @@ + + + + + + INSERT INTO comment ( + user_id, comment_id, article_id, root_comment_id, + to_comment_id, content, comment_like_count, status + ) VALUES ( + #{user_id}, #{comment_id}, #{article_id}, #{root_comment_id}, + #{to_comment_id}, #{content}, #{comment_like_count}, #{status} + ) + + + + + + + + UPDATE comment + SET comment_like_count = #{likeCount} + WHERE comment_id = #{commentId} + + + + UPDATE comment + SET status = 0 + WHERE comment_id = #{commentId} + + + + UPDATE comment + SET status = #{status}, update_time = NOW() + WHERE comment_id = #{commentId} + + diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/CustomPageMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/CustomPageMapper.xml new file mode 100644 index 0000000..ce2051b --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/CustomPageMapper.xml @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + id, uuid, page_name, page_code, alias, create_time, status + + + + + + + + + + + + DELETE FROM custom_page + WHERE id = #{id,jdbcType=INTEGER} + + + + INSERT INTO custom_page + + + uuid, + + + page_name, + + + page_code, + + + alias, + + + status, + + + + + #{uuid,jdbcType=VARCHAR}, + + + #{pageName,jdbcType=VARCHAR}, + + + #{pageCode,jdbcType=LONGVARCHAR}, + + + #{alias,jdbcType=VARCHAR}, + + + #{status,jdbcType=INTEGER}, + + + + + + UPDATE custom_page + + + page_name = #{pageName,jdbcType=VARCHAR}, + + + page_code = #{pageCode,jdbcType=LONGVARCHAR}, + + + alias = #{alias,jdbcType=VARCHAR}, + + + status = #{status,jdbcType=INTEGER}, + + + WHERE id = #{id,jdbcType=INTEGER} + + + + UPDATE custom_page + SET status = #{status} + WHERE id = #{id} + + + diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/DashboardMenuMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/DashboardMenuMapper.xml new file mode 100644 index 0000000..5f63b1d --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/DashboardMenuMapper.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO dashboard_menu (title, icon, link, parent_id, permission_code, sort_order, create_time, type) + VALUES (#{title}, #{icon}, #{link}, #{parent_id}, #{permission_code}, #{sort_order}, NOW(), #{type}) + + + + + + + + DELETE FROM dashboard_menu WHERE id IN + + #{id} + + + diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/EditArticleMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/EditArticleMapper.xml new file mode 100644 index 0000000..a32e36b --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/EditArticleMapper.xml @@ -0,0 +1,241 @@ + + + + + + + AND a.status != 0 + + AND (a.article_name LIKE CONCAT('%', #{keyword}, '%') OR a.excerpt LIKE CONCAT('%', #{keyword}, '%')) + + + AND a.status = #{status} + + + AND a.category_id IN + + #{cid} + + + + AND EXISTS (SELECT 1 FROM article_tag_relation atr_filter + WHERE atr_filter.article_id = a.article_id AND atr_filter.article_tag = #{tag}) + + + AND a.create_time >= #{startTime} + + + AND a.create_time <= #{endTime} + + + + + + + + + + + + + + + + + + + + + + + + + UPDATE article + SET + article_name = #{article_name}, + article_cover = #{article_cover}, + excerpt = #{excerpt}, + article_content = #{article_content}, + password = #{password}, + category_id = ( + SELECT c.id + FROM article_categories c + LEFT JOIN article_categories p ON c.parent_id = p.id + WHERE c.article_categories = #{category} + AND p.id IS NULL + ), + alias = #{alias}, + status = #{status} + + , enable_comment = #{enable_comment} + + + , create_time = #{create_time} + + WHERE article_id = #{article_id} + + + + UPDATE article + SET + article_name = #{article_name}, + article_cover = #{article_cover}, + excerpt = #{excerpt}, + article_content = #{article_content}, + password = #{password}, + category_id = ( + SELECT c.id + FROM article_categories c + LEFT JOIN article_categories p ON c.parent_id = p.id + WHERE c.article_categories = #{childCategory} + AND ( + + p.article_categories = #{parentCategory} + + + p.id IS NULL + + ) + ), + alias = #{alias}, + status = #{status} + + , enable_comment = #{enable_comment} + + + , create_time = #{create_time} + + WHERE article_id = #{article_id} + + + + + + UPDATE article + SET status = 0 + WHERE article_id = #{article_id} AND uuid = #{uuid} + + + + + UPDATE article + SET status = 2 + WHERE article_id = #{article_id} AND uuid = #{uuid} + + + + + UPDATE article + SET status = #{status} + WHERE article_id = #{article_id} AND uuid = #{uuid} + + + + + UPDATE article + SET password = #{password} + WHERE article_id = #{article_id} + + + + + DELETE FROM article_tag_relation WHERE article_id = #{articleId} + + + + INSERT INTO article_tag_relation(article_id, article_tag) + VALUES (#{articleId}, #{tagName}) + + + + + + + + + + UPDATE article + SET status = #{status} + WHERE article_id = #{articleId} + + + diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/FriendLinksConfigMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/FriendLinksConfigMapper.xml new file mode 100644 index 0000000..316f280 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/FriendLinksConfigMapper.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + UPDATE friend_links_config + SET site_name = #{siteName}, + site_url = #{siteUrl}, + site_logo = #{siteLogo}, + description = #{description}, + apply_rules = #{applyRules} + WHERE id = #{id} + + + + INSERT INTO friend_links_config ( + site_name, + site_url, + site_logo, + description, + apply_rules, + status + ) VALUES ( + #{siteName}, + #{siteUrl}, + #{siteLogo}, + #{description}, + #{applyRules}, + #{status} + ) + + + \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/FriendLinksMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/FriendLinksMapper.xml new file mode 100644 index 0000000..9d926ca --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/FriendLinksMapper.xml @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + INSERT INTO friend_links ( + uuid, + site_name, + site_url, + site_icon, + site_description, + webmaster_name, + contact, + status + ) VALUES ( + #{uuid}, + #{siteName}, + #{siteUrl}, + #{siteIcon}, + #{siteDescription}, + #{webmasterName}, + #{contact}, + #{status} + ) + + + + + UPDATE friend_links + SET status = #{status} + WHERE id = #{id} + + + + + UPDATE friend_links + SET + site_name = #{siteName}, + site_url = #{siteUrl}, + site_icon = #{siteIcon}, + site_description = #{siteDescription}, + webmaster_name = #{webmasterName}, + contact = #{contact} + WHERE id = #{id} + + + + + UPDATE friend_links + SET status = 0 + WHERE id = #{id} + + + + + DELETE FROM friend_links + WHERE id = #{id} + + + + + + + + + + + + + + + + + + diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/HotArticleMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/HotArticleMapper.xml new file mode 100644 index 0000000..b36c2c5 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/HotArticleMapper.xml @@ -0,0 +1,32 @@ + + + + + + + + + + diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/LlmConfigMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/LlmConfigMapper.xml new file mode 100644 index 0000000..35ac886 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/LlmConfigMapper.xml @@ -0,0 +1,27 @@ + + + + + + + + UPDATE llm_config + SET api_key = #{api_key}, + api_url = #{api_url}, + model = #{model} + + + + INSERT INTO llm_config (api_key, api_url, model) + VALUES (#{api_key}, #{api_url}, #{model}) + + diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/LlmPromptConfigMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/LlmPromptConfigMapper.xml new file mode 100644 index 0000000..6997b0b --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/LlmPromptConfigMapper.xml @@ -0,0 +1,17 @@ + + + + + + + + UPDATE llm_prompt_config + SET prompt_content = #{prompt_content} + WHERE prompt_name = #{prompt_name} + + + diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/LoginMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/LoginMapper.xml new file mode 100644 index 0000000..300cdb9 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/LoginMapper.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/MenusMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/MenusMapper.xml new file mode 100644 index 0000000..e20adae --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/MenusMapper.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + INSERT INTO menus (menus_id, user_id, menus_name, menus_url, sort_order, status) + VALUES (#{menus_id}, #{user_id}, #{menus_name}, #{menus_url}, #{sort_order}, #{status}) + + + + UPDATE menus + SET menus_name = #{menus_name}, + menus_url = #{menus_url}, + user_id = #{user_id} + WHERE menus_id = #{menus_id} + + + + UPDATE menus SET status = 0 WHERE menus_id = #{menusId} + + + + UPDATE menus + SET sort_order = #{sort_order} + WHERE menus_id = #{menus_id} + + + \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/MusicMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/MusicMapper.xml new file mode 100644 index 0000000..e39832e --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/MusicMapper.xml @@ -0,0 +1,62 @@ + + + + + + + INSERT INTO music (uuid, music_name, music_author, music_url, create_time, status) + VALUES (#{uuid}, #{musicName}, #{musicAuthor}, #{musicUrl}, NOW(), 1) + + + + + UPDATE music + + + music_name = #{musicName}, + + + music_author = #{musicAuthor}, + + + music_url = #{musicUrl}, + + + WHERE id = #{id} + + + + + UPDATE music + SET status = 0 + WHERE id = #{id} + + + + + + + + + + + + diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/PermissionMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/PermissionMapper.xml new file mode 100644 index 0000000..b6f4128 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/PermissionMapper.xml @@ -0,0 +1,24 @@ + + + + + + + + INSERT INTO permission (code, name, module) + VALUES (#{code}, #{name}, 'plugin') + ON DUPLICATE KEY UPDATE name = #{name} + + + + DELETE FROM permission WHERE code IN + + #{code} + + + + diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/PluginConfigMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/PluginConfigMapper.xml new file mode 100644 index 0000000..f39df15 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/PluginConfigMapper.xml @@ -0,0 +1,23 @@ + + + + + + + + + INSERT INTO plugin_config (plugin_name, config_key, config_value) + VALUES (#{pluginName}, #{configKey}, #{configValue}) + ON DUPLICATE KEY UPDATE config_value = VALUES(config_value) + + + + DELETE FROM plugin_config WHERE plugin_name = #{pluginName} + + + diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/PluginMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/PluginMapper.xml new file mode 100644 index 0000000..2dfd1a9 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/PluginMapper.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + INSERT INTO plugin (name, version, display_name, description, author, requires, + jar_file, sha256, enabled, setting_name, config_map_name) + VALUES (#{name}, #{version}, #{display_name}, #{description}, #{author}, #{requires}, + #{jar_file}, #{sha256}, #{enabled}, #{setting_name}, #{config_map_name}) + + + + UPDATE plugin SET enabled = #{enabled} WHERE name = #{name} + + + + UPDATE plugin + SET version = #{version}, + jar_file = #{jar_file}, + sha256 = #{sha256}, + display_name = #{display_name}, + description = #{description}, + author = #{author}, + requires = #{requires}, + setting_name = #{setting_name}, + config_map_name = #{config_map_name} + WHERE name = #{name} + + + + DELETE FROM plugin WHERE name = #{name} + + + diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/PluginSqlAuditMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/PluginSqlAuditMapper.xml new file mode 100644 index 0000000..4c49ef7 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/PluginSqlAuditMapper.xml @@ -0,0 +1,24 @@ + + + + + + + INSERT INTO plugin_sql_audit (plugin_name, category, sql_text, cost_millis) + VALUES (#{plugin_name}, #{category}, #{sql_text}, #{cost_millis}) + + + + + + + diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/ReadArticleMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/ReadArticleMapper.xml new file mode 100644 index 0000000..526b8d6 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/ReadArticleMapper.xml @@ -0,0 +1,190 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + UPDATE article + SET page_views = page_views + 1 + WHERE alias = #{alias} + + + + + UPDATE article + SET page_views = page_views + #{count} + WHERE alias = #{alias} + + + + + + + + + + + + + diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/RegisterMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/RegisterMapper.xml new file mode 100644 index 0000000..9cd6378 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/RegisterMapper.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + INSERT INTO user_information(uuid, username, phone, email, password, status) + VALUES (#{uuid} ,#{username}, #{phone}, #{email}, #{password}, 1) + + + \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/RoleMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/RoleMapper.xml new file mode 100644 index 0000000..5dd7e27 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/RoleMapper.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + INSERT INTO role (code, name, description, status) + VALUES (#{code}, #{name}, #{description}, #{status}) + + + + + UPDATE role + + code = #{code}, + name = #{name}, + description = #{description}, + status = #{status}, + + WHERE id = #{id} + + + + + DELETE FROM role WHERE id = #{id} + + + + + INSERT INTO role_permission (role_id, permission_id) + VALUES (#{roleId}, #{permissionId}) + + + + + DELETE FROM role_permission + WHERE role_id = #{roleId} AND permission_id = #{permissionId} + + + + + + + + + + + + + + INSERT INTO permission (code, name, module, create_time) + VALUES (#{code}, #{name}, #{module}, NOW()) + + + + + + diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/SiteConfigMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/SiteConfigMapper.xml new file mode 100644 index 0000000..e2cc425 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/SiteConfigMapper.xml @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + + + + + + + + + + + UPDATE site_config + SET site_name = #{site_name}, + register_user_permission = #{register_user_permission}, + copyright = #{copyright}, + article_status = #{article_status}, + hero_image = #{hero_image}, + hero_title = #{hero_title}, + hero_subtitle = #{hero_subtitle}, + site_icon = #{site_icon}, + site_theme = #{site_theme}, + expansion_server = #{expansion_server}, + enable_notification = #{enable_notification}, + smtp_host = #{smtp_host}, + smtp_port = #{smtp_port}, + mail_sender_email = #{mail_sender_email}, + mail_sender_name = #{mail_sender_name}, + mail_username = #{mail_username}, + mail_password = #{mail_password}, + mail_protocol = #{mail_protocol}, + mail_enable_tls = #{mail_enable_tls}, + mail_enable_ssl = #{mail_enable_ssl}, + mail_default_encoding = #{mail_default_encoding}, + icp_record_number = #{icp_record_number}, + mps_record_number = #{mps_record_number}, + enable_register = #{enable_register}, + enable_music = #{enable_music}, + admin_post_no_review = #{admin_post_no_review}, + enable_llm = #{enable_llm}, + enable_llm_article_review = #{enable_llm_article_review}, + enable_llm_create_article = #{enable_llm_create_article}, + global_head_code = #{global_head_code}, + content_head_code = #{content_head_code}, + footer_code = #{footer_code}, + enable_image_compression = #{enable_image_compression}, + image_compression_threads = #{image_compression_threads}, + proxy_resource_download = #{proxy_resource_download}, + enable_comment = #{enable_comment}, + comment_status = #{comment_status}, + enable_llm_comment_review = #{enable_llm_comment_review}, + admin_comment_no_review = #{admin_comment_no_review}, + default_storage = #{default_storage} + WHERE id = 1 + + + + + + + + + + + + + + \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/StatisticsMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/StatisticsMapper.xml new file mode 100644 index 0000000..6539a54 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/StatisticsMapper.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/StorageMethodMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/StorageMethodMapper.xml new file mode 100644 index 0000000..2290669 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/StorageMethodMapper.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO storage_method (uuid, user_uuid, name, type, config, status) + VALUES (#{uuid}, #{user_uuid}, #{name}, #{type}, #{config}, #{status}) + + + + UPDATE storage_method + SET name = #{name}, + type = #{type}, + config = #{config}, + status = #{status} + WHERE uuid = #{uuid} + + + + UPDATE storage_method SET status = 0 WHERE uuid = #{uuid} + + + + UPDATE storage_method SET status = 1 WHERE uuid = #{uuid} + + + + DELETE FROM storage_method WHERE uuid = #{uuid} + + \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/StorageMigrationLogMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/StorageMigrationLogMapper.xml new file mode 100644 index 0000000..ed66294 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/StorageMigrationLogMapper.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO storage_migration_log (source_storage_id, target_storage_id, total, success, failed, status) + VALUES (#{source_storage_id}, #{target_storage_id}, #{total}, #{success}, #{failed}, #{status}) + + + + UPDATE storage_migration_log + SET total = #{total}, success = #{success}, failed = #{failed}, status = #{status} + WHERE id = #{id} + + + + INSERT INTO storage_migration_failed_item (migration_id, attachment_id, file_path, error_msg) + VALUES (#{migration_id}, #{attachment_id}, #{file_path}, #{error_msg}) + + + + INSERT INTO storage_migration_failed_item (migration_id, attachment_id, file_path, error_msg) + VALUES + + (#{item.migration_id}, #{item.attachment_id}, #{item.file_path}, #{item.error_msg}) + + + + + + + + + \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/SystematicNotificationMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/SystematicNotificationMapper.xml new file mode 100644 index 0000000..5138bdc --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/SystematicNotificationMapper.xml @@ -0,0 +1,70 @@ + + + + + + INSERT INTO systematic_notification (uuid, content, type, status) + VALUES (#{uuid}, #{content}, #{type}, #{status}) + + + + + + + + + + + + + + + + + + UPDATE systematic_notification + SET status = #{status} + WHERE id = #{id} + + + + UPDATE systematic_notification + SET status = 0 + WHERE id = #{id} + + + diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/UploadArticleMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/UploadArticleMapper.xml new file mode 100644 index 0000000..cc1562e --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/UploadArticleMapper.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + INSERT INTO article(uuid, article_id, article_name, article_cover, excerpt, article_content, password, category_id, alias, status, enable_comment, create_time) + VALUES (#{uuid}, #{article_id}, #{article_name}, #{article_cover}, #{excerpt}, #{article_content}, #{password}, + (SELECT c.id + FROM article_categories c + LEFT JOIN article_categories p ON c.parent_id = p.id + WHERE c.article_categories = #{category} + AND p.id IS NULL), + #{alias}, #{status}, #{enable_comment}, + + #{create_time} + + + CURRENT_TIMESTAMP + ) + + + + INSERT INTO article(uuid, article_id, article_name, article_cover, excerpt, article_content, password, category_id, alias, status, enable_comment, create_time) + VALUES (#{uuid}, #{article_id}, #{article_name}, #{article_cover}, #{excerpt}, #{article_content}, #{password}, + (SELECT c.id + FROM article_categories c + LEFT JOIN article_categories p ON c.parent_id = p.id + WHERE c.article_categories = #{childCategory} + + AND p.article_categories = #{parentCategory} + ), + #{alias}, #{status}, #{enable_comment}, + + #{create_time} + + + CURRENT_TIMESTAMP + ) + + + + + + INSERT INTO article_tag_relation(article_id, article_tag) + VALUES (#{articleId}, #{tagName}) + + + + DELETE FROM article_tag_relation WHERE article_id = #{articleId} + + + diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/UploadAttachmentMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/UploadAttachmentMapper.xml new file mode 100644 index 0000000..93c6d78 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/UploadAttachmentMapper.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + INSERT INTO attachment(uuid, attachment_id, original_filename, attachment_path, access_key, storage_id, content_type, status) + VALUES (#{uuid}, #{attachment_id}, #{original_filename}, #{attachment_path}, #{access_key}, #{storage_id}, #{content_type}, 1) + + + + + diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/UserInformationMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/UserInformationMapper.xml new file mode 100644 index 0000000..f0f83c1 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/UserInformationMapper.xml @@ -0,0 +1,344 @@ + + + + + + + + + + + + UPDATE user_information + + username = #{username}, + avatar = #{avatar}, + phone = #{phone}, + email = #{email}, + gender = #{gender}, + password = #{password}, + birthday = #{birthday}, + + WHERE uuid = #{uuid} AND status = 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + WHERE u.status != 0 + + AND (u.username LIKE CONCAT('%', #{keyword}, '%') + OR u.email LIKE CONCAT('%', #{keyword}, '%') + OR u.phone LIKE CONCAT('%', #{keyword}, '%')) + + + + + + + + + + + INSERT INTO user_role (user_id, role_id) + VALUES (#{userId}, #{roleId}) + ON DUPLICATE KEY UPDATE role_id = #{roleId} + + + + UPDATE user_information + SET status = #{status} + WHERE id = #{userId} + + + + + + + + + + + UPDATE user_information + SET password = #{password} + WHERE username = #{username} + + + + + + + + + + + + + + + + + + + INSERT INTO user_information ( + uuid, username, email, phone, gender, password, + birthday, status, avatar + ) VALUES ( + #{uuid}, #{username}, #{email}, #{phone}, #{gender}, + #{password}, #{birthday}, #{status}, #{avatar} + ) + + + diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/UserPermissionMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/UserPermissionMapper.xml new file mode 100644 index 0000000..38b9736 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/UserPermissionMapper.xml @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/UserRoleMapper.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/UserRoleMapper.xml new file mode 100644 index 0000000..7b8aa22 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mapper/UserRoleMapper.xml @@ -0,0 +1,41 @@ + + + + + + + INSERT INTO user_role (user_id, role_id) + VALUES (#{userId}, #{roleId}) + + + + + DELETE FROM user_role + WHERE user_id = #{userId} AND role_id = #{roleId} + + + + + + + + + + + + diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/mybatis-config.xml b/demo/dim_stack/backend/dimstack-app/src/main/resources/mybatis-config.xml new file mode 100644 index 0000000..108c7b6 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/mybatis-config.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/system_version.txt b/demo/dim_stack/backend/dimstack-app/src/main/resources/system_version.txt new file mode 100644 index 0000000..12129c6 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/system_version.txt @@ -0,0 +1 @@ +13.01 \ No newline at end of file diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/templates/init/result.html b/demo/dim_stack/backend/dimstack-app/src/main/resources/templates/init/result.html new file mode 100644 index 0000000..9fdc2cb --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/templates/init/result.html @@ -0,0 +1,151 @@ + + + + + + 配置结果 + + + + + +
+
+ +
+

+

+
+ +
+

+ +
+
+ 提示:请重启应用以使新配置生效。 +
+ + 重新配置 + +
+ + +
+ +
+
+ + diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/templates/init/setup.html b/demo/dim_stack/backend/dimstack-app/src/main/resources/templates/init/setup.html new file mode 100644 index 0000000..0b07f44 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/templates/init/setup.html @@ -0,0 +1,413 @@ + + + + + + 次元栈 - 初始化配置 + + + + + +
+
+
+

DimStack 论坛系统初始化向导

+
+ +
+
+ +
+
服务器配置
+
+ + +
+
+ +
+
管理员账户设置
+
+ + +
+
+ + +
+
+ +
+
MySQL 数据库配置
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+ + +
+
+ +
+
Redis 配置
+ +
+ + +
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+
+ +
+ + +
+ +
+ + +
+ +
+
+
高级配置
+ +
+ + +
+ +
+ + +
+
+
+ + +
+
+
+
+ + + + diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/templates/seo_404.html b/demo/dim_stack/backend/dimstack-app/src/main/resources/templates/seo_404.html new file mode 100644 index 0000000..fb428c7 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/templates/seo_404.html @@ -0,0 +1,8 @@ + + +文章不存在 + +

文章不存在

+

该页面可能已被删除

+ + diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/templates/seo_article.html b/demo/dim_stack/backend/dimstack-app/src/main/resources/templates/seo_article.html new file mode 100644 index 0000000..d271e71 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/templates/seo_article.html @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+

发布时间:

+ +
+ + + diff --git a/demo/dim_stack/backend/dimstack-app/src/main/resources/templates/seo_home.html b/demo/dim_stack/backend/dimstack-app/src/main/resources/templates/seo_home.html new file mode 100644 index 0000000..a7e6511 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/main/resources/templates/seo_home.html @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ +
+
    +
  • +

    + +

    +

    + +
  • +
+
+ + + diff --git a/demo/dim_stack/backend/dimstack-app/src/test/java/xyz/lingview/dimstack/test/ArticleTagAndCategoryTest.java b/demo/dim_stack/backend/dimstack-app/src/test/java/xyz/lingview/dimstack/test/ArticleTagAndCategoryTest.java new file mode 100644 index 0000000..a0f3c3b --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/test/java/xyz/lingview/dimstack/test/ArticleTagAndCategoryTest.java @@ -0,0 +1,72 @@ +//package xyz.lingview.dimstack.test; +// +//import org.junit.jupiter.api.Test; +//import org.springframework.beans.factory.annotation.Autowired; +//import org.springframework.boot.test.context.SpringBootTest; +//import xyz.lingview.dimstack.domain.ArticleTag; +//import xyz.lingview.dimstack.domain.ArticleCategory; +//import xyz.lingview.dimstack.mapper.ArticleCategoryMapper; +//import xyz.lingview.dimstack.mapper.ArticleTagMapper; +// +//import java.util.List; +// +//import static org.junit.jupiter.api.Assertions.*; +// +//@SpringBootTest +//public class ArticleTagAndCategoryTest { +// +// @Autowired +// private ArticleTagMapper articleTagMapper; +// +// @Autowired +// private ArticleCategoryMapper articleCategoryMapper; +// +// @Test +// public void testFindAllEnabledTags() { +// List tags = articleTagMapper.findAllEnabledTags(); +// +// assertNotNull(tags, "查询结果不应为null"); +// +// System.out.println("查询到的标签数量: " + tags.size()); +// for (ArticleTag tag : tags) { +// System.out.println("标签ID: " + tag.getId()); +// System.out.println("标签名称: " + tag.getTag_name()); +// System.out.println("标签说明: " + tag.getTag_explain()); +// System.out.println("创建者: " + tag.getFounder()); +// System.out.println("创建时间: " + tag.getCreate_time()); +// System.out.println("状态: " + tag.getStatus()); +// System.out.println("------------------------"); +// } +// } +// +// @Test +// public void testFindAllEnabledCategories() { +// List categories = articleCategoryMapper.findAllEnabledCategories(); +// +// assertNotNull(categories, "查询结果不应为null"); +// +// System.out.println("查询到的分类数量: " + categories.size()); +// for (ArticleCategory category : categories) { +// System.out.println("分类ID: " + category.getId()); +// System.out.println("分类名称: " + category.getArticle_categories()); +// System.out.println("分类说明: " + category.getCategories_explain()); +// System.out.println("创建者: " + category.getFounder()); +// System.out.println("创建时间: " + category.getCreate_time()); +// System.out.println("状态: " + category.getStatus()); +// System.out.println("------------------------"); +// } +// } +// +// @Test +// public void testTagsAndCategoriesNotEmpty() { +// +// List tags = articleTagMapper.findAllEnabledTags(); +// List categories = articleCategoryMapper.findAllEnabledCategories(); +// +// assertNotNull(tags); +// assertNotNull(categories); +// +// System.out.println("启用的标签数量: " + tags.size()); +// System.out.println("启用的分类数量: " + categories.size()); +// } +//} diff --git a/demo/dim_stack/backend/dimstack-app/src/test/java/xyz/lingview/dimstack/test/PermissionBasedTestControllerTest.java b/demo/dim_stack/backend/dimstack-app/src/test/java/xyz/lingview/dimstack/test/PermissionBasedTestControllerTest.java new file mode 100644 index 0000000..03c8bb3 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/test/java/xyz/lingview/dimstack/test/PermissionBasedTestControllerTest.java @@ -0,0 +1,144 @@ +//package xyz.lingview.dimstack.test; +// +//import org.junit.jupiter.api.BeforeAll; +//import org.junit.jupiter.api.Test; +//import org.junit.jupiter.api.TestInstance; +//import org.junit.jupiter.params.ParameterizedTest; +//import org.junit.jupiter.params.provider.MethodSource; +// +//import java.net.HttpCookie; +//import java.net.URI; +//import java.net.http.HttpClient; +//import java.net.http.HttpRequest; +//import java.net.http.HttpResponse; +//import java.time.Duration; +//import java.util.stream.Stream; +// +//import static org.junit.jupiter.api.Assertions.assertTrue; +//// 本测试在作者权限下测试 +//@TestInstance(TestInstance.Lifecycle.PER_CLASS) +//public class PermissionBasedTestControllerTest { +// +// private static final String BASE_URL = "http://127.0.0.1:2222"; +// private static final String SESSION_COOKIE = "Y2UwYzg3NWEtNWU2ZS00Y2JlLWJlMjUtMDdkMmI2NjI3MDQy"; +// private HttpClient client; +// +// @BeforeAll +// void setUp() { +// client = HttpClient.newBuilder() +// .connectTimeout(Duration.ofSeconds(10)) +// .build(); +// } +// +// // 用户拥有的权限对应的接口 +// static Stream permittedEndpoints() { +// return Stream.of( +// new TestCase("/api/test/permission/view", "post:view", 200), +// new TestCase("/api/test/permission/create", "post:create", 200), +// new TestCase("/api/test/permission/edit/own", "post:edit:own", 200), +// new TestCase("/api/test/permission/delete/own", "post:delete:own", 200), +// new TestCase("/api/test/permission/submit", "post:submit", 200), +// new TestCase("/api/test/permission/public", "public", 200) +// ); +// } +// +// // 用户没有的权限对应的接口 +// static Stream forbiddenEndpoints() { +// return Stream.of( +// new TestCase("/api/test/permission/edit/any", "post:edit:any", 403), +// new TestCase("/api/test/permission/delete/any", "post:delete:any", 403), +// new TestCase("/api/test/permission/publish", "post:publish", 403), +// new TestCase("/api/test/permission/review", "post:review", 403) +// ); +// } +// +// // 多权限接口测试 +// static Stream multiPermissionEndpoints() { +// return Stream.of( +// // AND模式: 需要 post:view AND post:create (用户都有) +// new TestCase("/api/test/permission/multi-and", "post:view & post:create", 200), +// // OR模式: 需要 post:edit:own OR post:edit:any (用户有post:edit:own) +// new TestCase("/api/test/permission/multi-or", "post:edit:own | post:edit:any", 200) +// ); +// } +// +// @ParameterizedTest +// @MethodSource("permittedEndpoints") +// void testPermittedEndpoints(TestCase testCase) throws Exception { +// HttpResponse response = sendGetRequest(testCase.endpoint); +// +// System.out.printf("测试有权限接口: %s (需要权限: %s)%n", testCase.endpoint, testCase.requiredPermission); +// System.out.printf(" 期望状态码: %d, 实际状态码: %d%n", testCase.expectedStatus, response.statusCode()); +// +// if (response.statusCode() == 200) { +// assertTrue(response.body().contains("\"success\":true"), +// "Response should indicate success for endpoint: " + testCase.endpoint); +// System.out.println(" ✓ 权限验证通过"); +// } else { +// System.out.println(" ✗ 权限验证失败"); +// } +// System.out.println(); +// } +// +// @ParameterizedTest +// @MethodSource("forbiddenEndpoints") +// void testForbiddenEndpoints(TestCase testCase) throws Exception { +// HttpResponse response = sendGetRequest(testCase.endpoint); +// +// System.out.printf("测试无权限接口: %s (需要权限: %s)%n", testCase.endpoint, testCase.requiredPermission); +// System.out.printf(" 期望状态码: %d, 实际状态码: %d%n", testCase.expectedStatus, response.statusCode()); +// +// if (response.statusCode() == 403) { +// System.out.println(" ✓ 权限正确拒绝"); +// } else if (response.statusCode() == 200) { +// System.out.println(" ⚠ 权限异常通过"); +// } else { +// System.out.println(" ? 其他响应状态: " + response.statusCode()); +// } +// System.out.println(); +// } +// +// @ParameterizedTest +// @MethodSource("multiPermissionEndpoints") +// void testMultiPermissionEndpoints(TestCase testCase) throws Exception { +// HttpResponse response = sendGetRequest(testCase.endpoint); +// +// System.out.printf("测试多权限接口: %s (需要权限: %s)%n", testCase.endpoint, testCase.requiredPermission); +// System.out.printf(" 期望状态码: %d, 实际状态码: %d%n", testCase.expectedStatus, response.statusCode()); +// +// if (response.statusCode() == 200) { +// System.out.println(" ✓ 多权限验证通过"); +// } else if (response.statusCode() == 403) { +// System.out.println(" ✗ 多权限验证失败"); +// } else { +// System.out.println(" ? 其他响应状态: " + response.statusCode()); +// } +// System.out.println(); +// } +// +// private HttpResponse sendGetRequest(String endpoint) throws Exception { +// HttpCookie cookie = new HttpCookie("SESSION", SESSION_COOKIE); +// +// HttpRequest request = HttpRequest.newBuilder() +// .uri(URI.create(BASE_URL + endpoint)) +// .header("Cookie", cookie.toString()) +// .header("Accept", "application/json") +// .timeout(Duration.ofSeconds(30)) +// .GET() +// .build(); +// +// return client.send(request, HttpResponse.BodyHandlers.ofString()); +// } +// +// static class TestCase { +// final String endpoint; +// final String requiredPermission; +// final int expectedStatus; +// +// TestCase(String endpoint, String requiredPermission, int expectedStatus) { +// this.endpoint = endpoint; +// this.requiredPermission = requiredPermission; +// this.expectedStatus = expectedStatus; +// } +// } +//} diff --git a/demo/dim_stack/backend/dimstack-app/src/test/java/xyz/lingview/dimstack/test/PermissionMapperIntegrationTest.java b/demo/dim_stack/backend/dimstack-app/src/test/java/xyz/lingview/dimstack/test/PermissionMapperIntegrationTest.java new file mode 100644 index 0000000..6fa43e8 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-app/src/test/java/xyz/lingview/dimstack/test/PermissionMapperIntegrationTest.java @@ -0,0 +1,27 @@ +//package xyz.lingview.dimstack.test; +// +//import org.junit.jupiter.api.Test; +//import org.springframework.beans.factory.annotation.Autowired; +//import org.springframework.boot.test.context.SpringBootTest; +//import org.springframework.transaction.annotation.Transactional; +//import xyz.lingview.dimstack.mapper.UserPermissionMapper; +// +//import java.util.List; +//import static org.junit.jupiter.api.Assertions.*; +// +//@SpringBootTest +//@Transactional +//public class PermissionMapperIntegrationTest { +// +// @Autowired +// private UserPermissionMapper permissionMapper; +// +// @Test +// public void testFindPermissionCodesByUserName() { +// List permissionCodes = permissionMapper.findPermissionCodesByUserName("admin"); +// +// assertNotNull(permissionCodes); +// System.out.println("Permissions: " + permissionCodes); +// System.out.println("Permission count: " + permissionCodes.size()); +// } +//} diff --git a/demo/dim_stack/backend/dimstack-plugin-api/pom.xml b/demo/dim_stack/backend/dimstack-plugin-api/pom.xml new file mode 100644 index 0000000..9e014b6 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-plugin-api/pom.xml @@ -0,0 +1,24 @@ + + + 4.0.0 + + + xyz.lingview.dimstack + dimstack-parent + 1.0-SNAPSHOT + + + dimstack-plugin-api + jar + Dim Stack Plugin API + Plugin SDK: extension point interfaces, BasePlugin, PluginContext and other plugin contracts; the only host artifact a plugin project depends on + + + + org.pf4j + pf4j + + + diff --git a/demo/dim_stack/backend/dimstack-plugin-api/src/main/java/xyz/lingview/dimstack/plugin/api/BasePlugin.java b/demo/dim_stack/backend/dimstack-plugin-api/src/main/java/xyz/lingview/dimstack/plugin/api/BasePlugin.java new file mode 100644 index 0000000..5f63287 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-plugin-api/src/main/java/xyz/lingview/dimstack/plugin/api/BasePlugin.java @@ -0,0 +1,23 @@ +package xyz.lingview.dimstack.plugin.api; + +import org.pf4j.Plugin; + +/** + * @Author: lingview + * @Date: 2026/07/31 22:47:09 + * @Description: 插件基类 + * @Version: 1.0 + */ +public abstract class BasePlugin extends Plugin { + + private final PluginContext pluginContext; + + protected BasePlugin(PluginContext pluginContext) { + super(pluginContext.getWrapper()); + this.pluginContext = pluginContext; + } + + public PluginContext getPluginContext() { + return pluginContext; + } +} diff --git a/demo/dim_stack/backend/dimstack-plugin-api/src/main/java/xyz/lingview/dimstack/plugin/api/ExtensionGetter.java b/demo/dim_stack/backend/dimstack-plugin-api/src/main/java/xyz/lingview/dimstack/plugin/api/ExtensionGetter.java new file mode 100644 index 0000000..e12713f --- /dev/null +++ b/demo/dim_stack/backend/dimstack-plugin-api/src/main/java/xyz/lingview/dimstack/plugin/api/ExtensionGetter.java @@ -0,0 +1,14 @@ +package xyz.lingview.dimstack.plugin.api; + +import java.util.List; + +/** + * @Author: lingview + * @Date: 2026/07/31 22:47:09 + * @Description: 扩展点获取接口 + * @Version: 1.0 + */ +public interface ExtensionGetter { + + List getExtensions(Class type); +} diff --git a/demo/dim_stack/backend/dimstack-plugin-api/src/main/java/xyz/lingview/dimstack/plugin/api/ExtensionPoint.java b/demo/dim_stack/backend/dimstack-plugin-api/src/main/java/xyz/lingview/dimstack/plugin/api/ExtensionPoint.java new file mode 100644 index 0000000..3aa6b3b --- /dev/null +++ b/demo/dim_stack/backend/dimstack-plugin-api/src/main/java/xyz/lingview/dimstack/plugin/api/ExtensionPoint.java @@ -0,0 +1,10 @@ +package xyz.lingview.dimstack.plugin.api; + +/** + * @Author: lingview + * @Date: 2026/07/31 22:47:09 + * @Description: 扩展点标记 + * @Version: 1.0 + */ +public interface ExtensionPoint extends org.pf4j.ExtensionPoint { +} diff --git a/demo/dim_stack/backend/dimstack-plugin-api/src/main/java/xyz/lingview/dimstack/plugin/api/PluginContext.java b/demo/dim_stack/backend/dimstack-plugin-api/src/main/java/xyz/lingview/dimstack/plugin/api/PluginContext.java new file mode 100644 index 0000000..015c5d2 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-plugin-api/src/main/java/xyz/lingview/dimstack/plugin/api/PluginContext.java @@ -0,0 +1,55 @@ +package xyz.lingview.dimstack.plugin.api; + +import org.pf4j.PluginWrapper; + +/** + * @Author: lingview + * @Date: 2026/07/31 22:47:09 + * @Description: 插件上下文 + * @Version: 1.0 + */ +public class PluginContext { + + private final PluginWrapper wrapper; + private final String settingName; + private final String configMapName; + private final RuntimeMode runtimeMode; + + public PluginContext(PluginWrapper wrapper, String settingName, String configMapName, RuntimeMode runtimeMode) { + this.wrapper = wrapper; + this.settingName = settingName; + this.configMapName = configMapName; + this.runtimeMode = runtimeMode; + } + + public String getName() { + return wrapper.getPluginId(); + } + + public String getVersion() { + return wrapper.getDescriptor().getVersion(); + } + + public PluginWrapper getWrapper() { + return wrapper; + } + + public String getSettingName() { + return settingName; + } + + public String getConfigMapName() { + return configMapName; + } + + public RuntimeMode getRuntimeMode() { + return runtimeMode; + } + + public enum RuntimeMode { + + DEVELOPMENT, + + DEPLOYMENT + } +} diff --git a/demo/dim_stack/backend/dimstack-plugin-api/src/main/java/xyz/lingview/dimstack/plugin/api/PluginDb.java b/demo/dim_stack/backend/dimstack-plugin-api/src/main/java/xyz/lingview/dimstack/plugin/api/PluginDb.java new file mode 100644 index 0000000..87a9bf1 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-plugin-api/src/main/java/xyz/lingview/dimstack/plugin/api/PluginDb.java @@ -0,0 +1,16 @@ +package xyz.lingview.dimstack.plugin.api; + +import javax.sql.DataSource; + +/** + * @Author: lingview + * @Date: 2026/09/30 21:47:12 + * @Description: 插件数据库访问 + * @Version: 1.0 + */ +public interface PluginDb { + + String table(String name); + + DataSource dataSource(); +} diff --git a/demo/dim_stack/backend/dimstack-plugin-api/src/main/java/xyz/lingview/dimstack/plugin/api/RequiresPermission.java b/demo/dim_stack/backend/dimstack-plugin-api/src/main/java/xyz/lingview/dimstack/plugin/api/RequiresPermission.java new file mode 100644 index 0000000..d515418 --- /dev/null +++ b/demo/dim_stack/backend/dimstack-plugin-api/src/main/java/xyz/lingview/dimstack/plugin/api/RequiresPermission.java @@ -0,0 +1,20 @@ +package xyz.lingview.dimstack.plugin.api; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * @Author: lingview + * @Date: 2026/10/09 14:39:03 + * @Description: 插件接口权限注解 + * @Version: 1.0 + */ +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface RequiresPermission { + + String[] value(); + boolean all() default false; +} diff --git a/demo/dim_stack/backend/dimstack-plugin-api/src/main/java/xyz/lingview/dimstack/plugin/api/SettingFetcher.java b/demo/dim_stack/backend/dimstack-plugin-api/src/main/java/xyz/lingview/dimstack/plugin/api/SettingFetcher.java new file mode 100644 index 0000000..739c33a --- /dev/null +++ b/demo/dim_stack/backend/dimstack-plugin-api/src/main/java/xyz/lingview/dimstack/plugin/api/SettingFetcher.java @@ -0,0 +1,12 @@ +package xyz.lingview.dimstack.plugin.api; + +/** + * @Author: lingview + * @Date: 2026/07/31 22:47:09 + * @Description: 插件配置读取接口 + * @Version: 1.0 + */ +public interface SettingFetcher { + + T fetch(String configMapName, String key, Class clazz); +} diff --git a/demo/dim_stack/backend/pom.xml b/demo/dim_stack/backend/pom.xml new file mode 100644 index 0000000..809553b --- /dev/null +++ b/demo/dim_stack/backend/pom.xml @@ -0,0 +1,46 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.2 + + + xyz.lingview.dimstack + dimstack-parent + 1.0-SNAPSHOT + pom + Dim Stack Core + Dim Stack Core + + + dimstack-plugin-api + dimstack-app + + + + 17 + 17 + UTF-8 + 3.15.0 + + + + + + xyz.lingview.dimstack + dimstack-plugin-api + ${project.version} + + + org.pf4j + pf4j + ${pf4j.version} + + + + diff --git a/demo/dim_stack/cli/src/config/__init__.py b/demo/dim_stack/cli/src/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/demo/dim_stack/cli/src/config/cli_config.py b/demo/dim_stack/cli/src/config/cli_config.py new file mode 100644 index 0000000..29f1f45 --- /dev/null +++ b/demo/dim_stack/cli/src/config/cli_config.py @@ -0,0 +1,67 @@ +import os +import json +from pathlib import Path + + +class CliConfig: + + CONFIG_DIR = ".dim_stack-cli" + CONFIG_FILE = "config.json" + + def __init__(self): + self.config_path = self._get_config_path() + + def _get_config_path(self) -> Path: + home_dir = Path.home() + config_dir = home_dir / self.CONFIG_DIR + return config_dir / self.CONFIG_FILE + + def load_config(self) -> dict: + if not self.config_path.exists(): + self._create_default_config() + + with open(self.config_path, "r", encoding="utf-8") as f: + return json.load(f) + + def save_config(self, config: dict) -> None: + self.config_path.parent.mkdir(parents=True, exist_ok=True) + + with open(self.config_path, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2, ensure_ascii=False) + + def get_project_dir(self) -> str | None: + config = self.load_config() + return config.get("project_dir") + + def set_project_dir(self, project_dir: str) -> None: + abs_path = os.path.abspath(project_dir) + + if not os.path.isdir(abs_path): + raise ValueError(f"目录不存在: {abs_path}") + + config = self.load_config() + config["project_dir"] = abs_path + self.save_config(config) + + def get_jar_name(self) -> str: + config = self.load_config() + jar_name = config.get("jar_name") + return jar_name if jar_name else "dimstack-1.0-SNAPSHOT.jar" + + def set_jar_name(self, jar_name: str) -> None: + if not jar_name.endswith(".jar"): + jar_name = jar_name + ".jar" + config = self.load_config() + config["jar_name"] = jar_name + self.save_config(config) + + def _create_default_config(self) -> None: + from datetime import datetime + default_config = { + "project_dir": "", + "created_at": datetime.now().isoformat(), + } + self.save_config(default_config) + + def config_exists(self) -> bool: + return self.config_path.exists() diff --git a/demo/dim_stack/cli/src/config/config_read.py b/demo/dim_stack/cli/src/config/config_read.py new file mode 100644 index 0000000..0c09cac --- /dev/null +++ b/demo/dim_stack/cli/src/config/config_read.py @@ -0,0 +1,13 @@ +from ruamel.yaml import YAML +from model.config_model import DimStackConfig + + +class Config: + + def read_yaml(self, filepath: str): + yaml = YAML() + yaml.preserve_quotes = True + + with open(filepath, "r", encoding="utf-8") as f: + raw = yaml.load(f) + return DimStackConfig.model_validate(raw) \ No newline at end of file diff --git a/demo/dim_stack/cli/src/core/__init__.py b/demo/dim_stack/cli/src/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/demo/dim_stack/cli/src/core/command_router.py b/demo/dim_stack/cli/src/core/command_router.py new file mode 100644 index 0000000..8a8f203 --- /dev/null +++ b/demo/dim_stack/cli/src/core/command_router.py @@ -0,0 +1,65 @@ +import sys + + +class CommandRouter: + + def __init__(self): + self.commands = {} + + def register(self, name: str, handler): + self.commands[name] = handler + + def parse_port(self) -> int | None: + for arg in sys.argv[2:]: + if arg.startswith("--port="): + port_str = arg.split("=", 1)[1] + try: + return int(port_str) + except ValueError: + print(f"✗ 无效的端口号: {port_str}") + sys.exit(1) + return None + + def dispatch(self): + if len(sys.argv) < 2: + self._show_help() + return + + command = sys.argv[1] + + if command == "help": + self._show_help() + return + + if command not in self.commands: + print(f"✗ 未知命令: {command}") + self._show_help() + sys.exit(1) + + port = self.parse_port() + success = self.commands[command](port=port) + sys.exit(0 if success else 1) + + def _show_help(self): + print("Dim Stack CLI") + print() + print("系统管理命令:") + print(" start [--port=<端口>] - 启动系统") + print(" stop [--port=<端口>] - 关闭系统") + print(" restart [--port=<端口>] - 重启系统") + print() + print("配置管理命令:") + print(" config - 打开配置菜单") + print(" set-dir <路径> - 设置项目目录") + print(" get-dir - 查看当前项目目录") + print(" set-jar <名称> - 设置 JAR 名称") + print(" get-jar - 查看当前 JAR 名称") + print() + print("其他命令:") + print(" help - 显示此帮助信息") + print() + print("示例:") + print(" ds start --port=2223") + print(" ds restart --port=8080") + print(" ds set-jar my-app.jar") + print() diff --git a/demo/dim_stack/cli/src/core/server_control.py b/demo/dim_stack/cli/src/core/server_control.py new file mode 100644 index 0000000..be43edb --- /dev/null +++ b/demo/dim_stack/cli/src/core/server_control.py @@ -0,0 +1,270 @@ +import os +import sys +import subprocess +from pathlib import Path +from config.config_read import Config +from config.cli_config import CliConfig + + +class ServerControl: + + def __init__(self): + self.config = self._load_config() + self.port = self.config.server.port + self.project_dir = self._get_project_dir() + self.config_manager = CliConfig() + + def _load_config(self): + config_path = self._find_config_path() + config = Config() + return config.read_yaml(config_path) + + def _find_config_path(self) -> str: + project_dir = self._get_project_dir() + if project_dir: + config_path = os.path.join(project_dir, "config", "application.yml") + if os.path.exists(config_path): + return config_path + + cwd_config = os.path.join(os.getcwd(), "config", "application.yml") + if os.path.exists(cwd_config): + return cwd_config + + repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) + config_path = os.path.join(repo_root, "config", "application.yml") + if os.path.exists(config_path): + return config_path + + cli_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + config_path = os.path.join(cli_root, "config", "application.yml") + if os.path.exists(config_path): + return config_path + + raise FileNotFoundError("找不到配置文件 application.yml") + + def _get_project_dir(self) -> str | None: + config_mgr = CliConfig() + return config_mgr.get_project_dir() + + def _find_process_by_port(self) -> int | None: + try: + if os.name == "nt": + result = subprocess.run( + f"netstat -ano | findstr :{self.port}", + capture_output=True, + text=True, + shell=True + ) + + if result.returncode != 0 or not result.stdout.strip(): + return None + + for line in result.stdout.strip().split("\n"): + if "LISTENING" in line: + parts = line.split() + if len(parts) >= 5: + pid = int(parts[-1]) + return pid + + return None + else: + result = subprocess.run( + f"lsof -ti :{self.port}", + capture_output=True, + text=True, + shell=True + ) + + if result.returncode != 0 or not result.stdout.strip(): + return None + + pid = int(result.stdout.strip().split("\n")[0]) + return pid + + except Exception as e: + print(f"✗ 查找进程时出错: {e}") + return None + + def _kill_process(self, pid: int) -> bool: + try: + if os.name == "nt": + result = subprocess.run( + f"taskkill /F /PID {pid}", + capture_output=True, + text=True, + shell=True + ) + + if result.returncode == 0: + return True + else: + print(f"✗ 杀死进程失败: {result.stderr.strip()}") + return False + else: + result = subprocess.run( + f"kill -9 {pid}", + capture_output=True, + text=True, + shell=True + ) + + return result.returncode == 0 + + except Exception as e: + print(f"✗ 杀死进程时出错: {e}") + return False + + def _get_jar_path(self) -> str: + jar_name = self.config_manager.get_jar_name() + + if self.project_dir: + jar_path = os.path.join(self.project_dir, jar_name) + if os.path.exists(jar_path): + return jar_path + + repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) + jar_path = os.path.join(repo_root, jar_name) + if os.path.exists(jar_path): + return jar_path + + raise FileNotFoundError(f"找不到 {jar_name} 文件") + + def start(self, port: int | None = None): + use_port = port if port is not None else self.port + print(f"正在检查端口 {use_port} 是否已被占用...") + + original_port = self.port + if port is not None: + self.port = port + + pid = self._find_process_by_port() + if pid is not None: + print(f"✗ 系统已在运行中 (PID: {pid}, 端口: {use_port})") + self.port = original_port + return False + + print(f"端口 {use_port} 可用,正在启动系统...") + + try: + jar_path = self._get_jar_path() + print(f"使用 JAR: {jar_path}") + + work_dir = self.project_dir if self.project_dir else os.path.dirname(jar_path) + print(f"工作目录: {work_dir}") + + cmd = ["java", "-jar", jar_path] + if port is not None: + cmd.append(f"--server.port={port}") + print(f"自定义端口: {port}") + + if os.name == "nt": + CREATE_NO_WINDOW = 0x08000000 + DETACHED_PROCESS = 0x00000008 + subprocess.Popen( + cmd, + cwd=work_dir, + creationflags=CREATE_NO_WINDOW | DETACHED_PROCESS, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL + ) + else: + subprocess.Popen( + cmd, + cwd=work_dir, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True + ) + + self.port = original_port + + print(f"✓ 系统启动中... (端口: {use_port})") + print(" 提示: 可以通过日志查看启动状态") + return True + + except Exception as e: + print(f"✗ 启动失败: {e}") + self.port = original_port + return False + + def stop(self, port: int | None = None): + use_port = port if port is not None else self.port + print(f"正在检查端口 {use_port} 上的进程...") + + original_port = self.port + if port is not None: + self.port = port + + pid = self._find_process_by_port() + if pid is None: + print(f"✗ 系统未在运行 (端口: {use_port})") + self.port = original_port + return False + + print(f"找到进程 PID: {pid},正在关闭...") + + if self._kill_process(pid): + print(f"✓ 系统已关闭 (PID: {pid})") + self.port = original_port + return True + else: + print(f"✗ 关闭失败") + self.port = original_port + return False + + def restart(self, port: int | None = None): + use_port = port if port is not None else self.port + print("正在重启系统...") + + original_port = self.port + if port is not None: + self.port = port + + pid = self._find_process_by_port() + if pid is not None: + print(f"检测到运行中的进程 (PID: {pid}),正在关闭...") + if not self._kill_process(pid): + print("✗ 关闭失败,取消重启") + self.port = original_port + return False + print("✓ 已关闭") + else: + print("系统未在运行,直接启动") + + import time + time.sleep(2) + + self.port = original_port + + return self.start(port=port) + + +def main(): + if len(sys.argv) < 2: + print("用法:") + print(" python server_control.py start - 启动系统") + print(" python server_control.py stop - 关闭系统") + print(" python server_control.py restart - 重启系统") + sys.exit(1) + + command = sys.argv[1] + control = ServerControl() + + if command == "start": + success = control.start() + sys.exit(0 if success else 1) + elif command == "stop": + success = control.stop() + sys.exit(0 if success else 1) + elif command == "restart": + success = control.restart() + sys.exit(0 if success else 1) + else: + print(f"✗ 未知命令: {command}") + print("支持的命令: start, stop, restart") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/demo/dim_stack/cli/src/main.py b/demo/dim_stack/cli/src/main.py new file mode 100644 index 0000000..8671ad3 --- /dev/null +++ b/demo/dim_stack/cli/src/main.py @@ -0,0 +1,91 @@ +import os +import sys +from view.cli_menu import CliMenu +from core.server_control import ServerControl +from core.command_router import CommandRouter + + +def cmd_config(**kwargs) -> bool: + menu = CliMenu() + menu.show_menu() + return True + + +def cmd_set_dir(**kwargs) -> bool: + if len(sys.argv) < 3: + print("用法: ds set-dir <目录路径>") + sys.exit(1) + from config.cli_config import CliConfig + config_mgr = CliConfig() + try: + config_mgr.set_project_dir(sys.argv[2]) + print(f"✓ 项目运行目录已设置为: {os.path.abspath(sys.argv[2])}") + return True + except ValueError as e: + print(f"✗ 设置失败: {e}") + sys.exit(1) + + +def cmd_get_dir(**kwargs) -> bool: + from config.cli_config import CliConfig + config_mgr = CliConfig() + project_dir = config_mgr.get_project_dir() + if project_dir: + print(project_dir) + else: + print("未设置项目运行目录") + return True + + +def cmd_set_jar(**kwargs) -> bool: + if len(sys.argv) < 3: + print("用法: ds set-jar ") + sys.exit(1) + from config.cli_config import CliConfig + config_mgr = CliConfig() + try: + config_mgr.set_jar_name(sys.argv[2]) + print(f"✓ JAR 名称已设置为: {sys.argv[2]}") + return True + except Exception as e: + print(f"✗ 设置失败: {e}") + sys.exit(1) + + +def cmd_get_jar(**kwargs) -> bool: + from config.cli_config import CliConfig + config_mgr = CliConfig() + print(config_mgr.get_jar_name()) + return True + + +def cmd_start(port: int = None, **kwargs) -> bool: + control = ServerControl() + return control.start(port=port) + + +def cmd_stop(port: int = None, **kwargs) -> bool: + control = ServerControl() + return control.stop(port=port) + + +def cmd_restart(port: int = None, **kwargs) -> bool: + control = ServerControl() + return control.restart(port=port) + + +def main(): + router = CommandRouter() + router.register("config", cmd_config) + router.register("set-dir", cmd_set_dir) + router.register("get-dir", cmd_get_dir) + router.register("set-jar", cmd_set_jar) + router.register("get-jar", cmd_get_jar) + router.register("start", cmd_start) + router.register("stop", cmd_stop) + router.register("restart", cmd_restart) + router.dispatch() + + +if __name__ == "__main__": + main() diff --git a/demo/dim_stack/cli/src/model/__init__.py b/demo/dim_stack/cli/src/model/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/demo/dim_stack/cli/src/model/config_model.py b/demo/dim_stack/cli/src/model/config_model.py new file mode 100644 index 0000000..04d281d --- /dev/null +++ b/demo/dim_stack/cli/src/model/config_model.py @@ -0,0 +1,169 @@ +from pydantic import BaseModel, Field, field_validator +import re + +def parse_duration(value: str | int) -> int: + if isinstance(value, (int, float)): + return int(value) + value = str(value).strip().lower() + match = re.match(r'^(-?\d+)(ms|s|m|h)?$', value) + if not match: + raise ValueError(f"Invalid duration format: {value}") + num, unit = int(match.group(1)), match.group(2) or 'ms' + multipliers = {'ms': 1, 's': 1000, 'm': 60000, 'h': 3600000} + return num * multipliers[unit] + +class VirtualThreadsConfig(BaseModel): + enabled: bool = False + +class JacksonConfig(BaseModel): + time_zone: str = Field(default="GMT+8", alias="time-zone") + date_format: str = Field(default="yyyy-MM-dd HH:mm:ss", alias="date-format") + + model_config = {"populate_by_name": True} + +class HikariConfig(BaseModel): + maximum_pool_size: int = Field(default=20, alias="maximum-pool-size") + minimum_idle: int = Field(default=5, alias="minimum-idle") + connection_timeout: int = Field(default=30000, alias="connection-timeout") + idle_timeout: int = Field(default=600000, alias="idle-timeout") + max_lifetime: int = Field(default=1800000, alias="max-lifetime") + + model_config = {"populate_by_name": True} + +class DatasourceConfig(BaseModel): + driver_class_name: str = Field(alias="driver-class-name") + url: str + username: str + password: str + hikari: HikariConfig = Field(default_factory=HikariConfig) + + model_config = {"populate_by_name": True} + +class MultipartConfig(BaseModel): + enabled: bool = True + max_file_size: str = Field(default="100MB", alias="max-file-size") + max_request_size: str = Field(default="100MB", alias="max-request-size") + + model_config = {"populate_by_name": True} + +class ServletConfig(BaseModel): + multipart: MultipartConfig = Field(default_factory=MultipartConfig) + +class LettucePoolConfig(BaseModel): + max_active: int = Field(default=8, alias="max-active") + max_idle: int = Field(default=8, alias="max-idle") + min_idle: int = Field(default=0, alias="min-idle") + max_wait: int = Field(default=-1, alias="max-wait") + + @field_validator("max_wait", mode="before") + @classmethod + def convert_max_wait(cls, v): + return parse_duration(v) + + model_config = {"populate_by_name": True} + +class LettuceConfig(BaseModel): + pool: LettucePoolConfig = Field(default_factory=LettucePoolConfig) + +class RedisDataConfig(BaseModel): + host: str = "127.0.0.1" + port: int = 6379 + database: int = 0 + timeout: int = 5000 + lettuce: LettuceConfig = Field(default_factory=LettuceConfig) + + @field_validator("timeout", mode="before") + @classmethod + def convert_timeout(cls, v): + return parse_duration(v) + + model_config = {"populate_by_name": True} + +class ThymeleafServletConfig(BaseModel): + content_type: str = Field(default="text/html", alias="content-type") + model_config = {"populate_by_name": True} + +class ThymeleafConfig(BaseModel): + cache: bool = True + prefix: str = "classpath:/templates/" + suffix: str = ".html" + encoding: str = "UTF-8" + servlet: ThymeleafServletConfig = Field(default_factory=ThymeleafServletConfig) + +class MyBatisConfig(BaseModel): + type_aliases_package: str = Field(alias="type-aliases-package") + mapper_locations: str = Field(alias="mapper-locations") + config_location: str = Field(alias="config-location") + + model_config = {"populate_by_name": True} + +class TomcatConfig(BaseModel): + uri_encoding: str = Field(default="UTF-8", alias="uri-encoding") + threads_max: int = Field(default=200, alias="threads.max") + threads_min_spare: int = Field(default=10, alias="threads.min-spare") + remoteip_protocol_header: str = Field(default="X-Forwarded-Proto", alias="remoteip.protocol-header") + remoteip_remote_ip_header: str = Field(default="X-Forwarded-For", alias="remoteip.remote-ip-header") + + model_config = {"populate_by_name": True} + +class ServerConfig(BaseModel): + port: int = 2222 + servlet_context_path: str = Field(default="/", alias="servlet.context-path") + tomcat: TomcatConfig = Field(default_factory=TomcatConfig) + forward_headers_strategy: str = Field(default="native", alias="forward-headers-strategy") + + model_config = {"populate_by_name": True} + +class LoggingLevelConfig(BaseModel): + xyz_lingview_dimstack: str = Field(default="info", alias="xyz.lingview.dimstack") + org_springframework: str = Field(default="warn", alias="org.springframework") + org_springframework_security: str = Field(default="info", alias="org.springframework.security") + org_springframework_session: str = Field(default="info", alias="org.springframework.session") + org_springframework_web: str = Field(default="info", alias="org.springframework.web") + + model_config = {"populate_by_name": True} + +class LoggingConfig(BaseModel): + level: LoggingLevelConfig = Field(default_factory=LoggingLevelConfig) + +class FileConfig(BaseModel): + data_root: str = Field(default=".", alias="data-root") + upload_dir: str = Field(default="upload", alias="upload-dir") + log_root: str = Field(default=".", alias="log-root") + + model_config = {"populate_by_name": True} + +class AppRedisConfig(BaseModel): + enabled: bool = False + +class ThemeConfig(BaseModel): + active_theme: str = Field(default="default", alias="active-theme") + themes_path: str = Field(default="themes", alias="themes-path") + + model_config = {"populate_by_name": True} + +class AppConfig(BaseModel): + redis: AppRedisConfig = Field(default_factory=AppRedisConfig) + theme: ThemeConfig = Field(default_factory=ThemeConfig) + +class SpringConfig(BaseModel): + threads_virtual: VirtualThreadsConfig = Field( + default_factory=VirtualThreadsConfig, alias="threads.virtual" + ) + jackson: JacksonConfig = Field(default_factory=JacksonConfig) + datasource: DatasourceConfig + servlet: ServletConfig = Field(default_factory=ServletConfig) + data_redis: RedisDataConfig = Field(alias="data.redis", default_factory=RedisDataConfig) + thymeleaf: ThymeleafConfig = Field(default_factory=ThymeleafConfig) + + model_config = {"populate_by_name": True} + +class DimStackConfig(BaseModel): + spring: SpringConfig + mybatis: MyBatisConfig + server: ServerConfig = Field(default_factory=ServerConfig) + logging: LoggingConfig = Field(default_factory=LoggingConfig) + file: FileConfig = Field(default_factory=FileConfig) + app: AppConfig = Field(default_factory=AppConfig) + + model_config = {"populate_by_name": True} \ No newline at end of file diff --git a/demo/dim_stack/cli/src/view/__init__.py b/demo/dim_stack/cli/src/view/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/demo/dim_stack/cli/src/view/cli_menu.py b/demo/dim_stack/cli/src/view/cli_menu.py new file mode 100644 index 0000000..4047af4 --- /dev/null +++ b/demo/dim_stack/cli/src/view/cli_menu.py @@ -0,0 +1,122 @@ +import os +from config.cli_config import CliConfig + + +class CliMenu: + + def __init__(self): + self.config_manager = CliConfig() + + def show_menu(self): + while True: + self._clear_screen() + self._print_header() + self._print_current_config() + self._print_menu_options() + + choice = input("\n请选择操作 [0-4]: ").strip() + + if choice == "1": + self._set_project_dir() + elif choice == "2": + self._set_jar_name() + elif choice == "3": + self._view_config() + elif choice == "4": + self._reset_config() + elif choice == "0": + print("\n再见!") + break + else: + input("\n无效选择,按回车继续...") + + def _clear_screen(self): + os.system("cls" if os.name == "nt" else "clear") + + def _print_header(self): + print("=" * 50) + print("Dim Stack CLI - 配置管理") + print("=" * 50) + + def _print_current_config(self): + project_dir = self.config_manager.get_project_dir() + jar_name = self.config_manager.get_jar_name() + print(f"\n当前项目运行目录: {project_dir or '未设置'}") + print(f"当前 JAR 名称: {jar_name}") + print("-" * 50) + + def _print_menu_options(self): + print("\n菜单选项:") + print(" 1. 设置项目运行目录") + print(" 2. 设置 JAR 名称") + print(" 3. 查看配置详情") + print(" 4. 重置配置") + print(" 0. 退出") + + def _set_project_dir(self): + print("\n--- 设置项目运行目录 ---") + new_dir = input("请输入项目运行目录路径 (留空取消): ").strip() + + if not new_dir: + print("已取消操作") + input("\n按回车继续...") + return + + new_dir = os.path.expanduser(new_dir) + + try: + self.config_manager.set_project_dir(new_dir) + print(f"\n✓ 项目运行目录已设置为: {os.path.abspath(new_dir)}") + except ValueError as e: + print(f"\n✗ 设置失败: {e}") + + input("\n按回车继续...") + + def _set_jar_name(self): + print("\n--- 设置 JAR 名称 ---") + current = self.config_manager.get_jar_name() + print(f"当前 JAR 名称: {current}") + new_jar = input("请输入 JAR 名称 (留空取消): ").strip() + + if not new_jar: + print("已取消操作") + input("\n按回车继续...") + return + + try: + self.config_manager.set_jar_name(new_jar) + print(f"\n✓ JAR 名称已设置为: {new_jar}") + except Exception as e: + print(f"\n✗ 设置失败: {e}") + + input("\n按回车继续...") + + def _view_config(self): + print("\n--- 配置详情 ---") + + if not self.config_manager.config_exists(): + print("配置文件不存在") + else: + config = self.config_manager.load_config() + print(f"配置文件路径: {self.config_manager.config_path}") + print(f"\n配置内容:") + for key, value in config.items(): + print(f" {key}: {value}") + + input("\n按回车继续...") + + def _reset_config(self): + print("\n--- 重置配置 ---") + confirm = input("确定要重置配置吗? (y/n): ").strip().lower() + + if confirm == "y": + try: + if self.config_manager.config_exists(): + os.remove(self.config_manager.config_path) + print("✓ 配置已重置") + except Exception as e: + print(f"✗ 重置失败: {e}") + else: + print("已取消操作") + + input("\n按回车继续...") diff --git a/demo/dim_stack/docs/architecture/cache-mode.md b/demo/dim_stack/docs/architecture/cache-mode.md new file mode 100644 index 0000000..2d60cb1 --- /dev/null +++ b/demo/dim_stack/docs/architecture/cache-mode.md @@ -0,0 +1,5 @@ +# 缓存模式 + +缓存模式流程图: + +![缓存模式](../../images/memory_mode.svg) diff --git a/demo/dim_stack/docs/architecture/startup-flow.md b/demo/dim_stack/docs/architecture/startup-flow.md new file mode 100644 index 0000000..35d209a --- /dev/null +++ b/demo/dim_stack/docs/architecture/startup-flow.md @@ -0,0 +1,5 @@ +# 系统启动流程 + +系统启动流程图: + +![启动流程图](../../images/start_up_flowchart.svg) diff --git a/demo/dim_stack/docs/deployment.md b/demo/dim_stack/docs/deployment.md new file mode 100644 index 0000000..de2e60e --- /dev/null +++ b/demo/dim_stack/docs/deployment.md @@ -0,0 +1,211 @@ +# 部署教程 + +## 环境要求 + +- **JDK**: 17+(推荐 21+ 以支持虚拟线程) +- **MySQL**: 5.7+ +- **Redis**: 5+(可选) +- **Node.js**: 22+ + +演示站:[https://apilinks.cn/](https://apilinks.cn/) + +--- + +## 宝塔面板部署 + +请移步 B 站查看详细部署视频: + +[https://www.bilibili.com/video/BV1qncgzkEHk/](https://www.bilibili.com/video/BV1qncgzkEHk/) + +--- + +## Linux 手动部署 + +### 1. 初始化 + +解压下载的压缩包,进入目录下执行: + +```bash +java -jar dimstack-1.0-SNAPSHOT.jar --server.port=2223 +``` + +> `--server.port=2223` 为可选参数,用于指定运行端口 + +运行后找到终端输出的地址,在浏览器打开初始化页面: + +``` +http://localhost:2223/init/setup +``` + +按照向导填写管理员用户名、密码、站点端口、日志级别、MySQL 及 Redis 信息,点击确认后系统会自动完成初始化。 + +![初始化设置页面](../images/initialization_setup_page.png) + +![初始化向导表单](../images/initialization_wizard_form.png) + +填写完成后点击确认,出现以下界面即为成功: + +![初始化成功页面](../images/initialization_success_page.png) + +### 2. 启动测试 + +初始化完成后重启服务: + +```bash +java -jar dimstack-1.0-SNAPSHOT.jar +``` + +看到主页正常加载即为成功: + +![主页加载成功](../images/homepage_loaded_successfully.png) + +--- + +## Docker 部署 + +项目提供 Docker 一键部署脚本,支持自动检测首次运行并完成初始化。 + +```bash +# 赋予执行权限 +chmod +x docker-run.sh + +# 启动服务(默认端口 2222) +./docker-run.sh + +# 自定义端口(宿主机端口:容器端口) +./docker-run.sh 8080 2222 +``` + +> 新手推荐使用上面的原生部署方式,Docker 部署对新手并不友好。 + +--- + +## systemd 开机自启(Linux) + +> 使用宝塔面板等运维工具部署的可忽略此节。 + +### 1. 创建服务文件 + +```bash +sudo vim /etc/systemd/system/dimstack.service +``` + +### 2. 写入配置 + +```ini +[Unit] +Description=Dim Stack Forum Backend Service +After=network.target mysql.service redis.service +Wants=mysql.service redis.service + +[Service] +Type=simple +User=root +Group=root +WorkingDirectory=/root/dimstack + +Environment="JAVA_OPTS=-server -Xms512m -Xmx1g -XX:+UseG1GC -XX:+UseStringDeduplication -Dfile.encoding=UTF-8" + +ExecStart=/bin/sh -c 'exec java $JAVA_OPTS -jar dimstack-1.0-SNAPSHOT.jar' + +SuccessExitStatus=143 + +Restart=always +RestartSec=15 + +StartLimitInterval=600s +StartLimitBurst=5 + +ProtectSystem=full +ProtectHome=false +PrivateTmp=true +PrivateDevices=true +NoNewPrivileges=true +RestrictSUIDSGID=true +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 + +OOMPolicy=continue +LimitNOFILE=65535 + +StandardOutput=journal +StandardError=journal +SyslogIdentifier=dim_stack + +[Install] +WantedBy=multi-user.target +``` + +> 请根据实际服务器情况修改 `WorkingDirectory`、`User` 等配置。 + +![systemd服务文件配置](../images/systemd_service_file_config.png) + +### 3. 重载 systemd 配置 + +```bash +sudo systemctl daemon-reload +``` + +![systemd daemon-reload命令](../images/systemd_daemon_reload_command.png) + +### 4. 启动并检查状态 + +```bash +sudo systemctl start dimstack +``` + +![启动dimstack服务](../images/start_dimstack_service_command.png) + +```bash +sudo systemctl status dimstack +``` + +![检查dimstack服务状态](../images/check_dimstack_service_status_command.png) + +### 5. 设置开机自启 + +```bash +sudo systemctl enable dimstack +``` + +![启用dimstack开机自启](../images/enable_dimstack_autostart_command.png) + +--- + +## 构建脚本 + +项目提供跨平台构建脚本,支持一键完成前后端构建与打包。 + +> 需要将 `scripts` 目录下的构建脚本复制到项目根目录执行。 + +![scripts目录](../images/scripts.png) + +### 环境要求 + +- Java 17+(推荐 21+) +- Maven +- Node.js 22+ +- npm + +### Linux + +```bash +# 赋予执行权限 +chmod +x build_release.sh + +# 执行构建 +./build_release.sh +``` + +### Windows + +以管理员身份运行 PowerShell: + +```powershell +# 设置执行策略(首次运行需要) +Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser + +# 执行构建脚本 +.\build_release.ps1 +``` + +构建完成后,jar 包将位于项目根目录。 diff --git a/demo/dim_stack/docs/features/code-injection.md b/demo/dim_stack/docs/features/code-injection.md new file mode 100644 index 0000000..2dd0b4e --- /dev/null +++ b/demo/dim_stack/docs/features/code-injection.md @@ -0,0 +1,17 @@ +# 代码注入功能 + +系统提供自定义全局 head、内容页 head 以及页脚自定义功能。 + +目前 head 和页脚都已支持 js 脚本执行 + +## 功能说明 + +- **全局 head**:在除了后台的所有页面生效 +- **内容页 head**:仅在文章页生效(全局 head 在内容页也生效) +- **页脚自定义**:在除了后台的所有页面生效 + +![代码注入配置](../../images/custom_code_injection_config.png) + +## 安全提醒 + +> 请务必检查注入的代码是否安全,尽量不要引入外部链接,注意防范 XSS、CSRF 等常见攻击手段。 diff --git a/demo/dim_stack/docs/features/comments.md b/demo/dim_stack/docs/features/comments.md new file mode 100644 index 0000000..a0563df --- /dev/null +++ b/demo/dim_stack/docs/features/comments.md @@ -0,0 +1,81 @@ +# 评论区功能 + +评论区采用**两级开关**控制:管理员全局开关 + 文章独立开关,两级均开启时用户才能评论。 + +## 管理员总开关 + +在系统配置页面中控制,管理员可以开启或关闭整个系统的评论功能。 + +![评论区总开关](../../images/comment_section_toggle_switch.png) + +| 属性 | 说明 | +|------|------| +| 配置项 | `enable_comment` | +| 接口 | `GET /api/site/enable-comment` → `{"enableComment": true/false}` | +| 默认值 | 开启(`null` 视为开启) | + +关闭后,所有文章均无法评论,添加评论时返回:`站点评论区已关闭`。 + +## 评论管理与审核 + +拥有 `system:comments:review` 权限的管理员可在后台查看和管理所有评论。 + +![评论区管理](../../images/comment_section_management.png) + +### 人工审核 + +当 `comment_status` 配置为 `3`(待审核)时,用户提交的评论不会直接展示,需审核员在后台手动处理: + +![评论区审核](../../images/comment_section_review.png) + +**审核操作:** + +- **通过**:将评论状态从 `3` 改为 `1`,评论公开展示,同时通知评论作者和文章作者 +- **违规**:将评论状态从 `3` 改为 `4`,评论不展示,仅通知评论作者审核结果 + +### 审核接口 + +| 接口 | 说明 | +|------|------| +| `GET /api/commentreview/pending?page=&size=` | 待审核评论列表(status=3) | +| `GET /api/commentreview/all?page=&size=` | 全部评论列表(status≠0) | +| `PUT /api/commentreview/{id}/status` | 更新状态:`{"status": 1}` 通过 / `{"status": 4}` 违规 | + +### 评论状态 + +| 状态值 | 含义 | +|--------|------| +| `0` | 已删除(软删除) | +| `1` | 已发布(前台可见) | +| `3` | 待审核 | +| `4` | 违规 | + +## 文章子开关 + +每篇文章有独立的评论开关(`enable_comment` 字段),作者可控制单篇文章是否允许评论。 + +![评论区子开关1](../../images/comment_section_toggles_switch.png) + +![评论区子开关2](../../images/comment_section_toggles_switch.png) + +| 属性 | 说明 | +|------|------| +| 字段 | 文章 `enable_comment`(`1` 开启 / `0` 关闭) | + +关闭后,该文章无法评论,添加评论时返回:`该文章评论区已关闭`。 + +> 子开关仅在管理员总开关开启时生效。 + +## 校验逻辑 + +添加评论时,后端校验顺序: + +1. 文章是否存在 +2. 站点总开关是否开启(`siteConfigUtil.isCommentEnabled()`) +3. 当前文章是否开启评论(`article.enable_comment != 0`) + +全部通过后,评论状态由 `comment_status` 配置决定(`1` 直接发布 / `3` 待审核)。 + +## 相关功能 + +- [大模型审核与生成](llm_generation_and_review.md#评论区审核模块) — 评论大模型自动审核 diff --git a/demo/dim_stack/docs/features/external-resources.md b/demo/dim_stack/docs/features/external-resources.md new file mode 100644 index 0000000..d3e1a85 --- /dev/null +++ b/demo/dim_stack/docs/features/external-resources.md @@ -0,0 +1,52 @@ +# 外部资源本地化 + +系统支持导入其他博客或笔记软件文章的功能,并且可以将文章内的图片、视频外链转化为本地存储。 + +## 启用方式 + +在站点配置中开启此功能(默认关闭)。 + +![外部资源本地化配置1](../../images/localization_of_external_resources_1.png) + +## 使用方法 + +1. 点击快捷工具栏中的下载图标 +2. 打开本地化操作页面 + +![外部资源本地化配置2](../../images/localization_of_external_resources_2.png) + +3. 点击"全部本地化"按钮 + +![外部资源本地化配置3](../../images/localization_of_external_resources_3.png) + +4. 后台处理完成后会自动关闭对话框,外链已转化为内部链接 + +![外部资源本地化配置4](../../images/localization_of_external_resources_4.png) + +## 安全防护规则 + +系统实施了严格的 IP 来源分类与访问控制策略: + +### IP 来源控制 + +- **localhost 请求**(包括 127.0.0.1、localhost):可访问所有地址(含 localhost、内网和公网) +- **非 localhost 请求**(如局域网客户端):禁止访问服务器自身的 localhost 或 127.x.x.x 地址,防止对本地服务的探测 +- **公网请求**:禁止访问任何私有 IP 地址(10.x.x.x、172.16–31.x.x、192.168.x.x、169.254.x.x 等),仅允许访问公网地址 + +### DNS 重绑定防护 + +- 将 localhost、127.0.0.1、任意 *.localhost 子域、127.x.x.x 网段,以及 DNS 解析结果为回环地址的域名均视为本地回环资源 +- 建立 HTTP 连接前会重新解析域名,验证所得 IP 是否符合安全策略,防止 DNS 重绑定攻击 + +### 协议限制 + +- 仅支持 HTTP 和 HTTPS 协议 +- 拒绝 ftp、file 等其他协议 + +### 客户端 IP 获取 + +真实客户端 IP 的获取依赖于 `server.forward-headers-strategy: native` 配置,自动从 X-Forwarded-For 等标准代理头中提取原始 IP。 + +## 法律声明 + +> 外部资源本地化功能默认关闭,使用者请勿在境外服务器上部署本项目后开启此功能。如在中国境内部署,须确保遵守国家相关法律法规,严禁利用本功能爬取、存储、传播任何违法违规内容。任何个人或组织私自部署、修改、滥用本功能所产生的一切法律责任,均由使用者自行承担,与项目作者无关。 diff --git a/demo/dim_stack/docs/features/image-compression.md b/demo/dim_stack/docs/features/image-compression.md new file mode 100644 index 0000000..35d15c4 --- /dev/null +++ b/demo/dim_stack/docs/features/image-compression.md @@ -0,0 +1,43 @@ +# 智能图片压缩 + +系统提供图片智能压缩功能,可在系统配置中开启。 + +![智能图片压缩配置](../../images/smart_image_compression_config.png) + +## 压缩规则 + +- 小于 1MB 的 jpg/png 图片跳过压缩 +- webp 图片不压缩(因为已足够小) + +### JPEG 图片 + +| 文件大小 | 压缩质量 | +|---------|---------| +| ≤ 2MB | 0.55 | +| ≤ 5MB | 0.45 | +| > 5MB | 0.35 | + +### 其他格式(转 JPEG) + +| 文件大小 | 压缩质量 | +|---------|---------| +| ≤ 500KB | 0.7 | +| ≤ 2MB | 0.6 | +| ≤ 5MB | 0.5 | +| > 5MB | 0.4 | + +## 处理策略 + +图片会在第一次加载预览时进行压缩。正常情况下,在编辑器上传后就会进入压缩队列(老版本升级后会在图片第一次被访问时加入压缩队列)。 + +### 获取原图 + +如需获取原图,在图片 URL 后面加入 `?download=true`,例如: + +``` +https://apilinks.cn/file/5561656e3db242a6bf342714f08855ab?download=true +``` + +文章阅读器的图片查看功能中也添加了**下载原图**功能: + +![下载原图功能](../../images/article_reader_download_original_image.png) diff --git a/demo/dim_stack/docs/features/key.md b/demo/dim_stack/docs/features/key.md new file mode 100644 index 0000000..730f870 --- /dev/null +++ b/demo/dim_stack/docs/features/key.md @@ -0,0 +1,19 @@ +# 访问控制Key + +除了cookie的认证方式系统还提供key认证功能 **注:key的权限和对应用户权限一致** + +![key_1.png](../../images/key_1.png) + +![key_2.png](../../images/key_2.png) + +## 使用实例 + +只需要在请求的头部加上Key即可,下面以创建文章接口为例 + +```bash +POST {Base_URL}/api/uploadarticle +Authorization: Bearer {Key} +Content-Type: application/json +``` + +注:该功能为后续Agent维护功能做准备,目前已实现QwenCode调用接口创建文章(该skill会在充分测试后放出),文章url:https://lingview.xyz/article/cf096b18-bbfb-468c-a8ec-2dc84f8649f5 \ No newline at end of file diff --git a/demo/dim_stack/docs/features/llm_generation_and_review.md b/demo/dim_stack/docs/features/llm_generation_and_review.md new file mode 100644 index 0000000..1ba6f08 --- /dev/null +++ b/demo/dim_stack/docs/features/llm_generation_and_review.md @@ -0,0 +1,119 @@ +# 大模型相关功能 + +次元栈已支持大模型文章审核、文章生成、评论区审核功能。所有大模型功能依赖 `enable_llm` 总开关。 + +## 配置入口 + +在系统配置页面中找到大模型相关配置项,开启后即可使用。 + +![大模型配置界面](../../images/large_model_configuration_interface.png) + +--- + +## 文章审核模块 + +### 默认提示词 + +> 请谨慎修改提示词 + +![文章审核默认提示词](../../images/article_review_default_prompt.png) + +### 审核流程 + +大模型审核开启后,所有新发布的文章都会优先进行大模型分析: + +| 审核结果 | 处理方式 | +|----------|----------| +| **通过** | 文章直接发布 | +| **违规** | 文章直接标记为违规,不再通知管理员 | +| **异常** | 自动降级为通知有审核权限人员人工审核 | + +文章作者如认为模型判断有误,可联系管理员处理。 + +--- + +## 文章生成模块 + +### 默认提示词 + +> 请谨慎修改提示词 + +![文章生成默认提示词](../../images/article_generation_default_prompt.png) + +### 使用方式 + +文章生成功能开启后,用户可以在文章编辑器中快捷调用大模型生成文章内容。 + +![文章生成演示1](../../images/article_generation_demo_1.png) +![文章生成演示2](../../images/article_generation_demo_2.png) +![文章生成演示3](../../images/article_generation_demo_3.png) + +--- + +## 评论区审核模块 + +### 默认提示词 + +> 请谨慎修改提示词 + +![评论审核默认提示词](../../images/comment_review_default_prompt.png) + +大模型评论审核使用独立的 `comment_review` 提示词,可在提示词管理页面中自定义。模型需返回 JSON 格式 `{"compliant": true/false}`,系统据此判定通过或违规。 + +### 审核流程 + +``` +用户提交评论 + │ + ├─ 管理员免审(admin_comment_no_review=1)→ 直接发布(status=1) + │ + └─ 普通用户 → 按 comment_status 配置决定 + ├─ comment_status=1 → 直接发布 + └─ comment_status=3 → 待审核 + ├─ LLM审核开启 → 独立线程调用大模型 + │ ├─ PASS → 发布(status=1)+ 通知作者及文章作者 + │ ├─ REJECT → 违规(status=4)+ 通知作者 + │ └─ ERROR → 通知审核员人工介入 + └─ LLM审核未开启 → 通知审核员人工审核 +``` + +### 评论状态 + +| 状态值 | 含义 | +|--------|------| +| `0` | 已删除(软删除) | +| `1` | 已发布(前台可见) | +| `3` | 待审核 | +| `4` | 违规 | + +### 相关配置 + +| 配置项 | 类型 | 说明 | +|--------|------|------| +| `enable_llm` | 开关 | 大模型总开关,关闭后评论审核一并失效 | +| `enable_llm_comment_review` | 开关 | 评论区大模型审核开关(依赖总开关) | +| `comment_status` | 整数 | 评论默认状态:`1` 直接发布 / `3` 待审核 | +| `admin_comment_no_review` | 开关 | 审核权限管理员评论是否免审核(默认开启) | + +### 审核通知 + +| 场景 | 通知对象 | 通道 | +|------|---------|------| +| 评论进入审核 | 审核员 | 站内通知 + 邮件 | +| AI 通过 | 评论作者 + 文章作者/被回复者 | 站内通知 + 邮件 | +| AI 违规 | 评论作者 | 站内通知 + 邮件 | +| AI 异常 | 审核员 | 站内通知 + 邮件 | +| 人工通过 | 评论作者 + 文章作者/被回复者 | 站内通知 + 邮件 | +| 人工违规 | 评论作者 | 站内通知 + 邮件 | + +> 文章作者和被回复者**仅在审核通过后**才会收到新评论通知。 + +### 审核管理接口 + +| 接口 | 说明 | +|------|------| +| `GET /api/commentreview/pending?page=&size=` | 待审核评论列表(status=3) | +| `GET /api/commentreview/all?page=&size=` | 全部评论列表(status≠0) | +| `PUT /api/commentreview/{id}/status` | 更新状态:`{"status": 1}` 通过 / `{"status": 4}` 违规 | + +> 以上接口均需 `system:comments:review` 权限。 diff --git a/demo/dim_stack/docs/features/script_usage_instructions.md b/demo/dim_stack/docs/features/script_usage_instructions.md new file mode 100644 index 0000000..aab2b8f --- /dev/null +++ b/demo/dim_stack/docs/features/script_usage_instructions.md @@ -0,0 +1,90 @@ +# 脚本使用说明 + +> 所有脚本均在**项目根目录**下执行,通过 `scripts/xxx` 或 `.\scripts\xxx` 路径调用 + +--- + +## build_release.sh(linux) / build_release.ps1(windows) + +**功能:** 构建前后端产物。包含工具版本检查(Java >= 17、Node.js >= 22),前端构建产物自动复制到后端静态资源目录,最终输出 JAR 包到项目根目录 + +**用法:** + +```bash +# 完整构建前后端 +./scripts/build_release.sh +.\scripts\build_release.ps1 + +# 只构建前端(复制到后端 static 后结束,不打包后端) +./scripts/build_release.sh --skip-backend +.\scripts\build_release.ps1 --skip-backend + +# 只打包后端(跳过前端构建) +./scripts/build_release.sh --skip-frontend +.\scripts\build_release.ps1 --skip-frontend +``` + +--- + +## build_docker.sh + +**功能:** 构建 Docker 镜像并启动容器。首次运行会自动创建 `config/`、`upload/`、`logs/` 目录并构建镜像;后续启动跳过构建步骤直接复用已有镜像 + +**用法:** + +```bash +# 默认宿主机端口 2222,映射到容器内 2222 +./scripts/build_docker.sh + +# 自定义端口:宿主机 8080 -> 容器内 8080 +./scripts/build_docker.sh 8080 8080 +``` + +**首次启动后访问:** `http://localhost:<端口>/init/setup` + +--- + +## git_history.bat / git_history.sh + +**功能:** 导出 Git 提交记录到 `commits.txt`,格式:`commit-hash 作者 日期 提交信息` + +**用法:** + +```bash +# Windows +.\scripts\git_history.bat + +# Linux +./scripts/git_history.sh +``` + +--- + +## cli_build.sh + +**功能:** 使用 Nuitka 将 Python CLI 编译为单文件可执行文件 + +**用法:** + +```bash +./scripts/cli_build.sh +``` + +**前置依赖:** + +```bash +sudo apt-get install gcc patchelf +conda install libpython-static +``` + +--- + +## generate_update_log_yaml.py + +**功能:** 扫描 Git 历史中 `system_version.txt` 的版本变更记录,自动生成结构化的 `update_log.yaml` 更新日志文件,按版本号分组,提交信息自动归类(新功能、问题修复、安全修复等) + +**用法:** + +```bash +python scripts/generate_update_log_yaml.py +``` \ No newline at end of file diff --git a/demo/dim_stack/docs/features/seo.md b/demo/dim_stack/docs/features/seo.md new file mode 100644 index 0000000..68bed96 --- /dev/null +++ b/demo/dim_stack/docs/features/seo.md @@ -0,0 +1,26 @@ +# SEO 优化与大模型读取 + +## 首页、文章页 SSR + +- 针对搜索引擎 User-Agent 自动返回服务器渲染的 HTML +- SSR 页面包含: + - ``:文章标题 + - `<meta name="description">`:文章摘要 + - `<meta name="keywords">`:文章标签 + - 文章内容和发布时间等 +- 普通用户访问则返回 SPA 首页,保持 React 的交互体验 +- 支持主流搜索引擎爬虫:Googlebot、Bingbot、Baiduspider、DuckDuckBot、Sogou、360Spider 等 + +## Sitemap 与 Robots + +支持动态生成 Sitemap 和 Robots.txt,智能处理反向代理头。 + +针对 Google、Bing 等主流搜索引擎的爬虫进行了独立优化。 + +![SEO优化支持](../../images/seo_optimization_support.png) + +## 大模型读取 + +文章内容可以被大部分网页 AI 直接读取(密码保护的文章无法读取)。 + +![大模型文章内容读取支持](../../images/llm_article_content_reading_support.png) diff --git a/demo/dim_stack/docs/features/storage.md b/demo/dim_stack/docs/features/storage.md new file mode 100644 index 0000000..041b7a5 --- /dev/null +++ b/demo/dim_stack/docs/features/storage.md @@ -0,0 +1,100 @@ +# 外置存储 + +系统支持将附件存储到外部存储服务,当前支持: +- **S3 协议兼容**的对象存储(阿里云 OSS、腾讯云 COS 测试无问题) +- **WebDAV 协议**的存储(OpenList 测试无问题) + +注:未来计划加入更多种存储方式(ftp、sftp等) + +## 启用方式 + +在站点配置的**存储配置**标签页中添加存储方式,并设为默认 +![img.png](../../images/storage_1.png) + +## 存储管理 + +### 禁用与启用 + +- 禁用后,该存储方式下的文件将无法访问,上传也会失败 +- 禁用前需先切换默认存储方式 +- 启用后恢复正常访问 + +### 删除 + +- 删除存储方式后不会清除存储桶内数据 +- 删除前需先切换默认存储方式 +- 删除后,该存储方式下的文件记录仍保留在数据库中,但无法访问 + +## S3 协议兼容存储 + +### 配置字段 + +![img.png](../../images/storage_2.png) + +| 字段 | 说明 | +|-------------------|----------------------------------------------| +| 端点 | 服务地址,如 `https://oss-cn-beijing.aliyuncs.com` | +| 区域 | 存储区域,如 `华北2(北京)` | +| 存储桶 | 存储桶名称 | +| 访问密钥 ID | AccessKey ID | +| 访问密钥 | AccessKey Secret | +| Path Style Access | MinIO 等自建服务需要开启 | + +### 文件访问 + +S3 存储的文件通过 Presigned URL(预签名 URL)访问: + +- 用户请求 `/file/{accessKey}` 时,后端生成临时有效的预签名 URL +- 浏览器通过 302 重定向直接从对象存储下载文件 +- 预览链接有效期 1 小时,下载链接有效期 5 分钟 + +## WebDAV 存储 + +### 配置字段 + +![img.png](../../images/storage_3.png) + +| 字段 | 说明 | +|-----------|--------------------------------------------| +| WebDAV 地址 | 完整地址,如 `https://pan.lingview.xyz/dav/test` | +| 用户名 | 登录用户名 | +| 密码 | 登录密码 | + +注:WebDAV 地址应写完整路径 + +### 文件访问 + +WebDAV 不支持预签名 URL,文件访问走后端**流式转发**: + +- 用户请求 `/file/{accessKey}` 时,后端从 WebDAV 服务器读取文件 +- 边读边向浏览器转发,不等待完整下载 + +### 注意事项 + +- 后端带宽压力:WebDAV 文件全部经后端转发,比 S3 的 302 重定向占用更多后端带宽 + +## 附件存储迁移 + +在全局附件管理中,点击「存储迁移」按钮,可将附件从一个存储方式迁移到另一个。 + +![img.png](../../images/storge_3.png) + +### 迁移流程 + +1. 选择源存储(被迁移)和目标存储(迁移到) +2. 点击「执行迁移」 +3. 系统逐条处理:从源存储读取 -> 写入目标存储 -> 删除源存储旧文件 -> 更新数据库记录 +4. 迁移完成后显示结果(成功数 / 失败数) + +### 失败重试 + +- 迁移失败的附件会列出失败原因 +- 点击「重试失败项」可对失败附件重新迁移 +- 源文件不存在的附件会自动跳过,不计入失败 + +### 注意事项 + +- 源存储和目标存储必须都是启用状态,且不能相同 +- 迁移过程中文件访问不受影响(仍在源存储) +- 迁移成功后源存储的旧文件会被自动删除 +- 删除源文件失败不影响迁移结果 diff --git a/demo/dim_stack/docs/legacy.md b/demo/dim_stack/docs/legacy.md new file mode 100644 index 0000000..9f5d054 --- /dev/null +++ b/demo/dim_stack/docs/legacy.md @@ -0,0 +1,19 @@ +# 老版本相关说明 + +## 数据库迁移 + +> v84+ 版本已支持一键更新系统,可忽略此节 + +v84 以下的版本没有一键更新系统功能,需要手动执行数据库更新脚本。 + +### 需要手动升级的版本 + +以下版本升级需要手动执行数据库升级脚本(如果数据库不是默认名 `dim_stack`,请将脚本中的 `USE dim_stack;` 修改为对应的数据库名): + +- **v54 → v55+** +- **v64 → v65+** +- **v77 → v84+** + +请到 `legacy/数据库更新脚本` 目录中下载对应的升级脚本,并根据自己的数据库版本选择 mysql5 或 mysql8 脚本。 + +[数据库更新脚本](../legacy/数据库更新脚本) \ No newline at end of file diff --git a/demo/dim_stack/docs/plugins/plugin-development-guide.md b/demo/dim_stack/docs/plugins/plugin-development-guide.md new file mode 100644 index 0000000..4f1f741 --- /dev/null +++ b/demo/dim_stack/docs/plugins/plugin-development-guide.md @@ -0,0 +1,682 @@ +# 次元栈 - 插件开发文档 + +面向插件开发者的接口说明与完整示例。 + +> 配套可运行示例工程:`plugin_example/hello/`,可编译安装(首次构建前需先安装 SDK,见 2.5)。文档中的 `my-plugin` 为占位名,示例工程统一使用 `hello` + +## 一、插件能做什么 + +一个插件就是一个 jar,安装后在宿主里独立运行: + +- **后端接口**:插件里的 Spring 组件由宿主加载进插件自己的容器,接口挂载在 `/api/plugins/{插件id}/` 下 +- **前端界面**:插件可以把页面、组件以 ES 模块的形式接入宿主前台和后台,与宿主共享同一份 React +- **后台菜单**:通过声明文件往后台侧边栏添加菜单 +- **数据库**:与宿主同一个库,用插件专属连接池读写自己的表,宿主自动记录 SQL 审计 +- **宿主服务**:按白名单读取站点配置、缓存、存储与扩展点 + +插件接口默认需要登录;确实需要匿名开放的,要在 `plugin.yaml` 里显式声明。接口还可以用 `@RequiresPermission` 声明权限码;插件自定义的权限码随插件注册进宿主权限体系,可由管理员授予角色(见 3.6)。 + +## 二、快速开始 + +### 2.1 工程结构 + +``` +my-plugin/ +├── pom.xml +└── src/main/ + ├── java/xyz/example/myplugin/ + │ └── HelloController.java + └── resources/ + ├── plugin.yaml # 插件描述文件(必需) + ├── extensions/menu.yaml # 后台菜单(可选) + └── ui/ # 前端界面(可选) + ├── ui-plugin.json + ├── entry.js + └── style.css +``` + +### 2.2 pom.xml + +```xml +<?xml version="1.0" encoding="UTF-8"?> +<project xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + <modelVersion>4.0.0</modelVersion> + + <groupId>xyz.example</groupId> + <artifactId>my-plugin</artifactId> + <version>1.0.0</version> + <packaging>jar</packaging> + + <properties> + <maven.compiler.source>17</maven.compiler.source> + <maven.compiler.target>17</maven.compiler.target> + <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> + </properties> + + <dependencies> + <!-- 插件 SDK:BasePlugin / PluginContext / PluginDb 等;运行时由宿主提供 --> + <dependency> + <groupId>xyz.lingview.dimstack</groupId> + <artifactId>dimstack-plugin-api</artifactId> + <version>1.0-SNAPSHOT</version> + <scope>provided</scope> + </dependency> + + <!-- 编译期用的 Spring 注解;运行时由宿主提供 --> + <dependency> + <groupId>org.springframework</groupId> + <artifactId>spring-context</artifactId> + <version>7.0.9</version> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>org.springframework</groupId> + <artifactId>spring-web</artifactId> + <version>7.0.9</version> + <scope>provided</scope> + </dependency> + <!-- 需要 JdbcTemplate 时再加 --> + <dependency> + <groupId>org.springframework</groupId> + <artifactId>spring-jdbc</artifactId> + <version>7.0.9</version> + <scope>provided</scope> + </dependency> + </dependencies> +</project> +``` + +> 注意:SDK 与 Spring 依赖必须写 `provided`,不要打进插件 jar。插件运行时拿到的都是宿主的类;自带一份会导致类冲突。 + +### 2.3 plugin.yaml + +```yaml +id: my-plugin +version: 1.0.0 +requires: '>=1.0.0' +displayName: 我的插件 +description: 演示插件:一个接口 + 一个配置项 +author: + name: 你的名字 +scanPackage: xyz.example.myplugin +configMapName: config +permissions: + - code: manage + name: 管理我的插件数据 +publicApiPaths: + - /hello +``` + +| 字段 | 必填 | 说明 | +| --- | --- | --- | +| `id` | 是 | 插件唯一标识,只允许字母、数字、`.`、`_`、`-`,最长 64 位,必须以字母或数字开头 | +| `version` | 是 | 版本号,只允许字母、数字、`.`、`_`、`+`、`-`,最长 32 位;升级时必须修改(当前只校验与当前版本不同) | +| `displayName` | 否 | 后台显示名称 | +| `description` | 否 | 后台显示描述 | +| `author` | 否 | 作者,可以写字符串,也可以写 `{name: xxx}` | +| `scanPackage` | 强烈建议 | Spring 组件扫描包。**不配置的话插件里的组件不会被注册**,插件等于空壳 | +| `publicApiPaths` | 否 | 免登录接口路径(相对 `/api/plugins/{id}`),见[3.6 路由与权限] | +| `permissions` | 否 | 插件自定义权限码声明(短码 + 名称),完整码为 `plugin:{插件id}:{短码}`,见[3.6 路由与权限] | +| `managedPaths` | 否 | 随插件分发、由宿主托管的资源目录,目前只支持 `themes/<主题名>` 形式 | +| `configMapName` | 否 | 配置存储名,默认 `config`,见[3.3 插件配置] | +| `requires` | 否 | 期望的宿主版本,当前仅记录,不做校验 | +| `pluginClass` | — | 不需要填写:插件实例统一由宿主创建 | + +### 2.4 一个后端接口 + +```java +package xyz.example.myplugin; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequestMapping("/api/plugins/my-plugin") +public class HelloController { + + @GetMapping("/hello") + public Map<String, Object> hello() { + return Map.of("message", "hello from my-plugin"); + } +} +``` + +### 2.5 打包与安装 + +插件依赖的 SDK(`dimstack-plugin-api`)从本地 Maven 仓库解析,首次构建前,先在宿主工程的 `backend` 目录执行一次(宿主升级、SDK 有变化后重跑一次): + +```bash +cd backend && mvn -pl dimstack-plugin-api -am install -DskipTests +``` + +然后在插件工程里打包: + +```bash +mvn clean package +``` + +打开后台[插件管理 -> 安装插件],上传 `target/my-plugin-1.0.0.jar`,然后在列表里点[启用]。 + +规则: + +- 只接受 `.jar` 文件,大小不超过 50MB;`plugin.yaml` 的 id、version 必须符合上面的格式 +- 上传安装后默认是**停用**状态,需要手动启用 +- 直接把 jar 放到宿主的 `plugins/` 目录也可以:启动时会被登记为[待启用],不会自动运行 + +## 三、后端开发 + +### 3.1 插件容器 + +每个插件有自己独立的 Spring 容器,容器里可以拿到三类东西: + +1. 插件自己 `scanPackage` 包下的组件(`@Component`、`@RestController` 等) +2. 宿主注入的基础 Bean(配置、数据库,见 3.2) +3. 白名单里的宿主服务(见 3.2 表格) + +插件容器**看不到宿主的内部 Bean**,这是有意的隔离设计。 + +### 3.2 可注入的 Bean + +在插件的组件里直接注入即可: + +| Bean | 类型 | 用途 | +| --- | --- | --- | +| `pluginContext` | `PluginContext` | 插件 id、版本、配置名 | +| `boundSettingFetcher` | `SettingFetcher` | 读取插件配置 | +| `pluginDb` | `PluginDb` | 表名前缀 + 插件专属连接池 | +| `pluginJdbcTemplate` | `JdbcTemplate` | 直接执行 SQL(已带审计) | +| `extensionGetter` | `ExtensionGetter` | 读取扩展点实现 | + +```java +@RestController +@RequestMapping("/api/plugins/my-plugin") +public class HelloController { + + private final PluginContext context; + private final SettingFetcher settingFetcher; + + public HelloController(PluginContext context, SettingFetcher settingFetcher) { + this.context = context; + this.settingFetcher = settingFetcher; + } +} +``` + +白名单里还有四个宿主服务可以按**名字**取(宿主类型不在插件类路径上,不能用类型注入): + +| 名字 | 说明 | +| --- | --- | +| `siteConfigService` | 站点配置读写 | +| `cacheService` | 缓存读写 | +| `storageFacadeService` | 文件存储 | +| `extensionGetter` | 扩展点读取(同时是 SDK 接口,可直接按类型注入) | + +```java +// 宿主内部类型拿不到编译期依赖, 用名字取后反射调用 +Object siteConfig = applicationContext.getBean("siteConfigService"); +Object value = siteConfig.getClass().getMethod("getSiteName").invoke(siteConfig); +``` + +### 3.3 插件配置 + +- 存储在宿主的 `plugin_config` 表里,存储名由 `plugin.yaml` 的 `configMapName` 决定(默认 `config`) +- 读取接口:`settingFetcher.fetch(配置名, "键", 类型)`——配置名传 `null` 或空串时用插件默认名 + +```java +Boolean enabled = settingFetcher.fetch(null, "enabled", Boolean.class); +if (enabled == null || enabled) { + // 默认开启 +} +``` + +- 后台的[设置]面板保存/读取配置走后台接口 `GET/PUT /api/plugins/{id}/config`(需要插件管理权限);插件如果自带 `config.yaml`,其中的内容会作为默认值展示在设置面板里 + +### 3.4 数据库访问 + +插件与宿主同库。**插件自己的表必须通过 `pluginDb.table("名字")` 生成表名**,宿主会自动加前缀: + +| 插件 id | `table("posts")` 结果 | +| --- | --- | +| `my-plugin` | `plugin_my_plugin_posts` | +| `demo.plugin` | `plugin_demo_plugin_posts` | + +前缀规则:插件 id 转小写,`.` 和 `-` 都替换为 `_`,前后拼 `plugin_` 与 `_`。两个插件归一化后前缀相同时,后启动的一方会被拒绝并报[插件表前缀冲突],避免共用表空间。 + +```java +@RestController +@RequestMapping("/api/plugins/my-plugin") +public class NoteController { + + private final PluginDb pluginDb; + private final JdbcTemplate jdbc; + + public NoteController(PluginDb pluginDb, JdbcTemplate jdbc) { + this.pluginDb = pluginDb; + this.jdbc = jdbc; + } + + @GetMapping("/notes/count") + public Map<String, Object> count() { + String table = pluginDb.table("notes"); + jdbc.execute("CREATE TABLE IF NOT EXISTS " + table + + " (id INT PRIMARY KEY AUTO_INCREMENT, content VARCHAR(255))"); + Integer rows = jdbc.queryForObject("SELECT COUNT(*) FROM " + table, Integer.class); + return Map.of("table", table, "rows", rows); + } +} +``` + +**SQL 审计**:宿主只记录三类 SQL,普通读写不会打扰你—— + +| 类型 | 触发条件 | +| --- | --- | +| 结构变更 | `CREATE`、`ALTER`、`DROP`、`TRUNCATE`、`RENAME`(含注释开头的语句) | +| 写宿主核心表 | `INSERT`、`UPDATE`、`DELETE` 等命中宿主的用户、文章、评论、配置等核心表 | +| 慢查询 | 执行超过 3 秒 | + +审计记录在后台[插件管理 -> 审计]里查看,卸载插件后记录仍然保留。需要注意的是:通过任何方式执行的 SQL 都会被记录,包括 `unwrap`、`getConnection` 取到连接后执行的语句。 + +**数据表不随插件卸载删除**,需要清理的话请在插件自己的逻辑里处理。 + +### 3.5 扩展点 + +扩展点机制用于[一个插件提供能力,其他插件或宿主来消费]。SDK 提供标记接口 `ExtensionPoint` 和读取接口 `ExtensionGetter`;具体扩展接口由你定义,随插件一起分发。 + +```java +// 1. 定义扩展接口(放在你随插件分发的 api 包里) +public interface GreetingProvider extends ExtensionPoint { + String greet(String name); +} + +// 2. 你的插件里给一个实现 +@Component +public class DefaultGreeting implements GreetingProvider { + @Override + public String greet(String name) { + return "你好, " + name; + } +} + +// 3. 任意插件(或宿主)里读取全部实现 +List<GreetingProvider> providers = extensionGetter.getExtensions(GreetingProvider.class); +``` + +宿主和所有已启用插件里的实现都会被收集到;停用的插件不参与。实现类用 `@Order` 或 `Ordered` 接口可以控制顺序。 + +### 3.6 路由与权限 + +- 插件接口默认走宿主统一鉴权:**必须登录**才能访问 +- 需要匿名开放的接口(比如给访客看的页面数据),在 `plugin.yaml` 的 `publicApiPaths` 里声明,路径相对 `/api/plugins/{插件id}`: + +```yaml +publicApiPaths: + - /content # 对应接口 /api/plugins/my-plugin/content +``` + +约束: + +- 只支持精确路径,不支持 `*`、`{}` 等通配符,最长 128 字符 +- 不允许 `//`、`.`、`..` 等路径段 +- 不能使用宿主保留路径:`/config`、`/start`、`/stop`、`/upgrade`、`/reload` +- 声明后也只有在插件真的注册了对应路由、且插件处于启用状态时才免登录 + +**接口权限码(可选)** + +插件接口除了"登录"之外,还可以用 SDK 的 `@RequiresPermission` 声明所需权限码。注解可以标在方法或类上(方法优先): + +```java +import xyz.lingview.dimstack.plugin.api.RequiresPermission; + +@GetMapping("/notes/delete") +@RequiresPermission("plugin:my-plugin:manage") // 写完整权限码, 见下 +public Map<String, Object> delete(Long id) { + ... +} +``` + +- 注解值写**完整权限码**(`plugin:{插件id}:{短码}` 或宿主内置码如 `plugin:management`);`all = true` 表示列出的权限码需要全部满足,默认是任一命中即可 +- 未标注解的接口保持"登录即可访问",行为不变 +- 匿名接口(`publicApiPaths` 声明的)不要标注解;标了仍要求登录 + 权限 + +**自定义权限码** + +把自己的权限码声明在 `plugin.yaml` 的 `permissions` 里(见 2.3),插件启用时宿主会自动注册进权限表,管理员就能在后台角色管理里勾选授权: + +```yaml +permissions: + - code: manage # 短码: 只允许小写字母/数字/下划线, 1-40 位 + name: 管理我的插件数据 # 权限名称, 建议带上插件名, 角色配置里更好辨认 +``` + +规则: + +- 完整权限码 = `plugin:{插件id}:{短码}`,比如 `plugin:my-plugin:manage`,总长不超过 100 字符 +- 启用时注册:新码自动插入;已存在的只更新名称,**已有的角色授权不会丢**(反复启用/重载安全) +- 某个码在新版本里不再声明时,启用新版本会自动把它删除(相关角色授权随之移除) +- 停用插件不删权限码;卸载插件时按插件前缀整体清理 +- 注册后还需要**在角色管理里勾选**该权限,对应角色的用户才能访问;新注册的码默认没有任何角色拥有 +- 宿主内置权限码清单见文末附录 + +### 3.7 后台菜单 + +插件启动时,宿主会读取插件 jar 里的 `extensions/menu.yaml`,把菜单写入后台侧边栏(挂在[设置 -> 插件管理]下,支持多层级嵌套): + +```yaml +dashboard-menu: + - title: 我的面板 + link: /dashboard/plugins/my-plugin/home + icon: plugin + permission: manage # 短码, 需在 plugin.yaml 的 permissions 里声明过 + - title: 帮助说明 + link: /dashboard/plugins/my-plugin/help +``` + +| 字段 | 必填 | 说明 | +| --- | --- | --- | +| `title` | 是 | 菜单文字 | +| `link` | 是 | 跳转路径,建议以 `/dashboard/plugins/{插件id}/` 开头,卸载时宿主按这个前缀清理 | +| `icon` | 否 | 图标名,默认 `plugin` | +| `permission` | 否 | 访问所需权限码,默认 `plugin:management`;只写短码(不含冒号)时展开为 `plugin:{插件id}:{短码}`,该短码必须已在 `permissions` 里声明,否则该菜单项会被跳过并在日志里告警 | + +菜单点击后跳转到 `link` 路径;插件在前端注册**与 link 相同路径**的路由(`context.registerRoute`,见 4.2),页面就会渲染在后台框架的内容区里。菜单在插件启用时写入、卸载时清理;停用不删菜单。 + +## 四、前端插件 + +插件的前端代码放在 jar 的 `resources/ui/` 目录下,宿主会以静态资源的方式提供:`/plugins/{插件id}/assets/ui/**`。 + +### 4.1 ui-plugin.json + +```json +{ + "format": "esm", + "entry": "entry.js", + "style": "style.css" +} +``` + +| 字段 | 说明 | +| --- | --- | +| `format` | 固定 `esm` | +| `entry` | 入口 JS(`.js` / `.mjs`),必须是 ES 模块 | +| `style` | 可选,插件样式(`.css`) | + +### 4.2 entry.js 契约 + +入口模块的默认导出必须是一个带 `setup(context)` 的对象;宿主启用插件时会调用 `setup`,停用/卸载时会自动回收。 + +```javascript +import React from 'react'; + +export default { + setup(context) { + // 注册一个前台页面 + context.registerRoute({ + path: '/my-plugin', + element: React.createElement('div', null, 'Hello from my-plugin'), + }); + + // 给后台仪表盘的挂件区注册一个组件 + context.registerExtensionPoint('dashboard:widgets:create', () => + React.createElement('div', null, '我的挂件')); + + // 注册清理回调:插件停用/卸载/重载时会执行 + context.onTeardown(() => { + console.log('my-plugin 已卸载'); + }); + }, +}; +``` + +`setup` 里的 `context` 提供: + +| 方法/属性 | 说明 | +| --- | --- | +| `pluginName` / `pluginVersion` | 当前插件的 id 与版本 | +| `registerRoute({path, element})` | 往宿主 SPA 注册页面路由(后台页面与菜单 `link` 用同一路径) | +| `registerExtensionPoint(name, fn)` | 往宿主扩展点注册组件,供宿主的挂槽渲染 | +| `onTeardown(fn)` | 注册清理回调(定时器、全局事件等副作用都在这里释放) | +| `fetchConfig()` | 读取插件配置(需要插件管理权限,适合设置面板) | +| `saveConfig(config)` | 保存插件配置(同上) | + +设置面板示例: + +```javascript +export default { + setup(context) { + context.registerExtensionPoint('plugin:settings:create', () => { + const [enabled, setEnabled] = React.useState(true); + + React.useEffect(() => { + context.fetchConfig().then((cfg) => { + if (typeof cfg.enabled === 'boolean') setEnabled(cfg.enabled); + }); + }, []); + + const save = async () => { + await context.saveConfig({ enabled }); + window.alert('已保存'); + }; + + return React.createElement('div', null, + React.createElement('label', null, + React.createElement('input', { + type: 'checkbox', + checked: enabled, + onChange: (e) => setEnabled(e.target.checked), + }), ' 启用'), + React.createElement('button', { onClick: save }, '保存'), + ); + }); + }, +}; +``` + +### 4.3 共享 React + +宿主通过 Import Map 提供 `react`、`react-dom`、`react-router-dom`。插件的入口模块必须这样引入: + +```javascript +import React from 'react'; +``` + +**不要**把 React 打进你的入口文件(也不要 import 打包后的自己的 React 副本),否则会出现两份 React、页面报错。入口文件建议直接用 `React.createElement` 写,或者用你自己的构建链把 JSX 编译成 ESM——但外部依赖要保留 `import 'react'` 的形式。 + +### 4.4 资源与缓存 + +- 样式文件由宿主自动挂载/卸载:启用时加载(URL 带内容指纹),停用/卸载时移除 +- 其他资源(图片、字体等)放在 `ui/` 下,通过相对路径引用即可 +- 修改插件后重新打包并升级版本号,前端资源会自动刷新缓存 + +## 五、完整示例 + +把上面各部分拼起来,就是一个可用的插件: + +**plugin.yaml** + +```yaml +id: my-plugin +version: 1.0.0 +displayName: 我的插件 +description: 演示插件 +author: + name: 你的名字 +scanPackage: xyz.example.myplugin +``` + +**NoteController.java**(后端接口 + 数据表) + +```java +package xyz.example.myplugin; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import xyz.lingview.dimstack.plugin.api.PluginDb; + +import java.util.Map; + +@RestController +@RequestMapping("/api/plugins/my-plugin") +public class NoteController { + + private final PluginDb pluginDb; + private final JdbcTemplate jdbc; + + public NoteController(PluginDb pluginDb, JdbcTemplate jdbc) { + this.pluginDb = pluginDb; + this.jdbc = jdbc; + } + + @GetMapping("/notes/count") + public Map<String, Object> count() { + String table = pluginDb.table("notes"); + jdbc.execute("CREATE TABLE IF NOT EXISTS " + table + + " (id INT PRIMARY KEY AUTO_INCREMENT, content VARCHAR(255))"); + return Map.of("rows", jdbc.queryForObject("SELECT COUNT(*) FROM " + table, Integer.class)); + } +} +``` + +**extensions/menu.yaml** + +```yaml +dashboard-menu: + - title: 我的插件 + link: /dashboard/plugins/my-plugin/home +``` + +**ui/ui-plugin.json** + +```json +{ "format": "esm", "entry": "entry.js", "style": "style.css" } +``` + +**ui/entry.js** + +```javascript +import React from 'react'; + +export default { + setup(context) { + context.registerRoute({ + path: '/my-plugin', + element: React.createElement('div', null, 'Hello from my-plugin'), + }); + }, +}; +``` + +**ui/style.css** + +```css +.my-plugin { color: #1677ff; } +``` + +打包安装后:前台访问 `/my-plugin` 看到页面,后台菜单出现[我的插件],`/api/plugins/my-plugin/notes/count` 返回数据表行数。 + +## 六、生命周期与运维 + +| 操作 | 会发生什么 | +| --- | --- | +| 安装 | jar 落盘到 `plugins/` 并登记入库,默认停用 | +| 启用 | 创建插件容器、注册后端路由、注册声明的权限码、前端资源上线、加载菜单 | +| 停用 | 关容器、后端路由注销、前端资源下线;数据、配置、菜单、权限码保留 | +| 重载 | 相当于停用后立即启用,用于重打包或改配置后重新加载 | +| 升级 | 上传新版本 jar(版本号必须变化并更大);失败会自动回滚到旧版本 | +| 卸载 | 删除 jar、入库记录、配置、菜单与权限码;**数据表与 SQL 审计记录保留** | + +- 升级失败时宿主会自动恢复旧版本运行,接口报错里会说明原因 +- jar 文件被系统占用删不掉时,后台会提示[请重启后手动清理],重启后请手动删除 `plugins/` 下的残留 jar +- 插件可能被反复启用/重载,`setup` 里的副作用(定时器、事件监听)务必通过 `onTeardown` 释放 + +## 七、注意事项 + +1. `plugin.yaml` 的 id、version 有字符白名单,不符合会直接安装失败 +2. `scanPackage` 不配置或写错包名,插件里的组件不会被注册(日志里会显示[注册组件 0 个]) +3. SDK 与 Spring 依赖必须 `provided`,不要打进插件 jar +4. 插件表名一律用 `pluginDb.table()` 前缀;直接写宿主的用户、文章等核心表会被标记为[核心表写入]并告警 +5. 免登录接口用 `publicApiPaths` 声明,宿主保留路径(`/config`、`/start`、`/stop`、`/upgrade`、`/reload`)不可占用 +6. 前端入口必须 `import React from 'react'`,不要自带 React +7. 插件卸载不会删除你的数据表,需要清理请自行处理 +8. 插件与宿主同库同权限,请只操作自己的数据和必要的宿主配置 +9. `plugin.yaml` 未加引号的文本值(如 `description`、`displayName`)里不要出现半角冒号加空格(`示例: 接口`),否则 YAML 解析失败,安装只会报[读取 plugin.yaml 失败]且不指出位置;需要冒号时用全角[:] +10. `@RequiresPermission` 与菜单 `permission` 里的自定义短码,都必须先在 `plugin.yaml` 的 `permissions` 里声明;未声明的短码接口会 403、菜单项会被跳过并在日志里告警 +11. 权限码在插件启用时注册、卸载时清理、停用不删;新注册的码默认没有任何角色拥有,需要在角色管理里勾选授权后对应角色才能访问 + +## 附录:宿主权限码清单 + +以下为宿主内置权限码(取自初始化数据,**以实际部署为准**),可在 `@RequiresPermission` 里直接引用: + +| 权限码 | 名称 | 模块 | +| --- | --- | --- | +| `system:attachment:view` | 查看任意附件 | attachment | +| `system:attachment:delete` | 删除任意附件 | attachment | +| `system:attachment:undelete` | 撤销删除任意附件 | attachment | +| `system:attachment:management` | 系统附件管理完整操作 | attachment | +| `attachment:add` | 添加附件 | attachment | +| `attachment:view` | 查看自己附件 | attachment | +| `attachment:delete` | 删除自己附件 | attachment | +| `attachment:undelete` | 撤销删除自己附件 | attachment | +| `attachment:edit` | 管理自己附件(完整操作) | attachment | +| `system:comments:view` | 查看任意评论 | comments | +| `system:comments:edit` | 编辑任意评论 | comments | +| `system:comments:delete` | 删除任意评论 | comments | +| `system:comments:management` | 评论管理(完整操作) | comments | +| `comments:add` | 用户评论 | comments | +| `comments:delete` | 删除自己评论 | comments | +| `comments:like` | 点赞评论 | comments | +| `comments:edit` | 评论完整操作 | comments | +| `system:post:review` | 审核文章 | post | +| `system:custompage:add` | 添加自定义页面 | custompage | +| `system:custompage:update` | 更新自定义页面 | custompage | +| `system:custompage:delete` | 删除自定义页面 | custompage | +| `system:custompage:view` | 获取自己的自定义页面 | custompage | +| `system:custompage:management` | 自定义页面完整操作 | custompage | +| `post:view` | 获取文章列表 | post | +| `post:add` | 用户创建文章 | post | +| `post:update` | 用户更新文章 | post | +| `post:details` | 获取文章详情 | post | +| `post:delete` | 删除自己文章 | post | +| `post:unpublish` | 取消发布文章 | post | +| `post:publish` | 发布文章 | post | +| `post:removepassword` | 移除文章密码 | post | +| `post:edit` | 编辑文章(对自己文章完整操作) | post | +| `system:theme:management` | 系统主题管理 | theme | +| `system:friendlinks:management` | 系统友链管理 | friendlinks | +| `system:menus:management` | 系统菜单管理 | menu | +| `system:music:management` | 系统音乐管理 | config | +| `system:role:management` | 系统角色管理 | role | +| `system:config:management` | 系统配置管理 | config | +| `system:tags:management` | 系统标签管理 | tags | +| `system:categories:management` | 系统分类管理 | categories | +| `system:update:management` | 系统更新管理 | update | +| `system:user:management` | 管理用户信息 | user | +| `content:menus` | 内容管理菜单 | menus | +| `user:menus` | 用户管理菜单 | menus | +| `custompages:menus` | 自定义页面菜单 | menus | +| `article:menus` | 文章管理菜单 | menus | +| `articlereview:menus` | 文章审核菜单 | menus | +| `comments:menus` | 评论管理菜单 | menus | +| `tags:menus` | 标签管理菜单 | menus | +| `categories:menus` | 分类管理菜单 | menus | +| `friendlinks:menus` | 友链管理菜单 | menus | +| `globalattachments:menus` | 全局附件菜单 | menus | +| `attachments:menus` | 我的附件菜单 | menus | +| `menus:menus` | 菜单管理菜单 | menus | +| `settings:menus` | 系统设置菜单 | menus | +| `config:menus` | 配置管理菜单 | menus | +| `update:menus` | 系统更新菜单 | menus | +| `theme:menus` | 主题管理菜单 | menus | +| `role:menus` | 角色管理菜单 | menus | +| `system:announcement:management` | 公告管理 | announcement | +| `announcement:menus` | 公告管理菜单 | menus | +| `system:comments:review` | 审核评论 | comments | +| `commentreview:menus` | 评论审核菜单 | menus | +| `plugin:management` | 插件管理 | plugin | +| `plugin:menus` | 插件管理菜单 | menus | diff --git a/demo/dim_stack/docs/themes/themes-frontend-routing-guide.md b/demo/dim_stack/docs/themes/themes-frontend-routing-guide.md new file mode 100644 index 0000000..65f0614 --- /dev/null +++ b/demo/dim_stack/docs/themes/themes-frontend-routing-guide.md @@ -0,0 +1,2728 @@ +# 次元栈 - DimStack API + +次元栈主题开发者接口文档 + +## RouterController - 路由控制器 +**需要主题开发者适配的路由** +**描述**: 主题需要实现的页面以及路由 + +**路径**: 多个路径匹配 + +**方法类型**: GET + +**内容类型**: application/x-www-form-urlencoded + +**路径参数**: + +| 路径 | 示例 | +|-----------------|--------------| +| `/` | 无 | +| `/login` | 登录页面 | +| `/register` | 注册页面 | +| `/article/{articleId}` | 文章相关路径 | +| `/?category={分类名}` | 分类相关路径(首页按分类筛选) | +| `/tag/{tagName}` | 标签相关路径 | +| `/custom-page/{alias}` | 自定义页面 | +| `/friend-links` | 友链页面 | +| `/forgot-password` | 找回密码页面 | + + +## GET 获取首页文章列表 + +GET /api/articles + +用于系统首页获取文章列表,支持按分类过滤 + +### 请求参数 + +|名称|位置|类型|必选|说明| +|---|---|---|---|---| +|page|query|integer(int32)| 否 |页码| +|size|query|integer(int32)| 否 |每页数量| +|category|query|string| 否 |按分类名过滤| + +> 返回示例 + +> 200 Response + +```json +{ + "data": [ + { + "id": 0, + "article_id": "string", + "title": "string", + "excerpt": "string", + "image": "string", + "date": "string", + "author": "string", + "author_avatar": "string", + "category": "string", + "tag": "string", + "alias": "string", + "password": true + } + ], + "total": 0, + "page": 0, + "size": 0, + "total_pages": 0 +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|文章列表|[PaginatedArticlesResponse](#schemapaginatedarticlesresponse)| + +## GET 获取站点名称 + +GET /api/site/name + +返回站点名称 + +> 返回示例 + +> 200 Response + +``` +"string" +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|站点名称|string| + +## GET 获取站点图标 + +GET /api/site/icon + +返回站点图标URL + +> 返回示例 + +> 200 Response + +``` +"string" +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|站点图标URL|string| + +## GET 获取首屏Hero配置 + +GET /api/site/hero + +返回首屏Hero配置 + +> 返回示例 + +> 200 Response + +```json +{ + "title": "string", + "subtitle": "string", + "image": "string" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|Hero配置|[HeroConfig](#schemaheroconfig)| + +## GET 获取版权信息 + +GET /api/site/copyright + +返回版权信息 + +> 返回示例 + +> 200 Response + +``` +"string" +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|版权信息|string| + +## GET 获取ICP备案号 + +GET /api/site/icp-record + +返回ICP备案号 + +> 返回示例 + +> 200 Response + +``` +"string" +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|ICP备案号|string| + +## GET 获取公安备案号 + +GET /api/site/mps-record + +返回公安备案号 + +> 返回示例 + +> 200 Response + +``` +"string" +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|公安备案号|string| + +## GET 获取评论开关状态 + +GET /api/site/enable-comment + +返回评论是否启用 + +> 返回示例 + +> 200 Response + +```json +{ + "code": 0, + "data": { + "enableComment": true + }, + "message": "string" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|评论开关状态|[EnableCommentResponse](#schemaenablecommentresponse)| + +## GET 获取注册开关状态 + +GET /api/site/enable-register + +返回注册是否启用 + +> 返回示例 + +> 200 Response + +```json +{ + "code": 0, + "data": { + "enableRegister": true + }, + "message": "string" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|注册开关状态|[EnableRegisterResponse](#schemaenableregisterresponse)| + +## GET 获取全局自定义代码 + +GET /api/site/global-head-code + +返回全局自定义代码 + +> 返回示例 + +> 200 Response + +```json +{ + "code": 0, + "data": { + "globalHeadCode": "string", + "contentHeadCode": "string", + "footerCode": "string" + }, + "message": "string" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|全局自定义代码|[CustomCodeResponse](#schemacustomcoderesponse)| + +## GET 获取内容自定义代码 + +GET /api/site/content-head-code + +返回内容自定义代码 + +> 返回示例 + +> 200 Response + +```json +{ + "code": 0, + "data": { + "globalHeadCode": "string", + "contentHeadCode": "string", + "footerCode": "string" + }, + "message": "string" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|内容自定义代码|[CustomCodeResponse](#schemacustomcoderesponse)| + +## GET 获取页脚自定义代码 + +GET /api/site/footer-code + +返回页脚自定义代码 + +> 返回示例 + +> 200 Response + +```json +{ + "code": 0, + "data": { + "globalHeadCode": "string", + "contentHeadCode": "string", + "footerCode": "string" + }, + "message": "string" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|页脚自定义代码|[CustomCodeResponse](#schemacustomcoderesponse)| + +## GET 获取站点公告 + +GET /api/announcement + +获取站点最新公告内容,无需登录。无公告时 data 为 null + +> 返回示例 + +> 200 Response + +```json +{ + "code": 200, + "data": { + "id": 1, + "content": "<p>站点公告HTML内容</p>", + "create_time": "2025-01-01T00:00:00", + "update_time": "2025-01-15T12:00:00" + }, + "message": "success" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|公告内容|[Announcement](#schemaannouncement)| + +## GET 获取验证码 + +GET /api/captcha + +获取图形验证码,返回Base64图片与配套的key + +> 返回示例 + +> 200 Response + +```json +{ + "code": 0, + "data": { + "image": "string", + "key": "string" + }, + "message": "string" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|验证码信息|[CaptchaResponse](#schemacaptcharesponse)| + +## POST 用户注册接口 + +POST /api/register + +用户注册 + +> Body 请求参数 + +```json +{ + "username": "string", + "email": "string", + "phone": "string", + "password": "string", + "captcha": "string", + "captchaKey": "string" +} +``` + +### 请求参数 + +|名称|位置|类型|必选|说明| +|---|---|---|---|---| +|body|body|[RegisterRequest](#schemaregisterrequest)| 是 |none| + +> 返回示例 + +> 200 Response + +```json +{ + "code": 0, + "data": {}, + "message": "string" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|注册结果|[ApiResponse](#schemaapiresponse)| + +## POST 用户登录接口 + +POST /api/login + +用户登录 + +> Body 请求参数 + +```json +{ + "username": "string", + "password": "string", + "captcha": "string", + "captchaKey": "string" +} +``` + +### 请求参数 + +|名称|位置|类型|必选|说明| +|---|---|---|---|---| +|body|body|[LoginRequest](#schemaloginrequest)| 是 |none| + +> 返回示例 + +> 200 Response + +```json +{ + "code": 0, + "data": {}, + "message": "string" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|登录结果|[ApiResponse](#schemaapiresponse)| + +## POST 用户登出接口 + +POST /api/logout + +用户登出 + +> 返回示例 + +> 200 Response + +```json +{ + "code": 0, + "data": {}, + "message": "string" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|登出结果|[ApiResponse](#schemaapiresponse)| + +## POST 发送找回密码验证码 + +POST /api/forgot-password/send-captcha + +发送找回密码验证码到邮箱 + +> Body 请求参数 + +```json +{ + "username": "string", + "email": "string" +} +``` + +### 请求参数 + +|名称|位置|类型|必选|说明| +|---|---|---|---|---| +|body|body|[ForgotPasswordSendCaptchaRequest](#schemaforgotpasswordsendcaptcharequest)| 是 |none| + +> 返回示例 + +> 200 Response + +```json +{ + "code": 0, + "data": {}, + "message": "string" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|发送结果|[ApiResponse](#schemaapiresponse)| + +## POST 校验找回密码验证码 + +POST /api/forgot-password/verify + +校验找回密码验证码 + +> Body 请求参数 + +```json +{ + "username": "string", + "email": "string", + "captcha": "string" +} +``` + +### 请求参数 + +|名称|位置|类型|必选|说明| +|---|---|---|---|---| +|body|body|[ForgotPasswordVerifyRequest](#schemaforgotpasswordverifyrequest)| 是 |none| + +> 返回示例 + +> 200 Response + +```json +{ + "code": 0, + "data": {}, + "message": "string" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|验证结果|[ApiResponse](#schemaapiresponse)| + +## POST 重置密码 + +POST /api/forgot-password/reset + +重置用户密码 + +> Body 请求参数 + +```json +{ + "username": "string", + "email": "string", + "newPassword": "string", + "confirmNewPassword": "string" +} +``` + +### 请求参数 + +|名称|位置|类型|必选|说明| +|---|---|---|---|---| +|body|body|[ForgotPasswordResetRequest](#schemaforgotpasswordresetrequest)| 是 |none| + +> 返回示例 + +> 200 Response + +```json +{ + "code": 0, + "data": {}, + "message": "string" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|重置结果|[ApiResponse](#schemaapiresponse)| + +## GET 获取登录状态 + +GET /api/user/status + +获取当前用户登录状态及基本信息 + +> 返回示例 + +> 200 Response + +```json +{ + "code": 0, + "data": { + "loggedIn": true, + "username": "string", + "avatar": "string", + "message": "string" + }, + "message": "string" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|用户登录状态|[UserStatusResponse](#schemauserstatusresponse)| +|401|[Unauthorized](https://tools.ietf.org/html/rfc7235#section-3.1)|未登录|[UnauthorizedResponse](#schemaunauthorizedresponse)| + +## GET 搜索文章 + +GET /api/articlesearch/search + +搜索文章 + +### 请求参数 + +|名称|位置|类型|必选|说明| +|---|---|---|---|---| +|keyword|query|string| 否 |搜索关键字(中文需URL编码)| + +> 返回示例 + +> 200 Response + +```json +{ + "code": 0, + "data": { + "count": 0, + "data": [ + { + "title": "string", + "alias": "string", + "id": 0, + "excerpt": "string" + } + ] + }, + "message": "string" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|搜索结果|[SearchResponse](#schemasearchresponse)| + +## GET 获取所有启用的标签 + +GET /api/tags + +返回所有启用的标签 + +> 返回示例 + +> 200 Response + +```json +{ + "code": 200, + "message": "success", + "data": [ + { + "id": 0, + "tag_name": "string", + "tag_explain": "string", + "founder": "string", + "create_time": "2019-08-24T14:15:22Z", + "status": 0 + } + ] +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|标签列表|[ApiResponse](#schemaapiresponse)| + +### 返回数据结构 + +状态码 **200** + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|code|integer(int32)|false|none||状态码| +|message|string|false|none||响应信息| +|data|[[Tag](#schematag)]|false|none||标签列表| +|» id|integer(int32)|false|none||标签ID| +|» tag_name|string|false|none||标签名称| +|» tag_explain|string|false|none||标签说明| +|» founder|string|false|none||创建者UUID| +|» create_time|string(date-time)|false|none||创建时间| +|» status|integer(int32)|false|none||状态(0禁用,1启用)| + +## GET 获取启用的分类 + +GET /api/categories + +返回启用的分类 + +> 返回示例 + +> 200 Response + +```json +{ + "code": 200, + "message": "success", + "data": [ + { + "id": 0, + "article_categories": "string", + "categories_explain": "string", + "founder": "string", + "create_time": "2019-08-24T14:15:22Z", + "status": 0, + "parent_id": 0, + "level": 0, + "full_path": "string", + "sort_order": 0, + "article_count": 0 + } + ] +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|分类列表|[ApiResponse](#schemaapiresponse)| + +### 返回数据结构 + +状态码 **200** + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|code|integer(int32)|false|none||状态码| +|message|string|false|none||响应信息| +|data|[[Category](#schemacategory)]|false|none||分类列表| +|» id|integer(int32)|false|none||分类ID| +|» article_categories|string|false|none||分类名称| +|» categories_explain|string|false|none||分类说明| +|» founder|string|false|none||创建者UUID| +|» create_time|string(date-time)|false|none||创建时间| +|» status|integer(int32)|false|none||状态(0禁用,1启用)| +|» parent_id|integer(int32)¦null|false|none||父分类ID(顶级为null)| +|» level|integer(int32)¦null|false|none||层级| +|» full_path|string¦null|false|none||分类完整路径| +|» sort_order|integer(int32)¦null|false|none||排序值| +|» article_count|integer(int32)¦null|false|none||文章数| + +## GET 获取所有已启用的分类及文章数 + +GET /api/categoriesandcount + +返回所有已启用的分类及文章数 + +> 返回示例 + +> 200 Response + +```json +{ + "code": 200, + "message": "success", + "data": [ + { + "id": 0, + "article_categories": "string", + "categories_explain": "string", + "founder": "string", + "create_time": "2019-08-24T14:15:22Z", + "status": 0, + "parent_id": 0, + "level": 0, + "full_path": "string", + "sort_order": 0, + "article_count": 0, + "articleCount": 0 + } + ] +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|分类列表|[ApiResponse](#schemaapiresponse)| + +### 返回数据结构 + +状态码 **200** + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|code|integer(int32)|false|none||状态码| +|message|string|false|none||响应信息| +|data|[CategoryWithCount](#schemacategorywithcount)|false|none||分类及文章数列表| + +## GET 获取某分类下的所有文章 + +GET /api/categories/articles + +获取某分类下的所有文章 + +### 请求参数 + +|名称|位置|类型|必选|说明| +|---|---|---|---|---| +|category|query|string| 是 |分类名称(中文需URL编码)| +|page|query|integer(int32)| 否 |当前页码| +|size|query|integer(int32)| 否 |每页文章数| + +> 返回示例 + +> 200 Response + +```json +{ + "code": 200, + "message": "success", + "data": { + "data": [ + { + "id": 0, + "article_id": "string", + "title": "string", + "excerpt": "string", + "image": "string", + "date": "string", + "author": "string", + "author_avatar": "string", + "category": "string", + "tag": "string", + "alias": "string", + "password": true + } + ], + "total": 0, + "page": 0, + "size": 0, + "total_pages": 0 + } +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|分类文章列表|[ApiResponse](#schemaapiresponse)| + +## GET 获取某标签下的所有文章 + +GET /api/tags/{tag}/articles + +获取某标签下的所有文章 + +### 请求参数 + +|名称|位置|类型|必选|说明| +|---|---|---|---|---| +|tag|path|string| 是 |标签名称(中文需URL编码)| +|page|query|integer(int32)| 否 |当前页码| +|size|query|integer(int32)| 否 |每页文章数| + +> 返回示例 + +> 200 Response + +```json +{ + "code": 200, + "message": "success", + "data": { + "data": [ + { + "id": 0, + "article_id": "string", + "title": "string", + "excerpt": "string", + "image": "string", + "date": "string", + "author": "string", + "author_avatar": "string", + "category": "string", + "tag": "string", + "alias": "string", + "password": true + } + ], + "total": 0, + "page": 0, + "size": 0, + "total_pages": 0 + } +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|标签文章列表|[ApiResponse](#schemaapiresponse)| + +## GET 获取热门文章 + +GET /api/hot/articles + +返回热门文章列表 + +> 返回示例 + +> 200 Response + +```json +{ + "code": 200, + "message": "success", + "data": [ + { + "id": 0, + "article_id": "string", + "title": "string", + "excerpt": "string", + "date": "string", + "author": "string", + "category": "string", + "page_views": 0, + "alias": "string" + } + ] +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|热门文章列表|[ApiResponse](#schemaapiresponse)| + +### 返回数据结构 + +状态码 **200** + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|code|integer(int32)|false|none||状态码| +|message|string|false|none||响应信息| +|data|[[HotArticle](#schemahotarticle)]|false|none||热门文章列表| +|» id|integer(int32)|false|none||文章ID| +|» article_id|string|false|none||文章UUID| +|» title|string|false|none||文章标题| +|» excerpt|string|false|none||文章摘要| +|» date|string|false|none||发布日期| +|» author|string|false|none||作者| +|» category|string|false|none||文章分类| +|» page_views|integer(int32)|false|none||浏览次数| +|» alias|string|false|none||文章别名| + +## GET 检查文章是否需要密码 + +GET /api/article/{alias}/check-password + +检查文章是否需要密码 + +### 请求参数 + +|名称|位置|类型|必选|说明| +|---|---|---|---|---| +|alias|path|string| 是 |文章别名| + +> 返回示例 + +> 200 Response + +```json +{ + "code": 0, + "data": { + "needPassword": true + }, + "message": "string" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|密码检查结果|[CheckPasswordResponse](#schemacheckpasswordresponse)| + +## GET 获取文章内容 + +GET /api/article/{alias} + +获取文章详细内容 + +### 请求参数 + +|名称|位置|类型|必选|说明| +|---|---|---|---|---| +|alias|path|string| 是 |文章别名| +|password|query|string| 否 |文章密码(按需填写)| + +> 返回示例 + +> 200 Response + +```json +{ + "code": 0, + "data": { + "id": 0, + "uuid": "string", + "article_id": "string", + "article_name": "string", + "article_cover": "string", + "excerpt": "string", + "article_content": "string", + "page_views": 0, + "like_count": 0, + "favorite_count": 0, + "password": "string", + "tag": "string", + "category": "string", + "alias": "string", + "author": "string", + "avatar": "string", + "create_time": "string", + "status": 0, + "enable_comment": 0 + }, + "message": "string" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|文章内容|[ArticleDetailResponse](#schemaarticledetailresponse)| + +## GET 获取文章点赞状态 + +GET /api/article/{alias}/liked + +获取当前用户对文章的点赞状态 + +### 请求参数 + +|名称|位置|类型|必选|说明| +|---|---|---|---|---| +|alias|path|string| 是 |文章别名| + +> 返回示例 + +> 200 Response + +```json +{ + "code": 0, + "data": { + "liked": true + }, + "message": "string" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|点赞状态|[LikeStatusResponse](#schemalikestatusresponse)| + +## POST 点赞/取消点赞文章 + +POST /api/article/{alias}/like + +点赞或取消点赞文章 + +### 请求参数 + +|名称|位置|类型|必选|说明| +|---|---|---|---|---| +|alias|path|string| 是 |文章别名| + +> 返回示例 + +> 200 Response + +```json +{ + "code": 0, + "data": { + "success": true + }, + "message": "string" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|点赞操作结果|[LikeResponse](#schemalikeresponse)| + +## GET 获取文章下所有评论 + +GET /api/comments/article/{articleAlias} + +获取文章下所有评论(树形结构) + +### 请求参数 + +|名称|位置|类型|必选|说明| +|---|---|---|---|---| +|articleAlias|path|string| 是 |文章别名| + +> 返回示例 + +> 200 Response + +```json +{ + "code": 200, + "message": "success", + "data": [ + { + "comment_id": "string", + "user_id": "string", + "username": "string", + "avatar": "string", + "content": "string", + "create_time": "2019-08-24T14:15:22Z", + "comment_like_count": 0, + "to_comment_id": "string", + "to_comment_user_id": "string", + "to_comment_username": "string", + "article_id": "string", + "article_title": "string", + "status": 0, + "is_liked": true, + "children": [ + { + "comment_id": "string", + "user_id": "string", + "username": "string", + "avatar": "string", + "content": "string", + "create_time": "2019-08-24T14:15:22Z", + "comment_like_count": 0, + "to_comment_id": "string", + "to_comment_user_id": "string", + "to_comment_username": "string", + "article_id": "string", + "article_title": "string", + "status": 0, + "is_liked": true, + "children": [ + { + "comment_id": "string", + "user_id": "string", + "username": "string", + "avatar": "string", + "content": "string", + "create_time": "2019-08-24T14:15:22Z", + "comment_like_count": 0, + "to_comment_id": "string", + "to_comment_user_id": "string", + "to_comment_username": "string", + "article_id": "string", + "article_title": "string", + "status": 0, + "is_liked": true, + "children": [ + null + ] + } + ] + } + ] + } + ] +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|评论列表|[ApiResponse](#schemaapiresponse)| + +### 返回数据结构 + +状态码 **200** + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|code|integer(int32)|false|none||状态码| +|message|string|false|none||响应信息| +|data|[[Comment](#schemacomment)]|false|none||评论列表| +|» comment_id|string|false|none||评论唯一标识| +|» user_id|string|false|none||评论者UUID| +|» username|string|false|none||评论者用户名| +|» avatar|string¦null|false|none||评论者头像| +|» content|string|false|none||评论内容| +|» create_time|string(date-time)|false|none||评论时间| +|» comment_like_count|integer(int64)|false|none||点赞数| +|» to_comment_id|string¦null|false|none||回复的评论ID| +|» to_comment_user_id|string¦null|false|none||被回复者UUID| +|» to_comment_username|string¦null|false|none||被回复者用户名| +|» article_id|string¦null|false|none||关联文章ID| +|» article_title|string¦null|false|none||关联文章标题| +|» status|integer(int32)¦null|false|none||评论状态| +|» is_liked|boolean¦null|false|none||当前用户是否已点赞| +|» children|[[Comment](#schemacomment)]|false|none||子评论列表| +|»» comment_id|string|false|none||评论唯一标识| +|»» user_id|string|false|none||评论者UUID| +|»» username|string|false|none||评论者用户名| +|»» avatar|string¦null|false|none||评论者头像| +|»» content|string|false|none||评论内容| +|»» create_time|string(date-time)|false|none||评论时间| +|»» comment_like_count|integer(int64)|false|none||点赞数| +|»» to_comment_id|string¦null|false|none||回复的评论ID| +|»» to_comment_user_id|string¦null|false|none||被回复者UUID| +|»» to_comment_username|string¦null|false|none||被回复者用户名| +|»» article_id|string¦null|false|none||关联文章ID| +|»» article_title|string¦null|false|none||关联文章标题| +|»» status|integer(int32)¦null|false|none||评论状态| +|»» is_liked|boolean¦null|false|none||当前用户是否已点赞| +|»» children|[[Comment](#schemacomment)]|false|none||子评论列表| + +## POST 添加评论 + +POST /api/comments + +添加评论到文章 + +> Body 请求参数 + +```json +{ + "article_alias": "string", + "content": "string", + "to_comment_id": "string" +} +``` + +### 请求参数 + +|名称|位置|类型|必选|说明| +|---|---|---|---|---| +|body|body|[AddCommentRequest](#schemaaddcommentrequest)| 是 |none| + +> 返回示例 + +> 200 Response + +```json +{ + "code": 200, + "data": null, + "message": "评论成功" +} +``` + +> 若评论需要审核,返回示例 + +```json +{ + "code": 200, + "data": null, + "message": "您的评论已提交,将在审核通过后展示" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|评论成功/待审核后展示|[ApiResponse](#schemaapiresponse)| + +## POST 点赞评论 + +POST /api/comments/{commentId}/like + +点赞评论 + +### 请求参数 + +|名称|位置|类型|必选|说明| +|---|---|---|---|---| +|commentId|path|string| 是 |评论唯一标识| + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|点赞成功|None| + +## DELETE 删除评论 + +DELETE /api/comments/{commentId} + +删除评论 + +### 请求参数 + +|名称|位置|类型|必选|说明| +|---|---|---|---|---| +|commentId|path|string| 是 |评论唯一标识| + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|删除成功|None| + +## GET 获取前台导航菜单 + +GET /api/frontendgetmenus + +返回前台导航菜单 + +> 返回示例 + +> 200 Response + +```json +{ + "code": 200, + "message": "success", + "data": [ + { + "menus_id": "string", + "menus_name": "string", + "menus_url": "string", + "sort_order": 0, + "status": 0, + "user_id": "string", + "username": "string" + } + ] +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|导航菜单列表|[ApiResponse](#schemaapiresponse)| + +### 返回数据结构 + +状态码 **200** + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|code|integer(int32)|false|none||状态码| +|message|string|false|none||响应信息| +|data|[[Menu](#schemamenu)]|false|none||导航菜单列表| +|» menus_id|string|false|none||菜单ID| +|» menus_name|string|false|none||菜单名称| +|» menus_url|string|false|none||菜单链接| +|» sort_order|integer(int32)|false|none||排序值| +|» status|integer(int32)|false|none||状态(0禁用,1启用)| +|» user_id|string|false|none||创建者UUID| +|» username|string|false|none||创建者用户名| + +## GET 根据别名获取自定义页面 + +GET /api/custom-pages/{alias} + +根据别名获取自定义页面 + +### 请求参数 + +|名称|位置|类型|必选|说明| +|---|---|---|---|---| +|alias|path|string| 是 |页面别名| + +> 返回示例 + +> 200 Response + +```json +{ + "code": 0, + "data": { + "id": 0, + "pageName": "string", + "pageCode": "string", + "alias": "string", + "creatorUsername": "string", + "createTime": "2019-08-24T14:15:22Z", + "status": 0 + }, + "message": "string" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|自定义页面详情|[CustomPageResponse](#schemacustompageresponse)| + +## GET 分页获取已审核友链 + +GET /api/friend-links/approved/page + +分页获取已审核友链 + +### 请求参数 + +|名称|位置|类型|必选|说明| +|---|---|---|---|---| +|page|query|integer(int32)| 否 |页码| +|size|query|integer(int32)| 否 |每页数量| + +> 返回示例 + +> 200 Response + +```json +{ + "code": 0, + "data": { + "total": 0, + "page": 0, + "size": 0, + "total_pages": 0, + "data": [ + { + "id": 0, + "siteName": "string", + "siteUrl": "string", + "siteIcon": "string", + "siteDescription": "string", + "webmasterName": "string", + "contact": "string", + "createTime": "string", + "status": 0 + } + ] + }, + "message": "string" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|友链列表|[PaginatedFriendLinksResponse](#schemapaginatedfriendlinksresponse)| + +## GET 获取本站友链信息 + +GET /api/friend-links/site-info + +获取本站友链信息(用于申请页展示) + +> 返回示例 + +> 200 Response + +```json +{ + "code": 0, + "data": {}, + "message": "string" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|本站友链信息|[SiteInfoResponse](#schemasiteinforesponse)| + +## POST 申请友链 + +POST /api/friend-links/apply + +申请友链 + +> Body 请求参数 + +```json +{ + "siteName": "string", + "siteUrl": "string", + "siteIcon": "string", + "siteDescription": "string", + "webmasterName": "string", + "contact": "string" +} +``` + +### 请求参数 + +|名称|位置|类型|必选|说明| +|---|---|---|---|---| +|body|body|[FriendLinkApplyRequest](#schemafriendlinkapplyrequest)| 是 |none| + +> 返回示例 + +> 200 Response + +```json +{ + "code": 0, + "data": {}, + "message": "string" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|申请结果|[ApiResponse](#schemaapiresponse)| + +## GET 获取已启用的音乐列表 + +GET /api/music/enabled + +返回已启用的音乐列表 + +> 返回示例 + +> 200 Response + +```json +{ + "code": 0, + "data": [ + { + "id": 0, + "musicName": "string", + "musicAuthor": "string", + "musicUrl": "string", + "createTime": "2019-08-24T14:15:22Z", + "status": 0 + } + ], + "message": "string" +} +``` + +### 返回结果 + +|状态码|状态码含义|说明|数据模型| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|音乐列表|[MusicListResponse](#schemamusiclistresponse)| + +# 数据模型 + +<h2 id="tocS_Article">Article</h2> + +<a id="schemaarticle"></a> +<a id="schema_Article"></a> +<a id="tocSarticle"></a> +<a id="tocsarticle"></a> + +```json +{ + "id": 0, + "article_id": "string", + "title": "string", + "excerpt": "string", + "image": "string", + "date": "string", + "author": "string", + "author_avatar": "string", + "category": "string", + "tag": "string", + "alias": "string", + "password": true +} + +``` + +### 属性 + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|id|integer(int32)|false|none||数据项的唯一标识| +|article_id|string|false|none||文章的UUID| +|title|string|false|none||文章标题| +|excerpt|string|false|none||文章摘要| +|image|string|false|none||文章封面图链接| +|date|string|false|none||发布时间 yyyy-MM-dd HH:mm:ss| +|author|string|false|none||作者名| +|author_avatar|string|false|none||作者头像链接| +|category|string|false|none||文章所属分类| +|tag|string¦null|false|none||文章标签,多个以英文逗号分隔| +|alias|string|false|none||文章别名(URL片段)| +|password|boolean|false|none||是否为加密文章| + +<h2 id="tocS_PaginatedArticlesResponse">PaginatedArticlesResponse</h2> + +<a id="schemapaginatedarticlesresponse"></a> +<a id="schema_PaginatedArticlesResponse"></a> +<a id="tocSpaginatedarticlesresponse"></a> +<a id="tocspaginatedarticlesresponse"></a> + +```json +{ + "data": [ + { + "id": 0, + "article_id": "string", + "title": "string", + "excerpt": "string", + "image": "string", + "date": "string", + "author": "string", + "author_avatar": "string", + "category": "string", + "tag": "string", + "alias": "string", + "password": true + } + ], + "total": 0, + "page": 0, + "size": 0, + "total_pages": 0 +} + +``` + +### 属性 + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|data|[[Article](#schemaarticle)]|false|none||none| +|total|integer(int32)|false|none||符合条件的数据总数| +|page|integer(int32)|false|none||当前页码| +|size|integer(int32)|false|none||每页数量| +|total_pages|integer(int32)|false|none||总页数| + +<h2 id="tocS_UnauthorizedResponse">UnauthorizedResponse</h2> + +<a id="schemaunauthorizedresponse"></a> +<a id="schema_UnauthorizedResponse"></a> +<a id="tocSunauthorizedresponse"></a> +<a id="tocsunauthorizedresponse"></a> + +```json +{ + "success": true, + "message": "string" +} + +``` + +### 属性 + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|success|boolean|false|none||请求是否成功| +|message|string|false|none||错误消息| + +<h2 id="tocS_HeroConfig">HeroConfig</h2> + +<a id="schemaheroconfig"></a> +<a id="schema_HeroConfig"></a> +<a id="tocSheroconfig"></a> +<a id="tocsheroconfig"></a> + +```json +{ + "title": "string", + "subtitle": "string", + "image": "string" +} + +``` + +### 属性 + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|title|string|false|none||主标题| +|subtitle|string|false|none||副标题| +|image|string|false|none||背景图链接| + +<h2 id="tocS_EnableCommentResponse">EnableCommentResponse</h2> + +<a id="schemaenablecommentresponse"></a> +<a id="schema_EnableCommentResponse"></a> +<a id="tocSenablecommentresponse"></a> +<a id="tocsenablecommentresponse"></a> + +```json +{ + "code": 0, + "data": { + "enableComment": true + }, + "message": "string" +} + +``` + +### 属性 + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|code|integer(int32)|false|none||状态码| +|data|object|false|none||none| +|» enableComment|boolean|false|none||是否启用评论| +|message|string|false|none||提示信息| + +<h2 id="tocS_EnableRegisterResponse">EnableRegisterResponse</h2> + +<a id="schemaenableregisterresponse"></a> +<a id="schema_EnableRegisterResponse"></a> +<a id="tocSenableregisterresponse"></a> +<a id="tocsenableregisterresponse"></a> + +```json +{ + "code": 0, + "data": { + "enableRegister": true + }, + "message": "string" +} + +``` + +### 属性 + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|code|integer(int32)|false|none||状态码| +|data|object|false|none||none| +|» enableRegister|boolean|false|none||是否启用注册| +|message|string|false|none||提示信息| + +<h2 id="tocS_CustomCodeResponse">CustomCodeResponse</h2> + +<a id="schemacustomcoderesponse"></a> +<a id="schema_CustomCodeResponse"></a> +<a id="tocScustomcoderesponse"></a> +<a id="tocscustomcoderesponse"></a> + +```json +{ + "code": 0, + "data": { + "globalHeadCode": "string", + "contentHeadCode": "string", + "footerCode": "string" + }, + "message": "string" +} + +``` + +### 属性 + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|code|integer(int32)|false|none||状态码| +|data|object|false|none||none| +|» globalHeadCode|string|false|none||全局头部代码| +|» contentHeadCode|string|false|none||内容头部代码| +|» footerCode|string|false|none||页脚代码| +|message|string|false|none||提示信息| + +<h2 id="tocS_CaptchaResponse">CaptchaResponse</h2> + +<a id="schemacaptcharesponse"></a> +<a id="schema_CaptchaResponse"></a> +<a id="tocScaptcharesponse"></a> +<a id="tocscaptcharesponse"></a> + +```json +{ + "code": 0, + "data": { + "image": "string", + "key": "string" + }, + "message": "string" +} + +``` + +### 属性 + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|code|integer(int32)|false|none||状态码| +|data|object|false|none||none| +|» image|string|false|none||图像的Base64编码| +|» key|string|false|none||验证码唯一标识| +|message|string|false|none||提示信息| + +<h2 id="tocS_RegisterRequest">RegisterRequest</h2> + +<a id="schemaregisterrequest"></a> +<a id="schema_RegisterRequest"></a> +<a id="tocSregisterrequest"></a> +<a id="tocsregisterrequest"></a> + +```json +{ + "username": "string", + "email": "string", + "phone": "string", + "password": "string", + "captcha": "string", + "captchaKey": "string" +} + +``` + +### 属性 + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|username|string|true|none||用户名| +|email|string|false|none||电子邮件地址| +|phone|string|false|none||手机号| +|password|string|true|none||密码| +|captcha|string|true|none||验证码| +|captchaKey|string|true|none||验证码密钥| + +<h2 id="tocS_LoginRequest">LoginRequest</h2> + +<a id="schemaloginrequest"></a> +<a id="schema_LoginRequest"></a> +<a id="tocSloginrequest"></a> +<a id="tocsloginrequest"></a> + +```json +{ + "username": "string", + "password": "string", + "captcha": "string", + "captchaKey": "string" +} + +``` + +### 属性 + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|username|string|true|none||用户名| +|password|string|true|none||密码| +|captcha|string|true|none||验证码| +|captchaKey|string|true|none||验证码密钥| + +<h2 id="tocS_ApiResponse">ApiResponse</h2> + +<a id="schemaapiresponse"></a> +<a id="schema_ApiResponse"></a> +<a id="tocSapiresponse"></a> +<a id="tocsapiresponse"></a> + +```json +{ + "code": 0, + "data": {}, + "message": "string" +} + +``` + +### 属性 + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|code|integer(int32)|false|none||状态码| +|data|object|false|none||返回数据| +|message|string|false|none||提示信息| + +<h2 id="tocS_ForgotPasswordSendCaptchaRequest">ForgotPasswordSendCaptchaRequest</h2> + +<a id="schemaforgotpasswordsendcaptcharequest"></a> +<a id="schema_ForgotPasswordSendCaptchaRequest"></a> +<a id="tocSforgotpasswordsendcaptcharequest"></a> +<a id="tocsforgotpasswordsendcaptcharequest"></a> + +```json +{ + "username": "string", + "email": "string" +} + +``` + +### 属性 + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|username|string|true|none||用户名| +|email|string|true|none||注册邮箱| + +<h2 id="tocS_ForgotPasswordVerifyRequest">ForgotPasswordVerifyRequest</h2> + +<a id="schemaforgotpasswordverifyrequest"></a> +<a id="schema_ForgotPasswordVerifyRequest"></a> +<a id="tocSforgotpasswordverifyrequest"></a> +<a id="tocsforgotpasswordverifyrequest"></a> + +```json +{ + "username": "string", + "email": "string", + "captcha": "string" +} + +``` + +### 属性 + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|username|string|true|none||用户名| +|email|string|true|none||邮箱| +|captcha|string|true|none||邮箱收到的验证码| + +<h2 id="tocS_ForgotPasswordResetRequest">ForgotPasswordResetRequest</h2> + +<a id="schemaforgotpasswordresetrequest"></a> +<a id="schema_ForgotPasswordResetRequest"></a> +<a id="tocSforgotpasswordresetrequest"></a> +<a id="tocsforgotpasswordresetrequest"></a> + +```json +{ + "username": "string", + "email": "string", + "newPassword": "string", + "confirmNewPassword": "string" +} + +``` + +### 属性 + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|username|string|true|none||用户名| +|email|string|true|none||邮箱| +|newPassword|string|true|none||新密码| +|confirmNewPassword|string|true|none||确认新密码| + +<h2 id="tocS_UserStatusResponse">UserStatusResponse</h2> + +<a id="schemauserstatusresponse"></a> +<a id="schema_UserStatusResponse"></a> +<a id="tocSuserstatusresponse"></a> +<a id="tocsuserstatusresponse"></a> + +```json +{ + "code": 0, + "data": { + "loggedIn": true, + "username": "string", + "avatar": "string", + "message": "string" + }, + "message": "string" +} + +``` + +### 属性 + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|code|integer(int32)|false|none||状态码| +|data|object|false|none||none| +|» loggedIn|boolean|false|none||是否已登录| +|» username|string|false|none||用户名| +|» avatar|string|false|none||头像链接| +|» message|string|false|none||状态消息| +|message|string|false|none||提示信息| + +<h2 id="tocS_SearchResponse">SearchResponse</h2> + +<a id="schemasearchresponse"></a> +<a id="schema_SearchResponse"></a> +<a id="tocSsearchresponse"></a> +<a id="tocssearchresponse"></a> + +```json +{ + "code": 0, + "data": { + "count": 0, + "data": [ + { + "title": "string", + "alias": "string", + "id": 0, + "excerpt": "string" + } + ] + }, + "message": "string" +} + +``` + +### 属性 + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|code|integer(int32)|false|none||状态码| +|data|object|false|none||none| +|» count|integer(int32)|false|none||匹配文章总数| +|» data|[object]|false|none||none| +|»» title|string|false|none||文章标题| +|»» alias|string|false|none||文章别名| +|»» id|integer(int32)|false|none||文章ID| +|»» excerpt|string|false|none||文章摘要| +|message|string|false|none||提示信息| + +<h2 id="tocS_Tag">Tag</h2> + +<a id="schematag"></a> +<a id="schema_Tag"></a> +<a id="tocStag"></a> +<a id="tocstag"></a> + +```json +{ + "id": 0, + "tag_name": "string", + "tag_explain": "string", + "founder": "string", + "create_time": "2019-08-24T14:15:22Z", + "status": 0 +} + +``` + +### 属性 + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|id|integer(int32)|false|none||标签ID| +|tag_name|string|false|none||标签名称| +|tag_explain|string|false|none||标签说明| +|founder|string|false|none||创建者UUID| +|create_time|string(date-time)|false|none||创建时间| +|status|integer(int32)|false|none||状态(0禁用,1启用)| + +<h2 id="tocS_Category">Category</h2> + +<a id="schemacategory"></a> +<a id="schema_Category"></a> +<a id="tocScategory"></a> +<a id="tocscategory"></a> + +```json +{ + "id": 0, + "article_categories": "string", + "categories_explain": "string", + "founder": "string", + "create_time": "2019-08-24T14:15:22Z", + "status": 0, + "parent_id": 0, + "level": 0, + "full_path": "string", + "sort_order": 0, + "article_count": 0 +} + +``` + +### 属性 + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|id|integer(int32)|false|none||分类ID| +|article_categories|string|false|none||分类名称| +|categories_explain|string|false|none||分类说明| +|founder|string|false|none||创建者UUID| +|create_time|string(date-time)|false|none||创建时间| +|status|integer(int32)|false|none||状态(0禁用,1启用)| +|parent_id|integer(int32)¦null|false|none||父分类ID(顶级为null)| +|level|integer(int32)¦null|false|none||层级| +|full_path|string¦null|false|none||分类完整路径| +|sort_order|integer(int32)¦null|false|none||排序值| +|article_count|integer(int32)¦null|false|none||文章数| + +<h2 id="tocS_CategoryWithCount">CategoryWithCount</h2> + +<a id="schemacategorywithcount"></a> +<a id="schema_CategoryWithCount"></a> +<a id="tocScategorywithcount"></a> +<a id="tocscategorywithcount"></a> + +```json +{ + "id": 0, + "article_categories": "string", + "categories_explain": "string", + "founder": "string", + "create_time": "2019-08-24T14:15:22Z", + "status": 0, + "parent_id": 0, + "level": 0, + "full_path": "string", + "sort_order": 0, + "article_count": 0, + "articleCount": 0 +} + +``` + +### 属性 + +allOf + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|*anonymous*|[Category](#schemacategory)|false|none||none| + +and + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|*anonymous*|object|false|none||none| +|» articleCount|integer(int64)|false|none||该分类下文章数量| + +<h2 id="tocS_HotArticle">HotArticle</h2> + +<a id="schemahotarticle"></a> +<a id="schema_HotArticle"></a> +<a id="tocShotarticle"></a> +<a id="tocshotarticle"></a> + +```json +{ + "id": 0, + "article_id": "string", + "title": "string", + "excerpt": "string", + "date": "string", + "author": "string", + "category": "string", + "page_views": 0, + "alias": "string" +} + +``` + +### 属性 + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|id|integer(int32)|false|none||文章ID| +|article_id|string|false|none||文章UUID| +|title|string|false|none||文章标题| +|excerpt|string|false|none||文章摘要| +|date|string|false|none||发布日期| +|author|string|false|none||作者| +|category|string|false|none||文章分类| +|page_views|integer(int32)|false|none||浏览次数| +|alias|string|false|none||文章别名| + +<h2 id="tocS_CheckPasswordResponse">CheckPasswordResponse</h2> + +<a id="schemacheckpasswordresponse"></a> +<a id="schema_CheckPasswordResponse"></a> +<a id="tocScheckpasswordresponse"></a> +<a id="tocscheckpasswordresponse"></a> + +```json +{ + "code": 0, + "data": { + "needPassword": true + }, + "message": "string" +} + +``` + +### 属性 + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|code|integer(int32)|false|none||状态码| +|data|object|false|none||none| +|» needPassword|boolean|false|none||是否需要密码| +|message|string|false|none||提示信息| + +<h2 id="tocS_ArticleDetailResponse">ArticleDetailResponse</h2> + +<a id="schemaarticledetailresponse"></a> +<a id="schema_ArticleDetailResponse"></a> +<a id="tocSarticledetailresponse"></a> +<a id="tocsarticledetailresponse"></a> + +```json +{ + "code": 0, + "data": { + "id": 0, + "uuid": "string", + "article_id": "string", + "article_name": "string", + "article_cover": "string", + "excerpt": "string", + "article_content": "string", + "page_views": 0, + "like_count": 0, + "favorite_count": 0, + "password": "string", + "tag": "string", + "category": "string", + "alias": "string", + "author": "string", + "avatar": "string", + "create_time": "string", + "status": 0, + "enable_comment": 0 + }, + "message": "string" +} + +``` + +### 属性 + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|code|integer(int32)|false|none||状态码| +|data|object|false|none||none| +|» id|integer(int32)|false|none||文章ID| +|» uuid|string|false|none||作者UUID| +|» article_id|string|false|none||文章唯一标识| +|» article_name|string|false|none||文章标题| +|» article_cover|string|false|none||文章封面图| +|» excerpt|string|false|none||文章摘要| +|» article_content|string|false|none||文章正文(Markdown)| +|» page_views|integer(int64)|false|none||阅读次数| +|» like_count|integer(int64)|false|none||点赞次数| +|» favorite_count|integer(int64)|false|none||收藏次数| +|» password|string|false|none||密码占位(已脱敏为******| +|» tag|string|false|none||文章标签| +|» category|string|false|none||文章分类| +|» alias|string|false|none||文章别名| +|» author|string|false|none||作者名| +|» avatar|string|false|none||作者头像| +|» create_time|string|false|none||创建时间| +|» status|integer(int32)|false|none||文章状态| +|» enable_comment|integer(int32)|false|none||是否允许评论(0/1)| +|message|string|false|none||提示信息| + +<h2 id="tocS_LikeStatusResponse">LikeStatusResponse</h2> + +<a id="schemalikestatusresponse"></a> +<a id="schema_LikeStatusResponse"></a> +<a id="tocSlikestatusresponse"></a> +<a id="tocslikestatusresponse"></a> + +```json +{ + "code": 0, + "data": { + "liked": true + }, + "message": "string" +} + +``` + +### 属性 + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|code|integer(int32)|false|none||状态码| +|data|object|false|none||none| +|» liked|boolean|false|none||是否已点赞| +|message|string|false|none||提示信息| + +<h2 id="tocS_LikeResponse">LikeResponse</h2> + +<a id="schemalikeresponse"></a> +<a id="schema_LikeResponse"></a> +<a id="tocSlikeresponse"></a> +<a id="tocslikeresponse"></a> + +```json +{ + "code": 0, + "data": { + "success": true + }, + "message": "string" +} + +``` + +### 属性 + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|code|integer(int32)|false|none||状态码| +|data|object|false|none||none| +|» success|boolean|false|none||操作是否成功| +|message|string|false|none||提示信息| + +<h2 id="tocS_Comment">Comment</h2> + +<a id="schemacomment"></a> +<a id="schema_Comment"></a> +<a id="tocScomment"></a> +<a id="tocscomment"></a> + +```json +{ + "comment_id": "string", + "user_id": "string", + "username": "string", + "avatar": "string", + "content": "string", + "create_time": "2019-08-24T14:15:22Z", + "comment_like_count": 0, + "to_comment_id": "string", + "to_comment_user_id": "string", + "to_comment_username": "string", + "article_id": "string", + "article_title": "string", + "status": 0, + "is_liked": true, + "children": [ + { + "comment_id": "string", + "user_id": "string", + "username": "string", + "avatar": "string", + "content": "string", + "create_time": "2019-08-24T14:15:22Z", + "comment_like_count": 0, + "to_comment_id": "string", + "to_comment_user_id": "string", + "to_comment_username": "string", + "article_id": "string", + "article_title": "string", + "status": 0, + "is_liked": true, + "children": [ + { + "comment_id": "string", + "user_id": "string", + "username": "string", + "avatar": "string", + "content": "string", + "create_time": "2019-08-24T14:15:22Z", + "comment_like_count": 0, + "to_comment_id": "string", + "to_comment_user_id": "string", + "to_comment_username": "string", + "article_id": "string", + "article_title": "string", + "status": 0, + "is_liked": true, + "children": [ + {} + ] + } + ] + } + ] +} + +``` + +### 属性 + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|comment_id|string|false|none||评论唯一标识| +|user_id|string|false|none||评论者UUID| +|username|string|false|none||评论者用户名| +|avatar|string¦null|false|none||评论者头像| +|content|string|false|none||评论内容| +|create_time|string(date-time)|false|none||评论时间| +|comment_like_count|integer(int64)|false|none||点赞数| +|to_comment_id|string¦null|false|none||回复的评论ID| +|to_comment_user_id|string¦null|false|none||被回复者UUID| +|to_comment_username|string¦null|false|none||被回复者用户名| +|article_id|string¦null|false|none||关联文章ID| +|article_title|string¦null|false|none||关联文章标题| +|status|integer(int32)¦null|false|none||评论状态| +|is_liked|boolean¦null|false|none||当前用户是否已点赞| +|children|[[Comment](#schemacomment)]|false|none||子评论列表| + +<h2 id="tocS_AddCommentRequest">AddCommentRequest</h2> + +<a id="schemaaddcommentrequest"></a> +<a id="schema_AddCommentRequest"></a> +<a id="tocSaddcommentrequest"></a> +<a id="tocsaddcommentrequest"></a> + +```json +{ + "article_alias": "string", + "content": "string", + "to_comment_id": "string" +} + +``` + +### 属性 + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|article_alias|string|true|none||文章别名| +|content|string|true|none||评论内容| +|to_comment_id|string¦null|false|none||回复的评论ID| + +<h2 id="tocS_Menu">Menu</h2> + +<a id="schemamenu"></a> +<a id="schema_Menu"></a> +<a id="tocSmenu"></a> +<a id="tocsmenu"></a> + +```json +{ + "menus_id": "string", + "menus_name": "string", + "menus_url": "string", + "sort_order": 0, + "status": 0, + "user_id": "string", + "username": "string" +} + +``` + +### 属性 + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|menus_id|string|false|none||菜单ID| +|menus_name|string|false|none||菜单名称| +|menus_url|string|false|none||菜单链接| +|sort_order|integer(int32)|false|none||排序值| +|status|integer(int32)|false|none||状态(0禁用,1启用)| +|user_id|string|false|none||创建者UUID| +|username|string|false|none||创建者用户名| + +<h2 id="tocS_CustomPageResponse">CustomPageResponse</h2> + +<a id="schemacustompageresponse"></a> +<a id="schema_CustomPageResponse"></a> +<a id="tocScustompageresponse"></a> +<a id="tocscustompageresponse"></a> + +```json +{ + "code": 0, + "data": { + "id": 0, + "pageName": "string", + "pageCode": "string", + "alias": "string", + "creatorUsername": "string", + "createTime": "2019-08-24T14:15:22Z", + "status": 0 + }, + "message": "string" +} + +``` + +### 属性 + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|code|integer(int32)|false|none||状态码| +|data|object|false|none||none| +|» id|integer(int32)|false|none||页面ID| +|» pageName|string|false|none||页面名称| +|» pageCode|string|false|none||页面HTML代码| +|» alias|string|false|none||页面别名| +|» creatorUsername|string|false|none||创建者用户名| +|» createTime|string(date-time)|false|none||创建时间| +|» status|integer(int32)|false|none||状态(0禁用,1启用)| +|message|string|false|none||提示信息| + +<h2 id="tocS_PaginatedFriendLinksResponse">PaginatedFriendLinksResponse</h2> + +<a id="schemapaginatedfriendlinksresponse"></a> +<a id="schema_PaginatedFriendLinksResponse"></a> +<a id="tocSpaginatedfriendlinksresponse"></a> +<a id="tocspaginatedfriendlinksresponse"></a> + +```json +{ + "code": 0, + "data": { + "total": 0, + "page": 0, + "size": 0, + "total_pages": 0, + "data": [ + { + "id": 0, + "siteName": "string", + "siteUrl": "string", + "siteIcon": "string", + "siteDescription": "string", + "webmasterName": "string", + "contact": "string", + "createTime": "string", + "status": 0 + } + ] + }, + "message": "string" +} + +``` + +### 属性 + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|code|integer(int32)|false|none||状态码| +|data|object|false|none||none| +|» total|integer(int32)|false|none||友链总数| +|» page|integer(int32)|false|none||当前页码| +|» size|integer(int32)|false|none||每页数量| +|» total_pages|integer(int32)|false|none||总页数| +|» data|[object]|false|none||none| +|»» id|integer(int32)|false|none||友链ID| +|»» siteName|string|false|none||站点名称| +|»» siteUrl|string|false|none||站点地址| +|»» siteIcon|string|false|none||站点图标| +|»» siteDescription|string|false|none||站点描述| +|»» webmasterName|string|false|none||站长名称| +|»» contact|string|false|none||联系方式| +|»» createTime|string|false|none||创建时间| +|»» status|integer(int32)|false|none||状态| +|message|string|false|none||提示信息| + +<h2 id="tocS_SiteInfoResponse">SiteInfoResponse</h2> + +<a id="schemasiteinforesponse"></a> +<a id="schema_SiteInfoResponse"></a> +<a id="tocSsiteinforesponse"></a> +<a id="tocssiteinforesponse"></a> + +```json +{ + "code": 0, + "data": {}, + "message": "string" +} + +``` + +### 属性 + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|code|integer(int32)|false|none||状态码| +|data|object¦null|false|none||站点信息| +|message|string|false|none||提示信息| + +<h2 id="tocS_FriendLinkApplyRequest">FriendLinkApplyRequest</h2> + +<a id="schemafriendlinkapplyrequest"></a> +<a id="schema_FriendLinkApplyRequest"></a> +<a id="tocSfriendlinkapplyrequest"></a> +<a id="tocsfriendlinkapplyrequest"></a> + +```json +{ + "siteName": "string", + "siteUrl": "string", + "siteIcon": "string", + "siteDescription": "string", + "webmasterName": "string", + "contact": "string" +} + +``` + +### 属性 + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|siteName|string|true|none||站点名称| +|siteUrl|string|true|none||站点地址| +|siteIcon|string|false|none||站点图标| +|siteDescription|string|true|none||站点描述| +|webmasterName|string|true|none||站长名称| +|contact|string|true|none||联系方式| + +<h2 id="tocS_MusicListResponse">MusicListResponse</h2> + +<a id="schemamusiclistresponse"></a> +<a id="schema_MusicListResponse"></a> +<a id="tocSmusiclistresponse"></a> +<a id="tocsmusiclistresponse"></a> + +```json +{ + "code": 0, + "data": [ + { + "id": 0, + "musicName": "string", + "musicAuthor": "string", + "musicUrl": "string", + "createTime": "2019-08-24T14:15:22Z", + "status": 0 + } + ], + "message": "string" +} + +``` + +### 属性 + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|code|integer(int32)|false|none||状态码| +|data|[object]|false|none||none| +|» id|integer(int32)|false|none||音乐ID| +|» musicName|string|false|none||曲名| +|» musicAuthor|string|false|none||作者| +|» musicUrl|string|false|none||音频地址| +|» createTime|string(date-time)|false|none||创建时间| +|» status|integer(int32)|false|none||状态| +|message|string|false|none||提示信息| + +<a id="schema_Announcement"></a> +<a id="tocSannouncement"></a> +<a id="tocsannouncement"></a> + +```json +{ + "code": 200, + "data": { + "id": 1, + "content": "string", + "create_time": "2025-01-01T00:00:00", + "update_time": "2025-01-15T12:00:00" + }, + "message": "success" +} +``` + +### 属性 + +|名称|类型|必选|约束|中文名|说明| +|---|---|---|---|---|---| +|code|integer(int32)|false|none||状态码| +|data|object|false|none||公告数据,无公告时为 null| +|» id|integer(int32)|false|none||公告ID| +|» content|string|false|none||公告内容(HTML)| +|» create_time|string(date-time)|false|none||创建时间| +|» update_time|string(date-time)|false|none||更新时间| +|message|string|false|none||提示信息| + diff --git a/demo/dim_stack/frontend/.gitignore b/demo/dim_stack/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/demo/dim_stack/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/demo/dim_stack/frontend/eslint.config.js b/demo/dim_stack/frontend/eslint.config.js new file mode 100644 index 0000000..d3210f0 --- /dev/null +++ b/demo/dim_stack/frontend/eslint.config.js @@ -0,0 +1,40 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{js,jsx}'], + extends: [ + js.configs.recommended, + reactHooks.configs['recommended-latest'], + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: { + ...globals.browser, + ...globals.node, + }, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + rules: { + 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + }, + }, + { + files: ['*.config.js', '*.config.mjs', 'tailwind.config.js'], + languageOptions: { + globals: { + ...globals.node, + }, + }, + } +]) diff --git a/demo/dim_stack/frontend/index.html b/demo/dim_stack/frontend/index.html new file mode 100644 index 0000000..a5e3070 --- /dev/null +++ b/demo/dim_stack/frontend/index.html @@ -0,0 +1,25 @@ +<!doctype html> +<html lang="cn"> + <head> + <meta charset="UTF-8" /> + <link rel="icon" type="image/svg+xml" href="/vite.svg" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title> + + + + +
+ + + diff --git a/demo/dim_stack/frontend/package.json b/demo/dim_stack/frontend/package.json new file mode 100644 index 0000000..6650e70 --- /dev/null +++ b/demo/dim_stack/frontend/package.json @@ -0,0 +1,59 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "predev": "npm run build:vendor", + "build": "vite build", + "prebuild": "npm run build:vendor", + "build:vendor": "vite build --config vite.vendor.config.js --mode react && vite build --config vite.vendor.config.js --mode react-dom && vite build --config vite.vendor.config.js --mode react-dom-client && vite build --config vite.vendor.config.js --mode react-jsx-runtime && vite build --config vite.vendor.config.js --mode react-router-dom", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@tailwindcss/postcss": "^4.3.3", + "axios": "1.20.0", + "docx-preview": "^0.3.7", + "dompurify": "3.4.16", + "framer-motion": "^12.23.12", + "lucide-react": "^0.542.0", + "music-metadata-browser": "^2.5.11", + "pdfjs-dist": "5.4.296", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-favicon": "^2.0.7", + "react-markdown": "^10.1.0", + "react-pdf": "^10.4.1", + "react-router-dom": "^7.8.2", + "react-syntax-highlighter": "^16.1.1", + "rehype-highlight": "^7.0.2", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "rehype-stringify": "^10.0.1", + "remark-breaks": "^4.0.0", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "unified": "^11.0.5", + "unist-util-visit": "^5.1.0", + "vite-plugin-node-polyfills": "^0.28.0" + }, + "devDependencies": { + "@eslint/js": "^9.33.0", + "@tailwindcss/vite": "^4.3.3", + "@types/react": "^19.1.10", + "@types/react-dom": "^19.1.7", + "@vitejs/plugin-react": "^6.1.2", + "autoprefixer": "^10.4.21", + "esbuild": "^0.28.2", + "eslint": "^9.33.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.3.0", + "postcss": "8.5.23", + "tailwindcss": "^4.3.3", + "vite": "8.0.16" + } +} diff --git a/demo/dim_stack/frontend/public/image_error.svg b/demo/dim_stack/frontend/public/image_error.svg new file mode 100644 index 0000000..06229bb --- /dev/null +++ b/demo/dim_stack/frontend/public/image_error.svg @@ -0,0 +1,6 @@ + + + + + 图片加载失败 + \ No newline at end of file diff --git a/demo/dim_stack/frontend/scripts/vendor-entries/react-dom-client.js b/demo/dim_stack/frontend/scripts/vendor-entries/react-dom-client.js new file mode 100644 index 0000000..1248d23 --- /dev/null +++ b/demo/dim_stack/frontend/scripts/vendor-entries/react-dom-client.js @@ -0,0 +1,6 @@ + +import * as ReactDOMClient from 'react-dom/client' +export default ReactDOMClient +export const createRoot = ReactDOMClient.createRoot +export const hydrateRoot = ReactDOMClient.hydrateRoot +export const version = ReactDOMClient.version diff --git a/demo/dim_stack/frontend/scripts/vendor-entries/react-dom.js b/demo/dim_stack/frontend/scripts/vendor-entries/react-dom.js new file mode 100644 index 0000000..14b0997 --- /dev/null +++ b/demo/dim_stack/frontend/scripts/vendor-entries/react-dom.js @@ -0,0 +1,14 @@ + +import * as ReactDOM from 'react-dom' +export default ReactDOM +export const __DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE = ReactDOM.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE +export const createPortal = ReactDOM.createPortal +export const flushSync = ReactDOM.flushSync +export const findDOMNode = ReactDOM.findDOMNode +export const hydrate = ReactDOM.hydrate +export const render = ReactDOM.render +export const unmountComponentAtNode = ReactDOM.unmountComponentAtNode +export const version = ReactDOM.version +export const preconnect = ReactDOM.preconnect +export const preload = ReactDOM.preload +export const preinit = ReactDOM.preinit diff --git a/demo/dim_stack/frontend/scripts/vendor-entries/react-jsx-runtime.js b/demo/dim_stack/frontend/scripts/vendor-entries/react-jsx-runtime.js new file mode 100644 index 0000000..b0d3cb1 --- /dev/null +++ b/demo/dim_stack/frontend/scripts/vendor-entries/react-jsx-runtime.js @@ -0,0 +1,6 @@ + +import * as jsxRuntime from 'react/jsx-runtime' +export default jsxRuntime +export const jsx = jsxRuntime.jsx +export const jsxs = jsxRuntime.jsxs +export const Fragment = jsxRuntime.Fragment diff --git a/demo/dim_stack/frontend/scripts/vendor-entries/react-router-dom.js b/demo/dim_stack/frontend/scripts/vendor-entries/react-router-dom.js new file mode 100644 index 0000000..17a8e07 --- /dev/null +++ b/demo/dim_stack/frontend/scripts/vendor-entries/react-router-dom.js @@ -0,0 +1,4 @@ + +import * as ReactRouterDOM from 'react-router-dom' +export default ReactRouterDOM +export * from 'react-router-dom' diff --git a/demo/dim_stack/frontend/scripts/vendor-entries/react.js b/demo/dim_stack/frontend/scripts/vendor-entries/react.js new file mode 100644 index 0000000..e5f9cb7 --- /dev/null +++ b/demo/dim_stack/frontend/scripts/vendor-entries/react.js @@ -0,0 +1,42 @@ +import * as React from 'react' +export default React +export const __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE = React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE +export const Activity = React.Activity +export const Children = React.Children +export const Component = React.Component +export const Fragment = React.Fragment +export const Profiler = React.Profiler +export const PureComponent = React.PureComponent +export const StrictMode = React.StrictMode +export const Suspense = React.Suspense +export const act = React.act +export const cache = React.cache +export const cloneElement = React.cloneElement +export const createContext = React.createContext +export const createElement = React.createElement +export const createFactory = React.createFactory +export const createRef = React.createRef +export const forwardRef = React.forwardRef +export const isValidElement = React.isValidElement +export const lazy = React.lazy +export const memo = React.memo +export const startTransition = React.startTransition +export const use = React.use +export const useActionState = React.useActionState +export const useCallback = React.useCallback +export const useContext = React.useContext +export const useDebugValue = React.useDebugValue +export const useDeferredValue = React.useDeferredValue +export const useEffect = React.useEffect +export const useId = React.useId +export const useImperativeHandle = React.useImperativeHandle +export const useInsertionEffect = React.useInsertionEffect +export const useLayoutEffect = React.useLayoutEffect +export const useMemo = React.useMemo +export const useOptimistic = React.useOptimistic +export const useReducer = React.useReducer +export const useRef = React.useRef +export const useState = React.useState +export const useSyncExternalStore = React.useSyncExternalStore +export const useTransition = React.useTransition +export const version = React.version diff --git a/demo/dim_stack/frontend/src/Api.jsx b/demo/dim_stack/frontend/src/Api.jsx new file mode 100644 index 0000000..0eb89c9 --- /dev/null +++ b/demo/dim_stack/frontend/src/Api.jsx @@ -0,0 +1,92 @@ +import apiClient from './utils/axios'; + +// 获取文章列表 +export const fetchArticles = async (page = 1, size = 10, category = null, signal = null) => { + try { + const params = { page, size }; + if (category) { + params.category = category; + } + + const config = { params }; + if (signal) { + config.signal = signal; + } + + const response = await apiClient.get('/articles', config); + return response; + } catch (error) { + console.error('获取文章列表失败:', error); + throw error; + } +}; + +export const fetchArticlesByCategory = async (category, page, pageSize) => { + try { + const response = await apiClient.get(`/categories/${encodeURIComponent(category)}/articles?page=${page}&size=${pageSize}`); + return response; + } catch (error) { + console.error('获取分类文章失败:', error); + throw error; + } +}; + +export const fetchStatistics = async () => { + try { + const [articleCount, userCount, commentCount, browseCount] = await Promise.all([ + apiClient.get('/statistics/articlecount'), + apiClient.get('/statistics/usercount'), + apiClient.get('/statistics/commentcount'), + apiClient.get('/statistics/browsecount') + ]); + + return { + stats: [ + { label: '文章', value: articleCount.data, icon: 'article' }, + { label: '用户', value: userCount.data, icon: 'user' }, + { label: '评论', value: commentCount.data, icon: 'comment' }, + { label: '浏览量', value: browseCount.data, icon: 'view' } + ] + }; + } catch (error) { + console.error('获取统计信息失败:', error); + throw error; + } +}; + + +export const fetchSiteName = async () => { + try { + const response = await apiClient.get('/site/name'); + return response; + } catch (error) { + console.error('获取站点名称失败:', error); + return null; + } +}; + + +export const fetchDashboardData = async () => { + try { + const response = await apiClient.get('/dashboard/menus'); + if (response.code === 200) { + return response.data; + } else { + throw new Error(response.message || '获取仪表盘数据失败'); + } + } catch (error) { + console.error('获取仪表盘数据失败:', error); + throw error; + } +} + + +export const fetchSiteIcon = async () => { + try { + const response = await apiClient.get('/site/icon'); + return response; + } catch (error) { + console.error('获取站点图标失败:', error); + return null; + } +}; \ No newline at end of file diff --git a/demo/dim_stack/frontend/src/App.jsx b/demo/dim_stack/frontend/src/App.jsx new file mode 100644 index 0000000..3fcdf6f --- /dev/null +++ b/demo/dim_stack/frontend/src/App.jsx @@ -0,0 +1,112 @@ +import { Routes, Route, useLocation } from 'react-router-dom' +import { useEffect, useState, lazy, Suspense } from 'react' +import Favicon from 'react-favicon' +import { fetchSiteName, fetchSiteIcon } from './Api.jsx' +import { getConfig } from './utils/config.jsx' +import MusicPlayer from './components/MusicPlayer'; +import AnnouncementModal from './components/AnnouncementModal'; +import { getPluginRoutes, subscribePluginRoutes } from './plugin/pluginRuntime.js'; + +const importArticleReader = () => import('./components/ArticleReader') + +const Home = lazy(() => import('./pages/Home')) +const Login = lazy(() => import('./components/Login')) +const Register = lazy(() => import('./components/Register')) +const Dashboard = lazy(() => import('./components/dashboard/Dashboard.jsx')) +const ArticleReader = lazy(importArticleReader) +const FriendLinks = lazy(() => import('./components/FriendLinks.jsx')) +const PageNotFound = lazy(() => import('./components/PageNotFound.jsx')) +const ForgotPassword = lazy(() => import('./components/ForgotPassword.jsx')) +const CustomHtmlPage = lazy(() => import('./components/CustomHtmlPage.jsx')) + +function App() { + const [faviconUrl, setFaviconUrl] = useState('/favicon.ico') + + const [pluginRoutes, setPluginRoutes] = useState(() => getPluginRoutes()) + const location = useLocation() + + useEffect(() => subscribePluginRoutes(setPluginRoutes), []) + + const isDashboardRoute = location.pathname.startsWith('/dashboard') + + useEffect(() => { + const setDocumentTitle = async () => { + try { + const siteName = await fetchSiteName(); + console.log('获取站点名称:', siteName); + document.title = siteName || ''; + } catch (error) { + console.error('设置站点标题失败:', error); + document.title = ''; + } + }; + + setDocumentTitle(); + }, []); + + useEffect(() => { + const setSiteIcon = async () => { + try { + const iconUrl = await fetchSiteIcon(); + console.log('获取站点图标URL:', iconUrl); + + if (iconUrl) { + const config = getConfig(); + const fullIconUrl = config.getFullUrl(iconUrl.trim()); + + console.log('处理后的图标URL:', fullIconUrl); + setFaviconUrl(fullIconUrl); + } else { + console.log('未获取到站点图标,使用默认图标'); + } + } catch (error) { + console.error('设置站点图标失败:', error); + } + }; + + setSiteIcon(); + }, []); + + useEffect(() => { + if (typeof window === 'undefined') return; + if (window.requestIdleCallback) { + const id = window.requestIdleCallback(() => importArticleReader(), { timeout: 2000 }); + return () => window.cancelIdleCallback?.(id); + } + const t = setTimeout(() => importArticleReader(), 1200); + return () => clearTimeout(t); + }, []); + + console.log('当前faviconUrl状态:', faviconUrl); + + return ( + <> + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + }> + } /> + {pluginRoutes + .filter((route) => route.path !== '/dashboard' && !route.path.startsWith('/dashboard/')) + .map((route, index) => ( + + ))} + } /> + + + {!isDashboardRoute && } + {!isDashboardRoute && } + + ) +} + +export default App \ No newline at end of file diff --git a/demo/dim_stack/frontend/src/components/AnnouncementModal.jsx b/demo/dim_stack/frontend/src/components/AnnouncementModal.jsx new file mode 100644 index 0000000..38a9d5b --- /dev/null +++ b/demo/dim_stack/frontend/src/components/AnnouncementModal.jsx @@ -0,0 +1,81 @@ +import { useState, useEffect } from 'react'; +import apiClient from '../utils/axios.jsx'; +import DOMPurify from 'dompurify'; + +const ANNOUNCEMENT_KEY = 'announcement_dismissed_at'; + +export default function AnnouncementModal() { + const [visible, setVisible] = useState(false); + const [content, setContent] = useState(''); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const dismissedAt = localStorage.getItem(ANNOUNCEMENT_KEY); + + apiClient.get('/announcement', { silent: true }) + .then((response) => { + const data = response.data || response; + if (data && data.content && data.update_time) { + if (dismissedAt !== data.update_time) { + setContent(data.content); + setVisible(true); + } + } + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + const handleClose = () => { + setVisible(false); + apiClient.get('/announcement', { silent: true }) + .then((response) => { + const data = response.data || response; + if (data && data.update_time) { + localStorage.setItem(ANNOUNCEMENT_KEY, data.update_time); + } + }) + .catch(() => {}); + }; + + if (loading || !visible || !content) return null; + + const renderContent = (html) => { + html = html.replace(/\n/g, '
'); + return DOMPurify.sanitize(html, { + ALLOWED_TAGS: ['img', 'br'], + ALLOWED_ATTR: ['src', 'alt'] + }); + }; + + return ( + <> +
+
+
e.stopPropagation()}> +
+

+ 公告 +

+ +
+
+ +
+
+
+
+ + ); +} diff --git a/demo/dim_stack/frontend/src/components/ArticleCard.jsx b/demo/dim_stack/frontend/src/components/ArticleCard.jsx new file mode 100644 index 0000000..eebedf6 --- /dev/null +++ b/demo/dim_stack/frontend/src/components/ArticleCard.jsx @@ -0,0 +1,187 @@ +import { getConfig } from '../utils/config'; +import { Link, useNavigate } from 'react-router-dom'; +import { getCategoryIcon, getTagIcon } from '../utils/IconUtils'; + +const safeTruncate = (text, maxLength) => { + if (!text) return ''; + return text.length > maxLength ? text.slice(0, maxLength) + '...' : text; +}; + +const renderTags = (tagsString, maxTags = 3, onTagClick) => { + if (!tagsString) return null; + const tags = tagsString.split(',').map(t => t.trim()).filter(Boolean); + const displayTags = tags.slice(0, maxTags); + + return displayTags.map((tag, index) => ( + + )); +}; + +export default function ArticleCard({ + article, + showImage = true, + forceMobile = false, + onTagClick, + onCategoryClick + }) { + const navigate = useNavigate(); + + const safeArticle = { + ...article, + title: article.title || '', + excerpt: article.excerpt || '', + author: article.author || '', + category: article.category || '', + alias: article.alias || '', + tag: article.tag || '', + author_avatar: article.author_avatar || '' + }; + + const getFullImageUrl = (url) => { + if (!url) return null; + const config = getConfig(); + return config.getFullUrl(url); + }; + + const imageUrl = getFullImageUrl(article.image) || '/image_error.svg'; + + const formatDate = (d) => { + if (!d) return ''; + const date = new Date(d); + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + }; + + const handleCardClick = () => navigate(`/article/${safeArticle.alias}`, { state: { needPassword: safeArticle.password } }); + + const handleTagClick = (tag) => { + onTagClick ? onTagClick(tag) : navigate(`/tag/${encodeURIComponent(tag)}`); + }; + + const handleCategoryClick = (e) => { + e.preventDefault(); + e.stopPropagation(); + onCategoryClick + ? onCategoryClick(safeArticle.category) + : navigate(`/category/${encodeURIComponent(safeArticle.category)}`); + }; + + if (forceMobile) { + return ( + <> + +
+
+ {safeArticle.title} +
+ +
+

+ {safeArticle.title} +

+ +
+

+ {safeArticle.excerpt} +

+
+ +
+ {safeArticle.category && ( + + )} + {renderTags(safeArticle.tag, 2, handleTagClick)} +
+ +
+ {safeArticle.author} + {safeArticle.author} + · + {formatDate(safeArticle.date)} +
+
+
+ + ); + } + + return ( +
+ {showImage && ( +
+ {safeArticle.title} +
+ )} + +
+

+ e.stopPropagation()}> + {safeTruncate(safeArticle.title, 30)} + +

+ +
+

+ {safeArticle.excerpt} +

+
+ +
+ {safeArticle.category && ( + + )} + {renderTags(safeArticle.tag, 3, handleTagClick)} +
+ +
+ + {safeArticle.author} + {safeArticle.author} + · + {formatDate(safeArticle.date)} + +
+
+
+ ); +} \ No newline at end of file diff --git a/demo/dim_stack/frontend/src/components/ArticleList.jsx b/demo/dim_stack/frontend/src/components/ArticleList.jsx new file mode 100644 index 0000000..db41ef6 --- /dev/null +++ b/demo/dim_stack/frontend/src/components/ArticleList.jsx @@ -0,0 +1,237 @@ +import React, { useEffect, useState, useRef } from 'react'; +import { useNavigate, useLocation, useSearchParams, useParams } from 'react-router-dom'; +import ArticleCard from '../components/ArticleCard'; + +import { fetchArticles } from '../Api.jsx'; +import apiClient from '../utils/axios'; + +export default function ArticleList() { + const [searchParams, setSearchParams] = useSearchParams(); + const { tagName } = useParams(); + const categoryName = searchParams.get('category'); + const pageParam = parseInt(searchParams.get('page')) || 1; + const navigate = useNavigate(); + const location = useLocation(); + + const [articles, setArticles] = useState([]); + const [loading, setLoading] = useState(true); + const [totalPages, setTotalPages] = useState(0); + const [showImages, setShowImages] = useState(true); + const [forceMobile, setForceMobile] = useState(() => { + if (typeof window !== 'undefined') return window.innerWidth < 768; + return false; + }); + + const gridRef = useRef(null); + const [holdHeight, setHoldHeight] = useState(null); + const abortControllerRef = useRef(null); + + useEffect(() => { + const checkMobile = () => setForceMobile(window.innerWidth < 768); + window.addEventListener('resize', checkMobile); + return () => window.removeEventListener('resize', checkMobile); + }, []); + + useEffect(() => { + const savedShowImages = localStorage.getItem('showImages'); + if (savedShowImages !== null) { + setShowImages(JSON.parse(savedShowImages)); + } + }, []); + + useEffect(() => { + loadArticlesFromUrl(); + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + }, [location.search, location.pathname, pageParam, tagName]); + + useEffect(() => { + localStorage.setItem('showImages', JSON.stringify(showImages)); + }, [showImages]); + + const loadArticlesFromUrl = async () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + try { + if (gridRef.current) { + setHoldHeight(gridRef.current.offsetHeight); + } + setLoading(true); + let result; + + if (categoryName) { + result = await apiClient.get(`/categories/articles?category=${encodeURIComponent(categoryName)}&page=${pageParam}&size=6`, { signal: abortController.signal }); + } else if (tagName) { + result = await apiClient.get(`/tags/${tagName}/articles?page=${pageParam}&size=6`, { signal: abortController.signal }); + } else { + result = await fetchArticles(pageParam, 6, null, abortController.signal); + } + + if (!abortController.signal.aborted) { + setArticles(Array.isArray(result.data?.data) ? result.data.data : []); + setTotalPages(result.data?.total_pages || 1); + } + } catch (error) { + if (error.name === 'AbortError' || error.code === 'ERR_CANCELED') { + return; + } + console.error('加载文章失败:', error); + if (!abortController.signal.aborted) { + setArticles([]); + setTotalPages(1); + } + } finally { + if (!abortController.signal.aborted) { + setLoading(false); + } + } + }; + + const handleCategoryChange = (category) => { + const newParams = new URLSearchParams(searchParams); + newParams.set('category', category); + newParams.delete('page'); + setSearchParams(newParams); + }; + + const handleTagChange = (tag) => { + navigate(`/tag/${tag}`); + }; + + const handleClearFilter = () => { + if (tagName) { + navigate('/'); + } else { + setSearchParams({}); + } + }; + + const toggleImageDisplay = () => { + setShowImages(!showImages); + }; + + const getCurrentFilterTitle = () => { + if (categoryName) return `${categoryName} 分类文章`; + if (tagName) return `${tagName} 标签文章`; + return '最新文章'; + }; + + return ( +
+
+

+ {getCurrentFilterTitle()} +

+ +
+
+ + {showImages ? '显示图片' : '隐藏图片'} + + +
+ +
+
+
+
+ + {(categoryName || tagName) && ( + + )} +
+
+ + {loading ? ( +
+ 加载中... +
+ ) : articles.length > 0 ? ( +
+ {articles.map(article => ( +
+ +
+ ))} +
+ ) : ( +
+ 暂无文章 +
+ )} + +
+ + + {pageParam} / {totalPages} + + +
+
+ ); +} \ No newline at end of file diff --git a/demo/dim_stack/frontend/src/components/ArticlePreview.jsx b/demo/dim_stack/frontend/src/components/ArticlePreview.jsx new file mode 100644 index 0000000..caba8a5 --- /dev/null +++ b/demo/dim_stack/frontend/src/components/ArticlePreview.jsx @@ -0,0 +1,959 @@ +import React, { useState, useEffect, useMemo, useRef } from 'react'; +import { Music, FileText, Copy, Check, Heart, Eye, Archive } from 'lucide-react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import rehypeRaw from 'rehype-raw'; +import rehypeSanitize from 'rehype-sanitize'; +import { PrismAsync as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import { getConfig } from '../utils/config'; +import ImageLightbox from './ImageLightbox'; +import DocumentPreviewModal from './dashboard/DocumentPreviewModal'; +import { useNavigate } from 'react-router-dom'; +import { getCategoryIcon, getTagIcon } from '../utils/IconUtils'; +import apiClient from '../utils/axios'; + +const rehypeSanitizeSchema = { + tagNames: [ + 'div','span','p','br','strong','em','u','s','del','ins', + 'h1','h2','h3','h4','h5','h6', + 'ul','ol','li','dl','dt','dd', + 'blockquote','pre','code', + 'table','thead','tbody','tr','th','td', + 'a','img','video','audio','source','track', + 'hr','sup','sub', + 'archive','document' + ], + attributes: { + '*': ['className', 'id', 'data*', 'style'], + 'a': ['href', 'title', 'target', 'rel'], + 'img': ['src', 'alt', 'title', 'width', 'height'], + 'video': ['src', 'controls', 'autoplay', 'loop', 'muted', 'poster', 'width', 'height', 'preload'], + 'audio': ['src', 'controls', 'autoplay', 'loop', 'muted', 'preload', 'data*'], + 'source': ['src', 'type', 'media'], + 'track': ['src', 'kind', 'srclang', 'label', 'default'], + 'archive': ['src', 'data*'], + 'document': ['src', 'data*'], + 'span': ['data-toc-id', 'style', 'className'] + }, + protocols: { + 'href': ['http', 'https', 'mailto'], + 'src': ['http', 'https'] + }, + clobberPrefix: 'user-content-', + clobber: ['name', 'id'] +}; + +const isSafeUrl = (url) => { + if (!url) return false; + return url.startsWith('http://') || url.startsWith('https://') || url.startsWith('/'); +}; + +const sanitizeHtmlContent = (content) => { + if (!content) return ''; + + let processedContent = content; + const dangerousBlocks = []; + + const dangerousPatterns = [ + { pattern: /]*>/gi, label: 'Meta标签' }, + { pattern: /]*>[\s\S]*?<\/script>/gi, label: 'JavaScript代码' }, + { pattern: /]*>[\s\S]*?<\/iframe>/gi, label: 'IFrame嵌入' }, + { pattern: /<(object|embed)[^>]*>[\s\S]*?<\/\1>/gi, label: '嵌入对象' }, + { pattern: /]*>/gi, label: 'Link标签' }, + { pattern: /]*>[\s\S]*?<\/style>/gi, label: 'CSS样式' }, + { pattern: /<\/?form[^>]*>/gi, label: '表单标签' }, + { pattern: /<(input|button|textarea|select|option)[^>]*>/gi, label: '表单元素' }, + { pattern: /]*>/gi, label: 'Base标签' } + ]; + + dangerousPatterns.forEach(({ pattern, label }) => { + const matches = []; + let match; + pattern.lastIndex = 0; + + while ((match = pattern.exec(processedContent)) !== null) { + matches.push({ + code: match[0], + index: match.index, + length: match[0].length + }); + } + + matches.reverse().forEach(({ code, index, length }) => { + const placeholder = `__DANGEROUS_BLOCK_${dangerousBlocks.length}__`; + dangerousBlocks.push({ + code: code, + label: label, + placeholder: placeholder + }); + processedContent = processedContent.substring(0, index) + + placeholder + + processedContent.substring(index + length); + }); + }); + + processedContent = processedContent + .replace(/javascript:/gi, 'about:blank#') + .replace(/vbscript:/gi, 'about:blank#') + .replace(/data:(?!image\/(png|jpg|jpeg|gif|svg\+xml|webp))[^"']*/gi, 'about:blank#') + .replace(/\s+(on\w+\s*=\s*["'][^"']*["'])/gi, (match, eventHandler) => { + const placeholder = `__DANGEROUS_BLOCK_${dangerousBlocks.length}__`; + dangerousBlocks.push({ + code: eventHandler.trim(), + label: '事件处理器', + placeholder: placeholder + }); + return ` ${placeholder}`; + }) + .replace(/style\s*=\s*["']([^"']*)["']/gi, (match, styleContent) => { + if (/javascript:|expression\(|@import|behavior:/i.test(styleContent)) { + const placeholder = `__DANGEROUS_BLOCK_${dangerousBlocks.length}__`; + dangerousBlocks.push({ + code: match, + label: '危险CSS样式', + placeholder: placeholder + }); + return placeholder; + } + return match; + }); + + dangerousBlocks.forEach(({ code, label, placeholder }) => { + const escapedCode = code + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + + const codeBlock = `
+
+ + 安全提示: ${label} + +
+
${escapedCode}
+
+ 此内容可能存在安全风险,已被安全地显示为代码块 +
+
`; + + processedContent = processedContent.split(placeholder).join(codeBlock); + }); + + processedContent = processedContent + .replace(/class="my-4 flex justify-center"/g, 'class="my-4"') + .replace(/style="width:\s*400px;"/g, 'style="max-width: 100%; max-height: 400px;"'); + + return processedContent; +}; + +function useTheme() { + const [isDark, setIsDark] = useState( + () => document.documentElement.classList.contains('dark') + ); + + useEffect(() => { + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === 'attributes' && mutation.attributeName === 'class') { + const isDarkNow = document.documentElement.classList.contains('dark'); + setIsDark(isDarkNow); + } + }); + }); + + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class'] + }); + + return () => observer.disconnect(); + }, []); + + return isDark; +} + +const preprocessMarkdown = (text) => { + if (!text) return ''; + + const codeBlockRegex = /(```[\s\S]*?```|`[^`\n]+`)/g; + + const parts = []; + let lastIndex = 0; + let match; + + while ((match = codeBlockRegex.exec(text)) !== null) { + if (match.index > lastIndex) { + parts.push({ + type: 'text', + content: text.slice(lastIndex, match.index), + }); + } + + parts.push({ + type: 'code', + content: match[0], + }); + + lastIndex = match.index + match[0].length; + } + + if (lastIndex < text.length) { + parts.push({ + type: 'text', + content: text.slice(lastIndex), + }); + } + + return parts + .map((part) => { + if (part.type === 'code') { + return part.content; + } + + let result = part.content; + + result = result.replace(/\n{3,}/g, (match) => { + const extra = match.length - 2; + return '\n\n' + '
\n\n'.repeat(extra); + }); + + result = result.replace(/([^\n])\n(?!\n)/g, '$1 \n'); + + result = result.replace( + /^(\s*)(\d+)\.(\s*)$/gm, + (_, space, num, tail) => `${space}${num}.\u200B${tail}` + ); + + return result; + }) + .join(''); +}; + +export default function ArticlePreview({ article }) { + const isDark = useTheme(); + const [renderKey, setRenderKey] = useState(0); + const [lightboxOpen, setLightboxOpen] = useState(false); + const [currentImageIndex, setCurrentImageIndex] = useState(0); + const [articleImages, setArticleImages] = useState([]); + const [isLiked, setIsLiked] = useState(false); + const [likeCount, setLikeCount] = useState(0); + const [liking, setLiking] = useState(false); + const [previewDoc, setPreviewDoc] = useState(null); + const navigate = useNavigate(); + + useEffect(() => { + setRenderKey(prev => prev + 1); + }, [isDark]); + + useEffect(() => { + if (article?.article_content) { + const images = []; + const htmlImgRegex = /]+src=["']([^"']+)["'][^>]*>/g; + let match; + while ((match = htmlImgRegex.exec(article.article_content)) !== null) { + const src = match[1]; + if (src) { + images.push({ + src: getFullImageUrl(src), + alt: '' + }); + } + } + + const markdownImgRegex = /!\[(.*?)\]\(([^)]+)\)/g; + while ((match = markdownImgRegex.exec(article.article_content)) !== null) { + const src = match[2]; + if (src) { + const fullSrc = getFullImageUrl(src); + if (!images.some(img => img.src === fullSrc)) { + images.push({ + src: fullSrc, + alt: '' + }); + } + } + } + + setArticleImages(images); + } + }, [article?.article_content]); + + useEffect(() => { + if (article) { + setLikeCount(article.like_count || 0); + + const fetchLikedStatus = async () => { + try { + const response = await apiClient.get(`/article/${article.alias}/liked`); + setIsLiked(response?.data?.liked || false); + } catch (error) { + console.error('获取点赞状态失败:', error); + setIsLiked(false); + } + }; + + fetchLikedStatus(); + } + }, [article]); + + const syntaxStyle = useMemo(() => { + return isDark ? oneDark : oneLight; + }, [isDark]); + + const handleCategoryClick = (categoryName) => { + const encodedCategory = encodeURIComponent(categoryName); + navigate(`/category?category=${encodedCategory}`); + }; + + const handleTagClick = (tagName) => { + navigate(`/tag/${tagName}`); + }; + + const handleLike = async (e) => { + e.preventDefault(); + e.stopPropagation(); + + if (liking) return; + + try { + setLiking(true); + const response = await apiClient.post(`/article/${article.alias}/like`); + + if (response.code === 200) { + const newIsLiked = !isLiked; + setIsLiked(newIsLiked); + setLikeCount(prev => newIsLiked ? prev + 1 : prev - 1); + } else if (response.message && response.message.includes('登录')) { + navigate('/login'); + } else { + console.error('点赞失败:', response.message); + } + } catch (err) { + console.error('点赞失败:', err); + if (err.response && err.response.status === 401) { + navigate('/login'); + } + } finally { + setLiking(false); + } + }; + + const getFullImageUrl = (url) => { + if (!url) return null; + + if (url.startsWith('http://') || url.startsWith('https://')) { + return url; + } + + let urlPath = url; + let urlParams = ''; + if (url.includes('?')) { + const parts = url.split('?'); + urlPath = parts[0]; + urlParams = '?' + parts[1]; + } + + try { + const config = getConfig(); + const baseUrl = config.getFullUrl(urlPath); + return baseUrl + urlParams; + } catch (error) { + if (urlPath.startsWith('/')) { + return urlPath + urlParams; + } + return `/upload/${urlPath}` + urlParams; + } + }; + + const CodeBlock = ({ className, children, ...props }) => { + const match = /language-(\w+)/.exec(className || ""); + const codeString = String(children).replace(/\n$/, ""); + const [localCopied, setLocalCopied] = useState(false); + + const handleCopyCode = async () => { + try { + // 大坑,navigator.clipboard只有在浏览器安全上下文才能正常使用,所以加入降级策略,真没招了()()() + const clipboard = navigator.clipboard || { + writeText: (text) => { + const copyInput = document.createElement('input'); + copyInput.value = text; + copyInput.style.position = 'fixed'; + copyInput.style.left = '-999999px'; + copyInput.style.top = '-999999px'; + document.body.appendChild(copyInput); + copyInput.select(); + document.execCommand('copy'); + document.body.removeChild(copyInput); + } + }; + + await clipboard.writeText(codeString); + setLocalCopied(true); + setTimeout(() => { + setLocalCopied(false); + }, 2000); + } catch (err) { + console.error('复制失败:', err); + } + }; + + return match ? ( +
+ + + {codeString} + +
+ ) : ( + + {children} + + ); + }; + + useEffect(() => { + const timer = setTimeout(() => { + window.dispatchEvent(new Event('article-headings-ready')); + }, 300); + + return () => clearTimeout(timer); + }, [article?.article_content, renderKey]); + + const headingIdsRef = useRef(new Map()); + + useEffect(() => { + if (article?.article_content) { + const counters = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0 }; + const idsMap = new Map(); + + const headingRegex = /^(#{1,6})\s+(.+)$/gm; + let match; + let index = 0; + + while ((match = headingRegex.exec(article.article_content)) !== null) { + const level = match[1].length; + const id = `toc-${level}-${counters[level]}`; + counters[level]++; + idsMap.set(index, id); + index++; + } + + headingIdsRef.current = idsMap; + } + }, [article?.article_content]); + + const currentHeadingIndexRef = useRef({ h1: 0, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 }); + const totalHeadingIndexRef = useRef(0); + + const renderMarkdownComponents = useMemo(() => { + currentHeadingIndexRef.current = { h1: 0, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 }; + totalHeadingIndexRef.current = 0; + + return { + h1: (props) => { + const id = `toc-1-${currentHeadingIndexRef.current.h1++}`; + totalHeadingIndexRef.current++; + return

; + }, + h2: (props) => { + const id = `toc-2-${currentHeadingIndexRef.current.h2++}`; + totalHeadingIndexRef.current++; + return

; + }, + h3: (props) => { + const id = `toc-3-${currentHeadingIndexRef.current.h3++}`; + totalHeadingIndexRef.current++; + return

; + }, + h4: (props) => { + const id = `toc-4-${currentHeadingIndexRef.current.h4++}`; + totalHeadingIndexRef.current++; + return

; + }, + h5: (props) => { + const id = `toc-5-${currentHeadingIndexRef.current.h5++}`; + totalHeadingIndexRef.current++; + return

; + }, + h6: (props) => { + const id = `toc-6-${currentHeadingIndexRef.current.h6++}`; + totalHeadingIndexRef.current++; + return
; + }, + p: (props) => ( +

+ ), + ul: (props) => ( +

    + ), + ol: (props) => ( +
      + ), + li: (props) => ( +
    1. + ), + blockquote: (props) => ( +
      + ), + table: (props) => ( +
      + + + ), + thead: (props) => ( + + ), + th: (props) => ( +
      + ), + td: (props) => ( + + ), + hr: (props) => ( +
      + ), + code: CodeBlock, + a: ({ href, children, ...props }) => { + if (!isSafeUrl(href)) { + return {children}; + } + return ( + + {children} + + ); + }, + img: ({ src, alt, ...props }) => { + if (!isSafeUrl(src)) return null; + const fullSrc = getFullImageUrl(src); + const imageIndex = articleImages.findIndex(img => img.src === fullSrc); + + return ( + + {alt { + if (imageIndex >= 0) { + setCurrentImageIndex(imageIndex); + } + setLightboxOpen(true); + }} + title="点击查看大图" + onError={(e) => { + e.target.src = '/image_error.svg'; + e.target.alt = '图片加载失败'; + }} + {...props} + /> + + ); + }, + video: ({ src, controls = true, ...props }) => { + if (!isSafeUrl(src)) return null; + const fullSrc = getFullImageUrl(src); + + return ( +
      + +
      + ); + }, + audio: ({ src, controls = true, preload = "metadata", ...props }) => { + if (!isSafeUrl(src)) return null; + + let fileName = props['data-filename']; + + if (!fileName && src.includes('?filename=')) { + try { + const url = new URL(src.startsWith('http') ? src : `http://dummy${src}`); + fileName = decodeURIComponent(url.searchParams.get('filename') || ''); + } catch (e) { + console.error('解析 URL 失败:', e); + } + } + + if (!fileName) { + fileName = src.split("/").pop()?.split("?")[0] || "音频文件"; + } + + const fullSrc = getFullImageUrl(src); + + return ( +
      +
      +
      + +
      +
      +
      + {fileName} +
      +
      + 音频文件 +
      +
      +
      + +
      + ); + }, + source: ({ src, type, ...props }) => { + if (!isSafeUrl(src)) return null; + const fullSrc = getFullImageUrl(src); + return ; + }, + archive: ({ src, ...props }) => { + if (!isSafeUrl(src)) return null; + + let displayName = props['data-filename']; + + if (!displayName && src.includes('?filename=')) { + try { + const url = new URL(src.startsWith('http') ? src : `http://dummy${src}`); + displayName = decodeURIComponent(url.searchParams.get('filename') || ''); + } catch (e) { + console.error('解析 URL 失败:', e); + } + } + + if (!displayName) { + displayName = src.split("/").pop()?.split("?")[0] || "压缩文件"; + } + + const fullSrc = getFullImageUrl(src); + + return ( +
      +
      +
      + +
      +
      +
      + {displayName} +
      +
      + 压缩文件 +
      +
      + + 下载 + +
      +
      + ); + }, + document: ({ src, ...props }) => { + if (!isSafeUrl(src)) return null; + + let displayName = props['data-filename']; + + if (!displayName && src.includes('?filename=')) { + try { + const url = new URL(src.startsWith('http') ? src : `http://dummy${src}`); + displayName = decodeURIComponent(url.searchParams.get('filename') || ''); + } catch (e) { + console.error('解析 URL 失败:', e); + } + } + + if (!displayName) { + displayName = src.split("/").pop()?.split("?")[0] || "文档文件"; + } + + const fileExtension = displayName.toLowerCase().split('.').pop(); + const isPdf = fileExtension === 'pdf'; + const isWord = ['doc', 'docx'].includes(fileExtension); + + let fileTypeLabel = '文档文件'; + let iconColor = 'bg-purple-300'; + + if (isPdf) { + fileTypeLabel = 'PDF文档'; + iconColor = 'bg-red-300'; + } else if (isWord) { + fileTypeLabel = 'Word文档'; + iconColor = 'bg-blue-300'; + } + + const fullSrc = getFullImageUrl(src); + + return ( +
      +
      +
      + +
      +
      +
      + {displayName} +
      +
      {fileTypeLabel}
      +
      + {(isPdf || isWord) && ( + + )} + + 下载 + +
      +
      + ); + } + }; + }, [syntaxStyle, articleImages, CodeBlock, article?.article_content]); + + const isMarkdownContent = (content) => { + if (!content) return false; + + const markdownPatterns = [ + /^#{1,6}\s/m, + /\*\*.*\*\*/, + /\*.*\*/, + /\[.*\]\(.*\)/, + /!\[.*\]\(.*\)/, + /^>\s/m, + /^[-*+]\s/m, + /^\d+\.\s/m, + /```[\s\S]*```/, + /`.*`/, + /]*>/i, + /]*>/i, + /]*>/i, + ]; + + return markdownPatterns.some(pattern => pattern.test(content)); + }; + + const renderArticleContent = (content) => { + if (!content) return null; + + currentHeadingIndexRef.current = { h1: 0, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 }; + totalHeadingIndexRef.current = 0; + + if (isMarkdownContent(content)) { + const processedContent = preprocessMarkdown(content); + + return ( +
      + +
      + ); + } else { + const sanitizedContent = sanitizeHtmlContent(content); + + return ( +
      + ); + } + }; + + if (!article) { + return null; + } + + return ( +
      +

      + {article.article_name} +

      + + {article.author && ( +
      +
      + {article.avatar && ( + {article.author} { e.target.src = '/image_error.svg'; }} + /> + )} +
      +

      作者:{article.author}

      +

      + 日期:{new Date(article.create_time).toLocaleDateString()} • + 阅读量:{article.page_views} +

      +
      +
      + + +
      + )} + + {(article.category || article.tag) && ( +
      + {article.category && ( + + )} + {article.tag && article.tag.split(',').map((tag, index) => ( + + ))} +
      + )} + +
      + {renderArticleContent(article.article_content)} +
      + + {lightboxOpen && ( + setLightboxOpen(false)} + onNavigate={(direction) => { + setCurrentImageIndex(prev => { + const newIndex = prev + direction; + if (newIndex < 0) return articleImages.length - 1; + if (newIndex >= articleImages.length) return 0; + return newIndex; + }); + }} + /> + )} + + {previewDoc && ( + setPreviewDoc(null)} + /> + )} +
      + ); +} \ No newline at end of file diff --git a/demo/dim_stack/frontend/src/components/ArticleReader.jsx b/demo/dim_stack/frontend/src/components/ArticleReader.jsx new file mode 100644 index 0000000..c6bb24f --- /dev/null +++ b/demo/dim_stack/frontend/src/components/ArticleReader.jsx @@ -0,0 +1,409 @@ +import React, { useEffect, useState } from 'react'; +import { useParams, useLocation } from 'react-router-dom'; +import { getConfig } from '../utils/config'; +import apiClient from '../utils/axios'; +import Header from './Header'; +import CategorySidebar from './CategorySidebar'; +import RecommendedArticles from './RecommendedArticles'; +import ArticlePreview from './ArticlePreview.jsx'; +import CommentSection from './CommentSection.jsx'; +import TagSidebar from "./TagSidebar.jsx"; +import TableOfContents from './TableOfContents.jsx'; +import ScriptAwareHtml from './ScriptAwareHtml'; + +export default function ArticleReader() { + const { articleId } = useParams(); + const location = useLocation(); + const [article, setArticle] = useState(null); + const [needPassword, setNeedPassword] = useState(false); + const [password, setPassword] = useState(''); + const [showPasswordInput, setShowPasswordInput] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [showBackToTop, setShowBackToTop] = useState(false); + const [globalHeadCode, setGlobalHeadCode] = useState(''); + const [contentHeadCode, setContentHeadCode] = useState(''); + const [footerCode, setFooterCode] = useState(''); + const [copyright, setCopyright] = useState(''); + const [icpRecord, setIcpRecord] = useState(''); + const [mpsRecord, setMpsRecord] = useState(''); + const [siteCommentEnabled, setSiteCommentEnabled] = useState(true); + + const getFullImageUrl = (url) => { + if (!url) return null; + + if (url.startsWith('http://') || url.startsWith('https://')) { + return url; + } + + try { + const config = getConfig(); + return config.getFullUrl(url); + } catch (error) { + if (url.startsWith('/')) { + return url; + } + return `/upload/${url}`; + } + }; + + useEffect(() => { + const needPwd = location.state?.needPassword; + if (needPwd === true) { + setNeedPassword(true); + setShowPasswordInput(true); + setLoading(false); + } else if (needPwd === false) { + fetchArticleContent(); + } else { + checkPasswordRequirement(); + } + }, [articleId]); + + useEffect(() => { + loadCodeInjection(); + loadSiteInfo(); + loadSiteCommentStatus(); + }, []); + + const loadSiteCommentStatus = async () => { + try { + const response = await apiClient.get('/site/enable-comment'); + if (response.code === 200 && response.data) { + setSiteCommentEnabled(response.data.enableComment !== false); + } + } catch (error) { + console.error('获取站点评论区状态失败:', error); + } + }; + + useEffect(() => { + const handleScroll = () => { + if (window.scrollY > 300) { + setShowBackToTop(true); + } else { + setShowBackToTop(false); + } + }; + + window.addEventListener('scroll', handleScroll); + return () => window.removeEventListener('scroll', handleScroll); + }, []); + + const checkPasswordRequirement = async () => { + try { + setLoading(true); + const response = await apiClient.get(`/article/${articleId}/check-password`); + if (response.code === 200) { + setNeedPassword(response.data.needPassword); + if (response.data.needPassword) { + setShowPasswordInput(true); + } else { + fetchArticleContent(); + } + } else { + setError(response.message || '检查文章密码失败'); + } + } catch (err) { + setError('检查文章密码失败'); + console.error(err); + } finally { + setLoading(false); + } + }; + + const fetchArticleContent = async (inputPassword = null) => { + try { + setLoading(true); + const params = inputPassword ? { password: inputPassword } : {}; + const response = await apiClient.get(`/article/${articleId}`, { params }); + + if (response.code === 200) { + setArticle(response.data); + setShowPasswordInput(false); + setError(''); + } else { + setError(response.message || '获取文章内容失败'); + if (response.message === '文章密码错误') { + setShowPasswordInput(true); + } + } + } catch (err) { + setError('获取文章内容失败'); + console.error(err); + } finally { + setLoading(false); + } + }; + + const handlePasswordSubmit = (e) => { + e.preventDefault(); + fetchArticleContent(password); + }; + + const scrollToTop = () => { + window.scrollTo({ + top: 0, + behavior: 'smooth' + }); + }; + + const loadCodeInjection = async () => { + try { + const globalHeadResponse = await apiClient.get('/site/global-head-code'); + if (globalHeadResponse?.data?.globalHeadCode) { + setGlobalHeadCode(globalHeadResponse.data.globalHeadCode); + } + + const contentHeadResponse = await apiClient.get('/site/content-head-code'); + if (contentHeadResponse?.data?.contentHeadCode) { + setContentHeadCode(contentHeadResponse.data.contentHeadCode); + } + + const footerResponse = await apiClient.get('/site/footer-code'); + if (footerResponse?.data?.footerCode) { + setFooterCode(footerResponse.data.footerCode); + } + } catch (error) { + console.error('加载代码注入配置失败:', error); + } + }; + + useEffect(() => { + if (globalHeadCode) { + const container = document.createElement('div'); + container.innerHTML = globalHeadCode; + + const scripts = container.querySelectorAll('script'); + scripts.forEach(script => { + const newScript = document.createElement('script'); + Array.from(script.attributes).forEach(attr => { + newScript.setAttribute(attr.name, attr.value); + }); + newScript.textContent = script.textContent; + document.head.appendChild(newScript); + }); + + const styles = container.querySelectorAll('style'); + styles.forEach(style => { + const newStyle = document.createElement('style'); + Array.from(style.attributes).forEach(attr => { + newStyle.setAttribute(attr.name, attr.value); + }); + newStyle.textContent = style.textContent; + document.head.appendChild(newStyle); + }); + + const otherElements = container.querySelectorAll(':not(script):not(style)'); + otherElements.forEach(element => { + if (element.parentElement === container) { + document.head.appendChild(element.cloneNode(true)); + } + }); + } + }, [globalHeadCode]); + + useEffect(() => { + if (contentHeadCode && article) { + const container = document.createElement('div'); + container.innerHTML = contentHeadCode; + + const scripts = container.querySelectorAll('script'); + scripts.forEach(script => { + const newScript = document.createElement('script'); + Array.from(script.attributes).forEach(attr => { + newScript.setAttribute(attr.name, attr.value); + }); + newScript.textContent = script.textContent; + document.head.appendChild(newScript); + }); + + const styles = container.querySelectorAll('style'); + styles.forEach(style => { + const newStyle = document.createElement('style'); + Array.from(style.attributes).forEach(attr => { + newStyle.setAttribute(attr.name, attr.value); + }); + newStyle.textContent = style.textContent; + document.head.appendChild(newStyle); + }); + + const otherElements = container.querySelectorAll(':not(script):not(style)'); + otherElements.forEach(element => { + if (element.parentElement === container) { + document.head.appendChild(element.cloneNode(true)); + } + }); + } + }, [contentHeadCode, article]); + + const loadSiteInfo = async () => { + try { + const copyrightResponse = await apiClient.get('/site/copyright'); + if (copyrightResponse) setCopyright(copyrightResponse); + + const icpResponse = await apiClient.get('/site/icp-record'); + if (icpResponse) setIcpRecord(icpResponse); + + const mpsResponse = await apiClient.get('/site/mps-record'); + if (mpsResponse) setMpsRecord(mpsResponse); + } catch (error) { + console.error('加载站点信息失败:', error); + } + }; + + if (loading) { + return ( +
      +
      +
      +
      +
      + 加载中... +
      +
      +
      + ); + } + + if (error) { + return ( +
      +
      +
      +
      + {error} +
      +
      +
      + ); + } + + return ( +
      +
      +
      + {showPasswordInput ? ( +
      +
      +
      +
      + + setPassword(e.target.value)} + className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" + placeholder="请输入文章密码" + /> +
      +
      + +
      +
      +
      +
      + ) : article ? ( +
      + {/* 文章封面 */} + {article.article_cover && ( +
      +
      + {article.article_name} { + e.target.src = '/image_error.svg'; + }} + /> +
      +
      +
      + )} + +
      +
      +
      +
      + +
      +
      + +
      +
      + + {siteCommentEnabled && article.enable_comment === 1 && ( + + )} +
      +
      +
      +
      + + + +
      +
      +
      + +
      +
      + ) : null} +
      + + {showBackToTop && ( + + )} + + +
      + ); +} \ No newline at end of file diff --git a/demo/dim_stack/frontend/src/components/CategorySidebar.jsx b/demo/dim_stack/frontend/src/components/CategorySidebar.jsx new file mode 100644 index 0000000..6519bee --- /dev/null +++ b/demo/dim_stack/frontend/src/components/CategorySidebar.jsx @@ -0,0 +1,132 @@ +import { useState, useEffect, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; +import apiClient from '../utils/axios'; +import { getCategoryIcon } from '../utils/IconUtils'; + +export default function CategorySidebar({ selectedCategory }) { + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showBottomGradient, setShowBottomGradient] = useState(false); + const [showTopGradient, setShowTopGradient] = useState(false); + const listRef = useRef(null); + const navigate = useNavigate(); + + useEffect(() => { + const fetchCategories = async () => { + try { + setLoading(true); + const data = await apiClient.get('/categoriesandcount'); + setCategories(Array.isArray(data.data) ? data.data : []); + setError(null); + } catch (err) { + console.error('获取分类数据失败:', err); + setError('获取分类数据失败'); + } finally { + setLoading(false); + } + }; + + fetchCategories(); + }, []); + + useEffect(() => { + const el = listRef.current; + if (!el) return; + + const checkScroll = () => { + const canScroll = el.scrollHeight > el.clientHeight; + const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 4; + const atTop = el.scrollTop <= 4; + setShowBottomGradient(canScroll && !atBottom); + setShowTopGradient(canScroll && !atTop); + }; + + checkScroll(); + el.addEventListener('scroll', checkScroll); + const ro = new ResizeObserver(checkScroll); + ro.observe(el); + + return () => { + el.removeEventListener('scroll', checkScroll); + ro.disconnect(); + }; + }, [categories]); + + const handleCategoryClick = (categoryName) => { + if (selectedCategory === categoryName) { + navigate('/'); + } else { + navigate(`/?category=${encodeURIComponent(categoryName)}`); + } + }; + + if (loading) { + return ( +
      +

      文章分类

      +
      + {[1, 2, 3].map((i) => ( +
      + ))} +
      +
      + ); + } + + if (error) { + return ( +
      +

      文章分类

      +
      {error}
      +
      + ); + } + + return ( +
      +

      文章分类

      +
      +
      + +
        + {categories.map((category) => ( +
      • + +
      • + ))} +
      + +
      +
      +
      + ); +} \ No newline at end of file diff --git a/demo/dim_stack/frontend/src/components/CommentSection.jsx b/demo/dim_stack/frontend/src/components/CommentSection.jsx new file mode 100644 index 0000000..165dc2e --- /dev/null +++ b/demo/dim_stack/frontend/src/components/CommentSection.jsx @@ -0,0 +1,465 @@ +import React, { useState, useEffect } from 'react'; +import apiClient from '../utils/axios'; +import { getConfig } from '../utils/config'; +import { showToast } from '../utils/toastManager.jsx'; + +const CommentSection = ({ articleAlias }) => { + const [comments, setComments] = useState([]); + const [newComment, setNewComment] = useState(''); + const [replyingTo, setReplyingTo] = useState(null); + const [replyContent, setReplyContent] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [currentUsername, setCurrentUsername] = useState(null); + + useEffect(() => { + fetchComments(); + }, [articleAlias]); + + useEffect(() => { + const username = localStorage.getItem('username'); + setCurrentUsername(username); + }, []); + + const getFullImageUrl = (url) => { + if (!url) return null; + + if (url.startsWith('http://') || url.startsWith('https://')) { + return url; + } + + try { + const config = getConfig(); + return config.getFullUrl(url); + } catch (error) { + if (url.startsWith('/')) { + return url; + } + + return `/upload/${url}`; + } + }; + + const fetchComments = async () => { + try { + setLoading(true); + const response = await apiClient.get(`/comments/article/${articleAlias}`); + setComments(response.data || []); + } catch (err) { + // setError('获取评论失败'); + console.error(err); + } finally { + setLoading(false); + } + }; + const handleNewCommentKeyDown = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + if (newComment.trim()) { + handleAddComment(e); + } + } + }; + + // 处理回复评论输入框的键盘事件 + const handleReplyCommentKeyDown = (e, commentId) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + if (replyContent.trim()) { + handleReplyComment(commentId); + } + } + }; + + // 发表新评论 + const handleAddComment = async (e) => { + e.preventDefault(); + if (!newComment.trim()) return; + + try { + setLoading(true); + const requestData = { + article_alias: articleAlias, + content: newComment + }; + + const response = await apiClient.post('/comments', requestData); + setNewComment(''); + fetchComments(); + setError(''); + if (response.message) { + showToast(response.message); + } + } catch (err) { + // setError('发表评论失败,请检查是否登录'); + console.error(err); + } finally { + setLoading(false); + } + }; + + // 回复评论 + const handleReplyComment = async (commentId) => { + if (!replyContent.trim()) return; + + try { + setLoading(true); + const requestData = { + article_alias: articleAlias, + content: replyContent, + to_comment_id: commentId + }; + + const response = await apiClient.post('/comments', requestData); + setReplyContent(''); + setReplyingTo(null); + fetchComments(); + setError(''); + if (response.message) { + showToast(response.message); + } + } catch (err) { + // setError('回复评论失败,请检查是否登录'); + console.error(err); + } finally { + setLoading(false); + } + }; + + // 点赞评论 + const handleLikeComment = async (commentId) => { + try { + await apiClient.post(`/comments/${commentId}/like`); + fetchComments(); + } catch (err) { + // setError('点赞失败,请检查是否登录'); + console.error(err); + } + }; + + + // 删除评论 + const handleDeleteComment = async (commentId) => { + if (!window.confirm('确定要删除这条评论吗?')) { + return; + } + + try { + await apiClient.delete(`/comments/${commentId}`); + fetchComments(); + setError(''); + } catch (err) { + // setError('删除评论失败'); + console.error(err); + } + }; + + const formatTime = (dateString) => { + const date = new Date(dateString); + const now = new Date(); + const diffInSeconds = Math.floor((now - date) / 1000); + + if (diffInSeconds < 60) { + return '刚刚'; + } else if (diffInSeconds < 3600) { + return `${Math.floor(diffInSeconds / 60)}分钟前`; + } else if (diffInSeconds < 86400) { + return `${Math.floor(diffInSeconds / 3600)}小时前`; + } else { + return `${Math.floor(diffInSeconds / 86400)}天前`; + } + }; + + const renderReplyComment = (comment, isNested = false, parentComment = null) => { + return ( +
      +
      + {/* 用户头像 */} +
      + {comment.avatar ? ( + {comment.username} { + e.target.src = '/image_error.svg'; + }} + /> + ) : ( +
      + )} +
      + + {/* 评论内容 */} +
      +
      +
      + + {comment.username || '匿名用户'} + + {parentComment && ( + + 回复 @{parentComment.username} + + )} + • + + {formatTime(comment.create_time)} + +
      + +

      + {comment.content} +

      +
      + +
      + + + + + {currentUsername && comment.username === currentUsername && ( + + )} +
      + + {/* 回复输入框 */} + {replyingTo === comment.comment_id && ( +
      +
      +