From 4cd4d3f133507413d0daa45a7cae5bad7607b535 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Fri, 4 Sep 2026 08:41:11 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E3=80=90=E5=90=8C=E6=AD=A5=E3=80=91BOOT=20?= =?UTF-8?q?=E5=92=8C=20CLOUD=20=E7=9A=84=E5=8A=9F=E8=83=BD=EF=BC=88pms?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 1 + sql/mysql/ruoyi-vue-pro.sql | 2 +- .../banner/core/BannerApplicationRunner.java | 6 + .../core/handler/GlobalExceptionHandler.java | 32 +- .../src/main/resources/application.yaml | 10 + yudao-module-pms/pom.xml | 24 + yudao-module-pms/yudao-module-pms-api/pom.xml | 33 + .../yudao/module/pms/api/package-info.java | 4 + .../yudao/module/pms/enums/ApiConstants.java | 19 + .../module/pms/enums/DictTypeConstants.java | 30 + .../module/pms/enums/ErrorCodeConstants.java | 114 ++ .../pms/enums/MessageTemplateConstants.java | 15 + .../enums/kb/PmsKnowledgeObjectTypeEnum.java | 41 + .../content/PmsKnowledgeContentLevelEnum.java | 59 + .../PmsKnowledgeDocumentStatusEnum.java | 53 + .../content/PmsKnowledgeDocumentTypeEnum.java | 43 + .../kb/library/PmsKnowledgeGroupTypeEnum.java | 44 + .../PmsKnowledgeLibraryMemberLevelEnum.java | 44 + .../pm/iteration/PmsIterationStatusEnum.java | 44 + .../pm/project/PmsProjectGroupTypeEnum.java | 44 + .../enums/pm/project/PmsProjectLevelEnum.java | 46 + .../pm/project/PmsProjectMemberLevelEnum.java | 76 ++ .../pm/project/PmsProjectSceneTypeEnum.java | 44 + .../pm/project/PmsProjectSortTypeEnum.java | 43 + .../pm/project/PmsProjectStatusEnum.java | 44 + .../enums/pm/project/PmsProjectTypeEnum.java | 43 + .../PmsWorkItemActivityContentEnum.java | 54 + .../workitem/PmsWorkItemDefectTypeEnum.java | 47 + .../PmsWorkItemLifecycleStatusEnum.java | 44 + .../pm/workitem/PmsWorkItemPriorityEnum.java | 45 + .../workitem/PmsWorkItemStatusTypeEnum.java | 44 + .../pm/workitem/PmsWorkItemTypeEnum.java | 44 + .../yudao-module-pms-server/pom.xml | 125 ++ .../module/pms/PmsServerApplication.java | 18 + ...sKnowledgeContentPermissionController.java | 93 ++ .../PmsKnowledgeDocumentController.java | 202 +++ .../PmsKnowledgeDocumentLabelController.java | 132 ++ .../content/PmsKnowledgeFolderController.java | 189 +++ .../PmsKnowledgeDocumentCreateReqVO.java | 51 + .../PmsKnowledgeDocumentMoveReqVO.java | 27 + .../document/PmsKnowledgeDocumentRespVO.java | 100 ++ .../PmsKnowledgeDocumentSearchPageReqVO.java | 33 + .../PmsKnowledgeDocumentUpdateReqVO.java | 39 + .../folder/PmsKnowledgeFolderMoveReqVO.java | 23 + .../vo/folder/PmsKnowledgeFolderRespVO.java | 48 + .../folder/PmsKnowledgeFolderSaveReqVO.java | 29 + .../vo/folder/PmsKnowledgeTreeRespVO.java | 82 ++ .../PmsKnowledgeDocumentLabelPageReqVO.java | 20 + .../PmsKnowledgeDocumentLabelRespVO.java | 24 + .../PmsKnowledgeDocumentLabelSaveReqVO.java | 25 + .../PmsKnowledgeContentPermissionRespVO.java | 57 + ...KnowledgeContentPermissionUpdateReqVO.java | 59 + ...PmsKnowledgeDocumentCommentController.java | 112 ++ .../PmsKnowledgeDocumentLikeController.java | 47 + .../PmsKnowledgeDocumentShareController.java | 101 ++ .../PmsKnowledgeFavoriteController.java | 77 ++ .../PmsKnowledgeViewRecordController.java | 38 + .../vo/PmsKnowledgeInteractionItemRespVO.java | 51 + .../PmsKnowledgeDocumentCommentRespVO.java | 43 + .../PmsKnowledgeDocumentCommentSaveReqVO.java | 28 + .../PmsKnowledgeFavoritePageReqVO.java | 19 + .../PmsKnowledgeFavoriteSaveReqVO.java | 22 + ...PmsKnowledgeDocumentSharePublicRespVO.java | 33 + .../PmsKnowledgeDocumentShareRespVO.java | 37 + .../PmsKnowledgeDocumentShareSaveReqVO.java | 20 + ...dgeDocumentShareUpdateMemberListReqVO.java | 20 + .../PmsKnowledgeRecentListRespVO.java | 22 + .../library/PmsKnowledgeGroupController.java | 106 ++ .../PmsKnowledgeLibraryController.java | 159 +++ .../PmsKnowledgeLibraryMemberController.java | 106 ++ ...PmsKnowledgeLibraryTemplateController.java | 103 ++ .../vo/group/PmsKnowledgeGroupRespVO.java | 30 + .../vo/group/PmsKnowledgeGroupSaveReqVO.java | 20 + .../vo/group/PmsKnowledgeGroupSortReqVO.java | 35 + .../PmsKnowledgeLibraryMoveGroupReqVO.java | 18 + .../library/PmsKnowledgeLibraryPageReqVO.java | 21 + .../vo/library/PmsKnowledgeLibraryRespVO.java | 57 + .../library/PmsKnowledgeLibrarySaveReqVO.java | 46 + .../PmsKnowledgeLibraryMemberRespVO.java | 37 + ...KnowledgeLibraryUpdateMemberListReqVO.java | 50 + .../PmsKnowledgeLibraryTemplatePageReqVO.java | 22 + .../PmsKnowledgeLibraryTemplateRespVO.java | 37 + .../PmsKnowledgeLibraryTemplateSaveReqVO.java | 66 + .../PmsKnowledgeRecycleController.java | 125 ++ .../vo/PmsKnowledgeRecycleDetailRespVO.java | 41 + .../vo/PmsKnowledgeRecyclePreviewRespVO.java | 28 + .../recycle/vo/PmsKnowledgeRecycleRespVO.java | 45 + .../pm/iteration/PmsIterationController.java | 152 +++ .../vo/PmsIterationOverviewRespVO.java | 107 ++ .../iteration/vo/PmsIterationPageReqVO.java | 29 + .../pm/iteration/vo/PmsIterationRespVO.java | 54 + .../iteration/vo/PmsIterationSaveReqVO.java | 53 + .../iteration/vo/PmsIterationStartReqVO.java | 34 + .../PmsProjectAnnouncementController.java | 129 ++ .../pm/project/PmsProjectController.java | 207 +++ .../project/PmsProjectFavoriteController.java | 47 + .../pm/project/PmsProjectGroupController.java | 97 ++ .../project/PmsProjectMemberController.java | 104 ++ .../project/PmsProjectTemplateController.java | 81 ++ .../PmsProjectAnnouncementRespVO.java | 37 + .../PmsProjectAnnouncementSaveReqVO.java | 30 + .../vo/group/PmsProjectGroupMoveReqVO.java | 18 + .../vo/group/PmsProjectGroupRespVO.java | 30 + .../vo/group/PmsProjectGroupSaveReqVO.java | 20 + .../vo/group/PmsProjectGroupSortReqVO.java | 36 + .../vo/member/PmsProjectMemberRespVO.java | 25 + .../vo/member/PmsProjectMemberSaveReqVO.java | 41 + .../vo/project/PmsProjectOverviewRespVO.java | 74 ++ .../vo/project/PmsProjectPageReqVO.java | 37 + .../project/vo/project/PmsProjectRespVO.java | 94 ++ .../vo/project/PmsProjectSaveReqVO.java | 73 ++ .../template/PmsProjectTemplatePageReqVO.java | 25 + .../vo/template/PmsProjectTemplateRespVO.java | 43 + .../template/PmsProjectTemplateSaveReqVO.java | 126 ++ .../pm/workbench/PmsWorkbenchController.java | 124 ++ .../workbench/vo/PmsWorkbenchCountRespVO.java | 24 + .../vo/PmsWorkbenchIterationRespVO.java | 39 + .../workbench/vo/PmsWorkbenchPageReqVO.java | 47 + .../vo/PmsWorkbenchWorkItemRespVO.java | 72 ++ .../PmsWorkItemActivityController.java | 62 + .../PmsWorkItemCommentController.java | 104 ++ .../pm/workitem/PmsWorkItemController.java | 299 +++++ .../workitem/PmsWorkItemLabelController.java | 71 ++ .../workitem/PmsWorkItemStatusController.java | 159 +++ .../PmsWorkItemWorkLogController.java | 124 ++ .../activity/PmsWorkItemActivityRespVO.java | 35 + .../vo/comment/PmsWorkItemCommentRespVO.java | 43 + .../comment/PmsWorkItemCommentSaveReqVO.java | 31 + .../vo/label/PmsWorkItemLabelRespVO.java | 24 + .../vo/label/PmsWorkItemLabelSaveReqVO.java | 25 + .../status/PmsWorkItemBoardConfigRespVO.java | 31 + .../PmsWorkItemBoardConfigSaveReqVO.java | 50 + .../PmsWorkItemStatusConfigUpdateReqVO.java | 33 + .../status/PmsWorkItemStatusCreateReqVO.java | 39 + .../status/PmsWorkItemStatusDeleteReqVO.java | 18 + .../vo/status/PmsWorkItemStatusRespVO.java | 37 + .../vo/status/PmsWorkItemStatusSortReqVO.java | 18 + .../status/PmsWorkItemStatusUpdateReqVO.java | 19 + .../vo/workitem/PmsWorkItemBoardReqVO.java | 21 + .../vo/workitem/PmsWorkItemBoardRespVO.java | 25 + .../vo/workitem/PmsWorkItemImportExcelVO.java | 121 ++ .../vo/workitem/PmsWorkItemImportRespVO.java | 24 + .../PmsWorkItemIterationUpdateReqVO.java | 20 + .../workitem/PmsWorkItemNameUpdateReqVO.java | 22 + .../vo/workitem/PmsWorkItemPageReqVO.java | 88 ++ .../PmsWorkItemPlanningSortReqVO.java | 25 + .../vo/workitem/PmsWorkItemRespVO.java | 125 ++ .../vo/workitem/PmsWorkItemSaveReqVO.java | 112 ++ .../vo/workitem/PmsWorkItemSortReqVO.java | 22 + .../worklog/PmsProjectWorkLogReportReqVO.java | 30 + .../PmsProjectWorkLogReportRespVO.java | 64 + .../vo/worklog/PmsWorkItemWorkLogRespVO.java | 43 + .../worklog/PmsWorkItemWorkLogSaveReqVO.java | 33 + .../PmsWorkItemWorkLogSummaryRespVO.java | 24 + .../module/pms/controller/package-info.java | 4 + .../PmsKnowledgeContentPermissionDO.java | 47 + ...PmsKnowledgeContentPermissionMemberDO.java | 55 + .../kb/content/PmsKnowledgeDocumentDO.java | 117 ++ .../content/PmsKnowledgeDocumentLabelDO.java | 37 + .../kb/content/PmsKnowledgeFolderDO.java | 78 ++ .../PmsKnowledgeDocumentCommentDO.java | 64 + .../PmsKnowledgeDocumentLikeDO.java | 43 + .../PmsKnowledgeDocumentShareDO.java | 70 ++ .../interaction/PmsKnowledgeFavoriteDO.java | 56 + .../interaction/PmsKnowledgeViewRecordDO.java | 56 + .../kb/library/PmsKnowledgeGroupDO.java | 51 + .../library/PmsKnowledgeGroupRelationDO.java | 52 + .../kb/library/PmsKnowledgeLibraryDO.java | 65 + .../library/PmsKnowledgeLibraryMemberDO.java | 56 + .../PmsKnowledgeLibraryTemplateDO.java | 80 ++ .../recycle/PmsKnowledgeRecycleRecordDO.java | 66 + .../pm/iteration/PmsIterationDO.java | 80 ++ .../pm/project/PmsProjectAnnouncementDO.java | 48 + .../dataobject/pm/project/PmsProjectDO.java | 92 ++ .../pm/project/PmsProjectFavoriteDO.java | 42 + .../pm/project/PmsProjectGroupDO.java | 53 + .../pm/project/PmsProjectGroupRelationDO.java | 52 + .../pm/project/PmsProjectMemberDO.java | 49 + .../pm/project/PmsProjectTemplateDO.java | 150 +++ .../pm/workitem/PmsWorkItemActivityDO.java | 53 + .../pm/workitem/PmsWorkItemBoardDO.java | 51 + .../pm/workitem/PmsWorkItemCommentDO.java | 63 + .../dataobject/pm/workitem/PmsWorkItemDO.java | 160 +++ .../pm/workitem/PmsWorkItemLabelDO.java | 37 + .../pm/workitem/PmsWorkItemMemberDO.java | 49 + .../pm/workitem/PmsWorkItemStatusDO.java | 74 ++ .../pm/workitem/PmsWorkItemUserSortDO.java | 53 + .../pm/workitem/PmsWorkItemWorkLogDO.java | 54 + .../PmsKnowledgeContentPermissionMapper.java | 23 + ...nowledgeContentPermissionMemberMapper.java | 50 + .../PmsKnowledgeDocumentLabelMapper.java | 19 + .../content/PmsKnowledgeDocumentMapper.java | 144 +++ .../kb/content/PmsKnowledgeFolderMapper.java | 51 + .../PmsKnowledgeDocumentCommentMapper.java | 30 + .../PmsKnowledgeDocumentLikeMapper.java | 32 + .../PmsKnowledgeDocumentShareMapper.java | 36 + .../PmsKnowledgeFavoriteMapper.java | 74 ++ .../PmsKnowledgeViewRecordMapper.java | 53 + .../kb/library/PmsKnowledgeGroupMapper.java | 47 + .../PmsKnowledgeGroupRelationMapper.java | 89 ++ .../kb/library/PmsKnowledgeLibraryMapper.java | 74 ++ .../PmsKnowledgeLibraryMemberMapper.java | 71 ++ .../PmsKnowledgeLibraryTemplateMapper.java | 34 + .../PmsKnowledgeRecycleRecordMapper.java | 52 + .../module/pms/dal/mysql/package-info.java | 6 + .../pm/iteration/PmsIterationMapper.java | 122 ++ .../project/PmsProjectAnnouncementMapper.java | 25 + .../pm/project/PmsProjectFavoriteMapper.java | 33 + .../pm/project/PmsProjectGroupMapper.java | 53 + .../PmsProjectGroupRelationMapper.java | 88 ++ .../mysql/pm/project/PmsProjectMapper.java | 90 ++ .../pm/project/PmsProjectMemberMapper.java | 61 + .../pm/project/PmsProjectTemplateMapper.java | 28 + .../workitem/PmsWorkItemActivityMapper.java | 37 + .../pm/workitem/PmsWorkItemBoardMapper.java | 31 + .../pm/workitem/PmsWorkItemCommentMapper.java | 34 + .../pm/workitem/PmsWorkItemLabelMapper.java | 20 + .../mysql/pm/workitem/PmsWorkItemMapper.java | 394 ++++++ .../pm/workitem/PmsWorkItemMemberMapper.java | 28 + .../pm/workitem/PmsWorkItemStatusMapper.java | 68 + .../workitem/PmsWorkItemUserSortMapper.java | 37 + .../pm/workitem/PmsWorkItemWorkLogMapper.java | 53 + .../module/pms/framework/package-info.java | 4 + .../rpc/config/RpcConfiguration.java | 15 + .../config/SecurityConfiguration.java | 39 + .../web/config/PmsWebConfiguration.java | 26 + .../pms/framework/web/package-info.java | 4 + .../job/kb/PmsKnowledgeRecycleCleanJob.java | 35 + .../yudao/module/pms/job/package-info.java | 4 + .../yudao/module/pms/package-info.java | 10 + .../PmsKnowledgeContentPermissionService.java | 165 +++ ...KnowledgeContentPermissionServiceImpl.java | 417 ++++++ .../PmsKnowledgeDocumentLabelService.java | 73 ++ .../PmsKnowledgeDocumentLabelServiceImpl.java | 125 ++ .../content/PmsKnowledgeDocumentService.java | 174 +++ .../PmsKnowledgeDocumentServiceImpl.java | 462 +++++++ .../kb/content/PmsKnowledgeFolderService.java | 122 ++ .../PmsKnowledgeFolderServiceImpl.java | 319 +++++ .../PmsKnowledgeDocumentCommentService.java | 49 + ...msKnowledgeDocumentCommentServiceImpl.java | 120 ++ .../PmsKnowledgeDocumentLikeService.java | 46 + .../PmsKnowledgeDocumentLikeServiceImpl.java | 71 ++ .../PmsKnowledgeDocumentShareService.java | 75 ++ .../PmsKnowledgeDocumentShareServiceImpl.java | 244 ++++ .../PmsKnowledgeFavoriteService.java | 100 ++ .../PmsKnowledgeFavoriteServiceImpl.java | 160 +++ .../PmsKnowledgeFolderReadService.java | 32 + .../PmsKnowledgeFolderReadServiceImpl.java | 61 + .../PmsKnowledgeInteractionTargetService.java | 35 + ...KnowledgeInteractionTargetServiceImpl.java | 171 +++ .../PmsKnowledgeViewRecordService.java | 58 + .../PmsKnowledgeViewRecordServiceImpl.java | 146 +++ .../kb/library/PmsKnowledgeGroupService.java | 103 ++ .../library/PmsKnowledgeGroupServiceImpl.java | 277 ++++ .../PmsKnowledgeLibraryMemberService.java | 166 +++ .../PmsKnowledgeLibraryMemberServiceImpl.java | 337 +++++ .../library/PmsKnowledgeLibraryService.java | 113 ++ .../PmsKnowledgeLibraryServiceImpl.java | 153 +++ .../PmsKnowledgeLibraryTemplateService.java | 74 ++ ...msKnowledgeLibraryTemplateServiceImpl.java | 158 +++ .../recycle/PmsKnowledgeRecycleService.java | 107 ++ .../PmsKnowledgeRecycleServiceImpl.java | 882 +++++++++++++ .../pm/iteration/PmsIterationService.java | 156 +++ .../pm/iteration/PmsIterationServiceImpl.java | 395 ++++++ .../PmsProjectAnnouncementService.java | 65 + .../PmsProjectAnnouncementServiceImpl.java | 106 ++ .../pm/project/PmsProjectFavoriteService.java | 43 + .../PmsProjectFavoriteServiceImpl.java | 60 + .../pm/project/PmsProjectGroupService.java | 95 ++ .../project/PmsProjectGroupServiceImpl.java | 279 ++++ .../pm/project/PmsProjectMemberService.java | 187 +++ .../project/PmsProjectMemberServiceImpl.java | 352 ++++++ .../service/pm/project/PmsProjectService.java | 132 ++ .../pm/project/PmsProjectServiceImpl.java | 379 ++++++ .../pm/project/PmsProjectTemplateService.java | 53 + .../PmsProjectTemplateServiceImpl.java | 186 +++ .../pm/workbench/PmsWorkbenchService.java | 43 + .../pm/workbench/PmsWorkbenchServiceImpl.java | 83 ++ .../workitem/PmsWorkItemActivityService.java | 99 ++ .../PmsWorkItemActivityServiceImpl.java | 240 ++++ .../pm/workitem/PmsWorkItemBoardService.java | 59 + .../workitem/PmsWorkItemBoardServiceImpl.java | 64 + .../workitem/PmsWorkItemCommentService.java | 64 + .../PmsWorkItemCommentServiceImpl.java | 153 +++ .../pm/workitem/PmsWorkItemLabelService.java | 74 ++ .../workitem/PmsWorkItemLabelServiceImpl.java | 95 ++ .../pm/workitem/PmsWorkItemService.java | 296 +++++ .../pm/workitem/PmsWorkItemServiceImpl.java | 955 ++++++++++++++ .../pm/workitem/PmsWorkItemStatusService.java | 158 +++ .../PmsWorkItemStatusServiceImpl.java | 462 +++++++ .../workitem/PmsWorkItemUserSortService.java | 46 + .../PmsWorkItemUserSortServiceImpl.java | 71 ++ .../workitem/PmsWorkItemWorkLogService.java | 85 ++ .../PmsWorkItemWorkLogServiceImpl.java | 239 ++++ .../src/main/resources/application-dev.yaml | 120 ++ .../src/main/resources/application-local.yaml | 142 +++ .../src/main/resources/application.yaml | 124 ++ .../src/main/resources/logback-spring.xml | 56 + ...sKnowledgeDocumentShareControllerTest.java | 50 + ...ledgeContentPermissionServiceImplTest.java | 280 +++++ ...KnowledgeDocumentLabelServiceImplTest.java | 158 +++ .../PmsKnowledgeDocumentServiceImplTest.java | 498 ++++++++ .../PmsKnowledgeFolderServiceImplTest.java | 247 ++++ ...owledgeDocumentCommentServiceImplTest.java | 145 +++ ...sKnowledgeDocumentLikeServiceImplTest.java | 86 ++ ...KnowledgeDocumentShareServiceImplTest.java | 227 ++++ .../PmsKnowledgeFavoriteServiceImplTest.java | 189 +++ ...ledgeInteractionTargetServiceImplTest.java | 101 ++ ...PmsKnowledgeViewRecordServiceImplTest.java | 158 +++ .../PmsKnowledgeGroupServiceImplTest.java | 203 +++ ...KnowledgeLibraryMemberServiceImplTest.java | 393 ++++++ .../PmsKnowledgeLibraryServiceImplTest.java | 215 ++++ ...owledgeLibraryTemplateServiceImplTest.java | 181 +++ .../PmsKnowledgeRecycleServiceImplTest.java | 385 ++++++ .../PmsIterationServiceImplTest.java | 311 +++++ ...PmsProjectAnnouncementServiceImplTest.java | 133 ++ .../PmsProjectFavoriteServiceImplTest.java | 82 ++ .../PmsProjectGroupServiceImplTest.java | 345 +++++ .../PmsProjectMemberServiceImplTest.java | 358 ++++++ .../pm/project/PmsProjectServiceImplTest.java | 333 +++++ .../PmsProjectTemplateServiceImplTest.java | 138 ++ .../PmsWorkbenchServiceImplTest.java | 106 ++ .../PmsWorkItemActivityServiceImplTest.java | 77 ++ .../PmsWorkItemCommentServiceImplTest.java | 118 ++ .../PmsWorkItemLabelServiceImplTest.java | 70 ++ .../workitem/PmsWorkItemServiceImplTest.java | 1119 +++++++++++++++++ .../PmsWorkItemStatusServiceImplTest.java | 426 +++++++ .../PmsWorkItemWorkLogServiceImplTest.java | 186 +++ .../test/resources/application-unit-test.yaml | 36 + .../src/test/resources/sql/clean.sql | 14 + .../src/test/resources/sql/create_tables.sql | 592 +++++++++ yudao-server/pom.xml | 14 + .../server/controller/DefaultController.java | 18 + 333 files changed, 32740 insertions(+), 8 deletions(-) create mode 100644 yudao-module-pms/pom.xml create mode 100644 yudao-module-pms/yudao-module-pms-api/pom.xml create mode 100644 yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/api/package-info.java create mode 100644 yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/ApiConstants.java create mode 100644 yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/DictTypeConstants.java create mode 100644 yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/ErrorCodeConstants.java create mode 100644 yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/MessageTemplateConstants.java create mode 100644 yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/kb/PmsKnowledgeObjectTypeEnum.java create mode 100644 yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/kb/content/PmsKnowledgeContentLevelEnum.java create mode 100644 yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/kb/content/PmsKnowledgeDocumentStatusEnum.java create mode 100644 yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/kb/content/PmsKnowledgeDocumentTypeEnum.java create mode 100644 yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/kb/library/PmsKnowledgeGroupTypeEnum.java create mode 100644 yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/kb/library/PmsKnowledgeLibraryMemberLevelEnum.java create mode 100644 yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/iteration/PmsIterationStatusEnum.java create mode 100644 yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/project/PmsProjectGroupTypeEnum.java create mode 100644 yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/project/PmsProjectLevelEnum.java create mode 100644 yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/project/PmsProjectMemberLevelEnum.java create mode 100644 yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/project/PmsProjectSceneTypeEnum.java create mode 100644 yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/project/PmsProjectSortTypeEnum.java create mode 100644 yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/project/PmsProjectStatusEnum.java create mode 100644 yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/project/PmsProjectTypeEnum.java create mode 100644 yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/workitem/PmsWorkItemActivityContentEnum.java create mode 100644 yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/workitem/PmsWorkItemDefectTypeEnum.java create mode 100644 yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/workitem/PmsWorkItemLifecycleStatusEnum.java create mode 100644 yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/workitem/PmsWorkItemPriorityEnum.java create mode 100644 yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/workitem/PmsWorkItemStatusTypeEnum.java create mode 100644 yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/workitem/PmsWorkItemTypeEnum.java create mode 100644 yudao-module-pms/yudao-module-pms-server/pom.xml create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/PmsServerApplication.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/PmsKnowledgeContentPermissionController.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/PmsKnowledgeDocumentController.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/PmsKnowledgeDocumentLabelController.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/PmsKnowledgeFolderController.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/document/PmsKnowledgeDocumentCreateReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/document/PmsKnowledgeDocumentMoveReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/document/PmsKnowledgeDocumentRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/document/PmsKnowledgeDocumentSearchPageReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/document/PmsKnowledgeDocumentUpdateReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/folder/PmsKnowledgeFolderMoveReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/folder/PmsKnowledgeFolderRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/folder/PmsKnowledgeFolderSaveReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/folder/PmsKnowledgeTreeRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/label/PmsKnowledgeDocumentLabelPageReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/label/PmsKnowledgeDocumentLabelRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/label/PmsKnowledgeDocumentLabelSaveReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/permission/PmsKnowledgeContentPermissionRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/permission/PmsKnowledgeContentPermissionUpdateReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/PmsKnowledgeDocumentCommentController.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/PmsKnowledgeDocumentLikeController.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/PmsKnowledgeDocumentShareController.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/PmsKnowledgeFavoriteController.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/PmsKnowledgeViewRecordController.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/PmsKnowledgeInteractionItemRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/comment/PmsKnowledgeDocumentCommentRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/comment/PmsKnowledgeDocumentCommentSaveReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/favorite/PmsKnowledgeFavoritePageReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/favorite/PmsKnowledgeFavoriteSaveReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/share/PmsKnowledgeDocumentSharePublicRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/share/PmsKnowledgeDocumentShareRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/share/PmsKnowledgeDocumentShareSaveReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/share/PmsKnowledgeDocumentShareUpdateMemberListReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/viewrecord/PmsKnowledgeRecentListRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/PmsKnowledgeGroupController.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/PmsKnowledgeLibraryController.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/PmsKnowledgeLibraryMemberController.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/PmsKnowledgeLibraryTemplateController.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/group/PmsKnowledgeGroupRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/group/PmsKnowledgeGroupSaveReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/group/PmsKnowledgeGroupSortReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/group/PmsKnowledgeLibraryMoveGroupReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/library/PmsKnowledgeLibraryPageReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/library/PmsKnowledgeLibraryRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/library/PmsKnowledgeLibrarySaveReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/member/PmsKnowledgeLibraryMemberRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/member/PmsKnowledgeLibraryUpdateMemberListReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/template/PmsKnowledgeLibraryTemplatePageReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/template/PmsKnowledgeLibraryTemplateRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/template/PmsKnowledgeLibraryTemplateSaveReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/recycle/PmsKnowledgeRecycleController.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/recycle/vo/PmsKnowledgeRecycleDetailRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/recycle/vo/PmsKnowledgeRecyclePreviewRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/recycle/vo/PmsKnowledgeRecycleRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/iteration/PmsIterationController.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/iteration/vo/PmsIterationOverviewRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/iteration/vo/PmsIterationPageReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/iteration/vo/PmsIterationRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/iteration/vo/PmsIterationSaveReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/iteration/vo/PmsIterationStartReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/PmsProjectAnnouncementController.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/PmsProjectController.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/PmsProjectFavoriteController.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/PmsProjectGroupController.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/PmsProjectMemberController.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/PmsProjectTemplateController.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/announcement/PmsProjectAnnouncementRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/announcement/PmsProjectAnnouncementSaveReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/group/PmsProjectGroupMoveReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/group/PmsProjectGroupRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/group/PmsProjectGroupSaveReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/group/PmsProjectGroupSortReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/member/PmsProjectMemberRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/member/PmsProjectMemberSaveReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/project/PmsProjectOverviewRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/project/PmsProjectPageReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/project/PmsProjectRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/project/PmsProjectSaveReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/template/PmsProjectTemplatePageReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/template/PmsProjectTemplateRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/template/PmsProjectTemplateSaveReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workbench/PmsWorkbenchController.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workbench/vo/PmsWorkbenchCountRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workbench/vo/PmsWorkbenchIterationRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workbench/vo/PmsWorkbenchPageReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workbench/vo/PmsWorkbenchWorkItemRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/PmsWorkItemActivityController.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/PmsWorkItemCommentController.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/PmsWorkItemController.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/PmsWorkItemLabelController.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/PmsWorkItemStatusController.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/PmsWorkItemWorkLogController.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/activity/PmsWorkItemActivityRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/comment/PmsWorkItemCommentRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/comment/PmsWorkItemCommentSaveReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/label/PmsWorkItemLabelRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/label/PmsWorkItemLabelSaveReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/status/PmsWorkItemBoardConfigRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/status/PmsWorkItemBoardConfigSaveReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/status/PmsWorkItemStatusConfigUpdateReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/status/PmsWorkItemStatusCreateReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/status/PmsWorkItemStatusDeleteReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/status/PmsWorkItemStatusRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/status/PmsWorkItemStatusSortReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/status/PmsWorkItemStatusUpdateReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemBoardReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemBoardRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemImportExcelVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemImportRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemIterationUpdateReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemNameUpdateReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemPageReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemPlanningSortReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemSaveReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemSortReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/worklog/PmsProjectWorkLogReportReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/worklog/PmsProjectWorkLogReportRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/worklog/PmsWorkItemWorkLogRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/worklog/PmsWorkItemWorkLogSaveReqVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/worklog/PmsWorkItemWorkLogSummaryRespVO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/package-info.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/content/PmsKnowledgeContentPermissionDO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/content/PmsKnowledgeContentPermissionMemberDO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/content/PmsKnowledgeDocumentDO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/content/PmsKnowledgeDocumentLabelDO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/content/PmsKnowledgeFolderDO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/interaction/PmsKnowledgeDocumentCommentDO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/interaction/PmsKnowledgeDocumentLikeDO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/interaction/PmsKnowledgeDocumentShareDO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/interaction/PmsKnowledgeFavoriteDO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/interaction/PmsKnowledgeViewRecordDO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/library/PmsKnowledgeGroupDO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/library/PmsKnowledgeGroupRelationDO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/library/PmsKnowledgeLibraryDO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/library/PmsKnowledgeLibraryMemberDO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/library/PmsKnowledgeLibraryTemplateDO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/recycle/PmsKnowledgeRecycleRecordDO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/iteration/PmsIterationDO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/project/PmsProjectAnnouncementDO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/project/PmsProjectDO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/project/PmsProjectFavoriteDO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/project/PmsProjectGroupDO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/project/PmsProjectGroupRelationDO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/project/PmsProjectMemberDO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/project/PmsProjectTemplateDO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/workitem/PmsWorkItemActivityDO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/workitem/PmsWorkItemBoardDO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/workitem/PmsWorkItemCommentDO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/workitem/PmsWorkItemDO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/workitem/PmsWorkItemLabelDO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/workitem/PmsWorkItemMemberDO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/workitem/PmsWorkItemStatusDO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/workitem/PmsWorkItemUserSortDO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/workitem/PmsWorkItemWorkLogDO.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/content/PmsKnowledgeContentPermissionMapper.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/content/PmsKnowledgeContentPermissionMemberMapper.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/content/PmsKnowledgeDocumentLabelMapper.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/content/PmsKnowledgeDocumentMapper.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/content/PmsKnowledgeFolderMapper.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/interaction/PmsKnowledgeDocumentCommentMapper.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/interaction/PmsKnowledgeDocumentLikeMapper.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/interaction/PmsKnowledgeDocumentShareMapper.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/interaction/PmsKnowledgeFavoriteMapper.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/interaction/PmsKnowledgeViewRecordMapper.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/library/PmsKnowledgeGroupMapper.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/library/PmsKnowledgeGroupRelationMapper.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/library/PmsKnowledgeLibraryMapper.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/library/PmsKnowledgeLibraryMemberMapper.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/library/PmsKnowledgeLibraryTemplateMapper.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/recycle/PmsKnowledgeRecycleRecordMapper.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/package-info.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/iteration/PmsIterationMapper.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/project/PmsProjectAnnouncementMapper.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/project/PmsProjectFavoriteMapper.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/project/PmsProjectGroupMapper.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/project/PmsProjectGroupRelationMapper.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/project/PmsProjectMapper.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/project/PmsProjectMemberMapper.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/project/PmsProjectTemplateMapper.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemActivityMapper.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemBoardMapper.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemCommentMapper.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemLabelMapper.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemMapper.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemMemberMapper.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemStatusMapper.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemUserSortMapper.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemWorkLogMapper.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/framework/package-info.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/framework/rpc/config/RpcConfiguration.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/framework/security/config/SecurityConfiguration.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/framework/web/config/PmsWebConfiguration.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/framework/web/package-info.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/job/kb/PmsKnowledgeRecycleCleanJob.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/job/package-info.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/package-info.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeContentPermissionService.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeContentPermissionServiceImpl.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeDocumentLabelService.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeDocumentLabelServiceImpl.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeDocumentService.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeDocumentServiceImpl.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeFolderService.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeFolderServiceImpl.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeDocumentCommentService.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeDocumentCommentServiceImpl.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeDocumentLikeService.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeDocumentLikeServiceImpl.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeDocumentShareService.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeDocumentShareServiceImpl.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeFavoriteService.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeFavoriteServiceImpl.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeFolderReadService.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeFolderReadServiceImpl.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeInteractionTargetService.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeInteractionTargetServiceImpl.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeViewRecordService.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeViewRecordServiceImpl.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeGroupService.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeGroupServiceImpl.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeLibraryMemberService.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeLibraryMemberServiceImpl.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeLibraryService.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeLibraryServiceImpl.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeLibraryTemplateService.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeLibraryTemplateServiceImpl.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/recycle/PmsKnowledgeRecycleService.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/recycle/PmsKnowledgeRecycleServiceImpl.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/iteration/PmsIterationService.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/iteration/PmsIterationServiceImpl.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectAnnouncementService.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectAnnouncementServiceImpl.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectFavoriteService.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectFavoriteServiceImpl.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectGroupService.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectGroupServiceImpl.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectMemberService.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectMemberServiceImpl.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectService.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectServiceImpl.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectTemplateService.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectTemplateServiceImpl.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workbench/PmsWorkbenchService.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workbench/PmsWorkbenchServiceImpl.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemActivityService.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemActivityServiceImpl.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemBoardService.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemBoardServiceImpl.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemCommentService.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemCommentServiceImpl.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemLabelService.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemLabelServiceImpl.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemService.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemServiceImpl.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemStatusService.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemStatusServiceImpl.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemUserSortService.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemUserSortServiceImpl.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemWorkLogService.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemWorkLogServiceImpl.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/resources/application-dev.yaml create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/resources/application-local.yaml create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/resources/application.yaml create mode 100644 yudao-module-pms/yudao-module-pms-server/src/main/resources/logback-spring.xml create mode 100644 yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/PmsKnowledgeDocumentShareControllerTest.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeContentPermissionServiceImplTest.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeDocumentLabelServiceImplTest.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeDocumentServiceImplTest.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeFolderServiceImplTest.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeDocumentCommentServiceImplTest.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeDocumentLikeServiceImplTest.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeDocumentShareServiceImplTest.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeFavoriteServiceImplTest.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeInteractionTargetServiceImplTest.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeViewRecordServiceImplTest.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeGroupServiceImplTest.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeLibraryMemberServiceImplTest.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeLibraryServiceImplTest.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeLibraryTemplateServiceImplTest.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/recycle/PmsKnowledgeRecycleServiceImplTest.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/iteration/PmsIterationServiceImplTest.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectAnnouncementServiceImplTest.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectFavoriteServiceImplTest.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectGroupServiceImplTest.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectMemberServiceImplTest.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectServiceImplTest.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectTemplateServiceImplTest.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/workbench/PmsWorkbenchServiceImplTest.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemActivityServiceImplTest.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemCommentServiceImplTest.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemLabelServiceImplTest.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemServiceImplTest.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemStatusServiceImplTest.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemWorkLogServiceImplTest.java create mode 100644 yudao-module-pms/yudao-module-pms-server/src/test/resources/application-unit-test.yaml create mode 100644 yudao-module-pms/yudao-module-pms-server/src/test/resources/sql/clean.sql create mode 100644 yudao-module-pms/yudao-module-pms-server/src/test/resources/sql/create_tables.sql diff --git a/pom.xml b/pom.xml index 621e0142f..00485c3bc 100644 --- a/pom.xml +++ b/pom.xml @@ -31,6 +31,7 @@ yudao-module-ai yudao-module-hrm yudao-module-fms + yudao-module-pms ${project.artifactId} diff --git a/sql/mysql/ruoyi-vue-pro.sql b/sql/mysql/ruoyi-vue-pro.sql index c31b0d63b..b8fd2e995 100644 --- a/sql/mysql/ruoyi-vue-pro.sql +++ b/sql/mysql/ruoyi-vue-pro.sql @@ -4204,7 +4204,7 @@ INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_i INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (8052, '知识库创建', 'pms:kb:library:create', 3, 2, 8050, '', '', '', '', 0, b'1', b'1', b'1', '1', '2026-08-19 00:28:42', '1', '2026-08-23 16:21:39', b'0'); INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (8053, '知识库修改', 'pms:kb:library:update', 3, 3, 8050, '', '', '', '', 0, b'1', b'1', b'1', '1', '2026-08-19 00:28:42', '1', '2026-08-23 16:21:39', b'0'); INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (8054, '知识库删除', 'pms:kb:library:delete', 3, 4, 8050, '', '', '', '', 0, b'1', b'1', b'1', '1', '2026-08-19 00:28:42', '1', '2026-08-23 16:21:39', b'0'); -INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (8060, '我的关注', 'pms:kb:knowledge-library:query', 2, 40, 8090, 'collection', 'ep:star', 'pms/kb/collection/index', 'PmsKnowledgeCollection', 0, b'1', b'1', b'0', '1', '2026-08-19 03:05:49', '1', '2026-08-23 16:12:56', b'0'); +INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (8060, '我的关注', 'pms:kb:knowledge-library:query', 2, 40, 8090, 'favorite', 'ep:star', 'pms/kb/favorite/index', 'PmsKnowledgeFavorite', 0, b'1', b'1', b'0', '1', '2026-08-19 03:05:49', '1', '2026-08-23 16:12:56', b'0'); INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (8070, '最近使用', 'pms:kb:knowledge-library:query', 2, 50, 8090, 'recent', 'ep:clock', 'pms/kb/recent/index', 'PmsKnowledgeRecent', 0, b'1', b'1', b'0', '1', '2026-08-19 03:05:49', '1', '2026-08-23 16:12:56', b'0'); INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (8080, '项目中心', '', 1, 10, 8000, 'pm', 'ep:grid', NULL, NULL, 0, b'1', b'1', b'1', '1', '2026-08-19 11:42:30', '1', '2026-08-20 09:05:58', b'0'); INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (8081, '归档项目', 'pms:pm:project:query', 2, 30, 8080, 'project/archive', 'ep:box', 'pms/pm/project/archive/index', 'PmsProjectArchive', 0, b'1', b'0', b'0', '1', '2026-08-19 11:42:30', '1', '2026-08-27 10:01:06', b'0'); diff --git a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/banner/core/BannerApplicationRunner.java b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/banner/core/BannerApplicationRunner.java index 517fd603e..2702c703a 100644 --- a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/banner/core/BannerApplicationRunner.java +++ b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/banner/core/BannerApplicationRunner.java @@ -43,6 +43,12 @@ public class BannerApplicationRunner implements ApplicationRunner { System.out.println("[CRM 系统 yudao-module-crm - 教程][参考 https://cloud.iocoder.cn/crm/build/ 开启]"); // MES 系统 System.out.println("[MES 系统 yudao-module-mes - 教程][参考 https://cloud.iocoder.cn/mes/build/ 开启]"); + // HRM 人力资源管理系统 + System.out.println("[HRM 人力资源管理系统 yudao-module-hrm - 教程][参考 https://cloud.iocoder.cn/hrm/build/ 开启]"); + // FMS 财务管理系统 + System.out.println("[FMS 财务管理系统 yudao-module-fms - 教程][参考 https://cloud.iocoder.cn/fms/build/ 开启]"); + // PMS 项目管理系统 + System.out.println("[PMS 项目管理系统 yudao-module-pms - 教程][参考 https://cloud.iocoder.cn/pms/build/ 开启]"); // IM 即时通讯 System.out.println("[IM 即时通讯 yudao-module-im - 教程][参考 https://cloud.iocoder.cn/im/build/ 开启]"); // 微信公众号 diff --git a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/handler/GlobalExceptionHandler.java b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/handler/GlobalExceptionHandler.java index e239d4811..85a19df3d 100644 --- a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/handler/GlobalExceptionHandler.java +++ b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/handler/GlobalExceptionHandler.java @@ -430,43 +430,61 @@ public class GlobalExceptionHandler { return CommonResult.error(NOT_IMPLEMENTED.getCode(), "[ERP 系统 yudao-module-erp - 表结构未导入][参考 https://cloud.iocoder.cn/erp/build/ 开启]"); } - // 6. WMS 仓库管理系统 + // 6. HRM 人力资源管理系统 + if (message.contains("hrm_")) { + log.error("[HRM 人力资源管理系统 yudao-module-hrm - 表结构未导入][参考 https://cloud.iocoder.cn/hrm/build/ 开启]"); + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[HRM 人力资源管理系统 yudao-module-hrm - 表结构未导入][参考 https://cloud.iocoder.cn/hrm/build/ 开启]"); + } + // 7. FMS 财务管理系统 + if (message.contains("fms_")) { + log.error("[FMS 财务管理系统 yudao-module-fms - 表结构未导入][参考 https://cloud.iocoder.cn/fms/build/ 开启]"); + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[FMS 财务管理系统 yudao-module-fms - 表结构未导入][参考 https://cloud.iocoder.cn/fms/build/ 开启]"); + } + // 8. PMS 项目管理系统 + if (message.contains("pms_")) { + log.error("[PMS 项目管理系统 yudao-module-pms - 表结构未导入][参考 https://cloud.iocoder.cn/pms/build/ 开启]"); + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[PMS 项目管理系统 yudao-module-pms - 表结构未导入][参考 https://cloud.iocoder.cn/pms/build/ 开启]"); + } + // 9. WMS 仓库管理系统 if (message.contains("wms_")) { log.error("[WMS 仓库管理系统 yudao-module-wms - 表结构未导入][参考 https://cloud.iocoder.cn/wms/build/ 开启]"); return CommonResult.error(NOT_IMPLEMENTED.getCode(), "[WMS 仓库管理系统 yudao-module-wms - 表结构未导入][参考 https://cloud.iocoder.cn/wms/build/ 开启]"); } - // 7. CRM 系统 + // 10. CRM 系统 if (message.contains("crm_")) { log.error("[CRM 系统 yudao-module-crm - 表结构未导入][参考 https://cloud.iocoder.cn/crm/build/ 开启]"); return CommonResult.error(NOT_IMPLEMENTED.getCode(), "[CRM 系统 yudao-module-crm - 表结构未导入][参考 https://cloud.iocoder.cn/crm/build/ 开启]"); } - // 8. MES 系统 + // 11. MES 系统 if (message.contains("mes_")) { log.error("[MES 系统 yudao-module-mes - 表结构未导入][参考 https://cloud.iocoder.cn/mes/build/ 开启]"); return CommonResult.error(NOT_IMPLEMENTED.getCode(), "[MES 系统 yudao-module-mes - 表结构未导入][参考 https://cloud.iocoder.cn/mes/build/ 开启]"); } - // 9. IM 即时通讯 + // 12. IM 即时通讯 if (message.contains("im_")) { log.error("[IM 即时通讯 yudao-module-im - 表结构未导入][参考 https://cloud.iocoder.cn/im/build/ 开启]"); return CommonResult.error(NOT_IMPLEMENTED.getCode(), "[IM 即时通讯 yudao-module-im - 表结构未导入][参考 https://cloud.iocoder.cn/im/build/ 开启]"); } - // 10. 支付平台 + // 13. 支付平台 if (message.contains("pay_")) { log.error("[支付模块 yudao-module-pay - 表结构未导入][参考 https://cloud.iocoder.cn/pay/build/ 开启]"); return CommonResult.error(NOT_IMPLEMENTED.getCode(), "[支付模块 yudao-module-pay - 表结构未导入][参考 https://cloud.iocoder.cn/pay/build/ 开启]"); } - // 11. AI 大模型 + // 14. AI 大模型 if (message.contains("ai_")) { log.error("[AI 大模型 yudao-module-ai - 表结构未导入][参考 https://cloud.iocoder.cn/ai/build/ 开启]"); return CommonResult.error(NOT_IMPLEMENTED.getCode(), "[AI 大模型 yudao-module-ai - 表结构未导入][参考 https://cloud.iocoder.cn/ai/build/ 开启]"); } - // 12. IoT 物联网 + // 15. IoT 物联网 if (message.contains("iot_")) { log.error("[IoT 物联网 yudao-module-iot - 表结构未导入][参考 https://doc.iocoder.cn/iot/build/ 开启]"); return CommonResult.error(NOT_IMPLEMENTED.getCode(), diff --git a/yudao-gateway/src/main/resources/application.yaml b/yudao-gateway/src/main/resources/application.yaml index d89bc7777..9a4657da4 100644 --- a/yudao-gateway/src/main/resources/application.yaml +++ b/yudao-gateway/src/main/resources/application.yaml @@ -230,6 +230,13 @@ spring: - Path=/admin-api/fms/** filters: - RewritePath=/admin-api/fms/v3/api-docs, /v3/api-docs # 配置,保证转发到 /v3/api-docs + ## pms-server 服务 + - id: pms-admin-api # 路由的编号 + uri: grayLb://pms-server + predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组 + - Path=/admin-api/pms/** + filters: + - RewritePath=/admin-api/pms/v3/api-docs, /v3/api-docs # 配置,保证转发到 /v3/api-docs x-forwarded: prefix-enabled: false # 避免 Swagger 重复带上额外的 /admin-api/system 前缀 default-filters: @@ -304,6 +311,9 @@ knife4j: - name: fms-server service-name: fms-server url: /admin-api/fms/v3/api-docs + - name: pms-server + service-name: pms-server + url: /admin-api/pms/v3/api-docs --- #################### 芋道相关配置 #################### diff --git a/yudao-module-pms/pom.xml b/yudao-module-pms/pom.xml new file mode 100644 index 000000000..14333d7bc --- /dev/null +++ b/yudao-module-pms/pom.xml @@ -0,0 +1,24 @@ + + + + cn.iocoder.cloud + yudao + ${revision} + + 4.0.0 + + yudao-module-pms-api + yudao-module-pms-server + + yudao-module-pms + pom + + ${project.artifactId} + + pms 包下,项目管理(Project Management System)。 + 例如说:项目、工作事项、迭代、看板、工时和知识库等等 + + + diff --git a/yudao-module-pms/yudao-module-pms-api/pom.xml b/yudao-module-pms/yudao-module-pms-api/pom.xml new file mode 100644 index 000000000..ae6c5c95a --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-api/pom.xml @@ -0,0 +1,33 @@ + + + + cn.iocoder.cloud + yudao-module-pms + ${revision} + + 4.0.0 + yudao-module-pms-api + jar + + ${project.artifactId} + + pms 模块 API,暴露给其它模块调用 + + + + + cn.iocoder.cloud + yudao-common + + + + + org.springframework.boot + spring-boot-starter-validation + true + + + + diff --git a/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/api/package-info.java b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/api/package-info.java new file mode 100644 index 000000000..163c18d5a --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/api/package-info.java @@ -0,0 +1,4 @@ +/** + * pms API 包,定义暴露给其它模块的 API + */ +package cn.iocoder.yudao.module.pms.api; diff --git a/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/ApiConstants.java b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/ApiConstants.java new file mode 100644 index 000000000..9147202f3 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/ApiConstants.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.pms.enums; + +import cn.iocoder.yudao.framework.common.enums.RpcConstants; + +/** + * API 相关的枚举 + * + * @author 芋道源码 + */ +public class ApiConstants { + + /** 服务名,需要和 spring.application.name 保持一致。 */ + public static final String NAME = "pms-server"; + + public static final String PREFIX = RpcConstants.RPC_API_PREFIX + "/pms"; + + public static final String VERSION = "1.0.0"; + +} diff --git a/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/DictTypeConstants.java b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/DictTypeConstants.java new file mode 100644 index 000000000..09f460044 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/DictTypeConstants.java @@ -0,0 +1,30 @@ +package cn.iocoder.yudao.module.pms.enums; + +/** + * PMS 字典类型的枚举类 + * + * @author 芋道源码 + */ +public interface DictTypeConstants { + + String PMS_WORK_ITEM_PRIORITY = "pms_work_item_priority"; // PMS 工作项优先级 + String PMS_WORK_ITEM_DEFECT_TYPE = "pms_work_item_defect_type"; // PMS 工作项缺陷类型 + String PMS_PROJECT_TYPE = "pms_project_type"; // PMS 项目类型 + String PMS_PROJECT_STATUS = "pms_project_status"; // PMS 项目状态 + String PMS_PROJECT_LEVEL = "pms_project_level"; // PMS 项目优先级 + String PMS_PROJECT_GROUP_TYPE = "pms_project_group_type"; // PMS 项目分组类型 + String PMS_PROJECT_SCENE_TYPE = "pms_project_scene_type"; // PMS 项目列表场景 + String PMS_PROJECT_SORT_TYPE = "pms_project_sort_type"; // PMS 项目排序类型 + String PMS_PROJECT_MEMBER_LEVEL = "pms_project_member_level"; // PMS 项目成员权限级别 + String PMS_ITERATION_STATUS = "pms_iteration_status"; // PMS 迭代状态 + String PMS_WORK_ITEM_TYPE = "pms_work_item_type"; // PMS 工作项类型 + String PMS_WORK_ITEM_STATUS_TYPE = "pms_work_item_status_type"; // PMS 工作项语义状态 + String PMS_WORK_ITEM_LIFECYCLE_STATUS = "pms_work_item_lifecycle_status"; // PMS 工作项生命周期状态 + String PMS_KNOWLEDGE_DOCUMENT_STATUS = "pms_knowledge_document_status"; // PMS 知识文档状态 + String PMS_KNOWLEDGE_CONTENT_LEVEL = "pms_knowledge_content_level"; // PMS 知识内容协作等级 + String PMS_KNOWLEDGE_GROUP_TYPE = "pms_knowledge_group_type"; // PMS 知识库分组类型 + String PMS_KNOWLEDGE_LIBRARY_MEMBER_LEVEL = "pms_knowledge_library_member_level"; // PMS 知识库成员等级 + String PMS_KNOWLEDGE_OBJECT_TYPE = "pms_knowledge_object_type"; // PMS 知识对象类型 + String PMS_KNOWLEDGE_DOCUMENT_TYPE = "pms_knowledge_document_type"; // PMS 知识文档类型 + +} diff --git a/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/ErrorCodeConstants.java b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/ErrorCodeConstants.java new file mode 100644 index 000000000..c7daa87b9 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/ErrorCodeConstants.java @@ -0,0 +1,114 @@ +package cn.iocoder.yudao.module.pms.enums; + +import cn.iocoder.yudao.framework.common.exception.ErrorCode; + +/** + * PMS 错误码常量 + * + * PMS 系统,使用 1-053-000-000 段 + * + * @author 芋道源码 + */ +public interface ErrorCodeConstants { + + // ========== 项目 1-053-100-000 ========== + ErrorCode PROJECT_NOT_EXISTS = new ErrorCode(1_053_100_000, "项目不存在"); + ErrorCode PROJECT_ACCESS_DENIED = new ErrorCode(1_053_100_001, "无权访问该项目"); + ErrorCode PROJECT_ADMIN_REQUIRED = new ErrorCode(1_053_100_002, "只有项目管理员可以执行该操作"); + ErrorCode PROJECT_STATUS_INVALID = new ErrorCode(1_053_100_004, "当前项目状态不允许执行该操作"); + ErrorCode PROJECT_WRITE_ACCESS_DENIED = new ErrorCode(1_053_100_005, "当前项目角色无编辑权限"); + ErrorCode PROJECT_ANNOUNCEMENT_NOT_EXISTS = new ErrorCode(1_053_100_006, "项目公告不存在"); + ErrorCode PROJECT_OWNER_REQUIRED = new ErrorCode(1_053_100_007, "只有项目拥有者可以执行该操作"); + + // ========== 项目成员 1-053-105-000 ========== + ErrorCode PROJECT_MEMBER_NOT_EXISTS = new ErrorCode(1_053_105_000, "项目成员不存在"); + ErrorCode PROJECT_MEMBER_CREATOR_CANNOT_REMOVE = new ErrorCode(1_053_105_001, "项目创建人不能移出项目"); + ErrorCode PROJECT_MEMBER_OWNER_LEVEL_CANNOT_ASSIGN = new ErrorCode(1_053_105_002, + "项目拥有者级别不能直接分配"); + ErrorCode PROJECT_MEMBER_OWNER_CANNOT_EXIT = new ErrorCode(1_053_105_003, "项目拥有者不能退出项目"); + + // ========== 项目分组 1-053-110-000 ========== + ErrorCode PROJECT_GROUP_NOT_EXISTS = new ErrorCode(1_053_110_000, "项目分组不存在"); + ErrorCode PROJECT_GROUP_NAME_DUPLICATE = new ErrorCode(1_053_110_001, "项目分组名称已存在"); + ErrorCode PROJECT_GROUP_DEFAULT_CANNOT_MODIFY = new ErrorCode(1_053_110_002, "默认项目分组不允许修改"); + ErrorCode PROJECT_GROUP_DEFAULT_CANNOT_DELETE = new ErrorCode(1_053_110_003, "默认项目分组不允许删除"); + + // ========== 项目模板 1-053-112-000 ========== + ErrorCode PROJECT_TEMPLATE_NOT_EXISTS = new ErrorCode(1_053_112_000, "项目模板不存在"); + ErrorCode PROJECT_TEMPLATE_NAME_DUPLICATE = new ErrorCode(1_053_112_001, "同一项目类型下的模板名称已存在"); + ErrorCode PROJECT_TEMPLATE_CONFIG_INVALID = new ErrorCode(1_053_112_002, "项目模板配置无效:{}"); + + // ========== 项目迭代 1-053-115-000 ========== + ErrorCode ITERATION_NOT_EXISTS = new ErrorCode(1_053_115_000, "项目迭代不存在"); + ErrorCode ITERATION_STATUS_INVALID = new ErrorCode(1_053_115_002, "当前迭代状态不允许执行该操作"); + + // ========== 工作项 1-053-120-000 ========== + ErrorCode WORK_ITEM_NOT_EXISTS = new ErrorCode(1_053_120_000, "工作项不存在"); + ErrorCode WORK_ITEM_TYPE_INVALID = new ErrorCode(1_053_120_001, "当前项目不支持该工作项类型"); + ErrorCode WORK_ITEM_STATUS_NOT_EXISTS = new ErrorCode(1_053_120_002, "工作项状态不存在"); + ErrorCode WORK_ITEM_STATUS_INVALID = new ErrorCode(1_053_120_003, "工作项状态与项目或类型不匹配"); + ErrorCode WORK_ITEM_TIME_INVALID = new ErrorCode(1_053_120_004, "工作项开始时间必须早于截止时间"); + ErrorCode WORK_ITEM_ITERATION_INVALID = new ErrorCode(1_053_120_005, "所属迭代与工作项项目不匹配"); + ErrorCode WORK_ITEM_PARENT_INVALID = new ErrorCode(1_053_120_006, "父工作项与当前工作项不匹配"); + ErrorCode WORK_ITEM_RELATED_REQUIREMENT_INVALID = new ErrorCode(1_053_120_007, "关联需求与当前工作项不匹配"); + ErrorCode WORK_ITEM_DEFECT_TYPE_INVALID = new ErrorCode(1_053_120_008, "只有缺陷工作项可以设置缺陷类型"); + ErrorCode WORK_ITEM_SORT_INVALID = new ErrorCode(1_053_120_009, "工作项看板排序数据无效"); + ErrorCode WORK_ITEM_STATUS_NAME_DUPLICATE = new ErrorCode(1_053_120_010, "工作项状态名称已存在"); + ErrorCode WORK_ITEM_STATUS_DEFAULT_CANNOT_DELETE = new ErrorCode(1_053_120_011, "工作项初始状态不能删除"); + ErrorCode WORK_ITEM_STATUS_TRANSFER_INVALID = new ErrorCode(1_053_120_012, "工作项状态迁移目标无效"); + ErrorCode WORK_ITEM_STATUS_SORT_INVALID = new ErrorCode(1_053_120_013, "工作项状态排序数据无效"); + ErrorCode WORK_ITEM_WORK_LOG_NOT_EXISTS = new ErrorCode(1_053_120_014, "工作项工时记录不存在"); + ErrorCode WORK_ITEM_WORK_LOG_INVALID = new ErrorCode(1_053_120_015, "工时记录与工作项不匹配"); + ErrorCode WORK_ITEM_LABEL_NOT_EXISTS = new ErrorCode(1_053_120_016, "工作项标签不存在"); + ErrorCode WORK_ITEM_LABELS_INVALID = new ErrorCode(1_053_120_017, "工作项标签数据无效"); + ErrorCode WORK_ITEM_COMMENT_NOT_EXISTS = new ErrorCode(1_053_120_018, "工作项评论不存在"); + ErrorCode WORK_ITEM_COMMENT_REPLY_INVALID = new ErrorCode(1_053_120_019, "回复的评论与工作项不匹配"); + ErrorCode WORK_ITEM_COMMENT_ACCESS_DENIED = new ErrorCode(1_053_120_020, "只能修改或删除自己的评论"); + ErrorCode WORK_ITEM_LIFECYCLE_STATUS_INVALID = new ErrorCode(1_053_120_021, "工作项当前生命周期状态不支持该操作"); + ErrorCode WORK_ITEM_DELETE_STATUS_INVALID = new ErrorCode(1_053_120_022, "只有回收站中的工作项才能彻底删除"); + + // ========== 知识库 1-053-130-000 ========== + ErrorCode KNOWLEDGE_LIBRARY_NOT_EXISTS = new ErrorCode(1_053_130_000, "知识库不存在"); + ErrorCode KNOWLEDGE_LIBRARY_ACCESS_DENIED = new ErrorCode(1_053_130_001, "无权访问该知识库"); + ErrorCode KNOWLEDGE_LIBRARY_WRITE_ACCESS_DENIED = new ErrorCode(1_053_130_002, "无权编辑该知识库"); + ErrorCode KNOWLEDGE_LIBRARY_ADMIN_REQUIRED = new ErrorCode(1_053_130_003, "只有知识库管理员可以执行该操作"); + ErrorCode KNOWLEDGE_LIBRARY_CREATOR_CANNOT_REMOVE = new ErrorCode(1_053_130_004, "知识库创建人不能移出知识库"); + ErrorCode KNOWLEDGE_LIBRARY_MEMBERS_INVALID = new ErrorCode(1_053_130_005, "知识库成员数据无效"); + ErrorCode KNOWLEDGE_FOLDER_NOT_EXISTS = new ErrorCode(1_053_130_006, "知识库文件夹不存在"); + ErrorCode KNOWLEDGE_FOLDER_PARENT_INVALID = new ErrorCode(1_053_130_007, "父文件夹与知识库不匹配"); + ErrorCode KNOWLEDGE_FOLDER_MOVE_INVALID = new ErrorCode(1_053_130_008, "文件夹不能移动到自身或其子文件夹中"); + ErrorCode KNOWLEDGE_DOCUMENT_NOT_EXISTS = new ErrorCode(1_053_130_009, "知识库文档不存在"); + ErrorCode KNOWLEDGE_DOCUMENT_PARENT_INVALID = new ErrorCode(1_053_130_010, "父文档与知识库不匹配"); + ErrorCode KNOWLEDGE_DOCUMENT_FOLDER_INVALID = new ErrorCode(1_053_130_011, "文档文件夹与知识库不匹配"); + ErrorCode KNOWLEDGE_DOCUMENT_MOVE_INVALID = new ErrorCode(1_053_130_012, "文档不能移动到自身或其子文档中"); + ErrorCode KNOWLEDGE_DOCUMENT_STATUS_INVALID = new ErrorCode(1_053_130_013, "文档状态无效"); + ErrorCode KNOWLEDGE_DOCUMENT_TYPE_INVALID = new ErrorCode(1_053_130_014, "文档类型无效"); + ErrorCode KNOWLEDGE_DOCUMENT_MOVE_TARGET_INVALID = new ErrorCode(1_053_130_015, "文档移动目标只能选择文件夹或父文档之一"); + ErrorCode KNOWLEDGE_GROUP_NOT_EXISTS = new ErrorCode(1_053_130_020, "知识库分组不存在"); + ErrorCode KNOWLEDGE_GROUP_NAME_DUPLICATE = new ErrorCode(1_053_130_021, "知识库分组名称已存在"); + ErrorCode KNOWLEDGE_GROUP_DEFAULT_CANNOT_MODIFY = new ErrorCode(1_053_130_022, "默认知识库分组不允许修改"); + ErrorCode KNOWLEDGE_GROUP_DEFAULT_CANNOT_DELETE = new ErrorCode(1_053_130_023, "默认知识库分组不允许删除"); + ErrorCode KNOWLEDGE_GROUP_SORT_DUPLICATE = new ErrorCode(1_053_130_024, "知识库分组排序列表存在重复分组"); + ErrorCode KNOWLEDGE_DOCUMENT_LABEL_NOT_EXISTS = new ErrorCode(1_053_130_030, "知识库文档标签不存在"); + ErrorCode KNOWLEDGE_RECYCLE_RECORD_NOT_EXISTS = new ErrorCode(1_053_130_040, "知识库回收站记录不存在"); + ErrorCode KNOWLEDGE_RECYCLE_TYPE_INVALID = new ErrorCode(1_053_130_041, "知识库回收站对象类型无效"); + ErrorCode KNOWLEDGE_INTERACTION_OBJECT_INVALID = new ErrorCode(1_053_130_050, "知识对象不存在或类型不匹配"); + ErrorCode KNOWLEDGE_FAVORITE_DUPLICATE = new ErrorCode(1_053_130_051, "该知识对象已关注"); + ErrorCode KNOWLEDGE_DOCUMENT_SHARE_ALREADY_OPEN = new ErrorCode(1_053_130_060, "该文档已开启分享"); + ErrorCode KNOWLEDGE_DOCUMENT_SHARE_INVALID = new ErrorCode(1_053_130_061, "文档分享不存在或已失效"); + ErrorCode KNOWLEDGE_CONTENT_PERMISSION_NOT_EXISTS = new ErrorCode(1_053_130_070, "知识内容协作权限不存在"); + ErrorCode KNOWLEDGE_CONTENT_ACCESS_DENIED = new ErrorCode(1_053_130_071, "无权访问该知识内容"); + ErrorCode KNOWLEDGE_CONTENT_WRITE_ACCESS_DENIED = new ErrorCode(1_053_130_072, "无权编辑该知识内容"); + ErrorCode KNOWLEDGE_CONTENT_DELETE_ACCESS_DENIED = new ErrorCode(1_053_130_073, "无权删除该知识内容"); + ErrorCode KNOWLEDGE_CONTENT_PERMISSION_MEMBERS_INVALID = new ErrorCode(1_053_130_074, "知识内容协作者数据无效"); + ErrorCode KNOWLEDGE_DOCUMENT_COMMENT_NOT_EXISTS = new ErrorCode(1_053_130_080, "知识库文档评论不存在"); + ErrorCode KNOWLEDGE_DOCUMENT_COMMENT_REPLY_INVALID = new ErrorCode(1_053_130_081, "回复的评论与文档不匹配"); + ErrorCode KNOWLEDGE_DOCUMENT_COMMENT_ACCESS_DENIED = new ErrorCode(1_053_130_082, "只能删除自己的评论"); + ErrorCode KNOWLEDGE_DOCUMENT_COMMENT_TYPE_INVALID = new ErrorCode(1_053_130_083, "只有富文本文档支持评论"); + ErrorCode KNOWLEDGE_LIBRARY_TEMPLATE_NOT_EXISTS = new ErrorCode(1_053_130_084, "知识库模板不存在"); + ErrorCode KNOWLEDGE_LIBRARY_TEMPLATE_INVALID = new ErrorCode(1_053_130_085, "知识库模板配置无效"); + ErrorCode KNOWLEDGE_LIBRARY_DIRECT_MEMBER_NOT_EXISTS = new ErrorCode(1_053_130_086, + "当前账号不是该知识库的直接成员"); + ErrorCode KNOWLEDGE_LIBRARY_TEMPLATE_NAME_DUPLICATE = new ErrorCode(1_053_130_087, "知识库模板名称已存在"); + +} diff --git a/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/MessageTemplateConstants.java b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/MessageTemplateConstants.java new file mode 100644 index 000000000..95c82fe0f --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/MessageTemplateConstants.java @@ -0,0 +1,15 @@ +package cn.iocoder.yudao.module.pms.enums; + +/** + * PMS 消息模板常量 + * + * @author 芋道源码 + */ +public interface MessageTemplateConstants { + + // ======================= 站内信模板 ======================= + + String WORK_ITEM_ASSIGNED = "pms_work_item_assigned"; // 工作项指派通知 + String KNOWLEDGE_DOCUMENT_SHARED = "pms_knowledge_document_shared"; // 知识文档分享通知 + +} diff --git a/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/kb/PmsKnowledgeObjectTypeEnum.java b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/kb/PmsKnowledgeObjectTypeEnum.java new file mode 100644 index 000000000..08ddf221a --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/kb/PmsKnowledgeObjectTypeEnum.java @@ -0,0 +1,41 @@ +package cn.iocoder.yudao.module.pms.enums.kb; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * PMS 知识对象类型枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum PmsKnowledgeObjectTypeEnum implements ArrayValuable { + + LIBRARY(1), // 知识库 + FOLDER(2), // 文件夹 + DOCUMENT(3), // 文档 + FILE(4); // 文件 + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(PmsKnowledgeObjectTypeEnum::getType).toArray(Integer[]::new); + + /** + * 类型 + */ + private final Integer type; + + @Override + public Integer[] array() { + return ARRAYS; + } + + public static PmsKnowledgeObjectTypeEnum valueOf(Integer type) { + return ArrayUtil.firstMatch(item -> item.getType().equals(type), values()); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/kb/content/PmsKnowledgeContentLevelEnum.java b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/kb/content/PmsKnowledgeContentLevelEnum.java new file mode 100644 index 000000000..f1703d789 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/kb/content/PmsKnowledgeContentLevelEnum.java @@ -0,0 +1,59 @@ +package cn.iocoder.yudao.module.pms.enums.kb.content; + +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * PMS 知识内容协作等级枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum PmsKnowledgeContentLevelEnum implements ArrayValuable { + + MANAGE(1, "管理员"), + EDIT(2, "可编辑"), + PREVIEW(3, "仅预览"), + DOWNLOAD(4, "可下载"), + UPLOAD_DOWNLOAD(5, "可上传下载"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(PmsKnowledgeContentLevelEnum::getLevel).toArray(Integer[]::new); + + /** + * 等级 + */ + private final Integer level; + /** + * 名称 + */ + private final String name; + + public static PmsKnowledgeContentLevelEnum valueOf(Integer level) { + return ArrayUtil.firstMatch(item -> item.getLevel().equals(level), values()); + } + + @Override + public Integer[] array() { + return ARRAYS; + } + + public static boolean canEdit(Integer level) { + return MANAGE.level.equals(level) || EDIT.level.equals(level) || UPLOAD_DOWNLOAD.level.equals(level); + } + + public static boolean canDelete(Integer level) { + return MANAGE.level.equals(level) || EDIT.level.equals(level); + } + + public static boolean canDownload(Integer level) { + return level != null && ObjectUtil.notEqual(PREVIEW.level, level); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/kb/content/PmsKnowledgeDocumentStatusEnum.java b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/kb/content/PmsKnowledgeDocumentStatusEnum.java new file mode 100644 index 000000000..bde69c576 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/kb/content/PmsKnowledgeDocumentStatusEnum.java @@ -0,0 +1,53 @@ +package cn.iocoder.yudao.module.pms.enums.kb.content; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import cn.iocoder.yudao.framework.common.util.collection.SetUtils; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; +import java.util.Set; + +/** + * PMS 知识库文档状态枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum PmsKnowledgeDocumentStatusEnum implements ArrayValuable { + + RECYCLED(-1, "回收站"), + DRAFT(0, "草稿"), + NORMAL(1, "正常"), + TEMPLATE(2, "模板"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(PmsKnowledgeDocumentStatusEnum::getStatus).toArray(Integer[]::new); + + /** + * 非回收站状态集合 + */ + public static final Set ACTIVE_STATUSES = SetUtils.asSet( + DRAFT.getStatus(), NORMAL.getStatus(), TEMPLATE.getStatus()); + + /** + * 状态 + */ + private final Integer status; + /** + * 名称 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + + public static PmsKnowledgeDocumentStatusEnum valueOf(Integer status) { + return ArrayUtil.firstMatch(item -> item.getStatus().equals(status), values()); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/kb/content/PmsKnowledgeDocumentTypeEnum.java b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/kb/content/PmsKnowledgeDocumentTypeEnum.java new file mode 100644 index 000000000..afdcca26e --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/kb/content/PmsKnowledgeDocumentTypeEnum.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.pms.enums.kb.content; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * PMS 知识库文档类型枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum PmsKnowledgeDocumentTypeEnum implements ArrayValuable { + + RICH_TEXT(3, "富文本"), + FILE(4, "文件"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(PmsKnowledgeDocumentTypeEnum::getType).toArray(Integer[]::new); + + /** + * 类型 + */ + private final Integer type; + /** + * 名称 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + + public static PmsKnowledgeDocumentTypeEnum valueOf(Integer type) { + return ArrayUtil.firstMatch(item -> item.getType().equals(type), values()); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/kb/library/PmsKnowledgeGroupTypeEnum.java b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/kb/library/PmsKnowledgeGroupTypeEnum.java new file mode 100644 index 000000000..9db1bed3c --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/kb/library/PmsKnowledgeGroupTypeEnum.java @@ -0,0 +1,44 @@ +package cn.iocoder.yudao.module.pms.enums.kb.library; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * PMS 知识库分组类型枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum PmsKnowledgeGroupTypeEnum implements ArrayValuable { + + ALL(1, "全部知识库"), + UNGROUPED(2, "未分组"), + CUSTOM(3, "自定义分组"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(PmsKnowledgeGroupTypeEnum::getType).toArray(Integer[]::new); + + /** + * 类型 + */ + private final Integer type; + /** + * 名称 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + + public static PmsKnowledgeGroupTypeEnum valueOf(Integer type) { + return ArrayUtil.firstMatch(item -> item.getType().equals(type), values()); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/kb/library/PmsKnowledgeLibraryMemberLevelEnum.java b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/kb/library/PmsKnowledgeLibraryMemberLevelEnum.java new file mode 100644 index 000000000..2751e39f9 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/kb/library/PmsKnowledgeLibraryMemberLevelEnum.java @@ -0,0 +1,44 @@ +package cn.iocoder.yudao.module.pms.enums.kb.library; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * PMS 知识库成员等级枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum PmsKnowledgeLibraryMemberLevelEnum implements ArrayValuable { + + CREATOR(1, "创建人"), + ADMIN(2, "管理员"), + MEMBER(3, "成员"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(PmsKnowledgeLibraryMemberLevelEnum::getLevel).toArray(Integer[]::new); + + /** + * 等级 + */ + private final Integer level; + /** + * 名称 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + + public static PmsKnowledgeLibraryMemberLevelEnum valueOf(Integer level) { + return ArrayUtil.firstMatch(item -> item.getLevel().equals(level), values()); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/iteration/PmsIterationStatusEnum.java b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/iteration/PmsIterationStatusEnum.java new file mode 100644 index 000000000..32ce03130 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/iteration/PmsIterationStatusEnum.java @@ -0,0 +1,44 @@ +package cn.iocoder.yudao.module.pms.enums.pm.iteration; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * PMS 迭代状态枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum PmsIterationStatusEnum implements ArrayValuable { + + PLANNED(1, "未开始"), + ACTIVE(2, "进行中"), + COMPLETED(3, "已完成"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(PmsIterationStatusEnum::getStatus).toArray(Integer[]::new); + + /** + * 状态 + */ + private final Integer status; + /** + * 名称 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + + public static PmsIterationStatusEnum valueOf(Integer status) { + return ArrayUtil.firstMatch(item -> item.getStatus().equals(status), values()); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/project/PmsProjectGroupTypeEnum.java b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/project/PmsProjectGroupTypeEnum.java new file mode 100644 index 000000000..d21abd8ac --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/project/PmsProjectGroupTypeEnum.java @@ -0,0 +1,44 @@ +package cn.iocoder.yudao.module.pms.enums.pm.project; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * PMS 项目分组类型枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum PmsProjectGroupTypeEnum implements ArrayValuable { + + ALL(1, "全部项目"), + UNGROUPED(2, "未分组"), + CUSTOM(3, "自定义分组"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(PmsProjectGroupTypeEnum::getType).toArray(Integer[]::new); + + /** + * 类型 + */ + private final Integer type; + /** + * 名称 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + + public static PmsProjectGroupTypeEnum valueOf(Integer type) { + return ArrayUtil.firstMatch(item -> item.getType().equals(type), values()); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/project/PmsProjectLevelEnum.java b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/project/PmsProjectLevelEnum.java new file mode 100644 index 000000000..6eaaaf4c6 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/project/PmsProjectLevelEnum.java @@ -0,0 +1,46 @@ +package cn.iocoder.yudao.module.pms.enums.pm.project; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * PMS 项目优先级枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum PmsProjectLevelEnum implements ArrayValuable { + + HIGHEST(1, "最高"), + HIGH(2, "较高"), + NORMAL(3, "普通"), + LOW(4, "较低"), + LOWEST(5, "最低"); + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(PmsProjectLevelEnum::getLevel) + .toArray(Integer[]::new); + + /** + * 优先级 + */ + private final Integer level; + /** + * 名称 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + + public static PmsProjectLevelEnum valueOf(Integer level) { + return ArrayUtil.firstMatch(item -> item.getLevel().equals(level), values()); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/project/PmsProjectMemberLevelEnum.java b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/project/PmsProjectMemberLevelEnum.java new file mode 100644 index 000000000..3f115287b --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/project/PmsProjectMemberLevelEnum.java @@ -0,0 +1,76 @@ +package cn.iocoder.yudao.module.pms.enums.pm.project; + +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; +import java.util.List; + +/** + * PMS 项目成员权限级别枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum PmsProjectMemberLevelEnum implements ArrayValuable { + + OWNER(1, "项目拥有者"), + ADMIN(2, "项目管理员"), + WRITE(3, "编辑"), + READ(4, "只读"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(PmsProjectMemberLevelEnum::getLevel).toArray(Integer[]::new); + + /** + * 可以管理项目的成员级别 + */ + public static final List MANAGER_LEVELS = Arrays.asList(OWNER.level, ADMIN.level); + /** + * 可以编辑项目业务数据的成员级别 + */ + public static final List WRITABLE_LEVELS = Arrays.asList(OWNER.level, ADMIN.level, WRITE.level); + + /** + * 权限级别 + */ + private final Integer level; + /** + * 权限级别名称 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + + public static boolean isOwner(Integer level) { + return ObjUtil.equal(OWNER.level, level); + } + + public static boolean isAdmin(Integer level) { + return ObjUtil.equal(ADMIN.level, level); + } + + public static boolean isManager(Integer level) { + return isOwner(level) || isAdmin(level); + } + + public static boolean isWritable(Integer level) { + return isManager(level) || ObjUtil.equal(WRITE.level, level); + } + + public static boolean isReadable(Integer level) { + return valueOf(level) != null; + } + + public static PmsProjectMemberLevelEnum valueOf(Integer level) { + return ArrayUtil.firstMatch(item -> ObjUtil.equal(item.level, level), values()); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/project/PmsProjectSceneTypeEnum.java b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/project/PmsProjectSceneTypeEnum.java new file mode 100644 index 000000000..c1bc5dc5e --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/project/PmsProjectSceneTypeEnum.java @@ -0,0 +1,44 @@ +package cn.iocoder.yudao.module.pms.enums.pm.project; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * PMS 项目列表场景枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum PmsProjectSceneTypeEnum implements ArrayValuable { + + ALL(1, "全部项目"), + MANAGED(2, "我负责的"), + PARTICIPATED(3, "我参与的"); + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(PmsProjectSceneTypeEnum::getType) + .toArray(Integer[]::new); + + /** + * 场景类型 + */ + private final Integer type; + /** + * 名称 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + + public static PmsProjectSceneTypeEnum valueOf(Integer type) { + return ArrayUtil.firstMatch(item -> item.getType().equals(type), values()); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/project/PmsProjectSortTypeEnum.java b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/project/PmsProjectSortTypeEnum.java new file mode 100644 index 000000000..04a3101a2 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/project/PmsProjectSortTypeEnum.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.pms.enums.pm.project; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * PMS 项目排序类型枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum PmsProjectSortTypeEnum implements ArrayValuable { + + ACCESS_TIME(1, "按访问时间"), + CREATE_TIME(2, "按创建时间"); + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(PmsProjectSortTypeEnum::getType) + .toArray(Integer[]::new); + + /** + * 排序类型 + */ + private final Integer type; + /** + * 名称 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + + public static PmsProjectSortTypeEnum valueOf(Integer type) { + return ArrayUtil.firstMatch(item -> item.getType().equals(type), values()); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/project/PmsProjectStatusEnum.java b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/project/PmsProjectStatusEnum.java new file mode 100644 index 000000000..ae40e12e3 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/project/PmsProjectStatusEnum.java @@ -0,0 +1,44 @@ +package cn.iocoder.yudao.module.pms.enums.pm.project; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * PMS 项目状态枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum PmsProjectStatusEnum implements ArrayValuable { + + ACTIVE(1, "进行中"), + ARCHIVED(2, "已归档"), + RECYCLED(3, "回收站"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(PmsProjectStatusEnum::getStatus).toArray(Integer[]::new); + + /** + * 状态 + */ + private final Integer status; + /** + * 名称 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + + public static PmsProjectStatusEnum valueOf(Integer status) { + return ArrayUtil.firstMatch(item -> item.getStatus().equals(status), values()); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/project/PmsProjectTypeEnum.java b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/project/PmsProjectTypeEnum.java new file mode 100644 index 000000000..61af5beab --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/project/PmsProjectTypeEnum.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.pms.enums.pm.project; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * PMS 项目类型枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum PmsProjectTypeEnum implements ArrayValuable { + + GENERAL(1, "通用项目"), + AGILE(2, "敏捷开发项目"); + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(PmsProjectTypeEnum::getType) + .toArray(Integer[]::new); + + /** + * 类型 + */ + private final Integer type; + /** + * 名称 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + + public static PmsProjectTypeEnum valueOf(Integer type) { + return ArrayUtil.firstMatch(item -> item.getType().equals(type), values()); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/workitem/PmsWorkItemActivityContentEnum.java b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/workitem/PmsWorkItemActivityContentEnum.java new file mode 100644 index 000000000..79cb798f9 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/workitem/PmsWorkItemActivityContentEnum.java @@ -0,0 +1,54 @@ +package cn.iocoder.yudao.module.pms.enums.pm.workitem; + +import cn.hutool.core.util.StrUtil; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * PMS 工作项动态内容枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum PmsWorkItemActivityContentEnum { + + WORK_ITEM_CREATED("创建了工作项"), + WORK_ITEM_FIELD_UPDATED("将{}从「{}」修改为「{}」"), + WORK_ITEM_DESCRIPTION_UPDATED("更新了工作项描述"), + WORK_ITEM_LABELS_UPDATED("将标签从「{}」修改为「{}」"), + WORK_ITEM_MEMBERS_UPDATED("将参与人从「{}」修改为「{}」"), + WORK_ITEM_ATTACHMENTS_UPDATED("将附件从「{}」修改为「{}」"), + WORK_ITEM_PARENT_UPDATED("将父工作项从「{}」修改为「{}」"), + WORK_ITEM_NAME_UPDATED("将工作项名称从「{}」修改为「{}」"), + WORK_ITEM_STATUS_UPDATED("将状态从「{}」修改为「{}」"), + WORK_ITEM_MOVED_TO_BACKLOG("将工作项移回待规划"), + WORK_ITEM_PLANNED_TO_ITERATION("将工作项规划到迭代"), + WORK_ITEM_ARCHIVED("归档了工作项"), + WORK_ITEM_RECYCLED("将工作项移入回收站"), + WORK_ITEM_RESTORED("恢复了工作项"), + + COMMENT_CREATED("添加了评论"), + COMMENT_REPLIED("回复了评论"), + COMMENT_UPDATED("更新了评论"), + COMMENT_DELETED("删除了评论"), + + WORK_LOG_CREATED("登记了 {} 小时工时"), + WORK_LOG_UPDATED("更新了工时记录"); + + /** + * 动态内容模板 + */ + private final String content; + + /** + * 格式化动态内容 + * + * @param arguments 模板参数 + * @return 动态内容 + */ + public String format(Object... arguments) { + return StrUtil.format(content, arguments); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/workitem/PmsWorkItemDefectTypeEnum.java b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/workitem/PmsWorkItemDefectTypeEnum.java new file mode 100644 index 000000000..5436b1f3a --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/workitem/PmsWorkItemDefectTypeEnum.java @@ -0,0 +1,47 @@ +package cn.iocoder.yudao.module.pms.enums.pm.workitem; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * PMS 缺陷类型枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum PmsWorkItemDefectTypeEnum implements ArrayValuable { + + FUNCTION(1, "功能问题"), + UI(2, "界面优化"), + USABILITY(3, "易用性问题"), + SECURITY(4, "安全问题"), + PERFORMANCE(5, "性能问题"), + CODE(6, "代码错误"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(PmsWorkItemDefectTypeEnum::getType).toArray(Integer[]::new); + + /** + * 缺陷类型 + */ + private final Integer type; + /** + * 名称 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + + public static PmsWorkItemDefectTypeEnum valueOf(Integer type) { + return ArrayUtil.firstMatch(item -> item.getType().equals(type), values()); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/workitem/PmsWorkItemLifecycleStatusEnum.java b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/workitem/PmsWorkItemLifecycleStatusEnum.java new file mode 100644 index 000000000..67f47ecd3 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/workitem/PmsWorkItemLifecycleStatusEnum.java @@ -0,0 +1,44 @@ +package cn.iocoder.yudao.module.pms.enums.pm.workitem; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * PMS 工作项生命周期状态枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum PmsWorkItemLifecycleStatusEnum implements ArrayValuable { + + ACTIVE(1, "正常"), + ARCHIVED(2, "已归档"), + RECYCLED(3, "回收站"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(PmsWorkItemLifecycleStatusEnum::getStatus).toArray(Integer[]::new); + + /** + * 状态 + */ + private final Integer status; + /** + * 名称 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + + public static PmsWorkItemLifecycleStatusEnum valueOf(Integer status) { + return ArrayUtil.firstMatch(item -> item.getStatus().equals(status), values()); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/workitem/PmsWorkItemPriorityEnum.java b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/workitem/PmsWorkItemPriorityEnum.java new file mode 100644 index 000000000..096d793d0 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/workitem/PmsWorkItemPriorityEnum.java @@ -0,0 +1,45 @@ +package cn.iocoder.yudao.module.pms.enums.pm.workitem; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * PMS 工作项优先级枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum PmsWorkItemPriorityEnum implements ArrayValuable { + + NONE(0, "无"), + LOW(1, "低"), + MEDIUM(2, "中"), + HIGH(3, "高"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(PmsWorkItemPriorityEnum::getPriority).toArray(Integer[]::new); + + /** + * 优先级 + */ + private final Integer priority; + /** + * 名称 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + + public static PmsWorkItemPriorityEnum valueOf(Integer priority) { + return ArrayUtil.firstMatch(item -> item.getPriority().equals(priority), values()); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/workitem/PmsWorkItemStatusTypeEnum.java b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/workitem/PmsWorkItemStatusTypeEnum.java new file mode 100644 index 000000000..6a799514c --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/workitem/PmsWorkItemStatusTypeEnum.java @@ -0,0 +1,44 @@ +package cn.iocoder.yudao.module.pms.enums.pm.workitem; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * PMS 工作项语义状态枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum PmsWorkItemStatusTypeEnum implements ArrayValuable { + + PENDING(1, "未开始"), + PROCESSING(2, "进行中"), + COMPLETED(3, "已完成"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(PmsWorkItemStatusTypeEnum::getType).toArray(Integer[]::new); + + /** + * 状态类型 + */ + private final Integer type; + /** + * 名称 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + + public static PmsWorkItemStatusTypeEnum valueOf(Integer type) { + return ArrayUtil.firstMatch(item -> item.getType().equals(type), values()); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/workitem/PmsWorkItemTypeEnum.java b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/workitem/PmsWorkItemTypeEnum.java new file mode 100644 index 000000000..9e6d9c7ec --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-api/src/main/java/cn/iocoder/yudao/module/pms/enums/pm/workitem/PmsWorkItemTypeEnum.java @@ -0,0 +1,44 @@ +package cn.iocoder.yudao.module.pms.enums.pm.workitem; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * PMS 工作项类型枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum PmsWorkItemTypeEnum implements ArrayValuable { + + REQUIREMENT(2, "需求"), + TASK(3, "任务"), + DEFECT(4, "缺陷"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(PmsWorkItemTypeEnum::getType).toArray(Integer[]::new); + + /** + * 类型 + */ + private final Integer type; + /** + * 名称 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + + public static PmsWorkItemTypeEnum valueOf(Integer type) { + return ArrayUtil.firstMatch(item -> item.getType().equals(type), values()); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/pom.xml b/yudao-module-pms/yudao-module-pms-server/pom.xml new file mode 100644 index 000000000..876eff6d7 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/pom.xml @@ -0,0 +1,125 @@ + + + + cn.iocoder.cloud + yudao-module-pms + ${revision} + + 4.0.0 + yudao-module-pms-server + + ${project.artifactId} + + pms 包下,项目管理(Project Management System)。 + 例如说:项目、工作事项、迭代、看板、工时和知识库等等 + + + + + + cn.iocoder.cloud + yudao-spring-boot-starter-env + + + + + cn.iocoder.cloud + yudao-module-pms-api + ${revision} + + + cn.iocoder.cloud + yudao-module-system-api + ${revision} + + + cn.iocoder.cloud + yudao-module-infra-api + ${revision} + + + + + cn.iocoder.cloud + yudao-spring-boot-starter-biz-tenant + + + + + cn.iocoder.cloud + yudao-spring-boot-starter-security + + + + + cn.iocoder.cloud + yudao-spring-boot-starter-mybatis + + + + + cn.iocoder.cloud + yudao-spring-boot-starter-rpc + + + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-discovery + + + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-config + + + + + cn.iocoder.cloud + yudao-spring-boot-starter-job + + + + + cn.iocoder.cloud + yudao-spring-boot-starter-excel + + + + + cn.iocoder.cloud + yudao-spring-boot-starter-monitor + + + + + cn.iocoder.cloud + yudao-spring-boot-starter-test + test + + + + + + ${project.artifactId} + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring.boot.version} + + + + repackage + + + + + + + + diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/PmsServerApplication.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/PmsServerApplication.java new file mode 100644 index 000000000..85a11e6f6 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/PmsServerApplication.java @@ -0,0 +1,18 @@ +package cn.iocoder.yudao.module.pms; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * PMS 项目的启动类 + * + * @author 芋道源码 + */ +@SpringBootApplication +public class PmsServerApplication { + + public static void main(String[] args) { + SpringApplication.run(PmsServerApplication.class, args); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/PmsKnowledgeContentPermissionController.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/PmsKnowledgeContentPermissionController.java new file mode 100644 index 000000000..9cd6ac046 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/PmsKnowledgeContentPermissionController.java @@ -0,0 +1,93 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.content; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.number.NumberUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.permission.PmsKnowledgeContentPermissionRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.permission.PmsKnowledgeContentPermissionUpdateReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeContentPermissionDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeContentPermissionMemberDO; +import cn.iocoder.yudao.module.pms.service.kb.content.PmsKnowledgeContentPermissionService; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - PMS 知识内容协作权限") +@RestController +@RequestMapping("/pms/kb/content-permission") +@Validated +public class PmsKnowledgeContentPermissionController { + + @Resource + private PmsKnowledgeContentPermissionService permissionService; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private DeptApi deptApi; + + @GetMapping("/get") + @Operation(summary = "获得知识内容协作权限") + @Parameter(name = "id", description = "协作权限编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:kb:library:query')") + public CommonResult getContentPermission(@RequestParam("id") Long id) { + Long userId = getLoginUserId(); + PmsKnowledgeContentPermissionDO permission = permissionService.getContentPermission(id, userId); + List members = permissionService.getContentPermissionMemberList(id, userId); + Integer currentUserLevel = permissionService.getCurrentUserContentPermissionLevel( + id, permission.getLibraryId(), userId); + return success(buildPermissionRespVO(permission, members, currentUserLevel)); + } + + @PutMapping("/update") + @Operation(summary = "更新知识内容协作权限") + @PreAuthorize("@ss.hasPermission('pms:kb:library:update')") + public CommonResult updateContentPermission(@Valid @RequestBody PmsKnowledgeContentPermissionUpdateReqVO updateReqVO) { + permissionService.updateContentPermission(updateReqVO, getLoginUserId()); + return success(true); + } + + // ==================== 拼接 VO ==================== + + private PmsKnowledgeContentPermissionRespVO buildPermissionRespVO(PmsKnowledgeContentPermissionDO permission, + List members, + Integer currentUserLevel) { + Map userMap = adminUserApi.getUserMap( + convertSet(members, PmsKnowledgeContentPermissionMemberDO::getUserId)); + Map deptMap = deptApi.getDeptMap( + convertSet(members, PmsKnowledgeContentPermissionMemberDO::getDeptId)); + // 拼接 VO 返回 + PmsKnowledgeContentPermissionRespVO permissionVO = BeanUtils.toBean(permission, PmsKnowledgeContentPermissionRespVO.class) + .setCreatorUserId(NumberUtils.parseLong(permission.getCreator())) + .setCurrentUserLevel(currentUserLevel) + .setMembers(BeanUtils.toBean(members, PmsKnowledgeContentPermissionRespVO.Member.class)); + permissionVO.getMembers().forEach(member -> { + findAndThen(userMap, member.getUserId(), user -> member.setUserName(user.getNickname())); + findAndThen(deptMap, member.getDeptId(), dept -> member.setDeptName(dept.getName())); + }); + return permissionVO; + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/PmsKnowledgeDocumentController.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/PmsKnowledgeDocumentController.java new file mode 100644 index 000000000..aca906ca5 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/PmsKnowledgeDocumentController.java @@ -0,0 +1,202 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.content; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.number.NumberUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.document.PmsKnowledgeDocumentCreateReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.document.PmsKnowledgeDocumentMoveReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.document.PmsKnowledgeDocumentRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.document.PmsKnowledgeDocumentSearchPageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.document.PmsKnowledgeDocumentUpdateReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeDocumentDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.interaction.PmsKnowledgeDocumentLikeDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryDO; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeContentLevelEnum; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeDocumentTypeEnum; +import cn.iocoder.yudao.module.pms.service.kb.content.PmsKnowledgeContentPermissionService; +import cn.iocoder.yudao.module.pms.service.kb.content.PmsKnowledgeDocumentService; +import cn.iocoder.yudao.module.pms.service.kb.interaction.PmsKnowledgeDocumentLikeService; +import cn.iocoder.yudao.module.pms.service.kb.interaction.PmsKnowledgeFavoriteService; +import cn.iocoder.yudao.module.pms.service.kb.interaction.PmsKnowledgeViewRecordService; +import cn.iocoder.yudao.module.pms.service.kb.library.PmsKnowledgeLibraryService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - PMS 知识库文档") +@RestController +@RequestMapping("/pms/kb/document") +@Validated +public class PmsKnowledgeDocumentController { + + @Resource + private PmsKnowledgeDocumentService documentService; + @Resource + private PmsKnowledgeContentPermissionService contentPermissionService; + @Resource + private PmsKnowledgeFavoriteService favoriteService; + @Resource + private PmsKnowledgeDocumentLikeService documentLikeService; + @Resource + private PmsKnowledgeViewRecordService viewRecordService; + @Resource + private PmsKnowledgeLibraryService libraryService; + + @Resource + private AdminUserApi adminUserApi; + + @PostMapping("/create") + @Operation(summary = "创建文档") + @PreAuthorize("@ss.hasPermission('pms:kb:library:update')") + public CommonResult createDocument(@Valid @RequestBody PmsKnowledgeDocumentCreateReqVO createReqVO) { + return success(documentService.createDocument(createReqVO, getLoginUserId())); + } + + @PutMapping("/update") + @Operation(summary = "更新文档内容") + @PreAuthorize("@ss.hasPermission('pms:kb:library:update')") + public CommonResult updateDocument(@Valid @RequestBody PmsKnowledgeDocumentUpdateReqVO updateReqVO) { + documentService.updateDocument(updateReqVO, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "将文档移入回收站") + @Parameter(name = "id", description = "文档编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:kb:library:delete')") + public CommonResult deleteDocument(@RequestParam("id") Long id) { + documentService.deleteDocument(id, getLoginUserId()); + return success(true); + } + + @PutMapping("/move") + @Operation(summary = "移动文档") + @PreAuthorize("@ss.hasPermission('pms:kb:library:update')") + public CommonResult moveDocument(@Valid @RequestBody PmsKnowledgeDocumentMoveReqVO moveReqVO) { + documentService.moveDocument(moveReqVO, getLoginUserId()); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得文档详情") + @Parameter(name = "id", description = "文档编号", required = true, example = "1024") + @Parameter(name = "view", description = "是否记录本次浏览", example = "true") + @PreAuthorize("@ss.hasPermission('pms:kb:library:query')") + public CommonResult getDocument( + @RequestParam("id") Long id, + @RequestParam(value = "view", defaultValue = "false") Boolean view) { + // 查询文档 + Long userId = getLoginUserId(); + PmsKnowledgeDocumentDO document = documentService.getDocument(id, userId); + // 记录浏览 + if (Boolean.TRUE.equals(view)) { + viewRecordService.createViewRecord(document.getLibraryId(), document.getType(), document.getId(), userId); + } + // 构建文档 VO + return success(buildDocumentRespVO(document, userId)); + } + + @GetMapping("/search-page") + @Operation(summary = "使用 MySQL 全局搜索文档") + @PreAuthorize("@ss.hasPermission('pms:kb:library:query')") + public CommonResult> getDocumentSearchPage( + @Valid PmsKnowledgeDocumentSearchPageReqVO pageReqVO) { + PageResult pageResult = documentService.getDocumentSearchPage( + pageReqVO, getLoginUserId()); + return success(new PageResult<>(buildDocumentRespVOList(pageResult.getList()), pageResult.getTotal())); + } + + // ==================== 拼接 VO ==================== + + private PmsKnowledgeDocumentRespVO buildDocumentRespVO(PmsKnowledgeDocumentDO document, Long userId) { + // 1.1 拼接当前用户的内容权限和互动状态 + Integer currentUserLevel = contentPermissionService.getCurrentUserContentPermissionLevel( + document.getPermissionId(), document.getLibraryId(), userId); + Long creatorUserId = NumberUtils.parseLong(document.getCreator()); + PmsKnowledgeDocumentRespVO documentVO = BeanUtils.toBean(document, PmsKnowledgeDocumentRespVO.class) + .setCreatorUserId(creatorUserId).setCurrentUserLevel(currentUserLevel) + .setDownloadStatus(PmsKnowledgeContentLevelEnum.canDownload(currentUserLevel)) + .setPreviewUrl(document.getContent()) + .setFavoriteStatus(favoriteService.isFavorite(document.getType(), document.getId(), userId)); + // 1.2 文件不可下载时,隐藏实际文件地址 + if (PmsKnowledgeDocumentTypeEnum.FILE.getType().equals(document.getType()) + && Boolean.FALSE.equals(documentVO.getDownloadStatus())) { + documentVO.setContent(null); + } + + // 2. 一次查询并拼接创建人和点赞用户信息 + List likes = documentLikeService.getDocumentLikeList(document.getId()); + Set relatedUserIds = convertSet(likes, PmsKnowledgeDocumentLikeDO::getUserId); + relatedUserIds.add(creatorUserId); + Map userMap = adminUserApi.getUserMap(relatedUserIds); + findAndThen(userMap, creatorUserId, user -> documentVO.setCreatorUserName(user.getNickname())); + documentVO.setLikeStatus(CollUtil.anyMatch(likes, like -> userId.equals(like.getUserId()))) + .setLikeUsers(convertList(likes, like -> { + PmsKnowledgeDocumentRespVO.LikeUser likeUser = new PmsKnowledgeDocumentRespVO.LikeUser() + .setId(like.getUserId()); + findAndThen(userMap, like.getUserId(), user -> + likeUser.setNickname(user.getNickname()).setAvatar(user.getAvatar())); + return likeUser; + })); + return documentVO; + } + + private List buildDocumentRespVOList(List documents) { + if (CollUtil.isEmpty(documents)) { + return Collections.emptyList(); + } + Map libraryMap = libraryService.getLibraryMap( + convertSet(documents, PmsKnowledgeDocumentDO::getLibraryId)); + Map userMap = adminUserApi.getUserMap(convertSet(documents, + document -> NumberUtils.parseLong(document.getCreator()))); + return convertList(documents, document -> { + PmsKnowledgeDocumentRespVO documentVO = BeanUtils.toBean(document, PmsKnowledgeDocumentRespVO.class) + .setContent(null).setContentSummary(buildContentSummary(document.getContent())) + .setCreatorUserId(NumberUtils.parseLong(document.getCreator())); + findAndThen(libraryMap, document.getLibraryId(), + library -> documentVO.setLibraryName(library.getName())); + findAndThen(userMap, documentVO.getCreatorUserId(), + user -> documentVO.setCreatorUserName(user.getNickname())); + return documentVO; + }); + } + + /** + * 构造搜索结果摘要,避免直接返回完整正文 + */ + private String buildContentSummary(String content) { + if (content == null || content.trim().isEmpty()) { + return null; + } + String summary = content.replaceAll("<[^>]+>", " ").replaceAll("\\s+", " ").trim(); + return summary.length() > 120 ? summary.substring(0, 120) + "…" : summary; + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/PmsKnowledgeDocumentLabelController.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/PmsKnowledgeDocumentLabelController.java new file mode 100644 index 000000000..98452d826 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/PmsKnowledgeDocumentLabelController.java @@ -0,0 +1,132 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.content; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.number.NumberUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.document.PmsKnowledgeDocumentRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.label.PmsKnowledgeDocumentLabelPageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.label.PmsKnowledgeDocumentLabelRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.label.PmsKnowledgeDocumentLabelSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeDocumentDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryDO; +import cn.iocoder.yudao.module.pms.service.kb.content.PmsKnowledgeDocumentLabelService; +import cn.iocoder.yudao.module.pms.service.kb.library.PmsKnowledgeLibraryService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - PMS 知识库文档标签") +@RestController +@RequestMapping("/pms/kb/document-label") +@Validated +public class PmsKnowledgeDocumentLabelController { + + @Resource + private PmsKnowledgeDocumentLabelService documentLabelService; + @Resource + private PmsKnowledgeLibraryService libraryService; + + @Resource + private AdminUserApi adminUserApi; + + @PostMapping("/create") + @Operation(summary = "创建文档标签") + @PreAuthorize("@ss.hasPermission('pms:kb:library:update')") + public CommonResult createDocumentLabel( + @Valid @RequestBody PmsKnowledgeDocumentLabelSaveReqVO saveReqVO) { + return success(documentLabelService.createDocumentLabel(saveReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "修改文档标签") + @PreAuthorize("@ss.hasPermission('pms:kb:library:update')") + public CommonResult updateDocumentLabel( + @Valid @RequestBody PmsKnowledgeDocumentLabelSaveReqVO saveReqVO) { + documentLabelService.updateDocumentLabel(saveReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除文档标签") + @Parameter(name = "id", description = "文档标签编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:kb:library:delete')") + public CommonResult deleteDocumentLabel(@RequestParam("id") Long id) { + documentLabelService.deleteDocumentLabel(id); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得文档标签详情") + @Parameter(name = "id", description = "文档标签编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:kb:library:query')") + public CommonResult getDocumentLabel(@RequestParam("id") Long id) { + return success(BeanUtils.toBean(documentLabelService.getDocumentLabel(id), + PmsKnowledgeDocumentLabelRespVO.class)); + } + + @GetMapping("/list") + @Operation(summary = "获得文档标签列表") + @PreAuthorize("@ss.hasPermission('pms:kb:library:query')") + public CommonResult> getDocumentLabelList() { + return success(BeanUtils.toBean(documentLabelService.getDocumentLabelList(), + PmsKnowledgeDocumentLabelRespVO.class)); + } + + @GetMapping("/document-page") + @Operation(summary = "获得指定标签的文档分页") + @PreAuthorize("@ss.hasPermission('pms:kb:library:query')") + public CommonResult> getDocumentPageByLabel( + @Valid PmsKnowledgeDocumentLabelPageReqVO pageReqVO) { + PageResult pageResult = documentLabelService.getDocumentPageByLabel( + pageReqVO, getLoginUserId()); + return success(new PageResult<>(buildDocumentRespVOList(pageResult.getList()), pageResult.getTotal())); + } + + // ==================== 拼接 VO ==================== + + private List buildDocumentRespVOList(List documents) { + if (CollUtil.isEmpty(documents)) { + return Collections.emptyList(); + } + Map libraryMap = libraryService.getLibraryMap( + convertSet(documents, PmsKnowledgeDocumentDO::getLibraryId)); + Map userMap = adminUserApi.getUserMap(convertSet(documents, + document -> NumberUtils.parseLong(document.getCreator()))); + return convertList(documents, document -> { + PmsKnowledgeDocumentRespVO documentVO = BeanUtils.toBean(document, PmsKnowledgeDocumentRespVO.class) + .setContent(null).setCreatorUserId(NumberUtils.parseLong(document.getCreator())); + findAndThen(libraryMap, document.getLibraryId(), + library -> documentVO.setLibraryName(library.getName())); + findAndThen(userMap, documentVO.getCreatorUserId(), + user -> documentVO.setCreatorUserName(user.getNickname())); + return documentVO; + }); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/PmsKnowledgeFolderController.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/PmsKnowledgeFolderController.java new file mode 100644 index 000000000..693f667b7 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/PmsKnowledgeFolderController.java @@ -0,0 +1,189 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.content; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.folder.PmsKnowledgeFolderMoveReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.folder.PmsKnowledgeFolderRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.folder.PmsKnowledgeFolderSaveReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.folder.PmsKnowledgeTreeRespVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeDocumentDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeFolderDO; +import cn.iocoder.yudao.module.pms.enums.kb.PmsKnowledgeObjectTypeEnum; +import cn.iocoder.yudao.module.pms.service.kb.content.PmsKnowledgeContentPermissionService; +import cn.iocoder.yudao.module.pms.service.kb.content.PmsKnowledgeDocumentService; +import cn.iocoder.yudao.module.pms.service.kb.content.PmsKnowledgeFolderService; +import cn.iocoder.yudao.module.pms.service.kb.interaction.PmsKnowledgeFavoriteService; +import cn.iocoder.yudao.module.pms.service.kb.interaction.PmsKnowledgeViewRecordService; +import cn.iocoder.yudao.module.pms.service.kb.library.PmsKnowledgeLibraryMemberService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - PMS 知识库文件夹") +@RestController +@RequestMapping("/pms/kb/folder") +@Validated +public class PmsKnowledgeFolderController { + + @Resource + private PmsKnowledgeFolderService folderService; + @Resource + private PmsKnowledgeDocumentService documentService; + @Resource + private PmsKnowledgeContentPermissionService contentPermissionService; + @Resource + private PmsKnowledgeLibraryMemberService libraryMemberService; + @Resource + private PmsKnowledgeFavoriteService favoriteService; + @Resource + private PmsKnowledgeViewRecordService viewRecordService; + + @PostMapping("/create") + @Operation(summary = "创建文件夹") + @PreAuthorize("@ss.hasPermission('pms:kb:library:update')") + public CommonResult createFolder(@Valid @RequestBody PmsKnowledgeFolderSaveReqVO saveReqVO) { + return success(folderService.createFolder(saveReqVO, getLoginUserId())); + } + + @PutMapping("/update") + @Operation(summary = "更新文件夹") + @PreAuthorize("@ss.hasPermission('pms:kb:library:update')") + public CommonResult updateFolder(@Valid @RequestBody PmsKnowledgeFolderSaveReqVO saveReqVO) { + folderService.updateFolder(saveReqVO, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "将文件夹移入回收站") + @Parameter(name = "id", description = "文件夹编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:kb:library:delete')") + public CommonResult deleteFolder(@RequestParam("id") Long id) { + folderService.deleteFolder(id, getLoginUserId()); + return success(true); + } + + @PutMapping("/move") + @Operation(summary = "移动文件夹") + @PreAuthorize("@ss.hasPermission('pms:kb:library:update')") + public CommonResult moveFolder(@Valid @RequestBody PmsKnowledgeFolderMoveReqVO moveReqVO) { + folderService.moveFolder(moveReqVO, getLoginUserId()); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得文件夹详情") + @Parameter(name = "id", description = "文件夹编号", required = true, example = "1024") + @Parameter(name = "view", description = "是否记录本次浏览", example = "true") + @PreAuthorize("@ss.hasPermission('pms:kb:library:query')") + public CommonResult getFolder( + @RequestParam("id") Long id, + @RequestParam(value = "view", defaultValue = "false") Boolean view) { + Long userId = getLoginUserId(); + PmsKnowledgeFolderDO folder = folderService.getFolder(id, userId); + if (Boolean.TRUE.equals(view)) { + viewRecordService.createViewRecord(folder.getLibraryId(), PmsKnowledgeObjectTypeEnum.FOLDER.getType(), + folder.getId(), userId); + } + return success(buildFolderRespVO(folder, userId)); + } + + @GetMapping("/tree") + @Operation(summary = "获得知识库目录树") + @Parameter(name = "libraryId", description = "知识库编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:kb:library:query')") + public CommonResult getFolderTree(@RequestParam("libraryId") Long libraryId) { + Long userId = getLoginUserId(); + List folders = folderService.getFolderList(libraryId, userId); + List documents = documentService.getDocumentList(libraryId, userId); + return success(buildTreeRespVO(libraryId, userId, + libraryMemberService.isLibraryWritable(libraryId, userId), + libraryMemberService.isLibraryAdmin(libraryId, userId), folders, documents)); + } + + // ==================== 拼接 VO ==================== + + private PmsKnowledgeFolderRespVO buildFolderRespVO(PmsKnowledgeFolderDO folder, Long userId) { + List folders = folderService.getFolderList(folder.getLibraryId(), userId); + List documents = documentService.getDocumentList(folder.getLibraryId(), userId); + int childFolderCount = CollUtil.count(folders, + item -> folder.getId().equals(item.getParentId())); + int documentCount = (int) CollUtil.count(documents, + item -> folder.getId().equals(item.getFolderId()) + && (item.getParentId() == null || item.getParentId() == 0L)); + return BeanUtils.toBean(folder, PmsKnowledgeFolderRespVO.class) + .setChildFolderCount(childFolderCount).setDocumentCount(documentCount) + .setCurrentUserLevel(contentPermissionService.getCurrentUserContentPermissionLevel(folder.getPermissionId(), folder.getLibraryId(), userId)) + .setFavoriteStatus(favoriteService.isFavorite(PmsKnowledgeObjectTypeEnum.FOLDER.getType(), folder.getId(), userId)); + } + + private PmsKnowledgeTreeRespVO buildTreeRespVO(Long libraryId, Long userId, boolean writeStatus, + boolean manageStatus, + List folders, List documents) { + // 1. 批量获得当前用户的内容权限等级 + Set permissionIds = convertSet(folders, PmsKnowledgeFolderDO::getPermissionId); + permissionIds.addAll(convertSet(documents, PmsKnowledgeDocumentDO::getPermissionId)); + Map levelMap = contentPermissionService.getCurrentUserContentPermissionLevelMap(permissionIds, libraryId, userId); + + // 2. 批量转换文件夹和文档节点 + Map folderNodeMap = convertMap(folders, + PmsKnowledgeFolderDO::getId, folder -> BeanUtils.toBean(folder, PmsKnowledgeTreeRespVO.FolderNode.class) + .setCurrentUserLevel(levelMap.get(folder.getPermissionId())).setChildren(new ArrayList<>()).setDocuments(new ArrayList<>())); + Map documentNodeMap = convertMap(documents, + PmsKnowledgeDocumentDO::getId, document -> BeanUtils.toBean(document, PmsKnowledgeTreeRespVO.DocumentNode.class) + .setCurrentUserLevel(levelMap.get(document.getPermissionId())).setChildren(new ArrayList<>())); + + // 3. 按父文档或所属文件夹组装文档树 + List rootDocuments = new ArrayList<>(); + for (PmsKnowledgeDocumentDO document : documents) { + PmsKnowledgeTreeRespVO.DocumentNode node = documentNodeMap.get(document.getId()); + PmsKnowledgeTreeRespVO.DocumentNode parentNode = documentNodeMap.get(document.getParentId()); + PmsKnowledgeTreeRespVO.FolderNode folderNode = folderNodeMap.get(document.getFolderId()); + if (parentNode != null) { + parentNode.getChildren().add(node); + } else if (folderNode != null) { + folderNode.getDocuments().add(node); + } else { + rootDocuments.add(node); + } + } + + // 4. 按父文件夹组装文件夹树 + List rootFolders = new ArrayList<>(); + for (PmsKnowledgeFolderDO folder : folders) { + PmsKnowledgeTreeRespVO.FolderNode node = folderNodeMap.get(folder.getId()); + PmsKnowledgeTreeRespVO.FolderNode parentNode = folderNodeMap.get(folder.getParentId()); + if (parentNode != null) { + parentNode.getChildren().add(node); + } else { + rootFolders.add(node); + } + } + return new PmsKnowledgeTreeRespVO().setLibraryId(libraryId).setWriteStatus(writeStatus) + .setManageStatus(manageStatus) + .setFolders(rootFolders).setDocuments(rootDocuments); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/document/PmsKnowledgeDocumentCreateReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/document/PmsKnowledgeDocumentCreateReqVO.java new file mode 100644 index 000000000..e4ef99b5d --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/document/PmsKnowledgeDocumentCreateReqVO.java @@ -0,0 +1,51 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.document; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeDocumentTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - PMS 知识库文档新增 Request VO") +@Data +public class PmsKnowledgeDocumentCreateReqVO { + + @Schema(description = "知识库编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "知识库编号不能为空") + private Long libraryId; + + @Schema(description = "文件夹编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "文件夹编号不能为空") + private Long folderId; + + @Schema(description = "父文档编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "父文档编号不能为空") + private Long parentId; + + @Schema(description = "文档标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "产品说明") + @NotBlank(message = "文档标题不能为空") + @Size(max = 255, message = "文档标题不能超过 255 个字符") + private String title; + + @Schema(description = "文档类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + @NotNull(message = "文档类型不能为空") + @InEnum(PmsKnowledgeDocumentTypeEnum.class) + private Integer type; + + @Schema(description = "文档内容或文件地址") + private String content; + + @Schema(description = "文件类型", example = "pdf") + @Size(max = 50, message = "文件类型不能超过 50 个字符") + private String fileType; + + @Schema(description = "文件大小,单位:字节", example = "102400") + @Min(value = 0, message = "文件大小不能小于 0") + @Max(value = 104857600, message = "文件大小不能超过 100 MB") + private Long fileSize; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/document/PmsKnowledgeDocumentMoveReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/document/PmsKnowledgeDocumentMoveReqVO.java new file mode 100644 index 000000000..d5bc012ca --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/document/PmsKnowledgeDocumentMoveReqVO.java @@ -0,0 +1,27 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.document; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - PMS 知识库文档移动 Request VO") +@Data +public class PmsKnowledgeDocumentMoveReqVO { + + @Schema(description = "文档编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "文档编号不能为空") + private Long id; + + @Schema(description = "目标知识库编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "目标知识库编号不能为空") + private Long targetLibraryId; + + @Schema(description = "目标文件夹编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "目标文件夹编号不能为空") + private Long targetFolderId; + + @Schema(description = "目标父文档编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "目标父文档编号不能为空") + private Long targetParentId; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/document/PmsKnowledgeDocumentRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/document/PmsKnowledgeDocumentRespVO.java new file mode 100644 index 000000000..d07fe31b5 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/document/PmsKnowledgeDocumentRespVO.java @@ -0,0 +1,100 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.document; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - PMS 知识库文档 Response VO") +@Data +public class PmsKnowledgeDocumentRespVO { + + @Schema(description = "文档编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "知识库编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long libraryId; + + @Schema(description = "协作权限编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long permissionId; + + @Schema(description = "当前用户协作等级", example = "3") + private Integer currentUserLevel; + + @Schema(description = "当前用户是否可下载", example = "true") + private Boolean downloadStatus; + + @Schema(description = "知识库名称", example = "产品知识库") + private String libraryName; + + @Schema(description = "文件夹编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long folderId; + + @Schema(description = "父文档编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long parentId; + + @Schema(description = "文档标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "产品说明") + private String title; + + @Schema(description = "正文摘要") + private String contentSummary; + + @Schema(description = "文档内容或文件地址") + private String content; + + @Schema(description = "文件预览地址(下载权限不足时仍可用于在线预览)") + private String previewUrl; + + @Schema(description = "文档类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + private Integer type; + + @Schema(description = "文件类型", example = "pdf") + private String fileType; + + @Schema(description = "文件大小,单位:字节", example = "102400") + private Long fileSize; + + @Schema(description = "文档状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer status; + + @Schema(description = "标签编号列表", example = "[1024, 2048]") + private List labelIds; + + @Schema(description = "创建人用户编号", example = "1") + private Long creatorUserId; + + @Schema(description = "创建人姓名", example = "芋道") + private String creatorUserName; + + @Schema(description = "当前用户是否已关注") + private Boolean favoriteStatus; + + @Schema(description = "当前用户是否已点赞") + private Boolean likeStatus; + + @Schema(description = "点赞用户列表") + private List likeUsers; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime updateTime; + + @Schema(description = "管理后台 - PMS 知识文档点赞用户 Response VO") + @Data + public static class LikeUser { + + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long id; + + @Schema(description = "用户昵称", example = "芋道") + private String nickname; + + @Schema(description = "用户头像") + private String avatar; + + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/document/PmsKnowledgeDocumentSearchPageReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/document/PmsKnowledgeDocumentSearchPageReqVO.java new file mode 100644 index 000000000..07d9b8bbc --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/document/PmsKnowledgeDocumentSearchPageReqVO.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.document; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - PMS 知识库文档全局搜索分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsKnowledgeDocumentSearchPageReqVO extends PageParam { + + @Schema(description = "搜索关键字", example = "产品方案") + private String keyword; + + @Schema(description = "知识库编号", example = "1024") + private Long libraryId; + + @Schema(description = "创建人用户编号", example = "1") + private Long creatorUserId; + + @Schema(description = "更新时间范围") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] updateTime; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/document/PmsKnowledgeDocumentUpdateReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/document/PmsKnowledgeDocumentUpdateReqVO.java new file mode 100644 index 000000000..7afdd5fa3 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/document/PmsKnowledgeDocumentUpdateReqVO.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.document; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - PMS 知识库文档修改 Request VO") +@Data +public class PmsKnowledgeDocumentUpdateReqVO { + + @Schema(description = "文档编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "文档编号不能为空") + private Long id; + + @Schema(description = "文档标题", example = "产品说明") + @Size(max = 255, message = "文档标题不能超过 255 个字符") + private String title; + + @Schema(description = "文档内容或文件地址") + private String content; + + @Schema(description = "标签编号列表;传空集合表示清空标签") + private List labelIds; + + @Schema(description = "文件类型", example = "pdf") + @Size(max = 50, message = "文件类型不能超过 50 个字符") + private String fileType; + + @Schema(description = "文件大小,单位:字节", example = "102400") + @Min(value = 0, message = "文件大小不能小于 0") + @Max(value = 104857600, message = "文件大小不能超过 100 MB") + private Long fileSize; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/folder/PmsKnowledgeFolderMoveReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/folder/PmsKnowledgeFolderMoveReqVO.java new file mode 100644 index 000000000..c30e02a87 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/folder/PmsKnowledgeFolderMoveReqVO.java @@ -0,0 +1,23 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.folder; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - PMS 知识库文件夹移动 Request VO") +@Data +public class PmsKnowledgeFolderMoveReqVO { + + @Schema(description = "文件夹编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "文件夹编号不能为空") + private Long id; + + @Schema(description = "目标知识库编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "目标知识库编号不能为空") + private Long targetLibraryId; + + @Schema(description = "目标父文件夹编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "目标父文件夹编号不能为空") + private Long targetParentId; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/folder/PmsKnowledgeFolderRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/folder/PmsKnowledgeFolderRespVO.java new file mode 100644 index 000000000..86bf50e83 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/folder/PmsKnowledgeFolderRespVO.java @@ -0,0 +1,48 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.folder; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - PMS 知识库文件夹 Response VO") +@Data +public class PmsKnowledgeFolderRespVO { + + @Schema(description = "文件夹编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "知识库编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long libraryId; + + @Schema(description = "协作权限编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long permissionId; + + @Schema(description = "当前用户协作等级", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + private Integer currentUserLevel; + + @Schema(description = "父文件夹编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long parentId; + + @Schema(description = "文件夹标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "产品文档") + private String title; + + @Schema(description = "直属子文件夹数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + private Integer childFolderCount; + + @Schema(description = "直属文档数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "12") + private Integer documentCount; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer status; + + @Schema(description = "当前用户是否已关注", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean favoriteStatus; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime updateTime; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/folder/PmsKnowledgeFolderSaveReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/folder/PmsKnowledgeFolderSaveReqVO.java new file mode 100644 index 000000000..5e3e81957 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/folder/PmsKnowledgeFolderSaveReqVO.java @@ -0,0 +1,29 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.folder; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - PMS 知识库文件夹新增/修改 Request VO") +@Data +public class PmsKnowledgeFolderSaveReqVO { + + @Schema(description = "文件夹编号", example = "1024") + private Long id; + + @Schema(description = "知识库编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "知识库编号不能为空") + private Long libraryId; + + @Schema(description = "父文件夹编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "父文件夹编号不能为空") + private Long parentId; + + @Schema(description = "文件夹标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "产品文档") + @NotBlank(message = "文件夹标题不能为空") + @Size(max = 255, message = "文件夹标题不能超过 255 个字符") + private String title; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/folder/PmsKnowledgeTreeRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/folder/PmsKnowledgeTreeRespVO.java new file mode 100644 index 000000000..9c3a9b207 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/folder/PmsKnowledgeTreeRespVO.java @@ -0,0 +1,82 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.folder; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - PMS 知识库目录树 Response VO") +@Data +public class PmsKnowledgeTreeRespVO { + + @Schema(description = "知识库编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long libraryId; + + @Schema(description = "当前用户是否可编辑", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + private Boolean writeStatus; + + @Schema(description = "当前用户是否可管理目录结构", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + private Boolean manageStatus; + + @Schema(description = "根文件夹列表", requiredMode = Schema.RequiredMode.REQUIRED) + private List folders; + + @Schema(description = "根文档列表", requiredMode = Schema.RequiredMode.REQUIRED) + private List documents; + + @Schema(description = "管理后台 - PMS 知识库文件夹树节点 Response VO") + @Data + public static class FolderNode { + + @Schema(description = "文件夹编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "协作权限编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long permissionId; + + @Schema(description = "当前用户协作等级", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + private Integer currentUserLevel; + + @Schema(description = "父文件夹编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long parentId; + + @Schema(description = "文件夹标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "产品文档") + private String title; + + @Schema(description = "子文件夹列表", requiredMode = Schema.RequiredMode.REQUIRED) + private List children; + + @Schema(description = "文件夹下的文档列表", requiredMode = Schema.RequiredMode.REQUIRED) + private List documents; + } + + @Schema(description = "管理后台 - PMS 知识库文档树节点 Response VO") + @Data + public static class DocumentNode { + + @Schema(description = "文档编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "协作权限编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long permissionId; + + @Schema(description = "当前用户协作等级", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + private Integer currentUserLevel; + + @Schema(description = "父文档编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long parentId; + + @Schema(description = "文档标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "产品说明") + private String title; + + @Schema(description = "文档类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + private Integer type; + + @Schema(description = "文件类型", example = "pdf") + private String fileType; + + @Schema(description = "子文档列表", requiredMode = Schema.RequiredMode.REQUIRED) + private List children; + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/label/PmsKnowledgeDocumentLabelPageReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/label/PmsKnowledgeDocumentLabelPageReqVO.java new file mode 100644 index 000000000..e675209cb --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/label/PmsKnowledgeDocumentLabelPageReqVO.java @@ -0,0 +1,20 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.label; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@Schema(description = "管理后台 - PMS 按标签查询文档分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsKnowledgeDocumentLabelPageReqVO extends PageParam { + + @Schema(description = "文档标签编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "文档标签编号不能为空") + private Long labelId; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/label/PmsKnowledgeDocumentLabelRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/label/PmsKnowledgeDocumentLabelRespVO.java new file mode 100644 index 000000000..be6ddd1cd --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/label/PmsKnowledgeDocumentLabelRespVO.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.label; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - PMS 知识库文档标签 Response VO") +@Data +public class PmsKnowledgeDocumentLabelRespVO { + + @Schema(description = "文档标签编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "标签名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "产品") + private String name; + + @Schema(description = "标签颜色", requiredMode = Schema.RequiredMode.REQUIRED, example = "#409EFF") + private String color; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/label/PmsKnowledgeDocumentLabelSaveReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/label/PmsKnowledgeDocumentLabelSaveReqVO.java new file mode 100644 index 000000000..1f77d0e68 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/label/PmsKnowledgeDocumentLabelSaveReqVO.java @@ -0,0 +1,25 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.label; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - PMS 知识库文档标签新增/修改 Request VO") +@Data +public class PmsKnowledgeDocumentLabelSaveReqVO { + + @Schema(description = "文档标签编号", example = "1024") + private Long id; + + @Schema(description = "标签名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "产品") + @NotBlank(message = "标签名称不能为空") + @Size(max = 255, message = "标签名称不能超过 255 个字符") + private String name; + + @Schema(description = "标签颜色", requiredMode = Schema.RequiredMode.REQUIRED, example = "#409EFF") + @NotBlank(message = "标签颜色不能为空") + @Size(max = 20, message = "标签颜色不能超过 20 个字符") + private String color; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/permission/PmsKnowledgeContentPermissionRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/permission/PmsKnowledgeContentPermissionRespVO.java new file mode 100644 index 000000000..c06b94cc4 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/permission/PmsKnowledgeContentPermissionRespVO.java @@ -0,0 +1,57 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.permission; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - PMS 知识内容协作权限 Response VO") +@Data +public class PmsKnowledgeContentPermissionRespVO { + + @Schema(description = "协作权限编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "知识库编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long libraryId; + + @Schema(description = "是否公开", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + private Boolean openStatus; + + @Schema(description = "公开协作等级", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + private Integer openLevel; + + @Schema(description = "创建人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long creatorUserId; + + @Schema(description = "当前用户协作等级", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer currentUserLevel; + + @Schema(description = "协作者列表", requiredMode = Schema.RequiredMode.REQUIRED) + private List members; + + @Schema(description = "管理后台 - PMS 知识内容协作者 Response VO") + @Data + public static class Member { + + @Schema(description = "协作者编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "用户编号", example = "1") + private Long userId; + + @Schema(description = "用户昵称", example = "芋道") + private String userName; + + @Schema(description = "部门编号", example = "100") + private Long deptId; + + @Schema(description = "部门名称", example = "研发部") + private String deptName; + + @Schema(description = "协作等级", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + private Integer level; + + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/permission/PmsKnowledgeContentPermissionUpdateReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/permission/PmsKnowledgeContentPermissionUpdateReqVO.java new file mode 100644 index 000000000..3ccedbdf3 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/content/vo/permission/PmsKnowledgeContentPermissionUpdateReqVO.java @@ -0,0 +1,59 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.permission; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeContentLevelEnum; +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - PMS 更新知识内容协作权限 Request VO") +@Data +public class PmsKnowledgeContentPermissionUpdateReqVO { + + @Schema(description = "协作权限编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "协作权限编号不能为空") + private Long id; + + @Schema(description = "是否公开", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + @NotNull(message = "是否公开不能为空") + private Boolean openStatus; + + @Schema(description = "公开协作等级", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + @NotNull(message = "公开协作等级不能为空") + @InEnum(PmsKnowledgeContentLevelEnum.class) + private Integer openLevel; + + @Schema(description = "协作者列表", requiredMode = Schema.RequiredMode.REQUIRED) + @Valid + @NotNull(message = "协作者列表不能为空") + private List members; + + @Schema(description = "管理后台 - PMS 知识内容协作者保存项") + @Data + public static class Member { + + @Schema(description = "用户编号", example = "1") + private Long userId; + + @Schema(description = "部门编号", example = "100") + private Long deptId; + + @Schema(description = "协作等级", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + @NotNull(message = "协作等级不能为空") + @InEnum(PmsKnowledgeContentLevelEnum.class) + private Integer level; + + @AssertTrue(message = "协作者必须且只能选择用户或部门") + @JsonIgnore + public boolean isUserOrDeptValid() { + return (userId != null && deptId == null) || (userId == null && deptId != null); + } + + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/PmsKnowledgeDocumentCommentController.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/PmsKnowledgeDocumentCommentController.java new file mode 100644 index 000000000..235e07cf7 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/PmsKnowledgeDocumentCommentController.java @@ -0,0 +1,112 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.interaction; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.comment.PmsKnowledgeDocumentCommentRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.comment.PmsKnowledgeDocumentCommentSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.interaction.PmsKnowledgeDocumentCommentDO; +import cn.iocoder.yudao.module.pms.service.kb.interaction.PmsKnowledgeDocumentCommentService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSetByFlatMap; +import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - PMS 知识库文档评论") +@RestController +@RequestMapping("/pms/kb/document-comment") +@Validated +public class PmsKnowledgeDocumentCommentController { + + @Resource + private PmsKnowledgeDocumentCommentService commentService; + + @Resource + private AdminUserApi adminUserApi; + + @PostMapping("/create") + @Operation(summary = "创建知识库文档评论") + @PreAuthorize("@ss.hasPermission('pms:kb:library:query')") + public CommonResult createDocumentComment(@Valid @RequestBody PmsKnowledgeDocumentCommentSaveReqVO saveReqVO) { + return success(commentService.createDocumentComment(saveReqVO, getLoginUserId())); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除知识库文档评论") + @Parameter(name = "id", description = "评论编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:kb:library:query')") + public CommonResult deleteDocumentComment(@RequestParam("id") Long id) { + commentService.deleteDocumentComment(id, getLoginUserId()); + return success(true); + } + + @GetMapping("/list") + @Operation(summary = "获得知识库文档评论列表") + @Parameter(name = "documentId", description = "文档编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:kb:library:query')") + public CommonResult> getDocumentCommentList( + @RequestParam("documentId") Long documentId) { + List comments = commentService.getDocumentCommentList(documentId, getLoginUserId()); + return success(buildCommentRespVOList(comments)); + } + + // ==================== 拼接 VO ==================== + + private List buildCommentRespVOList( + List comments) { + if (CollUtil.isEmpty(comments)) { + return Collections.emptyList(); + } + // 1. 批量查询评论人和回复对象 + Map userMap = adminUserApi.getUserMap(convertSetByFlatMap(comments, + comment -> Stream.of(comment.getUserId(), comment.getReplyUserId()))); + + // 2. 批量转换评论,避免回复组装依赖查询顺序 + Map commentMap = convertMap(comments, + PmsKnowledgeDocumentCommentDO::getId, comment -> { + PmsKnowledgeDocumentCommentRespVO commentVO = BeanUtils.toBean(comment, + PmsKnowledgeDocumentCommentRespVO.class).setChildren(new ArrayList<>()); + findAndThen(userMap, comment.getUserId(), user -> commentVO.setUserName(user.getNickname())); + findAndThen(userMap, comment.getReplyUserId(), + user -> commentVO.setReplyUserName(user.getNickname())); + return commentVO; + }); + + // 3. 按主评论组织回复列表 + List result = new ArrayList<>(); + for (PmsKnowledgeDocumentCommentDO comment : comments) { + PmsKnowledgeDocumentCommentRespVO commentVO = commentMap.get(comment.getId()); + if (PmsKnowledgeDocumentCommentDO.MAIN_ID_ROOT.equals(comment.getMainId())) { + result.add(commentVO); + continue; + } + findAndThen(commentMap, comment.getMainId(), mainComment -> mainComment.getChildren().add(commentVO)); + } + return result; + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/PmsKnowledgeDocumentLikeController.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/PmsKnowledgeDocumentLikeController.java new file mode 100644 index 000000000..98cd56568 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/PmsKnowledgeDocumentLikeController.java @@ -0,0 +1,47 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.interaction; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.pms.service.kb.interaction.PmsKnowledgeDocumentLikeService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - PMS 知识库文档点赞") +@RestController +@RequestMapping("/pms/kb/document-like") +@Validated +public class PmsKnowledgeDocumentLikeController { + + @Resource + private PmsKnowledgeDocumentLikeService documentLikeService; + + @PostMapping("/create") + @Operation(summary = "点赞文档") + @Parameter(name = "documentId", description = "文档编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:kb:library:query')") + public CommonResult createDocumentLike(@RequestParam("documentId") Long documentId) { + documentLikeService.createDocumentLike(documentId, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "取消点赞文档") + @Parameter(name = "documentId", description = "文档编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:kb:library:query')") + public CommonResult deleteDocumentLike(@RequestParam("documentId") Long documentId) { + documentLikeService.deleteDocumentLike(documentId, getLoginUserId()); + return success(true); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/PmsKnowledgeDocumentShareController.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/PmsKnowledgeDocumentShareController.java new file mode 100644 index 000000000..361c81d29 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/PmsKnowledgeDocumentShareController.java @@ -0,0 +1,101 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.interaction; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore; +import cn.iocoder.yudao.module.infra.api.file.FileApi; +import cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.share.PmsKnowledgeDocumentSharePublicRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.share.PmsKnowledgeDocumentShareRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.share.PmsKnowledgeDocumentShareSaveReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.share.PmsKnowledgeDocumentShareUpdateMemberListReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeDocumentDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.interaction.PmsKnowledgeDocumentShareDO; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeDocumentTypeEnum; +import cn.iocoder.yudao.module.pms.service.kb.interaction.PmsKnowledgeDocumentShareService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.annotation.security.PermitAll; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - PMS 知识库文档分享") +@RestController +@RequestMapping("/pms/kb/document-share") +@Validated +public class PmsKnowledgeDocumentShareController { + + @Resource + private PmsKnowledgeDocumentShareService documentShareService; + + @Resource + private FileApi fileApi; + + @PostMapping("/open") + @Operation(summary = "开启文档分享") + @PreAuthorize("@ss.hasPermission('pms:kb:library:update')") + public CommonResult openShare( + @Valid @RequestBody PmsKnowledgeDocumentShareSaveReqVO reqVO) { + PmsKnowledgeDocumentShareDO share = documentShareService.openShare(reqVO.getDocumentId(), + reqVO.getShareUserIds(), getLoginUserId()); + return success(BeanUtils.toBean(share, PmsKnowledgeDocumentShareRespVO.class)); + } + + @PutMapping("/update-member-list") + @Operation(summary = "更新文档内部分享成员") + @PreAuthorize("@ss.hasPermission('pms:kb:library:update')") + public CommonResult updateShareMemberList( + @Valid @RequestBody PmsKnowledgeDocumentShareUpdateMemberListReqVO reqVO) { + documentShareService.updateShareMemberList(reqVO.getDocumentId(), reqVO.getShareUserIds(), getLoginUserId()); + return success(true); + } + + @PutMapping("/close") + @Operation(summary = "关闭文档分享") + @Parameter(name = "documentId", description = "文档编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:kb:library:update')") + public CommonResult closeShare(@RequestParam("documentId") Long documentId) { + documentShareService.closeShare(documentId, getLoginUserId()); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得文档分享配置") + @Parameter(name = "documentId", description = "文档编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:kb:library:query')") + public CommonResult getDocumentShare(@RequestParam("documentId") Long documentId) { + PmsKnowledgeDocumentShareDO share = documentShareService.getActiveDocumentShare(documentId, getLoginUserId()); + return success(BeanUtils.toBean(share, PmsKnowledgeDocumentShareRespVO.class)); + } + + @GetMapping("/get-by-token") + @Operation(summary = "通过分享令牌获得文档") + @Parameter(name = "token", description = "分享令牌", required = true) + @PermitAll + @TenantIgnore + public CommonResult getDocumentByToken( + @RequestParam("token") @NotBlank(message = "分享令牌不能为空") String token) { + PmsKnowledgeDocumentDO document = documentShareService.getDocumentByShareToken(token); + PmsKnowledgeDocumentSharePublicRespVO documentVO = BeanUtils.toBean( + document, PmsKnowledgeDocumentSharePublicRespVO.class); + if (PmsKnowledgeDocumentTypeEnum.FILE.getType().equals(document.getType())) { + documentVO.setContent(null).setPreviewUrl( + fileApi.presignGetUrl(document.getContent(), null).getCheckedData()); + } + return success(documentVO); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/PmsKnowledgeFavoriteController.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/PmsKnowledgeFavoriteController.java new file mode 100644 index 000000000..0439bb2aa --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/PmsKnowledgeFavoriteController.java @@ -0,0 +1,77 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.interaction; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.PmsKnowledgeInteractionItemRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.favorite.PmsKnowledgeFavoritePageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.favorite.PmsKnowledgeFavoriteSaveReqVO; +import cn.iocoder.yudao.module.pms.enums.kb.PmsKnowledgeObjectTypeEnum; +import cn.iocoder.yudao.module.pms.service.kb.interaction.PmsKnowledgeFavoriteService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - PMS 知识关注") +@RestController +@RequestMapping("/pms/kb/favorite") +@Validated +public class PmsKnowledgeFavoriteController { + + @Resource + private PmsKnowledgeFavoriteService favoriteService; + + @PostMapping("/create") + @Operation(summary = "关注知识对象") + @PreAuthorize("@ss.hasPermission('pms:kb:library:query')") + public CommonResult createFavorite(@Valid @RequestBody PmsKnowledgeFavoriteSaveReqVO saveReqVO) { + favoriteService.createFavorite(saveReqVO, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "取消关注知识对象") + @Parameter(name = "type", description = "对象类型", required = true, example = "3") + @Parameter(name = "entityId", description = "对象编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:kb:library:query')") + public CommonResult deleteFavorite( + @RequestParam("type") @InEnum(PmsKnowledgeObjectTypeEnum.class) Integer type, + @RequestParam("entityId") Long entityId) { + favoriteService.deleteFavorite(type, entityId, getLoginUserId()); + return success(true); + } + + @GetMapping("/page") + @Operation(summary = "获得我的关注分页") + @PreAuthorize("@ss.hasPermission('pms:kb:library:query')") + public CommonResult> getFavoritePage( + @Valid PmsKnowledgeFavoritePageReqVO pageReqVO) { + return success(favoriteService.getFavoritePage(pageReqVO, getLoginUserId())); + } + + @GetMapping("/list") + @Operation(summary = "获得知识库内我的关注列表") + @Parameter(name = "libraryId", description = "知识库编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:kb:library:query')") + public CommonResult> getFavoriteListByLibraryId( + @RequestParam("libraryId") Long libraryId) { + return success(favoriteService.getFavoriteListByLibraryId(libraryId, getLoginUserId())); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/PmsKnowledgeViewRecordController.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/PmsKnowledgeViewRecordController.java new file mode 100644 index 000000000..16fc9b3ac --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/PmsKnowledgeViewRecordController.java @@ -0,0 +1,38 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.interaction; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.viewrecord.PmsKnowledgeRecentListRespVO; +import cn.iocoder.yudao.module.pms.service.kb.interaction.PmsKnowledgeViewRecordService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - PMS 知识最近浏览") +@RestController +@RequestMapping("/pms/kb/view-record") +@Validated +public class PmsKnowledgeViewRecordController { + + @Resource + private PmsKnowledgeViewRecordService viewRecordService; + + @GetMapping("/recent-list") + @Operation(summary = "获得最近浏览列表") + @Parameter(name = "libraryId", description = "知识库编号;不传时查询全部可读知识库", example = "1024") + @PreAuthorize("@ss.hasPermission('pms:kb:library:query')") + public CommonResult getRecentViewRecordList( + @RequestParam(value = "libraryId", required = false) Long libraryId) { + return success(viewRecordService.getRecentViewRecordList(libraryId, getLoginUserId())); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/PmsKnowledgeInteractionItemRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/PmsKnowledgeInteractionItemRespVO.java new file mode 100644 index 000000000..ac61ad402 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/PmsKnowledgeInteractionItemRespVO.java @@ -0,0 +1,51 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - PMS 知识交互对象 Response VO") +@Data +public class PmsKnowledgeInteractionItemRespVO { + + @Schema(description = "记录编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "对象类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + private Integer type; + + @Schema(description = "对象编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long entityId; + + @Schema(description = "知识库编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long libraryId; + + @Schema(description = "知识库名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "产品知识库") + private String libraryName; + + @Schema(description = "对象名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "产品说明") + private String name; + + @Schema(description = "知识库简介") + private String description; + + @Schema(description = "文件夹编号", example = "1024") + private Long folderId; + + @Schema(description = "文档编号", example = "1024") + private Long documentId; + + @Schema(description = "文件类型", example = "pdf") + private String fileType; + + @Schema(description = "文件大小,单位:字节") + private Long fileSize; + + @Schema(description = "对象更新时间") + private LocalDateTime targetUpdateTime; + + @Schema(description = "记录时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/comment/PmsKnowledgeDocumentCommentRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/comment/PmsKnowledgeDocumentCommentRespVO.java new file mode 100644 index 000000000..fb0c4f6e4 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/comment/PmsKnowledgeDocumentCommentRespVO.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.comment; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - PMS 知识库文档评论 Response VO") +@Data +public class PmsKnowledgeDocumentCommentRespVO { + + @Schema(description = "评论编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "文档编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long documentId; + + @Schema(description = "评论人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long userId; + + @Schema(description = "评论人昵称", example = "芋道") + private String userName; + + @Schema(description = "主评论编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Long mainId; + + @Schema(description = "回复对象用户编号", example = "2") + private Long replyUserId; + + @Schema(description = "回复对象昵称", example = "源码") + private String replyUserName; + + @Schema(description = "评论内容", requiredMode = Schema.RequiredMode.REQUIRED) + private String content; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + @Schema(description = "回复列表", requiredMode = Schema.RequiredMode.REQUIRED) + private List children; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/comment/PmsKnowledgeDocumentCommentSaveReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/comment/PmsKnowledgeDocumentCommentSaveReqVO.java new file mode 100644 index 000000000..efbed8db2 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/comment/PmsKnowledgeDocumentCommentSaveReqVO.java @@ -0,0 +1,28 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.comment; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - PMS 知识库文档评论保存 Request VO") +@Data +public class PmsKnowledgeDocumentCommentSaveReqVO { + + @Schema(description = "文档编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "文档编号不能为空") + private Long documentId; + + @Schema(description = "主评论编号", example = "0") + private Long mainId; + + @Schema(description = "回复对象用户编号", example = "1") + private Long replyUserId; + + @Schema(description = "评论内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "文档内容很清楚") + @NotBlank(message = "评论内容不能为空") + @Size(max = 2000, message = "评论内容不能超过 2000 个字符") + private String content; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/favorite/PmsKnowledgeFavoritePageReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/favorite/PmsKnowledgeFavoritePageReqVO.java new file mode 100644 index 000000000..a8bdf557b --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/favorite/PmsKnowledgeFavoritePageReqVO.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.favorite; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.pms.enums.kb.PmsKnowledgeObjectTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Schema(description = "管理后台 - PMS 知识收藏(关注)分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class PmsKnowledgeFavoritePageReqVO extends PageParam { + + @Schema(description = "对象类型", example = "3") + @InEnum(value = PmsKnowledgeObjectTypeEnum.class, message = "对象类型必须是 {value}") + private Integer type; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/favorite/PmsKnowledgeFavoriteSaveReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/favorite/PmsKnowledgeFavoriteSaveReqVO.java new file mode 100644 index 000000000..e77b3f251 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/favorite/PmsKnowledgeFavoriteSaveReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.favorite; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.pms.enums.kb.PmsKnowledgeObjectTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - PMS 知识收藏(关注)保存 Request VO") +@Data +public class PmsKnowledgeFavoriteSaveReqVO { + + @Schema(description = "对象类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + @NotNull(message = "对象类型不能为空") + @InEnum(value = PmsKnowledgeObjectTypeEnum.class, message = "对象类型必须是 {value}") + private Integer type; + + @Schema(description = "对象编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "对象编号不能为空") + private Long entityId; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/share/PmsKnowledgeDocumentSharePublicRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/share/PmsKnowledgeDocumentSharePublicRespVO.java new file mode 100644 index 000000000..f93f2cf05 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/share/PmsKnowledgeDocumentSharePublicRespVO.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.share; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "PMS 知识库文档公开分享 Response VO") +@Data +public class PmsKnowledgeDocumentSharePublicRespVO { + + @Schema(description = "文档编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "文档标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "产品说明") + private String title; + + @Schema(description = "文档内容或文件地址") + private String content; + + @Schema(description = "文件临时预览地址") + private String previewUrl; + + @Schema(description = "文档类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + private Integer type; + + @Schema(description = "文件类型", example = "pdf") + private String fileType; + + @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime updateTime; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/share/PmsKnowledgeDocumentShareRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/share/PmsKnowledgeDocumentShareRespVO.java new file mode 100644 index 000000000..3c9fc5198 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/share/PmsKnowledgeDocumentShareRespVO.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.share; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - PMS 知识库文档分享 Response VO") +@Data +public class PmsKnowledgeDocumentShareRespVO { + + @Schema(description = "分享编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "文档编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long documentId; + + @Schema(description = "内部分享成员用户编号列表", requiredMode = Schema.RequiredMode.REQUIRED) + private List shareUserIds; + + @Schema(description = "外部查看令牌", requiredMode = Schema.RequiredMode.REQUIRED) + private String token; + + @Schema(description = "分享状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer status; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + @Schema(description = "关闭人用户编号") + private Long closeUserId; + + @Schema(description = "关闭时间") + private LocalDateTime closeTime; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/share/PmsKnowledgeDocumentShareSaveReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/share/PmsKnowledgeDocumentShareSaveReqVO.java new file mode 100644 index 000000000..886e4a622 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/share/PmsKnowledgeDocumentShareSaveReqVO.java @@ -0,0 +1,20 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.share; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - PMS 知识库文档分享保存 Request VO") +@Data +public class PmsKnowledgeDocumentShareSaveReqVO { + + @Schema(description = "文档编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "文档编号不能为空") + private Long documentId; + + @Schema(description = "内部分享成员用户编号列表") + private List<@NotNull(message = "分享成员用户编号不能为空") Long> shareUserIds; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/share/PmsKnowledgeDocumentShareUpdateMemberListReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/share/PmsKnowledgeDocumentShareUpdateMemberListReqVO.java new file mode 100644 index 000000000..36f503568 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/share/PmsKnowledgeDocumentShareUpdateMemberListReqVO.java @@ -0,0 +1,20 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.share; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - PMS 知识库文档分享成员列表更新 Request VO") +@Data +public class PmsKnowledgeDocumentShareUpdateMemberListReqVO { + + @Schema(description = "文档编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "文档编号不能为空") + private Long documentId; + + @Schema(description = "内部分享成员用户编号列表") + private List<@NotNull(message = "分享成员用户编号不能为空") Long> shareUserIds; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/viewrecord/PmsKnowledgeRecentListRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/viewrecord/PmsKnowledgeRecentListRespVO.java new file mode 100644 index 000000000..b0c539fea --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/vo/viewrecord/PmsKnowledgeRecentListRespVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.viewrecord; + +import cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.PmsKnowledgeInteractionItemRespVO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - PMS 知识最近浏览 Response VO") +@Data +public class PmsKnowledgeRecentListRespVO { + + @Schema(description = "今天浏览列表", requiredMode = Schema.RequiredMode.REQUIRED) + private List todayItems; + + @Schema(description = "昨天浏览列表", requiredMode = Schema.RequiredMode.REQUIRED) + private List yesterdayItems; + + @Schema(description = "更早 30 天浏览列表", requiredMode = Schema.RequiredMode.REQUIRED) + private List recent30DayItems; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/PmsKnowledgeGroupController.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/PmsKnowledgeGroupController.java new file mode 100644 index 000000000..c3b6424f0 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/PmsKnowledgeGroupController.java @@ -0,0 +1,106 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.library; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.group.PmsKnowledgeGroupRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.group.PmsKnowledgeGroupSaveReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.group.PmsKnowledgeGroupSortReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.group.PmsKnowledgeLibraryMoveGroupReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeGroupDO; +import cn.iocoder.yudao.module.pms.service.kb.library.PmsKnowledgeGroupService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - PMS 知识库分组") +@RestController +@RequestMapping("/pms/kb/group") +@Validated +public class PmsKnowledgeGroupController { + + @Resource + private PmsKnowledgeGroupService knowledgeGroupService; + + @PostMapping("/create") + @Operation(summary = "创建知识库分组") + @PreAuthorize("@ss.hasPermission('pms:kb:library:create')") + public CommonResult createGroup(@Valid @RequestBody PmsKnowledgeGroupSaveReqVO saveReqVO) { + return success(knowledgeGroupService.createGroup(saveReqVO, getLoginUserId())); + } + + @PutMapping("/update") + @Operation(summary = "修改知识库分组") + @PreAuthorize("@ss.hasPermission('pms:kb:library:update')") + public CommonResult updateGroup(@Valid @RequestBody PmsKnowledgeGroupSaveReqVO saveReqVO) { + knowledgeGroupService.updateGroup(saveReqVO, getLoginUserId()); + return success(true); + } + + @PutMapping("/update-sort") + @Operation(summary = "修改知识库分组排序") + @PreAuthorize("@ss.hasPermission('pms:kb:library:update')") + public CommonResult updateGroupSort( + @Valid @RequestBody PmsKnowledgeGroupSortReqVO sortReqVO) { + knowledgeGroupService.updateGroupSort(sortReqVO, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除知识库分组") + @Parameter(name = "id", description = "知识库分组编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:kb:library:delete')") + public CommonResult deleteGroup(@RequestParam("id") Long id) { + knowledgeGroupService.deleteGroup(id, getLoginUserId()); + return success(true); + } + + @GetMapping("/list") + @Operation(summary = "获得知识库分组列表") + @PreAuthorize("@ss.hasPermission('pms:kb:library:query')") + public CommonResult> getGroupList() { + Long userId = getLoginUserId(); + List groups = knowledgeGroupService.getGroupList(userId); + Map groupCountMap = knowledgeGroupService.getGroupLibraryCountMap(userId, + convertSet(groups, PmsKnowledgeGroupDO::getId)); + return success(convertList(groups, group -> BeanUtils.toBean(group, PmsKnowledgeGroupRespVO.class) + .setLibraryCount(groupCountMap.getOrDefault(group.getId(), 0)))); + } + + @GetMapping("/get") + @Operation(summary = "获得知识库分组") + @Parameter(name = "id", description = "知识库分组编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:kb:library:query')") + public CommonResult getGroup(@RequestParam("id") Long id) { + return success(BeanUtils.toBean(knowledgeGroupService.getGroup(id, getLoginUserId()), + PmsKnowledgeGroupRespVO.class)); + } + + @PutMapping("/move") + @Operation(summary = "移动知识库到个人分组") + @PreAuthorize("@ss.hasPermission('pms:kb:library:update')") + public CommonResult moveLibraryToGroup( + @Valid @RequestBody PmsKnowledgeLibraryMoveGroupReqVO moveReqVO) { + knowledgeGroupService.moveLibraryToGroup(moveReqVO, getLoginUserId()); + return success(true); + } +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/PmsKnowledgeLibraryController.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/PmsKnowledgeLibraryController.java new file mode 100644 index 000000000..2ef286af2 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/PmsKnowledgeLibraryController.java @@ -0,0 +1,159 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.library; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.number.NumberUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.library.PmsKnowledgeLibraryPageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.library.PmsKnowledgeLibraryRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.library.PmsKnowledgeLibrarySaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryMemberDO; +import cn.iocoder.yudao.module.pms.enums.kb.PmsKnowledgeObjectTypeEnum; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeDocumentTypeEnum; +import cn.iocoder.yudao.module.pms.enums.kb.library.PmsKnowledgeLibraryMemberLevelEnum; +import cn.iocoder.yudao.module.pms.service.kb.content.PmsKnowledgeDocumentService; +import cn.iocoder.yudao.module.pms.service.kb.interaction.PmsKnowledgeFavoriteService; +import cn.iocoder.yudao.module.pms.service.kb.library.PmsKnowledgeLibraryMemberService; +import cn.iocoder.yudao.module.pms.service.kb.library.PmsKnowledgeLibraryService; +import cn.iocoder.yudao.module.system.api.permission.PermissionApi; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import cn.iocoder.yudao.module.system.enums.permission.RoleCodeEnum; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - PMS 知识库") +@RestController +@RequestMapping("/pms/kb/library") +@Validated +public class PmsKnowledgeLibraryController { + + @Resource + private PmsKnowledgeLibraryService libraryService; + @Resource + private PmsKnowledgeLibraryMemberService memberService; + @Resource + private PmsKnowledgeDocumentService documentService; + @Resource + private PmsKnowledgeFavoriteService favoriteService; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private PermissionApi permissionApi; + + @PostMapping("/create") + @Operation(summary = "创建知识库") + @PreAuthorize("@ss.hasPermission('pms:kb:library:create')") + public CommonResult createLibrary(@Valid @RequestBody PmsKnowledgeLibrarySaveReqVO saveReqVO) { + return success(libraryService.createLibrary(saveReqVO, getLoginUserId())); + } + + @PutMapping("/update") + @Operation(summary = "更新知识库") + @PreAuthorize("@ss.hasPermission('pms:kb:library:update')") + public CommonResult updateLibrary(@Valid @RequestBody PmsKnowledgeLibrarySaveReqVO saveReqVO) { + libraryService.updateLibrary(saveReqVO, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除知识库") + @Parameter(name = "id", description = "知识库编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:kb:library:delete')") + public CommonResult deleteLibrary(@RequestParam("id") Long id) { + libraryService.deleteLibrary(id, getLoginUserId()); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得知识库详情") + @Parameter(name = "id", description = "知识库编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:kb:library:query')") + public CommonResult getLibrary(@RequestParam("id") Long id) { + Long userId = getLoginUserId(); + PmsKnowledgeLibraryDO library = memberService.validateLibraryReadable(id, userId); + return success(CollUtil.getFirst(buildLibraryRespVOList(Collections.singletonList(library), userId))); + } + + @GetMapping("/page") + @Operation(summary = "获得知识库分页") + @PreAuthorize("@ss.hasPermission('pms:kb:library:query')") + public CommonResult> getLibraryPage( + @Valid PmsKnowledgeLibraryPageReqVO pageReqVO) { + Long userId = getLoginUserId(); + PageResult pageResult = libraryService.getLibraryPage(pageReqVO, userId); + return success(new PageResult<>(buildLibraryRespVOList(pageResult.getList(), userId), pageResult.getTotal())); + } + + // ==================== 拼接 VO ==================== + + private List buildLibraryRespVOList( + List libraries, Long userId) { + if (CollUtil.isEmpty(libraries)) { + return Collections.emptyList(); + } + + // 1. 批量查询知识库成员、内容数量、创建人和当前用户权限信息 + Set libraryIds = convertSet(libraries, PmsKnowledgeLibraryDO::getId); + Map> memberListMap = memberService.getLibraryMemberListMap(libraryIds); + Map> documentTypeCountMap = documentService.getDocumentTypeCountMap(libraryIds); + Set userIds = convertSet(libraries, library -> NumberUtils.parseLong(library.getCreator())); + Map userMap = adminUserApi.getUserMap(userIds); + AdminUserRespDTO currentUser = adminUserApi.getUser(userId).getCheckedData(); + Long deptId = currentUser != null ? currentUser.getDeptId() : null; + boolean superAdmin = permissionApi.hasAnyRoles(userId, RoleCodeEnum.SUPER_ADMIN.getCode()).getCheckedData(); + Set favoriteLibraryIds = favoriteService.getFavoriteEntityIdSet( + PmsKnowledgeObjectTypeEnum.LIBRARY.getType(), libraryIds, userId); + + // 2. 拼接每个知识库的成员权限、统计数量、收藏状态和创建人名称 + return convertList(libraries, library -> { + List members = memberListMap.getOrDefault(library.getId(), Collections.emptyList()); + PmsKnowledgeLibraryMemberDO directMember = CollUtil.findOne(members, member -> userId.equals(member.getUserId())); + PmsKnowledgeLibraryMemberDO currentMember = directMember; + if (currentMember == null && deptId != null) { + currentMember = CollUtil.findOne(members, member -> deptId.equals(member.getDeptId())); + } + boolean adminStatus = superAdmin || currentMember != null + && (PmsKnowledgeLibraryMemberLevelEnum.CREATOR.getLevel().equals(currentMember.getLevel()) + || PmsKnowledgeLibraryMemberLevelEnum.ADMIN.getLevel().equals(currentMember.getLevel())); + PmsKnowledgeLibraryRespVO libraryVO = BeanUtils.toBean(library, PmsKnowledgeLibraryRespVO.class) + .setCreatorUserId(NumberUtils.parseLong(library.getCreator())).setMemberCount(members.size()) + .setWriteStatus(superAdmin || currentMember != null).setAdminStatus(adminStatus) + .setExitStatus(directMember != null && !PmsKnowledgeLibraryMemberLevelEnum.CREATOR.getLevel().equals(directMember.getLevel())) + .setFavoriteStatus(favoriteLibraryIds.contains(library.getId())); + Map typeCountMap = documentTypeCountMap.getOrDefault(library.getId(), Collections.emptyMap()); + libraryVO.setDocumentCount(typeCountMap.getOrDefault(PmsKnowledgeDocumentTypeEnum.RICH_TEXT.getType(), 0L)) + .setFileCount(typeCountMap.getOrDefault(PmsKnowledgeDocumentTypeEnum.FILE.getType(), 0L)); + findAndThen(userMap, libraryVO.getCreatorUserId(), user -> libraryVO.setCreatorUserName(user.getNickname())); + return libraryVO; + }); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/PmsKnowledgeLibraryMemberController.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/PmsKnowledgeLibraryMemberController.java new file mode 100644 index 000000000..8cf710a73 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/PmsKnowledgeLibraryMemberController.java @@ -0,0 +1,106 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.library; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.member.PmsKnowledgeLibraryMemberRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.member.PmsKnowledgeLibraryUpdateMemberListReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryMemberDO; +import cn.iocoder.yudao.module.pms.service.kb.library.PmsKnowledgeLibraryMemberService; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - PMS 知识库成员") +@RestController +@RequestMapping("/pms/kb/library-member") +@Validated +public class PmsKnowledgeLibraryMemberController { + + @Resource + private PmsKnowledgeLibraryMemberService memberService; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private DeptApi deptApi; + + @GetMapping("/list") + @Operation(summary = "获得知识库成员列表") + @Parameter(name = "libraryId", description = "知识库编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:kb:library:query')") + public CommonResult> getLibraryMemberList(@RequestParam("libraryId") Long libraryId) { + List members = memberService.getLibraryMemberList(libraryId, getLoginUserId()); + return success(buildLibraryMemberRespVOList(members)); + } + + @PutMapping("/update-list") + @Operation(summary = "更新知识库成员") + @PreAuthorize("@ss.hasPermission('pms:kb:library:update')") + public CommonResult updateLibraryMemberList(@Valid @RequestBody PmsKnowledgeLibraryUpdateMemberListReqVO updateReqVO) { + memberService.updateLibraryMemberList(updateReqVO, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/exit") + @Operation(summary = "主动退出知识库") + @Parameter(name = "libraryId", description = "知识库编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:kb:library:query')") + public CommonResult exitLibrary(@RequestParam("libraryId") Long libraryId) { + memberService.exitLibrary(libraryId, getLoginUserId()); + return success(true); + } + + // ==================== 拼接 VO ==================== + + private List buildLibraryMemberRespVOList( + List members) { + if (CollUtil.isEmpty(members)) { + return Collections.emptyList(); + } + + // 1. 批量查询用户和部门 + Map userMap = adminUserApi.getUserMap( + convertSet(members, PmsKnowledgeLibraryMemberDO::getUserId)); + Map deptMap = deptApi.getDeptMap( + convertSet(members, PmsKnowledgeLibraryMemberDO::getDeptId)); + Map parentDeptMap = deptApi.getDeptMap(convertSet(deptMap.values(), DeptRespDTO::getParentId)); + // 2. 转换成员响应,并拼接用户和部门名称 + return convertList(members, member -> { + PmsKnowledgeLibraryMemberRespVO memberVO = BeanUtils.toBean(member, PmsKnowledgeLibraryMemberRespVO.class); + findAndThen(userMap, member.getUserId(), user -> + memberVO.setNickname(user.getNickname()).setAvatar(user.getAvatar())); + findAndThen(deptMap, member.getDeptId(), dept -> { + memberVO.setDeptName(dept.getName()).setParentDeptId(dept.getParentId()); + findAndThen(parentDeptMap, dept.getParentId(), parent -> memberVO.setParentDeptName(parent.getName())); + }); + return memberVO; + }); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/PmsKnowledgeLibraryTemplateController.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/PmsKnowledgeLibraryTemplateController.java new file mode 100644 index 000000000..cf023f5e8 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/PmsKnowledgeLibraryTemplateController.java @@ -0,0 +1,103 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.library; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.template.PmsKnowledgeLibraryTemplatePageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.template.PmsKnowledgeLibraryTemplateRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.template.PmsKnowledgeLibraryTemplateSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryTemplateDO; +import cn.iocoder.yudao.module.pms.service.kb.library.PmsKnowledgeLibraryTemplateService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; + +/** + * 管理后台 - PMS 知识库模板 Controller + * + * @author 芋道源码 + */ +@Tag(name = "管理后台 - PMS 知识库模板") +@RestController +@RequestMapping("/pms/kb/library-template") +@Validated +public class PmsKnowledgeLibraryTemplateController { + + @Resource + private PmsKnowledgeLibraryTemplateService libraryTemplateService; + + @PostMapping("/create") + @Operation(summary = "创建知识库模板") + @PreAuthorize("@ss.hasPermission('pms:kb:library-template:create')") + public CommonResult createLibraryTemplate( + @Valid @RequestBody PmsKnowledgeLibraryTemplateSaveReqVO createReqVO) { + return success(libraryTemplateService.createLibraryTemplate(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新知识库模板") + @PreAuthorize("@ss.hasPermission('pms:kb:library-template:update')") + public CommonResult updateLibraryTemplate( + @Valid @RequestBody PmsKnowledgeLibraryTemplateSaveReqVO updateReqVO) { + libraryTemplateService.updateLibraryTemplate(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除知识库模板") + @Parameter(name = "id", description = "模板编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:kb:library-template:delete')") + public CommonResult deleteLibraryTemplate(@RequestParam("id") Long id) { + libraryTemplateService.deleteLibraryTemplate(id); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得知识库模板") + @Parameter(name = "id", description = "模板编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:kb:library-template:query')") + public CommonResult getLibraryTemplate(@RequestParam("id") Long id) { + PmsKnowledgeLibraryTemplateDO template = libraryTemplateService.getLibraryTemplate(id); + return success(BeanUtils.toBean(template, PmsKnowledgeLibraryTemplateRespVO.class)); + } + + @GetMapping("/simple-list") + @Operation(summary = "获取知识库模板全列表", description = "只包含开启状态模板,返回模板选择所需的基本信息和文档标题") + public CommonResult> getSimpleLibraryTemplateList() { + return success(convertList(libraryTemplateService.getLibraryTemplateList(CommonStatusEnum.ENABLE.getStatus()), + template -> new PmsKnowledgeLibraryTemplateRespVO().setId(template.getId()) + .setName(template.getName()).setCoverUrl(template.getCoverUrl()).setDescription(template.getDescription()) + .setDocuments(convertList(template.getDocuments(), document -> + new PmsKnowledgeLibraryTemplateSaveReqVO.Document().setTitle(document.getTitle()))))); + } + + @GetMapping("/page") + @Operation(summary = "获得知识库模板分页") + @PreAuthorize("@ss.hasPermission('pms:kb:library-template:query')") + public CommonResult> getLibraryTemplatePage( + @Valid PmsKnowledgeLibraryTemplatePageReqVO pageReqVO) { + PageResult pageResult = libraryTemplateService + .getLibraryTemplatePage(pageReqVO); + return success(new PageResult<>(BeanUtils.toBean(pageResult.getList(), PmsKnowledgeLibraryTemplateRespVO.class), + pageResult.getTotal())); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/group/PmsKnowledgeGroupRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/group/PmsKnowledgeGroupRespVO.java new file mode 100644 index 000000000..45e9c5f0a --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/group/PmsKnowledgeGroupRespVO.java @@ -0,0 +1,30 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.group; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - PMS 知识库分组 Response VO") +@Data +public class PmsKnowledgeGroupRespVO { + + @Schema(description = "知识库分组编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "分组名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "产品知识库") + private String name; + + @Schema(description = "显示顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer sort; + + @Schema(description = "分组类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + private Integer type; + + @Schema(description = "分组内知识库数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "5") + private Integer libraryCount; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/group/PmsKnowledgeGroupSaveReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/group/PmsKnowledgeGroupSaveReqVO.java new file mode 100644 index 000000000..5c567bf38 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/group/PmsKnowledgeGroupSaveReqVO.java @@ -0,0 +1,20 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.group; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - PMS 知识库分组新增/修改 Request VO") +@Data +public class PmsKnowledgeGroupSaveReqVO { + + @Schema(description = "知识库分组编号", example = "1024") + private Long id; + + @Schema(description = "分组名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "产品知识库") + @NotBlank(message = "分组名称不能为空") + @Size(max = 100, message = "分组名称不能超过 100 个字符") + private String name; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/group/PmsKnowledgeGroupSortReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/group/PmsKnowledgeGroupSortReqVO.java new file mode 100644 index 000000000..d44f561f1 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/group/PmsKnowledgeGroupSortReqVO.java @@ -0,0 +1,35 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.group; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - PMS 知识库分组排序 Request VO") +@Data +public class PmsKnowledgeGroupSortReqVO { + + @Schema(description = "知识库分组排序项", requiredMode = Schema.RequiredMode.REQUIRED) + @NotEmpty(message = "知识库分组排序项不能为空") + private List<@Valid Item> items; + + @Schema(description = "管理后台 - PMS 知识库分组排序项 Request VO") + @Data + public static class Item { + + @Schema(description = "知识库分组编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "知识库分组编号不能为空") + private Long id; + + @Schema(description = "显示顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "显示顺序不能为空") + @Min(value = 0, message = "显示顺序不能小于 0") + private Integer sort; + + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/group/PmsKnowledgeLibraryMoveGroupReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/group/PmsKnowledgeLibraryMoveGroupReqVO.java new file mode 100644 index 000000000..4581020b8 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/group/PmsKnowledgeLibraryMoveGroupReqVO.java @@ -0,0 +1,18 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.group; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - PMS 知识库移动分组 Request VO") +@Data +public class PmsKnowledgeLibraryMoveGroupReqVO { + + @Schema(description = "知识库编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "知识库编号不能为空") + private Long libraryId; + + @Schema(description = "目标分组编号;为空表示移出分组", example = "2048") + private Long groupId; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/library/PmsKnowledgeLibraryPageReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/library/PmsKnowledgeLibraryPageReqVO.java new file mode 100644 index 000000000..1de242347 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/library/PmsKnowledgeLibraryPageReqVO.java @@ -0,0 +1,21 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.library; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@Schema(description = "管理后台 - PMS 知识库分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsKnowledgeLibraryPageReqVO extends PageParam { + + @Schema(description = "知识库名称", example = "产品") + private String name; + + @Schema(description = "个人知识库分组编号", example = "1024") + private Long groupId; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/library/PmsKnowledgeLibraryRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/library/PmsKnowledgeLibraryRespVO.java new file mode 100644 index 000000000..20e6d434e --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/library/PmsKnowledgeLibraryRespVO.java @@ -0,0 +1,57 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.library; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - PMS 知识库 Response VO") +@Data +public class PmsKnowledgeLibraryRespVO { + + @Schema(description = "知识库编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "知识库名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "产品知识库") + private String name; + + @Schema(description = "知识库简介") + private String description; + + @Schema(description = "是否公开", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean openStatus; + + @Schema(description = "知识库封面") + private String coverUrl; + + @Schema(description = "创建人用户编号", example = "1") + private Long creatorUserId; + + @Schema(description = "创建人姓名", example = "芋道源码") + private String creatorUserName; + + @Schema(description = "成员数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "5") + private Integer memberCount; + + @Schema(description = "文档数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "12") + private Long documentCount; + + @Schema(description = "文件数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "4") + private Long fileCount; + + @Schema(description = "当前用户是否可以编辑内容", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean writeStatus; + + @Schema(description = "当前用户是否为知识库管理员", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean adminStatus; + + @Schema(description = "当前用户是否可以主动退出知识库", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean exitStatus; + + @Schema(description = "当前用户是否已关注", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean favoriteStatus; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/library/PmsKnowledgeLibrarySaveReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/library/PmsKnowledgeLibrarySaveReqVO.java new file mode 100644 index 000000000..04daa730a --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/library/PmsKnowledgeLibrarySaveReqVO.java @@ -0,0 +1,46 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.library; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - PMS 知识库新增/修改 Request VO") +@Data +public class PmsKnowledgeLibrarySaveReqVO { + + @Schema(description = "知识库编号", example = "1024") + private Long id; + + @Schema(description = "知识库名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "产品知识库") + @NotBlank(message = "知识库名称不能为空") + @Size(max = 50, message = "知识库名称不能超过 50 个字符") + private String name; + + @Schema(description = "知识库简介", example = "沉淀产品文档") + @Size(max = 300, message = "知识库简介不能超过 300 个字符") + private String description; + + @Schema(description = "是否公开", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + @NotNull(message = "知识库可见范围不能为空") + private Boolean openStatus; + + @Schema(description = "知识库封面") + @Size(max = 255, message = "知识库封面地址不能超过 255 个字符") + private String coverUrl; + + @Schema(description = "创建时的初始管理员用户编号列表", example = "[1, 2]") + private List adminUserIds; + + @Schema(description = "创建时的初始普通成员用户编号列表", example = "[3, 4]") + private List memberUserIds; + + @Schema(description = "模板编号,仅创建时有效", example = "1") + @Positive(message = "模板编号必须大于 0") + private Long templateId; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/member/PmsKnowledgeLibraryMemberRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/member/PmsKnowledgeLibraryMemberRespVO.java new file mode 100644 index 000000000..3772f2789 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/member/PmsKnowledgeLibraryMemberRespVO.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.member; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - PMS 知识库成员 Response VO") +@Data +public class PmsKnowledgeLibraryMemberRespVO { + + @Schema(description = "成员编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "用户编号", example = "1") + private Long userId; + + @Schema(description = "用户姓名", example = "芋道源码") + private String nickname; + + @Schema(description = "用户头像") + private String avatar; + + @Schema(description = "部门编号", example = "100") + private Long deptId; + + @Schema(description = "部门名称", example = "研发部") + private String deptName; + + @Schema(description = "父部门编号") + private Long parentDeptId; + + @Schema(description = "父部门名称") + private String parentDeptName; + + @Schema(description = "成员等级", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + private Integer level; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/member/PmsKnowledgeLibraryUpdateMemberListReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/member/PmsKnowledgeLibraryUpdateMemberListReqVO.java new file mode 100644 index 000000000..849db7a25 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/member/PmsKnowledgeLibraryUpdateMemberListReqVO.java @@ -0,0 +1,50 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.member; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.pms.enums.kb.library.PmsKnowledgeLibraryMemberLevelEnum; +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - PMS 更新知识库成员列表 Request VO") +@Data +public class PmsKnowledgeLibraryUpdateMemberListReqVO { + + @Schema(description = "知识库编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "知识库编号不能为空") + private Long libraryId; + + @Schema(description = "成员列表", requiredMode = Schema.RequiredMode.REQUIRED) + @Valid + @NotNull(message = "成员列表不能为空") + private List members; + + @Schema(description = "管理后台 - PMS 知识库成员更新项") + @Data + public static class Member { + + @Schema(description = "用户编号", example = "1") + private Long userId; + + @Schema(description = "部门编号", example = "100") + private Long deptId; + + @Schema(description = "成员等级", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + @NotNull(message = "成员等级不能为空") + @InEnum(PmsKnowledgeLibraryMemberLevelEnum.class) + private Integer level; + + @AssertTrue(message = "知识库成员必须且只能选择用户或部门") + @JsonIgnore + public boolean isUserOrDeptValid() { + return (userId != null && deptId == null) || (userId == null && deptId != null); + } + + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/template/PmsKnowledgeLibraryTemplatePageReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/template/PmsKnowledgeLibraryTemplatePageReqVO.java new file mode 100644 index 000000000..1a1aecf17 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/template/PmsKnowledgeLibraryTemplatePageReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.template; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Schema(description = "管理后台 - PMS 知识库模板分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class PmsKnowledgeLibraryTemplatePageReqVO extends PageParam { + + @Schema(description = "模板名称,模糊匹配", example = "产品研发") + private String name; + + @Schema(description = "模板状态", example = "0") + @InEnum(CommonStatusEnum.class) + private Integer status; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/template/PmsKnowledgeLibraryTemplateRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/template/PmsKnowledgeLibraryTemplateRespVO.java new file mode 100644 index 000000000..f292aae76 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/template/PmsKnowledgeLibraryTemplateRespVO.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.template; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - PMS 知识库模板 Response VO") +@Data +public class PmsKnowledgeLibraryTemplateRespVO { + + @Schema(description = "模板编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "模板名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "产品研发") + private String name; + + @Schema(description = "模板简介", example = "提供完善的产品流程文档") + private String description; + + @Schema(description = "模板封面地址", example = "/static/pms/kb/template/product.png") + private String coverUrl; + + @Schema(description = "模板状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer status; + + @Schema(description = "显示顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") + private Integer sort; + + @Schema(description = "模板文档列表", requiredMode = Schema.RequiredMode.REQUIRED) + private List documents; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/template/PmsKnowledgeLibraryTemplateSaveReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/template/PmsKnowledgeLibraryTemplateSaveReqVO.java new file mode 100644 index 000000000..5dcf251de --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/library/vo/template/PmsKnowledgeLibraryTemplateSaveReqVO.java @@ -0,0 +1,66 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.template; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - PMS 知识库模板新增/修改 Request VO") +@Data +public class PmsKnowledgeLibraryTemplateSaveReqVO { + + @Schema(description = "模板编号", example = "1024") + private Long id; + + @Schema(description = "模板名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "产品研发") + @NotBlank(message = "模板名称不能为空") + @Size(max = 100, message = "模板名称不能超过 100 个字符") + private String name; + + @Schema(description = "模板简介", example = "提供完善的产品流程文档") + @Size(max = 500, message = "模板简介不能超过 500 个字符") + private String description; + + @Schema(description = "模板封面地址", example = "/static/pms/kb/template/product.png") + @Size(max = 255, message = "模板封面地址不能超过 255 个字符") + private String coverUrl; + + @Schema(description = "模板状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "模板状态不能为空") + @InEnum(CommonStatusEnum.class) + private Integer status; + + @Schema(description = "显示顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") + @NotNull(message = "显示顺序不能为空") + @PositiveOrZero(message = "显示顺序不能小于 0") + private Integer sort; + + @Schema(description = "模板文档列表", requiredMode = Schema.RequiredMode.REQUIRED) + @NotEmpty(message = "模板文档不能为空") + @Valid + private List documents; + + @Schema(description = "模板文档") + @Data + public static class Document { + + @Schema(description = "文档标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "产品需求文档") + @NotBlank(message = "文档标题不能为空") + @Size(max = 255, message = "文档标题不能超过 255 个字符") + private String title; + + @Schema(description = "文档内容", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "文档内容不能为空") + private String content; + + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/recycle/PmsKnowledgeRecycleController.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/recycle/PmsKnowledgeRecycleController.java new file mode 100644 index 000000000..7699700e7 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/recycle/PmsKnowledgeRecycleController.java @@ -0,0 +1,125 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.recycle; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.kb.recycle.vo.PmsKnowledgeRecycleDetailRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.recycle.vo.PmsKnowledgeRecyclePreviewRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.recycle.vo.PmsKnowledgeRecycleRespVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeDocumentDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.recycle.PmsKnowledgeRecycleRecordDO; +import cn.iocoder.yudao.module.pms.enums.kb.PmsKnowledgeObjectTypeEnum; +import cn.iocoder.yudao.module.pms.service.kb.content.PmsKnowledgeDocumentService; +import cn.iocoder.yudao.module.pms.service.kb.recycle.PmsKnowledgeRecycleService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - PMS 知识库回收站") +@RestController +@RequestMapping("/pms/kb/recycle") +@Validated +public class PmsKnowledgeRecycleController { + + @Resource + private PmsKnowledgeRecycleService recycleService; + + @Resource + private AdminUserApi adminUserApi; + + @Resource + private PmsKnowledgeDocumentService documentService; + + @GetMapping("/library-list") + @Operation(summary = "获得已删除知识库列表") + @PreAuthorize("@ss.hasPermission('pms:kb:library:delete')") + public CommonResult> getLibraryRecycleList() { + List records = recycleService.getLibraryRecycleList(getLoginUserId()); + return success(buildRecycleRespVOList(records)); + } + + @GetMapping("/content-list") + @Operation(summary = "获得知识库最近删除内容") + @Parameter(name = "libraryId", description = "知识库编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:kb:library:delete')") + public CommonResult> getContentRecycleList( + @RequestParam("libraryId") Long libraryId) { + List records = recycleService.getContentRecycleList( + libraryId, getLoginUserId()); + return success(buildRecycleRespVOList(records)); + } + + @GetMapping("/content-detail") + @Operation(summary = "获得知识库最近删除详情") + @Parameter(name = "id", description = "回收站记录编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:kb:library:delete')") + public CommonResult getContentRecycleDetail( + @RequestParam("id") Long id) { + return success(recycleService.getContentRecycleDetail(id, getLoginUserId())); + } + + @GetMapping("/content-preview") + @Operation(summary = "预览知识库最近删除内容") + @Parameter(name = "id", description = "回收站记录编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:kb:library:delete')") + public CommonResult getContentRecyclePreview( + @RequestParam("id") Long id, + @RequestParam(value = "entityId", required = false) Long entityId) { + PmsKnowledgeDocumentDO document = recycleService.getContentRecyclePreview(id, entityId, getLoginUserId()); + return success(BeanUtils.toBean(document, PmsKnowledgeRecyclePreviewRespVO.class, + o -> o.setName(document.getTitle()))); + } + + @PutMapping("/restore") + @Operation(summary = "恢复回收站对象") + @Parameter(name = "id", description = "回收站记录编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:kb:library:delete')") + public CommonResult restoreContentRecycle(@RequestParam("id") Long id) { + recycleService.restoreContentRecycle(id, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/permanent-delete") + @Operation(summary = "彻底删除回收站对象") + @Parameter(name = "id", description = "回收站记录编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:kb:library:delete')") + public CommonResult deleteContentRecycle(@RequestParam("id") Long id) { + recycleService.deleteContentRecycle(id, getLoginUserId()); + return success(true); + } + + // ==================== 拼接 VO ==================== + + private List buildRecycleRespVOList(List records) { + Map userMap = adminUserApi.getUserMap( + convertSet(records, PmsKnowledgeRecycleRecordDO::getDeleteUserId)); + Map documentMap = documentService.getDocumentMap(convertSet(records, record -> + PmsKnowledgeObjectTypeEnum.FILE.getType().equals(record.getType()) ? record.getEntityId() : null)); + return convertList(records, record -> { + PmsKnowledgeRecycleRespVO recordVO = BeanUtils.toBean(record, PmsKnowledgeRecycleRespVO.class); + findAndThen(documentMap, record.getEntityId(), document -> recordVO.setFileSize(document.getFileSize())); + findAndThen(userMap, record.getDeleteUserId(), user -> recordVO.setDeleteUserName(user.getNickname())); + return recordVO; + }); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/recycle/vo/PmsKnowledgeRecycleDetailRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/recycle/vo/PmsKnowledgeRecycleDetailRespVO.java new file mode 100644 index 000000000..86f1f2553 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/recycle/vo/PmsKnowledgeRecycleDetailRespVO.java @@ -0,0 +1,41 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.recycle.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - PMS 知识库回收站详情 Response VO") +@Data +public class PmsKnowledgeRecycleDetailRespVO { + + @Schema(description = "根回收站记录") + private PmsKnowledgeRecycleRespVO root; + + @Schema(description = "级联删除内容") + private List children; + + @Data + public static class Item { + + @Schema(description = "内容编号", requiredMode = Schema.RequiredMode.REQUIRED) + private Long id; + + @Schema(description = "内容类型", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer type; + + @Schema(description = "内容名称", requiredMode = Schema.RequiredMode.REQUIRED) + private String name; + + @Schema(description = "父文档或父文件夹编号") + private Long parentId; + + @Schema(description = "所属文件夹编号") + private Long folderId; + + @Schema(description = "删除时间") + private LocalDateTime deleteTime; + + } +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/recycle/vo/PmsKnowledgeRecyclePreviewRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/recycle/vo/PmsKnowledgeRecyclePreviewRespVO.java new file mode 100644 index 000000000..9f10dfc6d --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/recycle/vo/PmsKnowledgeRecyclePreviewRespVO.java @@ -0,0 +1,28 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.recycle.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - PMS 知识库回收站内容预览 Response VO") +@Data +public class PmsKnowledgeRecyclePreviewRespVO { + + @Schema(description = "内容编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "内容类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + private Integer type; + + @Schema(description = "内容名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "产品说明") + private String name; + + @Schema(description = "文档正文或文件地址") + private String content; + + @Schema(description = "文件类型", example = "pdf") + private String fileType; + + @Schema(description = "文件大小,单位:字节", example = "102400") + private Long fileSize; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/recycle/vo/PmsKnowledgeRecycleRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/recycle/vo/PmsKnowledgeRecycleRespVO.java new file mode 100644 index 000000000..c561770b0 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/kb/recycle/vo/PmsKnowledgeRecycleRespVO.java @@ -0,0 +1,45 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.recycle.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - PMS 知识库回收站记录 Response VO") +@Data +public class PmsKnowledgeRecycleRespVO { + + @Schema(description = "回收站记录编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "知识库编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long libraryId; + + @Schema(description = "对象类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + private Integer type; + + @Schema(description = "对象编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") + private Long entityId; + + @Schema(description = "对象名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "产品说明") + private String name; + + @Schema(description = "文件大小,单位:字节") + private Long fileSize; + + @Schema(description = "删除人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long deleteUserId; + + @Schema(description = "删除人姓名", example = "芋道") + private String deleteUserName; + + @Schema(description = "删除时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime deleteTime; + + @Schema(description = "父对象编号,回收站详情中用于组装层级", example = "2048") + private Long parentId; + + @Schema(description = "所属文件夹编号,回收站详情中用于组装层级", example = "2048") + private Long folderId; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/iteration/PmsIterationController.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/iteration/PmsIterationController.java new file mode 100644 index 000000000..06f215bcd --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/iteration/PmsIterationController.java @@ -0,0 +1,152 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.iteration; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.pm.iteration.vo.PmsIterationOverviewRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.iteration.vo.PmsIterationPageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.iteration.vo.PmsIterationRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.iteration.vo.PmsIterationSaveReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.iteration.vo.PmsIterationStartReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.iteration.PmsIterationDO; +import cn.iocoder.yudao.module.pms.service.pm.iteration.PmsIterationService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - PMS 项目迭代") +@RestController +@RequestMapping("/pms/pm/iteration") +@Validated +public class PmsIterationController { + + @Resource + private PmsIterationService iterationService; + + @Resource + private AdminUserApi adminUserApi; + + @PostMapping("/create") + @Operation(summary = "创建项目迭代") + @PreAuthorize("@ss.hasPermission('pms:pm:iteration:create')") + public CommonResult createIteration(@Valid @RequestBody PmsIterationSaveReqVO saveReqVO) { + return success(iterationService.createIteration(saveReqVO, getLoginUserId())); + } + + @PutMapping("/update") + @Operation(summary = "更新项目迭代") + @PreAuthorize("@ss.hasPermission('pms:pm:iteration:update')") + public CommonResult updateIteration(@Valid @RequestBody PmsIterationSaveReqVO saveReqVO) { + iterationService.updateIteration(saveReqVO, getLoginUserId()); + return success(true); + } + + @PutMapping("/start") + @Operation(summary = "开始项目迭代") + @PreAuthorize("@ss.hasPermission('pms:pm:iteration:update')") + public CommonResult startIteration(@Valid @RequestBody PmsIterationStartReqVO startReqVO) { + iterationService.startIteration(startReqVO, getLoginUserId()); + return success(true); + } + + @PutMapping("/complete") + @Operation(summary = "完成项目迭代") + @Parameter(name = "id", description = "迭代编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:pm:iteration:update')") + public CommonResult completeIteration(@RequestParam("id") Long id) { + iterationService.completeIteration(id, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除项目迭代") + @Parameter(name = "id", description = "迭代编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:pm:iteration:delete')") + public CommonResult deleteIteration(@RequestParam("id") Long id) { + iterationService.deleteIteration(id, getLoginUserId()); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得项目迭代详情") + @Parameter(name = "id", description = "迭代编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:pm:iteration:query')") + public CommonResult getIteration(@RequestParam("id") Long id) { + PmsIterationDO iteration = iterationService.getIteration(id, getLoginUserId()); + PmsIterationRespVO iterationVO = buildIterationRespVO(iteration); + return success(iterationVO); + } + + @GetMapping("/page") + @Operation(summary = "获得项目迭代分页") + @PreAuthorize("@ss.hasPermission('pms:pm:iteration:query')") + public CommonResult> getIterationPage( + @Valid PmsIterationPageReqVO pageReqVO) { + PageResult pageResult = iterationService.getIterationPage(pageReqVO, getLoginUserId()); + List iterationVOList = buildIterationRespVOList(pageResult.getList()); + Map progressMap = iterationService.getIterationProgressMap( + convertSet(pageResult.getList(), PmsIterationDO::getId)); + iterationVOList.forEach(iteration -> iteration.setProgress(progressMap.getOrDefault(iteration.getId(), 0))); + return success(new PageResult<>(iterationVOList, pageResult.getTotal())); + } + + @GetMapping("/overview") + @Operation(summary = "获得迭代概览统计") + @Parameter(name = "id", description = "迭代编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:pm:iteration:query')") + public CommonResult getIterationOverview(@RequestParam("id") Long id) { + PmsIterationOverviewRespVO overview = iterationService.getIterationOverview(id, getLoginUserId()); + List activities = overview.getRecentActivities(); + Map activityUserMap = adminUserApi.getUserMap( + convertSet(activities, PmsIterationOverviewRespVO.ActivityItem::getOperatorUserId)); + activities.forEach(activity -> findAndThen(activityUserMap, activity.getOperatorUserId(), + user -> activity.setOperatorUserName(user.getNickname()))); + return success(overview.setRecentActivities(activities)); + } + + // ==================== 拼接 VO ==================== + + private PmsIterationRespVO buildIterationRespVO(PmsIterationDO iteration) { + return CollUtil.getFirst(buildIterationRespVOList(Collections.singletonList(iteration))); + } + + private List buildIterationRespVOList(List iterations) { + if (CollUtil.isEmpty(iterations)) { + return Collections.emptyList(); + } + + // 1. 批量查询负责人 + Map userMap = adminUserApi.getUserMap( + convertSet(iterations, PmsIterationDO::getOwnerUserId)); + + // 2. 转换迭代响应,并拼接负责人姓名 + return BeanUtils.toBean(iterations, PmsIterationRespVO.class, iterationVO -> + findAndThen(userMap, iterationVO.getOwnerUserId(), + user -> iterationVO.setOwnerUserName(user.getNickname()))); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/iteration/vo/PmsIterationOverviewRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/iteration/vo/PmsIterationOverviewRespVO.java new file mode 100644 index 000000000..98eacc198 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/iteration/vo/PmsIterationOverviewRespVO.java @@ -0,0 +1,107 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.iteration.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +@Schema(description = "管理后台 - PMS 迭代概览 Response VO") +@Data +public class PmsIterationOverviewRespVO { + + @Schema(description = "工作项总数", requiredMode = Schema.RequiredMode.REQUIRED, example = "100") + private Integer totalCount; + + @Schema(description = "未开始工作项数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "20") + private Integer pendingCount; + + @Schema(description = "进行中工作项数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "30") + private Integer processingCount; + + @Schema(description = "已完成工作项数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "50") + private Integer completedCount; + + @Schema(description = "完成进度百分比", requiredMode = Schema.RequiredMode.REQUIRED, example = "50") + private Integer progress; + + @Schema(description = "工作项类型数量统计", requiredMode = Schema.RequiredMode.REQUIRED, example = "{\"1\": 60, \"2\": 40}") + private Map typeCountMap; + + @Schema(description = "工作项类型和状态数量统计", requiredMode = Schema.RequiredMode.REQUIRED) + private Map> typeStatusCountMap; + + @Schema(description = "状态趋势", requiredMode = Schema.RequiredMode.REQUIRED) + private List statusTrends; + + @Schema(description = "燃尽图数据", requiredMode = Schema.RequiredMode.REQUIRED) + private List burnDowns; + + @Schema(description = "最近动态列表", requiredMode = Schema.RequiredMode.REQUIRED) + private List recentActivities; + + @Schema(description = "管理后台 - PMS 迭代概览状态趋势项 VO") + @Data + public static class TrendItem { + + @Schema(description = "日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-01-01") + private String date; + + @Schema(description = "当日未开始工作项数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "5") + private Integer pendingCount; + + @Schema(description = "当日进行中工作项数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + private Integer processingCount; + + @Schema(description = "当日已完成工作项数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + private Integer completedCount; + + } + + @Schema(description = "管理后台 - PMS 迭代概览燃尽图项 VO") + @Data + public static class BurnDownItem { + + @Schema(description = "日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-01-01") + private String date; + + @Schema(description = "理想剩余工时,单位:小时", requiredMode = Schema.RequiredMode.REQUIRED, example = "40") + private Integer idealRemaining; + + @Schema(description = "实际剩余工时,单位:小时", requiredMode = Schema.RequiredMode.REQUIRED, example = "38") + private Integer actualRemaining; + + } + + @Schema(description = "管理后台 - PMS 迭代概览动态项 VO") + @Data + public static class ActivityItem { + + @Schema(description = "动态编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long id; + + @Schema(description = "工作项编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long workItemId; + + @Schema(description = "工作项序号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + private Integer workItemSerialNumber; + + @Schema(description = "工作项名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "优化登录页") + private String workItemName; + + @Schema(description = "操作人编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long operatorUserId; + + @Schema(description = "操作人昵称", example = "芋道") + private String operatorUserName; + + @Schema(description = "动态内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "将状态更新为「已完成」") + private String content; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/iteration/vo/PmsIterationPageReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/iteration/vo/PmsIterationPageReqVO.java new file mode 100644 index 000000000..588fa4393 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/iteration/vo/PmsIterationPageReqVO.java @@ -0,0 +1,29 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.iteration.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.pms.enums.pm.iteration.PmsIterationStatusEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@Schema(description = "管理后台 - PMS 项目迭代分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsIterationPageReqVO extends PageParam { + + @Schema(description = "项目编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "项目编号不能为空") + private Long projectId; + + @Schema(description = "迭代名称", example = "第一期") + private String name; + + @Schema(description = "迭代状态", example = "1") + @InEnum(PmsIterationStatusEnum.class) + private Integer status; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/iteration/vo/PmsIterationRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/iteration/vo/PmsIterationRespVO.java new file mode 100644 index 000000000..2d0e40143 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/iteration/vo/PmsIterationRespVO.java @@ -0,0 +1,54 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.iteration.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - PMS 项目迭代 Response VO") +@Data +public class PmsIterationRespVO { + + @Schema(description = "迭代编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "项目编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long projectId; + + @Schema(description = "迭代名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "第一期") + private String name; + + @Schema(description = "负责人用户编号", example = "1") + private Long ownerUserId; + + @Schema(description = "迭代状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer status; + + @Schema(description = "负责人姓名", example = "芋道源码") + private String ownerUserName; + + @Schema(description = "开始时间") + private LocalDateTime startTime; + + @Schema(description = "结束时间") + private LocalDateTime endTime; + + @Schema(description = "迭代目标", example = "完成核心流程") + private String target; + + @Schema(description = "迭代描述") + private String description; + + @Schema(description = "完成时间") + private LocalDateTime finishTime; + + @Schema(description = "显示顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer sort; + + @Schema(description = "完成进度百分比", requiredMode = Schema.RequiredMode.REQUIRED, example = "50") + private Integer progress; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/iteration/vo/PmsIterationSaveReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/iteration/vo/PmsIterationSaveReqVO.java new file mode 100644 index 000000000..6e3bf5b17 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/iteration/vo/PmsIterationSaveReqVO.java @@ -0,0 +1,53 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.iteration.vo; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - PMS 项目迭代新增/修改 Request VO") +@Data +public class PmsIterationSaveReqVO { + + @Schema(description = "迭代编号", example = "1024") + private Long id; + + @Schema(description = "项目编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "项目编号不能为空") + private Long projectId; + + @Schema(description = "迭代名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "第一期") + @NotBlank(message = "迭代名称不能为空") + @Size(max = 100, message = "迭代名称长度不能超过 100 个字符") + private String name; + + @Schema(description = "负责人用户编号", example = "1") + private Long ownerUserId; + + @Schema(description = "开始时间") + private LocalDateTime startTime; + + @Schema(description = "结束时间") + private LocalDateTime endTime; + + @Schema(description = "迭代目标", example = "完成核心流程") + @Size(max = 255, message = "迭代目标长度不能超过 255 个字符") + private String target; + + @Schema(description = "迭代描述") + private String description; + + @Schema(hidden = true) + @AssertTrue(message = "迭代结束时间必须晚于开始时间,且开始、结束时间需要同时填写") + @JsonIgnore + public boolean isTimeRangeValid() { + return startTime == null && endTime == null + || startTime != null && endTime != null && endTime.isAfter(startTime); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/iteration/vo/PmsIterationStartReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/iteration/vo/PmsIterationStartReqVO.java new file mode 100644 index 000000000..dcfe68c12 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/iteration/vo/PmsIterationStartReqVO.java @@ -0,0 +1,34 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.iteration.vo; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - PMS 项目迭代开始 Request VO") +@Data +public class PmsIterationStartReqVO { + + @Schema(description = "迭代编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "迭代编号不能为空") + private Long id; + + @Schema(description = "开始时间", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "迭代开始时间不能为空") + private LocalDateTime startTime; + + @Schema(description = "结束时间", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "迭代结束时间不能为空") + private LocalDateTime endTime; + + @Schema(hidden = true) + @AssertTrue(message = "迭代结束时间必须晚于开始时间") + @JsonIgnore + public boolean isTimeRangeValid() { + return startTime == null || endTime == null || endTime.isAfter(startTime); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/PmsProjectAnnouncementController.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/PmsProjectAnnouncementController.java new file mode 100644 index 000000000..896afaaa0 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/PmsProjectAnnouncementController.java @@ -0,0 +1,129 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.project; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.number.NumberUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.announcement.PmsProjectAnnouncementRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.announcement.PmsProjectAnnouncementSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectAnnouncementDO; +import cn.iocoder.yudao.module.pms.service.pm.project.PmsProjectAnnouncementService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - PMS 项目公告") +@RestController +@RequestMapping("/pms/pm/project-announcement") +@Validated +public class PmsProjectAnnouncementController { + + @Resource + private PmsProjectAnnouncementService projectAnnouncementService; + @Resource + private AdminUserApi adminUserApi; + + @PostMapping("/create") + @Operation(summary = "创建项目公告") + @PreAuthorize("@ss.hasPermission('pms:pm:project:update')") + public CommonResult createProjectAnnouncement(@Valid @RequestBody PmsProjectAnnouncementSaveReqVO saveReqVO) { + return success(projectAnnouncementService.createProjectAnnouncement(saveReqVO, getLoginUserId())); + } + + @PutMapping("/update") + @Operation(summary = "更新项目公告") + @PreAuthorize("@ss.hasPermission('pms:pm:project:update')") + public CommonResult updateProjectAnnouncement( + @Valid @RequestBody PmsProjectAnnouncementSaveReqVO saveReqVO) { + projectAnnouncementService.updateProjectAnnouncement(saveReqVO, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除项目公告") + @Parameter(name = "id", description = "公告编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:pm:project:update')") + public CommonResult deleteProjectAnnouncement(@RequestParam("id") Long id) { + projectAnnouncementService.deleteProjectAnnouncement(id, getLoginUserId()); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得项目公告详情") + @Parameter(name = "id", description = "公告编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:pm:project:query')") + public CommonResult getProjectAnnouncement(@RequestParam("id") Long id) { + PmsProjectAnnouncementDO announcement = projectAnnouncementService + .getProjectAnnouncement(id, getLoginUserId()); + return success(buildProjectAnnouncementRespVO(announcement)); + } + + @GetMapping("/list") + @Operation(summary = "获得项目公告列表") + @Parameter(name = "projectId", description = "项目编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:pm:project:query')") + public CommonResult> getProjectAnnouncementList( + @RequestParam("projectId") Long projectId) { + List announcements = projectAnnouncementService + .getProjectAnnouncementList(projectId, getLoginUserId()); + List announcementVOList = buildProjectAnnouncementRespVOList(announcements); + return success(announcementVOList); + } + + // ==================== 拼接 VO ==================== + + /** + * 构建项目公告响应 + * + * @param announcement 项目公告 + * @return 项目公告响应 + */ + private PmsProjectAnnouncementRespVO buildProjectAnnouncementRespVO(PmsProjectAnnouncementDO announcement) { + return CollUtil.getFirst(buildProjectAnnouncementRespVOList(Collections.singletonList(announcement))); + } + + /** + * 构建项目公告响应列表 + * + * @param announcements 项目公告列表 + * @return 项目公告响应列表 + */ + private List buildProjectAnnouncementRespVOList( + List announcements) { + Map userMap = adminUserApi.getUserMap(convertSet(announcements, + announcement -> NumberUtils.parseLong(announcement.getCreator()))); + return convertList(announcements, announcement -> { + PmsProjectAnnouncementRespVO announcementVO = BeanUtils.toBean( + announcement, PmsProjectAnnouncementRespVO.class) + .setCreatorUserId(NumberUtils.parseLong(announcement.getCreator())); + findAndThen(userMap, announcementVO.getCreatorUserId(), + user -> announcementVO.setCreatorUserName(user.getNickname())); + return announcementVO; + }); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/PmsProjectController.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/PmsProjectController.java new file mode 100644 index 000000000..d6ea74a09 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/PmsProjectController.java @@ -0,0 +1,207 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.project; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.number.NumberUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.project.PmsProjectOverviewRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.project.PmsProjectPageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.project.PmsProjectRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.project.PmsProjectSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectDO; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemStatusTypeEnum; +import cn.iocoder.yudao.module.pms.service.pm.project.PmsProjectFavoriteService; +import cn.iocoder.yudao.module.pms.service.pm.project.PmsProjectMemberService; +import cn.iocoder.yudao.module.pms.service.pm.project.PmsProjectService; +import cn.iocoder.yudao.module.pms.service.pm.workitem.PmsWorkItemService; +import cn.iocoder.yudao.module.system.api.permission.PermissionApi; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import cn.iocoder.yudao.module.system.enums.permission.RoleCodeEnum; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - PMS 项目") +@RestController +@RequestMapping("/pms/pm/project") +@Validated +public class PmsProjectController { + + @Resource + private PmsProjectService projectService; + @Resource + private PmsProjectMemberService projectMemberService; + @Resource + private PmsProjectFavoriteService projectFavoriteService; + @Resource + private PmsWorkItemService workItemService; + @Resource + private AdminUserApi adminUserApi; + @Resource + private PermissionApi permissionApi; + + @PostMapping("/create") + @Operation(summary = "创建项目") + @PreAuthorize("@ss.hasPermission('pms:pm:project:create')") + public CommonResult createProject(@Valid @RequestBody PmsProjectSaveReqVO saveReqVO) { + return success(projectService.createProject(saveReqVO, getLoginUserId())); + } + + @PutMapping("/update") + @Operation(summary = "更新项目") + @PreAuthorize("@ss.hasPermission('pms:pm:project:update')") + public CommonResult updateProject(@Valid @RequestBody PmsProjectSaveReqVO saveReqVO) { + projectService.updateProject(saveReqVO, getLoginUserId()); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得项目详情") + @Parameter(name = "id", description = "项目编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:pm:project:query')") + public CommonResult getProject(@RequestParam("id") Long id) { + Long userId = getLoginUserId(); + PmsProjectDO project = projectService.getProjectAndUpdateVisitTime(id, userId); + return success(buildProjectRespVO(project, userId)); + } + + @GetMapping("/page") + @Operation(summary = "获得项目分页") + @PreAuthorize("@ss.hasPermission('pms:pm:project:query')") + public CommonResult> getProjectPage(@Valid PmsProjectPageReqVO pageReqVO) { + Long userId = getLoginUserId(); + PageResult pageResult = projectService.getProjectPage(pageReqVO, userId); + return success(new PageResult<>(buildProjectRespVOList(pageResult.getList(), userId), pageResult.getTotal())); + } + + @GetMapping("/favorite-list") + @Operation(summary = "获得星标项目列表") + @PreAuthorize("@ss.hasPermission('pms:pm:project:query')") + public CommonResult> getFavoriteProjectList() { + Long userId = getLoginUserId(); + return success(buildProjectRespVOList(projectService.getFavoriteProjectList(userId), userId)); + } + + @GetMapping("/overview") + @Operation(summary = "获得项目概况") + @Parameter(name = "projectId", description = "项目编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:pm:project:query')") + public CommonResult getProjectOverview(@RequestParam("projectId") Long projectId) { + return success(projectService.getProjectOverview(projectId, getLoginUserId())); + } + + @PutMapping("/archive") + @Operation(summary = "归档项目") + @Parameter(name = "id", description = "项目编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:pm:project:update')") + public CommonResult archiveProject(@RequestParam("id") Long id) { + projectService.archiveProject(id, getLoginUserId()); + return success(true); + } + + @PutMapping("/recycle") + @Operation(summary = "将项目移入回收站") + @Parameter(name = "id", description = "项目编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:pm:project:update')") + public CommonResult recycleProject(@RequestParam("id") Long id) { + projectService.recycleProject(id, getLoginUserId()); + return success(true); + } + + @PutMapping("/restore") + @Operation(summary = "恢复项目") + @Parameter(name = "id", description = "项目编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:pm:project:update')") + public CommonResult restoreProject(@RequestParam("id") Long id) { + projectService.restoreProject(id, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "彻底删除回收站项目") + @Parameter(name = "id", description = "项目编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:pm:project:delete')") + public CommonResult deleteProject(@RequestParam("id") Long id) { + projectService.deleteProject(id, getLoginUserId()); + return success(true); + } + + // ==================== 拼接 VO ==================== + + private PmsProjectRespVO buildProjectRespVO(PmsProjectDO project, Long userId) { + if (project == null) { + return null; + } + return CollUtil.getFirst(buildProjectRespVOList(Collections.singletonList(project), userId)); + } + + private List buildProjectRespVOList(List projects, Long userId) { + if (CollUtil.isEmpty(projects)) { + return Collections.emptyList(); + } + // 1. 批量查询创建人、项目管理员、成员数量和工作项完成度 + Set projectIds = convertSet(projects, PmsProjectDO::getId); + Map> managerUserIdListMap = projectMemberService.getProjectManagerUserIdListMap(projectIds); + Map memberCountMap = projectMemberService.getProjectMemberCountMap(projectIds); + Map> projectStatusCountMap = workItemService + .getProjectWorkItemStatusCountMap(projectIds); + Set userIds = convertSet(projects, project -> + NumberUtils.parseLong(project.getCreator())); + managerUserIdListMap.values().forEach(userIds::addAll); + Map userMap = adminUserApi.getUserMap(userIds); + Set ownerProjectIds = convertSet(projectMemberService.getOwnerProjectIdListByUserId(userId)); + Set managedProjectIds = convertSet(projectMemberService.getManagedProjectIdListByUserId(userId)); + Set memberProjectIds = convertSet(projectMemberService.getProjectIdListByUserId(userId)); + Set writableProjectIds = convertSet(projectMemberService.getWritableProjectIdListByUserId(userId)); + Set favoriteProjectIds = convertSet(projectFavoriteService.getFavoriteProjectIdListByUserId(userId)); + favoriteProjectIds.retainAll(projectIds); + boolean superAdmin = permissionApi.hasAnyRoles(userId, RoleCodeEnum.SUPER_ADMIN.getCode()).getCheckedData(); + // 2. 转换项目响应,并拼接展示字段 + return BeanUtils.toBean(projects, PmsProjectRespVO.class, projectVO -> { + findAndThen(userMap, NumberUtils.parseLong(projectVO.getCreator()), + creator -> projectVO.setCreatorName(creator.getNickname())); + List adminNames = new ArrayList<>(); + for (Long adminUserId : managerUserIdListMap.getOrDefault(projectVO.getId(), Collections.emptyList())) { + findAndThen(userMap, adminUserId, admin -> adminNames.add(admin.getNickname())); + } + Map statusCountMap = projectStatusCountMap.getOrDefault( + projectVO.getId(), Collections.emptyMap()); + projectVO.setAdminNames(adminNames).setMemberCount(memberCountMap.getOrDefault(projectVO.getId(), 0)) + .setPendingWorkItemCount(statusCountMap.getOrDefault(PmsWorkItemStatusTypeEnum.PENDING.getType(), 0L)) + .setProcessingWorkItemCount(statusCountMap.getOrDefault(PmsWorkItemStatusTypeEnum.PROCESSING.getType(), 0L)) + .setCompletedWorkItemCount(statusCountMap.getOrDefault(PmsWorkItemStatusTypeEnum.COMPLETED.getType(), 0L)) + .setOwnerStatus(superAdmin || ownerProjectIds.contains(projectVO.getId())) + .setAdminStatus(superAdmin || managedProjectIds.contains(projectVO.getId())) + .setMemberStatus(memberProjectIds.contains(projectVO.getId())) + .setExitStatus(memberProjectIds.contains(projectVO.getId()) && !ownerProjectIds.contains(projectVO.getId())) + .setWriteStatus(superAdmin || writableProjectIds.contains(projectVO.getId())) + .setFavoriteStatus(favoriteProjectIds.contains(projectVO.getId())); + }); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/PmsProjectFavoriteController.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/PmsProjectFavoriteController.java new file mode 100644 index 000000000..85db30053 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/PmsProjectFavoriteController.java @@ -0,0 +1,47 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.project; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.pms.service.pm.project.PmsProjectFavoriteService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - PMS 项目收藏") +@RestController +@RequestMapping("/pms/pm/project-favorite") +@Validated +public class PmsProjectFavoriteController { + + @Resource + private PmsProjectFavoriteService projectFavoriteService; + + @PostMapping("/create") + @Operation(summary = "收藏项目") + @Parameter(name = "projectId", description = "项目编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:pm:project:query')") + public CommonResult createProjectFavorite(@RequestParam("projectId") Long projectId) { + projectFavoriteService.createProjectFavorite(projectId, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "取消收藏项目") + @Parameter(name = "projectId", description = "项目编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:pm:project:query')") + public CommonResult deleteProjectFavorite(@RequestParam("projectId") Long projectId) { + projectFavoriteService.deleteProjectFavorite(projectId, getLoginUserId()); + return success(true); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/PmsProjectGroupController.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/PmsProjectGroupController.java new file mode 100644 index 000000000..ca2214da1 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/PmsProjectGroupController.java @@ -0,0 +1,97 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.project; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.group.PmsProjectGroupMoveReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.group.PmsProjectGroupRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.group.PmsProjectGroupSaveReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.group.PmsProjectGroupSortReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectGroupDO; +import cn.iocoder.yudao.module.pms.service.pm.project.PmsProjectGroupService; +import cn.iocoder.yudao.module.pms.service.pm.project.PmsProjectMemberService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - PMS 项目分组") +@RestController +@RequestMapping("/pms/pm/project-group") +@Validated +public class PmsProjectGroupController { + + @Resource + private PmsProjectGroupService projectGroupService; + @Resource + private PmsProjectMemberService projectMemberService; + + @PostMapping("/create") + @Operation(summary = "创建项目分组") + @PreAuthorize("@ss.hasPermission('pms:pm:project-group:create')") + public CommonResult createProjectGroup(@Valid @RequestBody PmsProjectGroupSaveReqVO createReqVO) { + return success(projectGroupService.createProjectGroup(createReqVO, getLoginUserId())); + } + + @PutMapping("/update") + @Operation(summary = "修改项目分组") + @PreAuthorize("@ss.hasPermission('pms:pm:project-group:update')") + public CommonResult updateProjectGroup(@Valid @RequestBody PmsProjectGroupSaveReqVO updateReqVO) { + projectGroupService.updateProjectGroup(updateReqVO, getLoginUserId()); + return success(true); + } + + @PutMapping("/update-sort") + @Operation(summary = "修改项目分组排序") + @PreAuthorize("@ss.hasPermission('pms:pm:project-group:update')") + public CommonResult updateProjectGroupSort(@Valid @RequestBody PmsProjectGroupSortReqVO sortReqVO) { + projectGroupService.updateProjectGroupSort(sortReqVO, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除项目分组") + @Parameter(name = "id", description = "项目分组编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:pm:project-group:delete')") + public CommonResult deleteProjectGroup(@RequestParam("id") Long id) { + projectGroupService.deleteProjectGroup(id, getLoginUserId()); + return success(true); + } + + @GetMapping("/list") + @Operation(summary = "获得项目分组列表") + @PreAuthorize("@ss.hasPermission('pms:pm:project-group:query')") + public CommonResult> getProjectGroupList() { + Long userId = getLoginUserId(); + List groups = projectGroupService.getProjectGroupList(userId); + List projectIds = projectMemberService.getActiveProjectIdListByUserId(userId); + Map groupCountMap = projectGroupService.getProjectGroupCountMap(userId, groups, projectIds); + return success(BeanUtils.toBean(groups, PmsProjectGroupRespVO.class, + group -> group.setProjectCount(groupCountMap.getOrDefault(group.getId(), 0)))); + } + + @PutMapping("/move-project") + @Operation(summary = "移动项目到个人分组") + @PreAuthorize("@ss.hasPermission('pms:pm:project-group:update')") + public CommonResult moveProjectToGroup(@Valid @RequestBody PmsProjectGroupMoveReqVO moveReqVO) { + projectGroupService.moveProjectToGroup(moveReqVO, getLoginUserId()); + return success(true); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/PmsProjectMemberController.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/PmsProjectMemberController.java new file mode 100644 index 000000000..40f8840f6 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/PmsProjectMemberController.java @@ -0,0 +1,104 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.project; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.member.PmsProjectMemberRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.member.PmsProjectMemberSaveReqVO; +import cn.iocoder.yudao.module.pms.service.pm.project.PmsProjectMemberService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - PMS 项目成员") +@RestController +@RequestMapping("/pms/pm/project-member") +@Validated +public class PmsProjectMemberController { + + @Resource + private PmsProjectMemberService projectMemberService; + + @Resource + private AdminUserApi adminUserApi; + + @GetMapping("/list") + @Operation(summary = "获得项目成员列表") + @Parameter(name = "projectId", description = "项目编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:pm:project-member:query')") + public CommonResult> getProjectMemberList( + @RequestParam("projectId") Long projectId) { + List members = projectMemberService.getProjectMemberList(projectId, getLoginUserId()); + return success(fillProjectMemberRespVOList(members)); + } + + @PutMapping("/update-list") + @Operation(summary = "保存项目成员及其权限级别") + @PreAuthorize("@ss.hasPermission('pms:pm:project-member:update')") + public CommonResult updateProjectMemberList(@Valid @RequestBody PmsProjectMemberSaveReqVO saveReqVO) { + projectMemberService.updateProjectMemberList(saveReqVO, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "移除项目成员") + @Parameters({ + @Parameter(name = "projectId", description = "项目编号", required = true, example = "1024"), + @Parameter(name = "userId", description = "后台用户编号", required = true, example = "1") + }) + @PreAuthorize("@ss.hasPermission('pms:pm:project-member:update')") + public CommonResult deleteProjectMember(@RequestParam("projectId") Long projectId, + @RequestParam("userId") Long userId) { + projectMemberService.deleteProjectMember(projectId, userId, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/exit") + @Operation(summary = "主动退出项目") + @Parameter(name = "projectId", description = "项目编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:pm:project-member:query')") + public CommonResult exitProject(@RequestParam("projectId") Long projectId) { + projectMemberService.exitProject(projectId, getLoginUserId()); + return success(true); + } + + // ==================== 拼接 VO ==================== + + private List fillProjectMemberRespVOList(List members) { + if (CollUtil.isEmpty(members)) { + return Collections.emptyList(); + } + // 1. 批量查询用户 + Map userMap = adminUserApi.getUserMap( + convertSet(members, PmsProjectMemberRespVO::getUserId)); + // 2. 拼接用户信息 + for (PmsProjectMemberRespVO memberVO : members) { + MapUtils.findAndThen(userMap, memberVO.getUserId(), + user -> memberVO.setNickname(user.getNickname()).setAvatar(user.getAvatar())); + } + return members; + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/PmsProjectTemplateController.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/PmsProjectTemplateController.java new file mode 100644 index 000000000..cb7e932b0 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/PmsProjectTemplateController.java @@ -0,0 +1,81 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.project; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.template.PmsProjectTemplatePageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.template.PmsProjectTemplateRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.template.PmsProjectTemplateSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectTemplateDO; +import cn.iocoder.yudao.module.pms.service.pm.project.PmsProjectTemplateService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; + +@Tag(name = "管理后台 - PMS 项目模板") +@RestController +@RequestMapping("/pms/pm/project-template") +@Validated +public class PmsProjectTemplateController { + + @Resource + private PmsProjectTemplateService projectTemplateService; + + @PostMapping("/create") + @Operation(summary = "创建项目模板") + @PreAuthorize("@ss.hasPermission('pms:pm:project-template:create')") + public CommonResult createProjectTemplate(@Valid @RequestBody PmsProjectTemplateSaveReqVO createReqVO) { + return success(projectTemplateService.createProjectTemplate(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新项目模板") + @PreAuthorize("@ss.hasPermission('pms:pm:project-template:update')") + public CommonResult updateProjectTemplate(@Valid @RequestBody PmsProjectTemplateSaveReqVO updateReqVO) { + projectTemplateService.updateProjectTemplate(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除项目模板") + @Parameter(name = "id", description = "模板编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:pm:project-template:delete')") + public CommonResult deleteProjectTemplate(@RequestParam("id") Long id) { + projectTemplateService.deleteProjectTemplate(id); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得项目模板") + @Parameter(name = "id", description = "模板编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:pm:project-template:query')") + public CommonResult getProjectTemplate(@RequestParam("id") Long id) { + PmsProjectTemplateDO template = projectTemplateService.getProjectTemplate(id); + return success(BeanUtils.toBean(template, PmsProjectTemplateRespVO.class)); + } + + @GetMapping("/page") + @Operation(summary = "获得项目模板分页") + @PreAuthorize("@ss.hasPermission('pms:pm:project-template:query')") + public CommonResult> getProjectTemplatePage( + @Valid PmsProjectTemplatePageReqVO pageReqVO) { + PageResult pageResult = projectTemplateService.getProjectTemplatePage(pageReqVO); + return success(BeanUtils.toBean(pageResult, PmsProjectTemplateRespVO.class)); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/announcement/PmsProjectAnnouncementRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/announcement/PmsProjectAnnouncementRespVO.java new file mode 100644 index 000000000..3c9e4f88f --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/announcement/PmsProjectAnnouncementRespVO.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.announcement; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - PMS 项目公告 Response VO") +@Data +public class PmsProjectAnnouncementRespVO { + + @Schema(description = "公告编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "项目编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") + private Long projectId; + + @Schema(description = "公告内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "项目启动") + private String content; + + @Schema(description = "附件地址列表", example = "[\"https://example.com/a.pdf\"]") + private List fileUrls; + + @Schema(description = "创建者用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long creatorUserId; + + @Schema(description = "创建者用户昵称", example = "芋道") + private String creatorUserName; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime updateTime; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/announcement/PmsProjectAnnouncementSaveReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/announcement/PmsProjectAnnouncementSaveReqVO.java new file mode 100644 index 000000000..f3b280a6b --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/announcement/PmsProjectAnnouncementSaveReqVO.java @@ -0,0 +1,30 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.announcement; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - PMS 项目公告保存 Request VO") +@Data +public class PmsProjectAnnouncementSaveReqVO { + + @Schema(description = "公告编号", example = "1024") + private Long id; + + @Schema(description = "项目编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "项目编号不能为空") + private Long projectId; + + @Schema(description = "公告内容", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "公告内容不能为空") + @Size(max = 5000, message = "公告内容不能超过 5000 个字符") + private String content; + + @Schema(description = "附件地址列表") + private List fileUrls; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/group/PmsProjectGroupMoveReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/group/PmsProjectGroupMoveReqVO.java new file mode 100644 index 000000000..58a74b009 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/group/PmsProjectGroupMoveReqVO.java @@ -0,0 +1,18 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.group; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - PMS 项目移动分组 Request VO") +@Data +public class PmsProjectGroupMoveReqVO { + + @Schema(description = "项目编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "项目编号不能为空") + private Long projectId; + + @Schema(description = "目标项目分组编号;不传表示移动到未分组", example = "2048") + private Long groupId; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/group/PmsProjectGroupRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/group/PmsProjectGroupRespVO.java new file mode 100644 index 000000000..c46dbef0f --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/group/PmsProjectGroupRespVO.java @@ -0,0 +1,30 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.group; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - PMS 项目分组 Response VO") +@Data +public class PmsProjectGroupRespVO { + + @Schema(description = "项目分组编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "分组名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "重点项目") + private String name; + + @Schema(description = "显示顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer sort; + + @Schema(description = "分组类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + private Integer type; + + @Schema(description = "创建时间") + private LocalDateTime createTime; + + @Schema(description = "项目数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "5") + private Integer projectCount; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/group/PmsProjectGroupSaveReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/group/PmsProjectGroupSaveReqVO.java new file mode 100644 index 000000000..1f368f4c5 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/group/PmsProjectGroupSaveReqVO.java @@ -0,0 +1,20 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.group; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - PMS 项目分组新增/修改 Request VO") +@Data +public class PmsProjectGroupSaveReqVO { + + @Schema(description = "项目分组编号", example = "1024") + private Long id; + + @Schema(description = "分组名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "重点项目") + @NotBlank(message = "分组名称不能为空") + @Size(max = 100, message = "分组名称不能超过 100 个字符") + private String name; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/group/PmsProjectGroupSortReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/group/PmsProjectGroupSortReqVO.java new file mode 100644 index 000000000..464b5f8e1 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/group/PmsProjectGroupSortReqVO.java @@ -0,0 +1,36 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.group; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - PMS 项目分组排序 Request VO") +@Data +public class PmsProjectGroupSortReqVO { + + @Schema(description = "项目分组排序项", requiredMode = Schema.RequiredMode.REQUIRED) + @Valid + @NotEmpty(message = "项目分组排序项不能为空") + private List items; + + @Schema(description = "管理后台 - PMS 项目分组排序项") + @Data + public static class Item { + + @Schema(description = "项目分组编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "项目分组编号不能为空") + private Long id; + + @Schema(description = "显示顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "显示顺序不能为空") + @Min(value = 0, message = "显示顺序不能小于 0") + private Integer sort; + + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/member/PmsProjectMemberRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/member/PmsProjectMemberRespVO.java new file mode 100644 index 000000000..7913faa70 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/member/PmsProjectMemberRespVO.java @@ -0,0 +1,25 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.member; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - PMS 项目成员 Response VO") +@Data +public class PmsProjectMemberRespVO { + + @Schema(description = "后台用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long userId; + + @Schema(description = "用户昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋道源码") + private String nickname; + + @Schema(description = "用户头像") + private String avatar; + + @Schema(description = "成员权限级别", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + private Integer level; + + @Schema(description = "是否项目创建人", example = "true") + private Boolean creatorStatus; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/member/PmsProjectMemberSaveReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/member/PmsProjectMemberSaveReqVO.java new file mode 100644 index 000000000..cde7b2468 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/member/PmsProjectMemberSaveReqVO.java @@ -0,0 +1,41 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.member; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.pms.enums.pm.project.PmsProjectMemberLevelEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - PMS 项目成员保存 Request VO") +@Data +public class PmsProjectMemberSaveReqVO { + + @Schema(description = "项目编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "项目编号不能为空") + private Long projectId; + + @Schema(description = "项目成员列表", requiredMode = Schema.RequiredMode.REQUIRED) + @NotEmpty(message = "项目成员不能为空") + @Valid + private List members; + + @Schema(description = "管理后台 - PMS 项目成员保存项") + @Data + public static class Member { + + @Schema(description = "后台用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "后台用户编号不能为空") + private Long userId; + + @Schema(description = "成员权限级别", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + @NotNull(message = "成员权限级别不能为空") + @InEnum(PmsProjectMemberLevelEnum.class) + private Integer level; + + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/project/PmsProjectOverviewRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/project/PmsProjectOverviewRespVO.java new file mode 100644 index 000000000..b1a9d5e1f --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/project/PmsProjectOverviewRespVO.java @@ -0,0 +1,74 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.project; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +@Schema(description = "管理后台 - PMS 项目概况 Response VO") +@Data +public class PmsProjectOverviewRespVO { + + @Schema(description = "工作项总数", requiredMode = Schema.RequiredMode.REQUIRED, example = "20") + private Long totalCount; + + @Schema(description = "未开始工作项数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "8") + private Long pendingCount; + + @Schema(description = "进行中工作项数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "6") + private Long processingCount; + + @Schema(description = "已完成工作项数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "6") + private Long completedCount; + + @Schema(description = "按工作项类型统计") + private Map typeCountMap; + + @Schema(description = "近十四日完成趋势") + private List completedTrends; + + @Schema(description = "分配给当前用户的未完成工作项") + private List assignedWorkItems; + + @Schema(description = "PMS 项目概况 - 趋势点") + @Data + public static class TrendPoint { + + @Schema(description = "日期", requiredMode = Schema.RequiredMode.REQUIRED) + private String date; + + @Schema(description = "当日完成数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + private Long count; + + } + + @Schema(description = "PMS 项目概况 - 分配给我的工作项") + @Data + public static class AssignedWorkItem { + + @Schema(description = "工作项编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "项目内序号", requiredMode = Schema.RequiredMode.REQUIRED, example = "12") + private Integer serialNumber; + + @Schema(description = "工作项类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + private Integer type; + + @Schema(description = "工作项标题", requiredMode = Schema.RequiredMode.REQUIRED) + private String name; + + @Schema(description = "语义状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + private Integer status; + + @Schema(description = "截止时间") + private LocalDateTime endTime; + + @Schema(description = "完成进度", requiredMode = Schema.RequiredMode.REQUIRED, example = "60") + private Integer progress; + + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/project/PmsProjectPageReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/project/PmsProjectPageReqVO.java new file mode 100644 index 000000000..86832d482 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/project/PmsProjectPageReqVO.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.project; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.pms.enums.pm.project.PmsProjectSceneTypeEnum; +import cn.iocoder.yudao.module.pms.enums.pm.project.PmsProjectSortTypeEnum; +import cn.iocoder.yudao.module.pms.enums.pm.project.PmsProjectStatusEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@Schema(description = "管理后台 - PMS 项目分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsProjectPageReqVO extends PageParam { + + @Schema(description = "项目名称", example = "官网") + private String name; + + @Schema(description = "列表场景", example = "1") + @InEnum(PmsProjectSceneTypeEnum.class) + private Integer sceneType = PmsProjectSceneTypeEnum.ALL.getType(); + + @Schema(description = "个人项目分组编号", example = "1024") + private Long groupId; + + @Schema(description = "项目状态", example = "1") + @InEnum(PmsProjectStatusEnum.class) + private Integer status = PmsProjectStatusEnum.ACTIVE.getStatus(); + + @Schema(description = "排序类型", example = "1") + @InEnum(PmsProjectSortTypeEnum.class) + private Integer sortType = PmsProjectSortTypeEnum.ACCESS_TIME.getType(); + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/project/PmsProjectRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/project/PmsProjectRespVO.java new file mode 100644 index 000000000..0dbefef00 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/project/PmsProjectRespVO.java @@ -0,0 +1,94 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.project; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - PMS 项目 Response VO") +@Data +public class PmsProjectRespVO { + + @Schema(description = "项目编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "项目名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "官网重构") + private String name; + + @Schema(description = "项目状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer status; + + @Schema(description = "项目类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer type; + + @Schema(description = "项目优先级", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + private Integer level; + + @Schema(description = "项目描述", example = "完成官网前后端重构") + private String description; + + @Schema(description = "是否公开", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + private Boolean openStatus; + + @Schema(description = "项目图标", requiredMode = Schema.RequiredMode.REQUIRED, example = "ep:folder") + private String icon; + + @Schema(description = "开始时间") + private LocalDateTime startTime; + + @Schema(description = "截止时间") + private LocalDateTime endTime; + + @Schema(description = "归档时间") + private LocalDateTime archiveTime; + + @Schema(description = "移入回收站时间") + private LocalDateTime recycleTime; + + @Schema(description = "最近访问时间") + private LocalDateTime accessTime; + + @Schema(description = "创建时间") + private LocalDateTime createTime; + + @Schema(description = "创建人编号", example = "1") + private String creator; + + @Schema(description = "创建人姓名", example = "芋道源码") + private String creatorName; + + @Schema(description = "项目管理员姓名列表") + private List adminNames; + + @Schema(description = "项目成员数量", example = "5") + private Integer memberCount; + + @Schema(description = "未开始工作项数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + private Long pendingWorkItemCount; + + @Schema(description = "进行中工作项数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + private Long processingWorkItemCount; + + @Schema(description = "已完成工作项数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "5") + private Long completedWorkItemCount; + + @Schema(description = "当前用户是否为项目管理员", example = "true") + private Boolean adminStatus; + + @Schema(description = "当前用户是否拥有项目所有者权限", example = "true") + private Boolean ownerStatus; + + @Schema(description = "当前用户是否为项目成员", example = "true") + private Boolean memberStatus; + + @Schema(description = "当前用户是否可以主动退出项目", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean exitStatus; + + @Schema(description = "当前用户是否可以编辑项目业务数据", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean writeStatus; + + @Schema(description = "当前用户是否已收藏", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean favoriteStatus; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/project/PmsProjectSaveReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/project/PmsProjectSaveReqVO.java new file mode 100644 index 000000000..8094bc2b9 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/project/PmsProjectSaveReqVO.java @@ -0,0 +1,73 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.project; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.pms.enums.pm.project.PmsProjectLevelEnum; +import cn.iocoder.yudao.module.pms.enums.pm.project.PmsProjectTypeEnum; +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - PMS 项目新增/修改 Request VO") +@Data +public class PmsProjectSaveReqVO { + + @Schema(description = "项目编号", example = "1024") + private Long id; + + @Schema(description = "项目名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "官网重构") + @NotBlank(message = "项目名称不能为空") + @Size(max = 31, message = "项目名称不能超过 31 个字符") + private String name; + + @Schema(description = "项目类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "项目类型不能为空") + @InEnum(PmsProjectTypeEnum.class) + private Integer type; + + @Schema(description = "项目优先级", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + @NotNull(message = "项目优先级不能为空") + @InEnum(PmsProjectLevelEnum.class) + private Integer level; + + @Schema(description = "项目描述", example = "完成官网前后端重构") + @Size(max = 500, message = "项目描述不能超过 500 个字符") + private String description; + + @Schema(description = "是否公开", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + @NotNull(message = "项目可见范围不能为空") + private Boolean openStatus; + + @Schema(description = "项目图标", requiredMode = Schema.RequiredMode.REQUIRED, example = "ep:folder") + @NotBlank(message = "项目图标不能为空") + private String icon; + + @Schema(description = "开始时间", example = "2026-08-01 00:00:00") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime startTime; + + @Schema(description = "截止时间", example = "2026-12-31 23:59:59") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime endTime; + + @Schema(description = "初始成员编号列表", example = "[1, 2]") + private List memberUserIds; + + @JsonIgnore + @Schema(hidden = true) + @AssertTrue(message = "项目截止时间必须晚于开始时间,且开始、截止时间需要同时填写") + public boolean isTimeValid() { + return startTime == null && endTime == null + || startTime != null && endTime != null && startTime.isBefore(endTime); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/template/PmsProjectTemplatePageReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/template/PmsProjectTemplatePageReqVO.java new file mode 100644 index 000000000..e754e6bb6 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/template/PmsProjectTemplatePageReqVO.java @@ -0,0 +1,25 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.template; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.pms.enums.pm.project.PmsProjectTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Schema(description = "管理后台 - PMS 项目模板分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class PmsProjectTemplatePageReqVO extends PageParam { + + @Schema(description = "模板名称,模糊匹配", example = "敏捷研发模板") + private String name; + + @Schema(description = "项目类型", example = "2") + @InEnum(PmsProjectTypeEnum.class) + private Integer projectType; + + @Schema(description = "模板状态", example = "0") + private Integer status; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/template/PmsProjectTemplateRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/template/PmsProjectTemplateRespVO.java new file mode 100644 index 000000000..6f647ac74 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/template/PmsProjectTemplateRespVO.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.template; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - PMS 项目模板 Response VO") +@Data +public class PmsProjectTemplateRespVO { + + @Schema(description = "模板编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "模板名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "敏捷研发模板") + private String name; + + @Schema(description = "模板描述", example = "适用于标准敏捷研发项目") + private String description; + + @Schema(description = "项目类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + private Integer projectType; + + @Schema(description = "模板状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer status; + + @Schema(description = "显示顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") + private Integer sort; + + @Schema(description = "启用的工作项类型列表", requiredMode = Schema.RequiredMode.REQUIRED) + private List itemTypes; + + @Schema(description = "工作项状态模板列表", requiredMode = Schema.RequiredMode.REQUIRED) + private List statuses; + + @Schema(description = "看板列模板列表", requiredMode = Schema.RequiredMode.REQUIRED) + private List boards; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/template/PmsProjectTemplateSaveReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/template/PmsProjectTemplateSaveReqVO.java new file mode 100644 index 000000000..4202291c8 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/project/vo/template/PmsProjectTemplateSaveReqVO.java @@ -0,0 +1,126 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.template; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.pms.enums.pm.project.PmsProjectTypeEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemStatusTypeEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - PMS 项目模板新增/修改 Request VO") +@Data +public class PmsProjectTemplateSaveReqVO { + + @Schema(description = "模板编号", example = "1024") + private Long id; + + @Schema(description = "模板名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "敏捷研发模板") + @NotBlank(message = "模板名称不能为空") + @Size(max = 100, message = "模板名称不能超过 100 个字符") + private String name; + + @Schema(description = "模板描述", example = "适用于标准敏捷研发项目") + @Size(max = 500, message = "模板描述不能超过 500 个字符") + private String description; + + @Schema(description = "项目类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + @NotNull(message = "项目类型不能为空") + @InEnum(PmsProjectTypeEnum.class) + private Integer projectType; + + @Schema(description = "模板状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "模板状态不能为空") + private Integer status; + + @Schema(description = "显示顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") + @NotNull(message = "显示顺序不能为空") + private Integer sort; + + @Schema(description = "启用的工作项类型列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "[2, 3, 4]") + @NotEmpty(message = "工作项类型不能为空") + private List itemTypes; + + @Schema(description = "工作项状态模板列表", requiredMode = Schema.RequiredMode.REQUIRED) + @Valid + @NotEmpty(message = "工作项状态不能为空") + private List statuses; + + @Schema(description = "看板列模板列表", requiredMode = Schema.RequiredMode.REQUIRED) + @Valid + @NotEmpty(message = "看板列不能为空") + private List boards; + + @Schema(description = "工作项状态模板") + @Data + public static class StatusTemplate { + + @Schema(description = "模板内稳定编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "task_todo") + @NotBlank(message = "状态编码不能为空") + @Size(max = 50, message = "状态编码不能超过 50 个字符") + private String code; + + @Schema(description = "状态名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "待处理") + @NotBlank(message = "状态名称不能为空") + @Size(max = 50, message = "状态名称不能超过 50 个字符") + private String name; + + @Schema(description = "工作项类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + @NotNull(message = "状态工作项类型不能为空") + @InEnum(PmsWorkItemTypeEnum.class) + private Integer workItemType; + + @Schema(description = "语义状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "语义状态不能为空") + @InEnum(PmsWorkItemStatusTypeEnum.class) + private Integer statusType; + + @Schema(description = "是否为初始状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + @NotNull(message = "是否为初始状态不能为空") + private Boolean defaultStatus; + + @Schema(description = "显示顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") + @NotNull(message = "状态显示顺序不能为空") + private Integer sort; + + @Schema(description = "所属看板列编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "todo") + @NotBlank(message = "状态所属看板列不能为空") + private String boardCode; + } + + @Schema(description = "看板列模板") + @Data + public static class BoardTemplate { + + @Schema(description = "模板内稳定编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "todo") + @NotBlank(message = "看板列编码不能为空") + @Size(max = 50, message = "看板列编码不能超过 50 个字符") + private String code; + + @Schema(description = "看板列名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "待处理") + @NotBlank(message = "看板列名称不能为空") + @Size(max = 50, message = "看板列名称不能超过 50 个字符") + private String name; + + @Schema(description = "工作项类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + @NotNull(message = "看板列工作项类型不能为空") + @InEnum(PmsWorkItemTypeEnum.class) + private Integer workItemType; + + @Schema(description = "显示顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") + @NotNull(message = "看板列显示顺序不能为空") + private Integer sort; + + @Schema(description = "关联的状态编码列表", requiredMode = Schema.RequiredMode.REQUIRED, + example = "[\"task_todo\"]") + @NotEmpty(message = "看板列关联状态不能为空") + private List statusCodes; + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workbench/PmsWorkbenchController.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workbench/PmsWorkbenchController.java new file mode 100644 index 000000000..eca2f134a --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workbench/PmsWorkbenchController.java @@ -0,0 +1,124 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workbench; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.number.NumberUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workbench.vo.PmsWorkbenchCountRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workbench.vo.PmsWorkbenchIterationRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workbench.vo.PmsWorkbenchPageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workbench.vo.PmsWorkbenchWorkItemRespVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.iteration.PmsIterationDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemStatusDO; +import cn.iocoder.yudao.module.pms.service.pm.project.PmsProjectMemberService; +import cn.iocoder.yudao.module.pms.service.pm.project.PmsProjectService; +import cn.iocoder.yudao.module.pms.service.pm.workbench.PmsWorkbenchService; +import cn.iocoder.yudao.module.pms.service.pm.workitem.PmsWorkItemStatusService; +import cn.iocoder.yudao.module.system.api.permission.PermissionApi; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import cn.iocoder.yudao.module.system.enums.permission.RoleCodeEnum; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +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; +import java.util.Map; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - PMS 工作台") +@RestController +@RequestMapping("/pms/pm/workbench") +@Validated +public class PmsWorkbenchController { + + @Resource + private PmsWorkbenchService workbenchService; + @Resource + private PmsProjectService projectService; + @Resource + private PmsProjectMemberService projectMemberService; + @Resource + private PmsWorkItemStatusService workItemStatusService; + @Resource + private PermissionApi permissionApi; + @Resource + private AdminUserApi adminUserApi; + + @GetMapping("/work-item-page") + @Operation(summary = "获得我负责的工作项分页") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:query')") + public CommonResult> getWorkbenchWorkItemPage( + @Valid PmsWorkbenchPageReqVO pageReqVO) { + // 1. 查询当前用户负责的工作项分页 + Long userId = getLoginUserId(); + PageResult pageResult = workbenchService.getWorkbenchWorkItemPage(pageReqVO, userId); + // 2.1 批量查询项目、状态及项目写权限 + Map projectMap = projectService.getProjectMap( + convertSet(pageResult.getList(), PmsWorkItemDO::getProjectId)); + Map statusMap = workItemStatusService.getWorkItemStatusMap( + convertSet(pageResult.getList(), PmsWorkItemDO::getStatusId)); + Set writableProjectIds = convertSet(projectMemberService.getWritableProjectIdListByUserId(userId)); + boolean superAdmin = permissionApi.hasAnyRoles(userId, RoleCodeEnum.SUPER_ADMIN.getCode()).getCheckedData(); + // 2.2 批量查询负责人和创建人 + Set userIds = convertSet(pageResult.getList(), PmsWorkItemDO::getAssigneeUserId); + userIds.addAll(convertSet(pageResult.getList(), workItem -> NumberUtils.parseLong(workItem.getCreator()))); + Map userMap = adminUserApi.getUserMap(userIds); + Map creatorUserIdMap = convertMap(pageResult.getList(), PmsWorkItemDO::getId, + workItem -> NumberUtils.parseLong(workItem.getCreator())); + // 3. 转换响应 VO,并拼接项目、状态、用户及写权限 + List workItemVOList = BeanUtils.toBean(pageResult.getList(), + PmsWorkbenchWorkItemRespVO.class, itemVO -> { + findAndThen(projectMap, itemVO.getProjectId(), project -> itemVO + .setProjectName(project.getName()).setProjectType(project.getType())); + findAndThen(statusMap, itemVO.getStatusId(), status -> itemVO.setStatusName(status.getName())); + findAndThen(userMap, itemVO.getAssigneeUserId(), + user -> itemVO.setAssigneeUserName(user.getNickname())); + itemVO.setCreatorUserId(creatorUserIdMap.get(itemVO.getId())); + findAndThen(userMap, itemVO.getCreatorUserId(), + user -> itemVO.setCreatorUserName(user.getNickname())); + itemVO.setWriteStatus(superAdmin || writableProjectIds.contains(itemVO.getProjectId())); + }); + return success(new PageResult<>(workItemVOList, pageResult.getTotal())); + } + + @GetMapping("/iteration-page") + @Operation(summary = "获得我负责的迭代分页") + @PreAuthorize("@ss.hasPermission('pms:pm:iteration:query')") + public CommonResult> getWorkbenchIterationPage( + @Valid PmsWorkbenchPageReqVO pageReqVO) { + // 1.1 查询当前用户负责的迭代分页 + PageResult pageResult = workbenchService.getWorkbenchIterationPage(pageReqVO, getLoginUserId()); + // 1.2 批量查询关联项目 + Map projectMap = projectService.getProjectMap( + convertSet(pageResult.getList(), PmsIterationDO::getProjectId)); + // 2. 转换响应 VO,并拼接项目展示字段 + List iterationVOList = BeanUtils.toBean(pageResult.getList(), + PmsWorkbenchIterationRespVO.class, itemVO -> + findAndThen(projectMap, itemVO.getProjectId(), project -> itemVO + .setProjectName(project.getName()).setProjectType(project.getType()))); + return success(new PageResult<>(iterationVOList, pageResult.getTotal())); + } + + @GetMapping("/count") + @Operation(summary = "获得我的未完成事项数量") + @PreAuthorize("@ss.hasAnyPermissions('pms:pm:work-item:query', 'pms:pm:iteration:query')") + public CommonResult getWorkbenchCount(PmsWorkbenchPageReqVO pageReqVO) { + return success(workbenchService.getWorkbenchCount(pageReqVO, getLoginUserId())); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workbench/vo/PmsWorkbenchCountRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workbench/vo/PmsWorkbenchCountRespVO.java new file mode 100644 index 000000000..8ed30b29e --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workbench/vo/PmsWorkbenchCountRespVO.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workbench.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.experimental.Accessors; + +@Schema(description = "管理后台 - PMS 工作台数量响应 VO") +@Data +@Accessors(chain = true) +public class PmsWorkbenchCountRespVO { + + @Schema(description = "需求数量", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer requirementCount; + + @Schema(description = "任务数量", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer taskCount; + + @Schema(description = "缺陷数量", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer defectCount; + + @Schema(description = "迭代数量", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer iterationCount; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workbench/vo/PmsWorkbenchIterationRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workbench/vo/PmsWorkbenchIterationRespVO.java new file mode 100644 index 000000000..d6453ee64 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workbench/vo/PmsWorkbenchIterationRespVO.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workbench.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - PMS 工作台迭代 Response VO") +@Data +public class PmsWorkbenchIterationRespVO { + + @Schema(description = "迭代编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "项目编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") + private Long projectId; + + @Schema(description = "项目名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "商城项目") + private String projectName; + + @Schema(description = "项目类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer projectType; + + @Schema(description = "迭代名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "第一个迭代") + private String name; + + @Schema(description = "迭代状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + private Integer status; + + @Schema(description = "开始时间", example = "2026-01-01 00:00:00") + private LocalDateTime startTime; + + @Schema(description = "结束时间", example = "2026-01-31 23:59:59") + private LocalDateTime endTime; + + @Schema(description = "迭代目标", example = "完成登录模块") + private String target; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workbench/vo/PmsWorkbenchPageReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workbench/vo/PmsWorkbenchPageReqVO.java new file mode 100644 index 000000000..b213fc714 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workbench/vo/PmsWorkbenchPageReqVO.java @@ -0,0 +1,47 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workbench.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemPriorityEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - PMS 工作台分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsWorkbenchPageReqVO extends PageParam { + + @Schema(description = "项目编号", example = "1024") + private Long projectId; + + @Schema(description = "工作项类型", example = "3") + @InEnum(PmsWorkItemTypeEnum.class) + private Integer type; + + @Schema(description = "名称", example = "登录") + private String name; + + @Schema(description = "状态,工作项为语义状态,迭代为迭代状态", example = "2") + private Integer status; + + @Schema(description = "优先级", example = "2") + @InEnum(PmsWorkItemPriorityEnum.class) + private Integer priority; + + @Schema(description = "迭代编号", example = "1024") + private Long iterationId; + + @Schema(description = "截止时间范围") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] endTime; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workbench/vo/PmsWorkbenchWorkItemRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workbench/vo/PmsWorkbenchWorkItemRespVO.java new file mode 100644 index 000000000..ed0ae1dda --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workbench/vo/PmsWorkbenchWorkItemRespVO.java @@ -0,0 +1,72 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workbench.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - PMS 工作台工作项 Response VO") +@Data +public class PmsWorkbenchWorkItemRespVO { + + @Schema(description = "工作项编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "项目编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") + private Long projectId; + + @Schema(description = "项目名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "商城项目") + private String projectName; + + @Schema(description = "项目类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer projectType; + + @Schema(description = "工作项类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + private Integer type; + + @Schema(description = "工作项序号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer serialNumber; + + @Schema(description = "工作项名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "优化登录页") + private String name; + + @Schema(description = "优先级", example = "2") + private Integer priority; + + @Schema(description = "负责人用户编号", example = "1024") + private Long assigneeUserId; + + @Schema(description = "负责人姓名", example = "芋道源码") + private String assigneeUserName; + + @Schema(description = "创建人用户编号", example = "2048") + private Long creatorUserId; + + @Schema(description = "创建人姓名", example = "源码") + private String creatorUserName; + + @Schema(description = "状态编号", example = "1") + private Long statusId; + + @Schema(description = "状态名称", example = "进行中") + private String statusName; + + @Schema(description = "语义状态", example = "2") + private Integer status; + + @Schema(description = "进度", example = "60") + private Integer progress; + + @Schema(description = "开始时间", example = "2026-01-01 10:00:00") + private LocalDateTime startTime; + + @Schema(description = "结束时间", example = "2026-01-31 18:00:00") + private LocalDateTime endTime; + + @Schema(description = "是否可编辑", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + private Boolean writeStatus; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/PmsWorkItemActivityController.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/PmsWorkItemActivityController.java new file mode 100644 index 000000000..f9508dfb4 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/PmsWorkItemActivityController.java @@ -0,0 +1,62 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workitem; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.activity.PmsWorkItemActivityRespVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemActivityDO; +import cn.iocoder.yudao.module.pms.service.pm.workitem.PmsWorkItemActivityService; +import cn.iocoder.yudao.module.pms.service.pm.workitem.PmsWorkItemService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - PMS 工作项动态") +@RestController +@RequestMapping("/pms/pm/work-item-activity") +@Validated +public class PmsWorkItemActivityController { + + @Resource + private PmsWorkItemActivityService activityService; + @Resource + private PmsWorkItemService workItemService; + + @Resource + private AdminUserApi adminUserApi; + + @GetMapping("/list") + @Operation(summary = "获得工作项动态列表") + @Parameter(name = "workItemId", description = "工作项编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:query')") + public CommonResult> getWorkItemActivityList( + @RequestParam("workItemId") Long workItemId) { + workItemService.getWorkItem(workItemId, getLoginUserId()); + List activities = activityService.getWorkItemActivityList(workItemId); + // 拼接 VO:批量查询操作人,并补充用户展示字段 + Map userMap = adminUserApi.getUserMap( + convertSet(activities, PmsWorkItemActivityDO::getOperatorUserId)); + List activityVOList = BeanUtils.toBean( + activities, PmsWorkItemActivityRespVO.class, activityVO -> + findAndThen(userMap, activityVO.getOperatorUserId(), user -> activityVO + .setOperatorUserName(user.getNickname()).setOperatorUserAvatar(user.getAvatar()))); + return success(activityVOList); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/PmsWorkItemCommentController.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/PmsWorkItemCommentController.java new file mode 100644 index 000000000..d741003fd --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/PmsWorkItemCommentController.java @@ -0,0 +1,104 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workitem; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.comment.PmsWorkItemCommentRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.comment.PmsWorkItemCommentSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemCommentDO; +import cn.iocoder.yudao.module.pms.service.pm.workitem.PmsWorkItemCommentService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - PMS 工作项评论") +@RestController +@RequestMapping("/pms/pm/work-item-comment") +@Validated +public class PmsWorkItemCommentController { + + @Resource + private PmsWorkItemCommentService commentService; + + @Resource + private AdminUserApi adminUserApi; + + @PostMapping("/create") + @Operation(summary = "创建工作项评论") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:update')") + public CommonResult createWorkItemComment(@Valid @RequestBody PmsWorkItemCommentSaveReqVO saveReqVO) { + return success(commentService.createWorkItemComment(saveReqVO, getLoginUserId())); + } + + @PutMapping("/update") + @Operation(summary = "修改工作项评论") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:update')") + public CommonResult updateWorkItemComment(@Valid @RequestBody PmsWorkItemCommentSaveReqVO saveReqVO) { + commentService.updateWorkItemComment(saveReqVO, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除工作项评论") + @Parameter(name = "id", description = "评论编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:update')") + public CommonResult deleteWorkItemComment(@RequestParam("id") Long id) { + commentService.deleteWorkItemComment(id, getLoginUserId()); + return success(true); + } + + @GetMapping("/list") + @Operation(summary = "获得工作项评论列表") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:query')") + public CommonResult> getWorkItemCommentList( + @RequestParam("workItemId") Long workItemId) { + List comments = commentService.getWorkItemCommentList(workItemId, getLoginUserId()); + // 拼接 VO:批量查询评论人和回复对象 + Set userIds = convertSet(comments, PmsWorkItemCommentDO::getUserId); + userIds.addAll(convertSet(comments, PmsWorkItemCommentDO::getReplyUserId)); + Map userMap = adminUserApi.getUserMap(userIds); + // 拼接 VO:转换评论,并按主评论组织回复列表 + Map mainCommentMap = new LinkedHashMap<>(); + List mainComments = new ArrayList<>(); + for (PmsWorkItemCommentDO comment : comments) { + PmsWorkItemCommentRespVO commentVO = BeanUtils.toBean(comment, PmsWorkItemCommentRespVO.class).setChildren(new ArrayList<>()); + findAndThen(userMap, comment.getUserId(), user -> commentVO.setUserName(user.getNickname())); + findAndThen(userMap, comment.getReplyUserId(), user -> commentVO.setReplyUserName(user.getNickname())); + if (PmsWorkItemCommentDO.MAIN_ID_ROOT.equals(comment.getMainId())) { + mainCommentMap.put(comment.getId(), commentVO); + mainComments.add(commentVO); + } else { + PmsWorkItemCommentRespVO mainComment = mainCommentMap.get(comment.getMainId()); + if (mainComment != null) { + mainComment.getChildren().add(commentVO); + } + } + } + return success(mainComments); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/PmsWorkItemController.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/PmsWorkItemController.java new file mode 100644 index 000000000..48d0dc12c --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/PmsWorkItemController.java @@ -0,0 +1,299 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workitem; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.label.PmsWorkItemLabelRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status.PmsWorkItemStatusUpdateReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemBoardReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemBoardRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemImportExcelVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemImportRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemIterationUpdateReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemNameUpdateReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemPageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemPlanningSortReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemSaveReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemSortReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.iteration.PmsIterationDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemLabelDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemStatusDO; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemPriorityEnum; +import cn.iocoder.yudao.module.pms.service.pm.iteration.PmsIterationService; +import cn.iocoder.yudao.module.pms.service.pm.workitem.PmsWorkItemLabelService; +import cn.iocoder.yudao.module.pms.service.pm.workitem.PmsWorkItemService; +import cn.iocoder.yudao.module.pms.service.pm.workitem.PmsWorkItemStatusService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertListByFlatMap; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSetByFlatMap; +import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - PMS 工作项") +@RestController +@RequestMapping("/pms/pm/work-item") +@Validated +public class PmsWorkItemController { + + @Resource + private PmsWorkItemService workItemService; + @Resource + private PmsWorkItemLabelService workItemLabelService; + @Resource + private PmsWorkItemStatusService workItemStatusService; + @Resource + private PmsIterationService iterationService; + + @Resource + private AdminUserApi adminUserApi; + + @PostMapping("/create") + @Operation(summary = "创建工作项") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:create')") + public CommonResult createWorkItem(@Valid @RequestBody PmsWorkItemSaveReqVO saveReqVO) { + return success(workItemService.createWorkItem(saveReqVO, getLoginUserId())); + } + + @PutMapping("/update") + @Operation(summary = "更新工作项") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:update')") + public CommonResult updateWorkItem(@Valid @RequestBody PmsWorkItemSaveReqVO saveReqVO) { + workItemService.updateWorkItem(saveReqVO, getLoginUserId()); + return success(true); + } + + @PutMapping("/update-name") + @Operation(summary = "更新工作项名称") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:update')") + public CommonResult updateWorkItemName(@Valid @RequestBody PmsWorkItemNameUpdateReqVO updateReqVO) { + workItemService.updateWorkItemName(updateReqVO, getLoginUserId()); + return success(true); + } + + @PutMapping("/update-status") + @Operation(summary = "更新工作项状态") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:update')") + public CommonResult updateWorkItemStatus(@Valid @RequestBody PmsWorkItemStatusUpdateReqVO updateReqVO) { + workItemService.updateWorkItemStatus(updateReqVO, getLoginUserId()); + return success(true); + } + + @PutMapping("/update-iteration") + @Operation(summary = "更新工作项所属迭代") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:update')") + public CommonResult updateWorkItemIteration(@Valid @RequestBody PmsWorkItemIterationUpdateReqVO updateReqVO) { + workItemService.updateWorkItemIteration(updateReqVO, getLoginUserId()); + return success(true); + } + + @PutMapping("/update-sort") + @Operation(summary = "更新看板列内工作项顺序") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:update')") + public CommonResult updateWorkItemSort(@Valid @RequestBody PmsWorkItemSortReqVO sortReqVO) { + workItemService.updateWorkItemSort(sortReqVO, getLoginUserId()); + return success(true); + } + + @PutMapping("/update-planning-sort") + @Operation(summary = "更新待规划或迭代内工作项顺序") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:update')") + public CommonResult updateWorkItemPlanningSort(@Valid @RequestBody PmsWorkItemPlanningSortReqVO sortReqVO) { + workItemService.updateWorkItemPlanningSort(sortReqVO, getLoginUserId()); + return success(true); + } + + @PutMapping("/archive") + @Operation(summary = "归档工作项") + @Parameter(name = "id", description = "工作项编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:update')") + public CommonResult archiveWorkItem(@RequestParam("id") Long id) { + workItemService.archiveWorkItem(id, getLoginUserId()); + return success(true); + } + + @PutMapping("/recycle") + @Operation(summary = "将工作项移入回收站") + @Parameter(name = "id", description = "工作项编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:update')") + public CommonResult recycleWorkItem(@RequestParam("id") Long id) { + workItemService.recycleWorkItem(id, getLoginUserId()); + return success(true); + } + + @PutMapping("/restore") + @Operation(summary = "恢复工作项") + @Parameter(name = "id", description = "工作项编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:update')") + public CommonResult restoreWorkItem(@RequestParam("id") Long id) { + workItemService.restoreWorkItem(id, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "彻底删除回收站中的工作项") + @Parameter(name = "id", description = "工作项编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:delete')") + public CommonResult deleteWorkItem(@RequestParam("id") Long id) { + workItemService.deleteWorkItem(id, getLoginUserId()); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得工作项详情") + @Parameter(name = "id", description = "工作项编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:query')") + public CommonResult getWorkItem(@RequestParam("id") Long id) { + PmsWorkItemDO workItem = workItemService.getWorkItem(id, getLoginUserId()); + PmsWorkItemRespVO workItemVO = buildWorkItemRespVO(workItem); + return success(workItemVO); + } + + @GetMapping("/page") + @Operation(summary = "获得工作项分页") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:query')") + public CommonResult> getWorkItemPage(@Valid PmsWorkItemPageReqVO pageReqVO) { + PageResult pageResult = workItemService.getWorkItemPage(pageReqVO, getLoginUserId()); + return success(new PageResult<>(buildWorkItemRespVOList(pageResult.getList()), pageResult.getTotal())); + } + + @GetMapping("/export-excel") + @Operation(summary = "导出工作项") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:export')") + public void exportWorkItemList(@Valid PmsWorkItemPageReqVO pageReqVO, + HttpServletResponse response) throws IOException { + pageReqVO.setPageSize(PageParam.PAGE_SIZE_NONE); + List list = workItemService.getWorkItemList(pageReqVO, getLoginUserId()); + List workItemVOList = buildWorkItemRespVOList(list); + ExcelUtils.write(response, "工作项.xlsx", "工作项", PmsWorkItemRespVO.class, workItemVOList); + } + + @GetMapping("/get-import-template") + @Operation(summary = "下载工作项导入模板") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:import')") + public void getWorkItemImportTemplate(HttpServletResponse response) throws IOException { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime deadline = now.plusDays(3); + PmsWorkItemImportExcelVO example = PmsWorkItemImportExcelVO.builder() + .name("登录页面开发").description("实现账号密码登录").statusName("") + .priority(PmsWorkItemPriorityEnum.MEDIUM.getPriority()) + .startTime(now).endTime(deadline) + .labels(Collections.emptyList()).progress(0).estimatedHours(16) + .build(); + ExcelUtils.write(response, "工作项导入模板.xlsx", "工作项", PmsWorkItemImportExcelVO.class, + Collections.singletonList(example)); + } + + @PostMapping("/import") + @Operation(summary = "导入工作项") + @Parameters({ + @Parameter(name = "projectId", description = "项目编号", required = true, example = "1024"), + @Parameter(name = "type", description = "工作项类型(需求、任务、缺陷)", required = true, example = "1"), + @Parameter(name = "file", description = "Excel 文件", required = true) + }) + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:import')") + public CommonResult importWorkItemList( + @RequestParam("projectId") Long projectId, + @RequestParam("type") Integer workItemType, + @RequestParam("file") MultipartFile file) throws Exception { + return success(workItemService.importWorkItemList(projectId, workItemType, + ExcelUtils.read(file, PmsWorkItemImportExcelVO.class), getLoginUserId())); + } + + @GetMapping("/board") + @Operation(summary = "获得工作项看板") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:query')") + public CommonResult> getWorkItemBoard(@Valid PmsWorkItemBoardReqVO queryReqVO) { + List columns = workItemService.getWorkItemBoard(queryReqVO, getLoginUserId()); + fillWorkItemRespVOList(convertListByFlatMap(columns, column -> column.getItems().stream())); + return success(columns); + } + + // ==================== 拼接 VO ==================== + + private PmsWorkItemRespVO buildWorkItemRespVO(PmsWorkItemDO workItem) { + return CollUtil.getFirst(buildWorkItemRespVOList(Collections.singletonList(workItem))); + } + + private List buildWorkItemRespVOList(List workItems) { + if (CollUtil.isEmpty(workItems)) { + return Collections.emptyList(); + } + return fillWorkItemRespVOList(BeanUtils.toBean(workItems, PmsWorkItemRespVO.class)); + } + + private List fillWorkItemRespVOList(List workItems) { + if (CollUtil.isEmpty(workItems)) { + return Collections.emptyList(); + } + + // 1.1 批量查询状态、迭代、关联需求和参与人 + Map statusMap = workItemStatusService.getWorkItemStatusMap( + convertSet(workItems, PmsWorkItemRespVO::getStatusId)); + Map iterationMap = iterationService.getIterationMap( + convertSet(workItems, PmsWorkItemRespVO::getIterationId)); + Map requirementMap = workItemService.getWorkItemMap( + convertSet(workItems, PmsWorkItemRespVO::getRelatedRequirementId)); + Map> memberUserIdListMap = workItemService.getWorkItemMemberUserIdListMap( + convertSet(workItems, PmsWorkItemRespVO::getId)); + Map labelMap = workItemLabelService.getWorkItemLabelMap( + convertSetByFlatMap(workItems, PmsWorkItemRespVO::getLabelIds, List::stream)); + // 1.2 批量查询负责人和参与人用户 + Set userIds = convertSetByFlatMap(workItems, workItem -> java.util.stream.Stream.concat( + java.util.stream.Stream.of(workItem.getAssigneeUserId()), + memberUserIdListMap.getOrDefault(workItem.getId(), Collections.emptyList()).stream())); + Map userMap = adminUserApi.getUserMap(userIds); + // 2. 拼接工作项展示字段 + for (PmsWorkItemRespVO workItemVO : workItems) { + findAndThen(statusMap, workItemVO.getStatusId(), status -> workItemVO.setStatusName(status.getName())); + findAndThen(iterationMap, workItemVO.getIterationId(), + iteration -> workItemVO.setIterationName(iteration.getName())); + findAndThen(requirementMap, workItemVO.getRelatedRequirementId(), + requirement -> workItemVO.setRelatedRequirementName(requirement.getName())); + findAndThen(userMap, workItemVO.getAssigneeUserId(), + assignee -> workItemVO.setAssigneeUserName(assignee.getNickname())); + List memberUserIds = memberUserIdListMap.getOrDefault(workItemVO.getId(), Collections.emptyList()); + List memberUserNames = convertList(convertList(memberUserIds, userMap::get), + AdminUserRespDTO::getNickname); + workItemVO.setMemberUserIds(memberUserIds).setMemberUserNames(memberUserNames); + workItemVO.setLabels(BeanUtils.toBean(convertList(workItemVO.getLabelIds(), labelMap::get), + PmsWorkItemLabelRespVO.class)); + } + return workItems; + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/PmsWorkItemLabelController.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/PmsWorkItemLabelController.java new file mode 100644 index 000000000..2a0b24b64 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/PmsWorkItemLabelController.java @@ -0,0 +1,71 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workitem; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.label.PmsWorkItemLabelRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.label.PmsWorkItemLabelSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemLabelDO; +import cn.iocoder.yudao.module.pms.service.pm.workitem.PmsWorkItemLabelService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +@Tag(name = "管理后台 - PMS 工作项标签") +@RestController +@RequestMapping("/pms/pm/work-item-label") +@Validated +public class PmsWorkItemLabelController { + + @Resource + private PmsWorkItemLabelService labelService; + + @PostMapping("/create") + @Operation(summary = "创建工作项标签") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:update')") + public CommonResult createWorkItemLabel(@Valid @RequestBody PmsWorkItemLabelSaveReqVO saveReqVO) { + return success(labelService.createWorkItemLabel(saveReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "修改工作项标签") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:update')") + public CommonResult updateWorkItemLabel(@Valid @RequestBody PmsWorkItemLabelSaveReqVO saveReqVO) { + labelService.updateWorkItemLabel(saveReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除工作项标签") + @Parameter(name = "id", description = "标签编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:update')") + public CommonResult deleteWorkItemLabel(@RequestParam("id") Long id) { + labelService.deleteWorkItemLabel(id); + return success(true); + } + + @GetMapping("/list") + @Operation(summary = "获得工作项标签列表") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:query')") + public CommonResult> getWorkItemLabelList( + @RequestParam(value = "name", required = false) String name) { + List labels = labelService.getWorkItemLabelList(name); + return success(BeanUtils.toBean(labels, PmsWorkItemLabelRespVO.class)); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/PmsWorkItemStatusController.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/PmsWorkItemStatusController.java new file mode 100644 index 000000000..3469acdfd --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/PmsWorkItemStatusController.java @@ -0,0 +1,159 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workitem; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status.PmsWorkItemBoardConfigRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status.PmsWorkItemBoardConfigSaveReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status.PmsWorkItemStatusConfigUpdateReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status.PmsWorkItemStatusCreateReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status.PmsWorkItemStatusDeleteReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status.PmsWorkItemStatusRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status.PmsWorkItemStatusSortReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemBoardDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemStatusDO; +import cn.iocoder.yudao.module.pms.service.pm.workitem.PmsWorkItemStatusService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - PMS 工作项状态") +@RestController +@RequestMapping("/pms/pm/work-item-status") +@Validated +public class PmsWorkItemStatusController { + + @Resource + private PmsWorkItemStatusService workItemStatusService; + + @PostMapping("/create") + @Operation(summary = "创建工作项状态") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:update')") + public CommonResult createWorkItemStatus( + @Valid @RequestBody PmsWorkItemStatusCreateReqVO createReqVO) { + return success(workItemStatusService.createWorkItemStatus(createReqVO, getLoginUserId())); + } + + @PutMapping("/update") + @Operation(summary = "更新工作项状态") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:update')") + public CommonResult updateWorkItemStatusConfig( + @Valid @RequestBody PmsWorkItemStatusConfigUpdateReqVO updateReqVO) { + workItemStatusService.updateWorkItemStatusConfig(updateReqVO, getLoginUserId()); + return success(true); + } + + @PutMapping("/update-default") + @Operation(summary = "更新工作项初始状态") + @Parameter(name = "id", description = "状态编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:update')") + public CommonResult updateDefaultWorkItemStatus(@RequestParam("id") Long id) { + workItemStatusService.updateDefaultWorkItemStatus(id, getLoginUserId()); + return success(true); + } + + @PutMapping("/update-sort") + @Operation(summary = "更新工作项状态顺序") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:update')") + public CommonResult updateWorkItemStatusSort( + @Valid @RequestBody PmsWorkItemStatusSortReqVO sortReqVO) { + workItemStatusService.updateWorkItemStatusSort(sortReqVO, getLoginUserId()); + return success(true); + } + + @PutMapping("/update-board-config") + @Operation(summary = "更新工作项看板列和状态映射") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:update')") + public CommonResult updateWorkItemBoardConfig( + @Valid @RequestBody PmsWorkItemBoardConfigSaveReqVO saveReqVO) { + workItemStatusService.updateWorkItemBoardConfig(saveReqVO, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除工作项状态并迁移工作项") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:update')") + public CommonResult deleteWorkItemStatus( + @Valid @RequestBody PmsWorkItemStatusDeleteReqVO deleteReqVO) { + workItemStatusService.deleteWorkItemStatus(deleteReqVO, getLoginUserId()); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得工作项状态详情") + @Parameter(name = "id", description = "状态编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:query')") + public CommonResult getWorkItemStatus(@RequestParam("id") Long id) { + PmsWorkItemStatusDO status = workItemStatusService.getWorkItemStatus(id, getLoginUserId()); + return success(BeanUtils.toBean(status, PmsWorkItemStatusRespVO.class)); + } + + @GetMapping("/list") + @Operation(summary = "获得工作项看板状态列表") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:query')") + public CommonResult> getWorkItemStatusList( + @RequestParam("projectId") Long projectId, @RequestParam("type") Integer workItemType) { + List statuses = workItemStatusService.getWorkItemStatusList(projectId, workItemType, + getLoginUserId()); + return success(BeanUtils.toBean(statuses, PmsWorkItemStatusRespVO.class)); + } + + @GetMapping("/get-board-config") + @Operation(summary = "获得工作项看板列和状态映射") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:query')") + public CommonResult getWorkItemBoardConfig( + @RequestParam("projectId") Long projectId, @RequestParam("type") Integer workItemType) { + // 1. 校验项目可读并查询已保存的看板列、状态 + List statuses = workItemStatusService + .getWorkItemStatusList(projectId, workItemType, getLoginUserId()); + List boards = workItemStatusService.getWorkItemBoardList(projectId, workItemType); + + // 2.1 按看板列名称聚合状态编号 + Map> statusIdMap = new LinkedHashMap<>(); + for (PmsWorkItemStatusDO status : statuses) { + if (StrUtil.isNotBlank(status.getBoardName())) { + statusIdMap.computeIfAbsent(status.getBoardName(), key -> new ArrayList<>()).add(status.getId()); + } + } + // 2.2 按看板列顺序组装响应,并保留空列 + Set assignedStatusIds = new LinkedHashSet<>(); + List boardVOs = new ArrayList<>(); + for (PmsWorkItemBoardDO board : boards) { + List statusIds = statusIdMap.getOrDefault(board.getName(), new ArrayList<>()); + assignedStatusIds.addAll(statusIds); + boardVOs.add(new PmsWorkItemBoardConfigRespVO.Board().setId(board.getId()) + .setName(board.getName()).setStatusIds(statusIds)); + } + // 2.3 收集未分配到看板列的状态 + List unassignedStatusIds = new ArrayList<>(); + for (PmsWorkItemStatusDO status : statuses) { + if (!assignedStatusIds.contains(status.getId())) { + unassignedStatusIds.add(status.getId()); + } + } + return success(new PmsWorkItemBoardConfigRespVO().setBoards(boardVOs).setUnassignedStatusIds(unassignedStatusIds)); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/PmsWorkItemWorkLogController.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/PmsWorkItemWorkLogController.java new file mode 100644 index 000000000..b44d1c0f3 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/PmsWorkItemWorkLogController.java @@ -0,0 +1,124 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workitem; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.number.NumberUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.worklog.PmsProjectWorkLogReportReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.worklog.PmsProjectWorkLogReportRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.worklog.PmsWorkItemWorkLogRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.worklog.PmsWorkItemWorkLogSaveReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.worklog.PmsWorkItemWorkLogSummaryRespVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemWorkLogDO; +import cn.iocoder.yudao.module.pms.service.pm.workitem.PmsWorkItemService; +import cn.iocoder.yudao.module.pms.service.pm.workitem.PmsWorkItemWorkLogService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - PMS 工作项工时") +@RestController +@RequestMapping("/pms/pm/work-item-work-log") +@Validated +public class PmsWorkItemWorkLogController { + + @Resource + private PmsWorkItemWorkLogService workLogService; + @Resource + private PmsWorkItemService workItemService; + + @Resource + private AdminUserApi adminUserApi; + + @PostMapping("/create") + @Operation(summary = "创建工作项工时记录") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:update')") + public CommonResult createWorkItemWorkLog(@Valid @RequestBody PmsWorkItemWorkLogSaveReqVO saveReqVO) { + return success(workLogService.createWorkItemWorkLog(saveReqVO, getLoginUserId())); + } + + @PutMapping("/update") + @Operation(summary = "更新工作项工时记录") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:update')") + public CommonResult updateWorkItemWorkLog(@Valid @RequestBody PmsWorkItemWorkLogSaveReqVO saveReqVO) { + workLogService.updateWorkItemWorkLog(saveReqVO, getLoginUserId()); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得工作项工时记录") + @Parameter(name = "id", description = "工时记录编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:query')") + public CommonResult getWorkItemWorkLog(@RequestParam("id") Long id) { + PmsWorkItemWorkLogDO workLog = workLogService.getWorkItemWorkLog(id, getLoginUserId()); + PmsWorkItemWorkLogRespVO respVO = BeanUtils.toBean(workLog, PmsWorkItemWorkLogRespVO.class) + .setCreatorUserId(NumberUtils.parseLong(workLog.getCreator())); + fillWorkItemWorkLogRespVOList(Collections.singletonList(respVO)); + return success(respVO); + } + + @GetMapping("/summary") + @Operation(summary = "获得工作项工时汇总") + @Parameter(name = "workItemId", description = "工作项编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:query')") + public CommonResult getWorkItemWorkLogSummary( + @RequestParam("workItemId") Long workItemId) { + PmsWorkItemWorkLogSummaryRespVO summary = workLogService.getWorkItemWorkLogSummary(workItemId, getLoginUserId()); + fillWorkItemWorkLogRespVOList(summary.getRecords()); + return success(summary); + } + + @GetMapping("/project-report") + @Operation(summary = "获得项目工时报表") + @PreAuthorize("@ss.hasPermission('pms:pm:work-item:query')") + public CommonResult getProjectWorkItemWorkLogReport( + @Valid PmsProjectWorkLogReportReqVO reqVO) { + PmsProjectWorkLogReportRespVO report = workLogService.getProjectWorkItemWorkLogReport( + reqVO, getLoginUserId()); + return success(report); + } + + // ==================== 拼接 VO ==================== + + private List fillWorkItemWorkLogRespVOList(List workLogs) { + if (CollUtil.isEmpty(workLogs)) { + return Collections.emptyList(); + } + // 1. 批量查询创建人和工作项 + Map userMap = adminUserApi.getUserMap(convertSet( + workLogs, PmsWorkItemWorkLogRespVO::getCreatorUserId)); + Map workItemMap = workItemService.getWorkItemMap( + convertSet(workLogs, PmsWorkItemWorkLogRespVO::getWorkItemId)); + // 2. 拼接创建人和工作项名称 + for (PmsWorkItemWorkLogRespVO workLog : workLogs) { + findAndThen(userMap, workLog.getCreatorUserId(), user -> workLog.setCreatorUserName(user.getNickname())); + findAndThen(workItemMap, workLog.getWorkItemId(), item -> workLog.setWorkItemName(item.getName())); + } + return workLogs; + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/activity/PmsWorkItemActivityRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/activity/PmsWorkItemActivityRespVO.java new file mode 100644 index 000000000..ac4546fba --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/activity/PmsWorkItemActivityRespVO.java @@ -0,0 +1,35 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.activity; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.experimental.Accessors; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - PMS 工作项动态响应 VO") +@Data +@Accessors(chain = true) +public class PmsWorkItemActivityRespVO { + + @Schema(description = "动态编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "工作项编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") + private Long workItemId; + + @Schema(description = "操作人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long operatorUserId; + + @Schema(description = "操作人姓名", example = "芋道") + private String operatorUserName; + + @Schema(description = "操作人头像") + private String operatorUserAvatar; + + @Schema(description = "动态内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "更新了工作项") + private String content; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/comment/PmsWorkItemCommentRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/comment/PmsWorkItemCommentRespVO.java new file mode 100644 index 000000000..956eea13f --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/comment/PmsWorkItemCommentRespVO.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.comment; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - PMS 工作项评论 Response VO") +@Data +public class PmsWorkItemCommentRespVO { + + @Schema(description = "评论编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "工作项编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long workItemId; + + @Schema(description = "评论人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long userId; + + @Schema(description = "评论人姓名", example = "芋道源码") + private String userName; + + @Schema(description = "主评论编号", example = "1024") + private Long mainId; + + @Schema(description = "回复对象用户编号", example = "2048") + private Long replyUserId; + + @Schema(description = "回复对象姓名", example = "源码") + private String replyUserName; + + @Schema(description = "评论内容", requiredMode = Schema.RequiredMode.REQUIRED) + private String content; + + @Schema(description = "回复列表") + private List children; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/comment/PmsWorkItemCommentSaveReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/comment/PmsWorkItemCommentSaveReqVO.java new file mode 100644 index 000000000..2832b4848 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/comment/PmsWorkItemCommentSaveReqVO.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.comment; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - PMS 工作项评论新增/修改 Request VO") +@Data +public class PmsWorkItemCommentSaveReqVO { + + @Schema(description = "评论编号", example = "1024") + private Long id; + + @Schema(description = "工作项编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "工作项编号不能为空") + private Long workItemId; + + @Schema(description = "主评论编号,回复时传入", example = "1024") + private Long mainId; + + @Schema(description = "回复对象用户编号", example = "2048") + private Long replyUserId; + + @Schema(description = "评论内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "已处理") + @NotBlank(message = "评论内容不能为空") + @Size(max = 2000, message = "评论内容不能超过 2000 个字符") + private String content; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/label/PmsWorkItemLabelRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/label/PmsWorkItemLabelRespVO.java new file mode 100644 index 000000000..73fb78f00 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/label/PmsWorkItemLabelRespVO.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.label; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - PMS 工作项标签 Response VO") +@Data +public class PmsWorkItemLabelRespVO { + + @Schema(description = "标签编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "标签名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "紧急") + private String name; + + @Schema(description = "标签颜色", requiredMode = Schema.RequiredMode.REQUIRED, example = "#409EFF") + private String color; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/label/PmsWorkItemLabelSaveReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/label/PmsWorkItemLabelSaveReqVO.java new file mode 100644 index 000000000..326afbfaa --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/label/PmsWorkItemLabelSaveReqVO.java @@ -0,0 +1,25 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.label; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - PMS 工作项标签新增/修改 Request VO") +@Data +public class PmsWorkItemLabelSaveReqVO { + + @Schema(description = "标签编号", example = "1024") + private Long id; + + @Schema(description = "标签名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "紧急") + @NotBlank(message = "标签名称不能为空") + @Size(max = 50, message = "标签名称不能超过 50 个字符") + private String name; + + @Schema(description = "标签颜色", requiredMode = Schema.RequiredMode.REQUIRED, example = "#409EFF") + @NotBlank(message = "标签颜色不能为空") + @Size(max = 20, message = "标签颜色不能超过 20 个字符") + private String color; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/status/PmsWorkItemBoardConfigRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/status/PmsWorkItemBoardConfigRespVO.java new file mode 100644 index 000000000..2dd65fa89 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/status/PmsWorkItemBoardConfigRespVO.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - PMS 工作项看板配置 Response VO") +@Data +public class PmsWorkItemBoardConfigRespVO { + + @Schema(description = "看板列列表") + private List boards; + + @Schema(description = "未放入看板的状态编号列表") + private List unassignedStatusIds; + + @Data + public static class Board { + + @Schema(description = "看板列编号") + private Long id; + + @Schema(description = "看板列名称") + private String name; + + @Schema(description = "关联状态编号列表") + private List statusIds; + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/status/PmsWorkItemBoardConfigSaveReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/status/PmsWorkItemBoardConfigSaveReqVO.java new file mode 100644 index 000000000..96baa402d --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/status/PmsWorkItemBoardConfigSaveReqVO.java @@ -0,0 +1,50 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - PMS 工作项看板配置保存 Request VO") +@Data +public class PmsWorkItemBoardConfigSaveReqVO { + + @Schema(description = "项目编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "项目编号不能为空") + private Long projectId; + + @Schema(description = "工作项类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + @NotNull(message = "工作项类型不能为空") + @InEnum(PmsWorkItemTypeEnum.class) + private Integer workItemType; + + @Schema(description = "看板列列表", requiredMode = Schema.RequiredMode.REQUIRED) + @Valid + @NotNull(message = "看板列列表不能为空") + private List boards; + + @Schema(description = "管理后台 - PMS 工作项看板列") + @Data + public static class Board { + + @Schema(description = "看板列编号", example = "1024") + private Long id; + + @Schema(description = "看板列名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "进行中") + @NotBlank(message = "看板列名称不能为空") + @Size(max = 50, message = "看板列名称不能超过 50 个字符") + private String name; + + @Schema(description = "关联状态编号列表,可为空", example = "[1024, 2048]") + @NotNull(message = "关联状态编号列表不能为空") + private List statusIds; + + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/status/PmsWorkItemStatusConfigUpdateReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/status/PmsWorkItemStatusConfigUpdateReqVO.java new file mode 100644 index 000000000..5634c54f5 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/status/PmsWorkItemStatusConfigUpdateReqVO.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemStatusTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - PMS 更新工作项状态配置 Request VO") +@Data +public class PmsWorkItemStatusConfigUpdateReqVO { + + @Schema(description = "状态编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "状态编号不能为空") + private Long id; + + @Schema(description = "状态名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "待验收") + @NotBlank(message = "状态名称不能为空") + @Size(max = 50, message = "状态名称不能超过 50 个字符") + private String name; + + @Schema(description = "语义状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + @NotNull(message = "语义状态不能为空") + @InEnum(PmsWorkItemStatusTypeEnum.class) + private Integer statusType; + + @Schema(description = "状态描述", example = "需求已拆分,等待开发") + @Size(max = 255, message = "状态描述不能超过 255 个字符") + private String description; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/status/PmsWorkItemStatusCreateReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/status/PmsWorkItemStatusCreateReqVO.java new file mode 100644 index 000000000..49a7e7b2c --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/status/PmsWorkItemStatusCreateReqVO.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemStatusTypeEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - PMS 创建工作项状态 Request VO") +@Data +public class PmsWorkItemStatusCreateReqVO { + + @Schema(description = "项目编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "项目不能为空") + private Long projectId; + + @Schema(description = "工作项类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + @NotNull(message = "工作项类型不能为空") + @InEnum(PmsWorkItemTypeEnum.class) + private Integer workItemType; + + @Schema(description = "状态名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "待验收") + @NotBlank(message = "状态名称不能为空") + @Size(max = 50, message = "状态名称不能超过 50 个字符") + private String name; + + @Schema(description = "语义状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + @NotNull(message = "语义状态不能为空") + @InEnum(PmsWorkItemStatusTypeEnum.class) + private Integer statusType; + + @Schema(description = "状态描述", example = "需求已拆分,等待开发") + @Size(max = 255, message = "状态描述不能超过 255 个字符") + private String description; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/status/PmsWorkItemStatusDeleteReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/status/PmsWorkItemStatusDeleteReqVO.java new file mode 100644 index 000000000..58318a8f2 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/status/PmsWorkItemStatusDeleteReqVO.java @@ -0,0 +1,18 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - PMS 删除工作项状态 Request VO") +@Data +public class PmsWorkItemStatusDeleteReqVO { + + @Schema(description = "待删除状态编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "待删除状态不能为空") + private Long id; + + @Schema(description = "工作项迁入状态编号;待删除状态存在工作项时必填", example = "2048") + private Long transferStatusId; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/status/PmsWorkItemStatusRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/status/PmsWorkItemStatusRespVO.java new file mode 100644 index 000000000..94be53a8c --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/status/PmsWorkItemStatusRespVO.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - PMS 工作项看板状态 Response VO") +@Data +public class PmsWorkItemStatusRespVO { + + @Schema(description = "状态编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "项目编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long projectId; + + @Schema(description = "工作项类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + private Integer workItemType; + + @Schema(description = "状态名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "未开始") + private String name; + + @Schema(description = "语义状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer statusType; + + @Schema(description = "状态描述", example = "需求已拆分,等待开发") + private String description; + + @Schema(description = "所属看板列名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "处理中") + private String boardName; + + @Schema(description = "是否初始状态", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean defaultStatus; + + @Schema(description = "显示顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer sort; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/status/PmsWorkItemStatusSortReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/status/PmsWorkItemStatusSortReqVO.java new file mode 100644 index 000000000..62aa73d92 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/status/PmsWorkItemStatusSortReqVO.java @@ -0,0 +1,18 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - PMS 工作项状态排序 Request VO") +@Data +public class PmsWorkItemStatusSortReqVO { + + @Schema(description = "状态编号列表,顺序即显示顺序", requiredMode = Schema.RequiredMode.REQUIRED) + @NotEmpty(message = "状态编号列表不能为空") + private List<@NotNull(message = "状态编号不能为空") Long> statusIds; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/status/PmsWorkItemStatusUpdateReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/status/PmsWorkItemStatusUpdateReqVO.java new file mode 100644 index 000000000..916932a94 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/status/PmsWorkItemStatusUpdateReqVO.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - PMS 更新工作项看板状态 Request VO") +@Data +public class PmsWorkItemStatusUpdateReqVO { + + @Schema(description = "工作项编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "工作项编号不能为空") + private Long id; + + @Schema(description = "目标看板状态编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") + @NotNull(message = "目标看板状态不能为空") + private Long statusId; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemBoardReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemBoardReqVO.java new file mode 100644 index 000000000..83f65d2d1 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemBoardReqVO.java @@ -0,0 +1,21 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@Schema(description = "管理后台 - PMS 工作项看板 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsWorkItemBoardReqVO extends PmsWorkItemPageReqVO { + + @Override + @NotNull(message = "工作项类型不能为空") + public Integer getType() { + return super.getType(); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemBoardRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemBoardRespVO.java new file mode 100644 index 000000000..cf89c1dd9 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemBoardRespVO.java @@ -0,0 +1,25 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem; + +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status.PmsWorkItemStatusRespVO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - PMS 工作项看板列 Response VO") +@Data +public class PmsWorkItemBoardRespVO { + + @Schema(description = "看板列编号", requiredMode = Schema.RequiredMode.REQUIRED) + private Long id; + + @Schema(description = "看板列名称", requiredMode = Schema.RequiredMode.REQUIRED) + private String name; + + @Schema(description = "映射到当前列的状态列表", requiredMode = Schema.RequiredMode.REQUIRED) + private List statuses; + + @Schema(description = "工作项列表", requiredMode = Schema.RequiredMode.REQUIRED) + private List items; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemImportExcelVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemImportExcelVO.java new file mode 100644 index 000000000..faff64860 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemImportExcelVO.java @@ -0,0 +1,121 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import cn.iocoder.yudao.framework.excel.core.annotations.DictFormat; +import cn.iocoder.yudao.framework.excel.core.annotations.ExcelColumnSelect; +import cn.iocoder.yudao.framework.excel.core.convert.DictConvert; +import cn.iocoder.yudao.framework.excel.core.convert.StringListConvert; +import cn.iocoder.yudao.module.pms.enums.DictTypeConstants; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.Min; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * PMS 工作项 Excel 导入 VO + * + * @author 芋道源码 + */ +@Schema(description = "管理后台 - PMS 工作项 Excel 导入 VO") +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +@ExcelIgnoreUnannotated +public class PmsWorkItemImportExcelVO { + + @ExcelProperty("事项标题") + @Schema(description = "工作项标题", example = "完成登录页") + private String name; + + @ExcelProperty("描述") + @Schema(description = "工作项描述", example = "实现账号密码登录") + private String description; + + @ExcelProperty("处理人用户编号") + @Schema(description = "处理人用户编号,为空时不指定处理人", example = "1024") + private Long assigneeUserId; + + @ExcelProperty("状态") + @Schema(description = "项目状态名称;为空时使用项目默认状态", example = "待处理") + private String statusName; + + @ExcelProperty(value = "优先级", converter = DictConvert.class) + @DictFormat(DictTypeConstants.PMS_WORK_ITEM_PRIORITY) + @ExcelColumnSelect(dictType = DictTypeConstants.PMS_WORK_ITEM_PRIORITY) + @Schema(description = "优先级,Excel 中填写字典标签", example = "中") + private Integer priority; + + @ExcelProperty("开始时间") + @Schema(description = "开始时间", example = "2026-08-30 09:00:00") + private LocalDateTime startTime; + + @ExcelProperty("截止时间") + @Schema(description = "截止时间", example = "2026-09-01 18:00:00") + private LocalDateTime endTime; + + @ExcelProperty(value = "标签", converter = StringListConvert.class) + @Schema(description = "标签名称列表,多个标签可用逗号、顿号或斜杠分隔", example = "前端/登录") + private List labels; + + @ExcelProperty("进度") + @Schema(description = "完成进度,范围 0-100", example = "50") + private Integer progress; + + @ExcelProperty("预估工时") + @Schema(description = "预估工时,单位:小时", example = "8") + private Integer estimatedHours; + + @ExcelProperty("实际投入时长") + @Schema(description = "实际投入时长,单位:小时", example = "4") + @Min(value = 1, message = "实际投入时长必须大于 0") + private Integer actualHours; + + @ExcelProperty("剩余工时") + @Schema(description = "剩余工时,单位:小时", example = "4") + @Min(value = 0, message = "剩余工时不能小于 0") + private Integer remainingHours; + + @ExcelProperty(value = "缺陷类型", converter = DictConvert.class) + @DictFormat(DictTypeConstants.PMS_WORK_ITEM_DEFECT_TYPE) + @ExcelColumnSelect(dictType = DictTypeConstants.PMS_WORK_ITEM_DEFECT_TYPE) + @Schema(description = "缺陷类型,Excel 中填写字典标签,仅缺陷工作项使用", example = "功能缺陷") + private Integer defectType; + + /** + * 导入时从 {@link #statusName} 解析出的项目状态编号,不作为 Excel 列 + */ + @Schema(hidden = true) + private Long statusId; + + /** + * 导入时从 {@link #labels} 解析出的标签编号列表,不作为 Excel 列 + */ + @Schema(hidden = true) + private List labelIds; + + /** + * 导入时使用的工作项类型,不作为 Excel 列 + */ + @Schema(hidden = true) + private Integer workItemType; + + @AssertTrue(message = "填写剩余工时时必须同时填写实际投入时长") + public boolean isWorkLogHoursValid() { + return actualHours != null || remainingHours == null; + } + + @AssertTrue(message = "缺陷类型仅可用于缺陷工作项") + public boolean isDefectTypeValid() { + return defectType == null || PmsWorkItemTypeEnum.DEFECT.getType().equals(workItemType); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemImportRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemImportRespVO.java new file mode 100644 index 000000000..6381833d5 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemImportRespVO.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +@Schema(description = "管理后台 - PMS 工作项导入 Response VO") +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class PmsWorkItemImportRespVO { + + @Schema(description = "导入成功数量", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer successCount; + + @Schema(description = "导入失败行及原因", requiredMode = Schema.RequiredMode.REQUIRED) + private Map failureReasons; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemIterationUpdateReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemIterationUpdateReqVO.java new file mode 100644 index 000000000..58ee23b03 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemIterationUpdateReqVO.java @@ -0,0 +1,20 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.experimental.Accessors; + +@Schema(description = "管理后台 - PMS 工作项所属迭代更新 Request VO") +@Data +@Accessors(chain = true) +public class PmsWorkItemIterationUpdateReqVO { + + @Schema(description = "工作项编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "工作项编号不能为空") + private Long id; + + @Schema(description = "迭代编号;为空表示移回待规划", example = "2048") + private Long iterationId; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemNameUpdateReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemNameUpdateReqVO.java new file mode 100644 index 000000000..5963bdb10 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemNameUpdateReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - PMS 工作项名称更新 Request VO") +@Data +public class PmsWorkItemNameUpdateReqVO { + + @Schema(description = "工作项编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "工作项编号不能为空") + private Long id; + + @Schema(description = "工作项标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "完成登录页") + @NotBlank(message = "工作项标题不能为空") + @Size(max = 100, message = "工作项标题不能超过 100 个字符") + private String name; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemPageReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemPageReqVO.java new file mode 100644 index 000000000..2248482aa --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemPageReqVO.java @@ -0,0 +1,88 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemLifecycleStatusEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemPriorityEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemStatusTypeEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import java.util.List; + +@Schema(description = "管理后台 - PMS 工作项分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsWorkItemPageReqVO extends PageParam { + + @Schema(description = "项目编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "项目编号不能为空") + private Long projectId; + + @Schema(description = "工作项类型", example = "3") + @InEnum(PmsWorkItemTypeEnum.class) + private Integer type; + + @Schema(description = "工作项类型列表", example = "[2, 3, 4]") + private List types; + + @Schema(description = "工作项标题", example = "登录") + private String name; + + @Schema(description = "语义状态", example = "1") + @InEnum(PmsWorkItemStatusTypeEnum.class) + private Integer status; + + @Schema(description = "语义状态列表", example = "[1, 2]") + private List statuses; + + @Schema(description = "优先级", example = "2") + @InEnum(PmsWorkItemPriorityEnum.class) + private Integer priority; + + @Schema(description = "优先级列表", example = "[2, 3]") + private List priorities; + + @Schema(description = "生命周期状态,1 正常,2 已归档,3 回收站", example = "1") + @InEnum(PmsWorkItemLifecycleStatusEnum.class) + private Integer lifecycleStatus; + + @Schema(description = "看板状态编号", example = "1024") + private Long statusId; + + @Schema(description = "迭代编号", example = "1024") + private Long iterationId; + + @Schema(description = "迭代编号列表", example = "[1024, 2048]") + private List iterationIds; + + @Schema(description = "排除的迭代编号列表", example = "[1024, 2048]") + private List excludedIterationIds; + + @Schema(description = "父工作项编号", example = "1024") + private Long parentId; + + @Schema(description = "标签编号列表", example = "[1024, 2048]") + private List labelIds; + + @Schema(description = "是否只查询未规划工作项", example = "true") + private Boolean unplannedOnly; + + @Schema(description = "是否用于待规划视图;为 true 时只查询一级工作项并按规划顺序返回", example = "true") + private Boolean planningOnly; + + @Schema(description = "是否只查询一级工作项", example = "true") + private Boolean rootOnly; + + @Schema(description = "负责人用户编号", example = "1024") + private Long assigneeUserId; + + @Schema(description = "负责人用户编号列表", example = "[1024, 2048]") + private List assigneeUserIds; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemPlanningSortReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemPlanningSortReqVO.java new file mode 100644 index 000000000..a7fb2783e --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemPlanningSortReqVO.java @@ -0,0 +1,25 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - PMS 待规划和迭代工作项排序 Request VO") +@Data +public class PmsWorkItemPlanningSortReqVO { + + @Schema(description = "项目编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "项目编号不能为空") + private Long projectId; + + @Schema(description = "迭代编号;为空时表示 Backlog", example = "2048") + private Long iterationId; + + @Schema(description = "工作项编号列表,顺序即显示顺序", requiredMode = Schema.RequiredMode.REQUIRED) + @NotEmpty(message = "工作项编号列表不能为空") + private List<@NotNull(message = "工作项编号不能为空") Long> workItemIds; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemRespVO.java new file mode 100644 index 000000000..92d562804 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemRespVO.java @@ -0,0 +1,125 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.label.PmsWorkItemLabelRespVO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - PMS 工作项 Response VO") +@Data +@ExcelIgnoreUnannotated +public class PmsWorkItemRespVO { + + @Schema(description = "工作项编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "项目编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long projectId; + + @Schema(description = "工作项类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + @ExcelProperty("类型") + private Integer type; + + @Schema(description = "项目内工作项序号", requiredMode = Schema.RequiredMode.REQUIRED, example = "12") + @ExcelProperty("编号") + private Integer serialNumber; + + @Schema(description = "工作项标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "完成登录页") + @ExcelProperty("标题") + private String name; + + @Schema(description = "工作项描述") + @ExcelProperty("描述") + private String description; + + @Schema(description = "优先级", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + @ExcelProperty("优先级") + private Integer priority; + + @Schema(description = "负责人用户编号", example = "1024") + private Long assigneeUserId; + + @Schema(description = "负责人姓名", example = "芋道源码") + @ExcelProperty("负责人") + private String assigneeUserName; + + @Schema(description = "参与人用户编号列表") + private List memberUserIds; + + @Schema(description = "参与人姓名列表") + private List memberUserNames; + + @Schema(description = "看板状态编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long statusId; + + @Schema(description = "看板状态名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "未开始") + @ExcelProperty("状态") + private String statusName; + + @Schema(description = "语义状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer status; + + @Schema(description = "生命周期状态,1 正常,2 已归档,3 回收站", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer lifecycleStatus; + + @Schema(description = "归档时间") + private LocalDateTime archiveTime; + + @Schema(description = "移入回收站时间") + private LocalDateTime recycleTime; + + @Schema(description = "所属迭代编号", example = "1024") + private Long iterationId; + + @Schema(description = "所属迭代名称", example = "第一期") + @ExcelProperty("所属迭代") + private String iterationName; + + @Schema(description = "父工作项编号", example = "1024") + private Long parentId; + + @Schema(description = "关联需求编号", example = "1024") + private Long relatedRequirementId; + + @Schema(description = "关联需求标题", example = "用户登录") + private String relatedRequirementName; + + @Schema(description = "缺陷类型", example = "1") + private Integer defectType; + + @Schema(description = "开始时间") + @ExcelProperty("开始时间") + private LocalDateTime startTime; + + @Schema(description = "截止时间") + @ExcelProperty("截止时间") + private LocalDateTime endTime; + + @Schema(description = "预估工时", example = "8") + @ExcelProperty("预估工时") + private Integer estimatedHours; + + @Schema(description = "完成进度", requiredMode = Schema.RequiredMode.REQUIRED, example = "50") + @ExcelProperty("完成进度") + private Integer progress; + + @Schema(description = "附件地址列表") + private List fileUrls; + + @Schema(description = "标签编号列表") + private List labelIds; + + @Schema(description = "标签列表") + private List labels; + + @Schema(description = "看板内显示顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer sort; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemSaveReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemSaveReqVO.java new file mode 100644 index 000000000..97ca93a8b --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemSaveReqVO.java @@ -0,0 +1,112 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemDefectTypeEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemPriorityEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - PMS 工作项新增/修改 Request VO") +@Data +public class PmsWorkItemSaveReqVO { + + @Schema(description = "工作项编号", example = "1024") + private Long id; + + @Schema(description = "项目编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "项目编号不能为空") + private Long projectId; + + @Schema(description = "工作项类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + @NotNull(message = "工作项类型不能为空") + @InEnum(PmsWorkItemTypeEnum.class) + private Integer type; + + @Schema(description = "工作项标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "完成登录页") + @NotBlank(message = "工作项标题不能为空") + @Size(max = 100, message = "工作项标题不能超过 100 个字符") + private String name; + + @Schema(description = "工作项描述", example = "实现账号密码登录") + @Size(max = 5000, message = "工作项描述不能超过 5000 个字符") + private String description; + + @Schema(description = "优先级", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + @NotNull(message = "优先级不能为空") + @InEnum(PmsWorkItemPriorityEnum.class) + private Integer priority; + + @Schema(description = "负责人用户编号", example = "1024") + private Long assigneeUserId; + + @Schema(description = "参与人用户编号列表", example = "[1024, 2048]") + private List memberUserIds; + + @Schema(description = "所属迭代编号", example = "1024") + private Long iterationId; + + @Schema(description = "父工作项编号", example = "1024") + private Long parentId; + + @Schema(description = "关联需求编号", example = "1024") + private Long relatedRequirementId; + + @Schema(description = "缺陷类型", example = "1") + @InEnum(PmsWorkItemDefectTypeEnum.class) + private Integer defectType; + + @Schema(description = "开始时间") + private LocalDateTime startTime; + + @Schema(description = "截止时间") + private LocalDateTime endTime; + + @Schema(description = "预估工时,单位:小时", example = "8") + @Min(value = 0, message = "预估工时不能小于 0") + private Integer estimatedHours; + + @Schema(description = "完成进度", example = "50") + @NotNull(message = "完成进度不能为空") + @Min(value = 0, message = "完成进度不能小于 0") + @Max(value = 100, message = "完成进度不能大于 100") + private Integer progress; + + @Schema(description = "附件地址列表") + private List fileUrls; + + @Schema(description = "标签编号列表", example = "[1024, 2048]") + private List labelIds; + + @Schema(description = "创建时同时新增的子工作项标题列表") + private List<@NotBlank(message = "子工作项标题不能为空") + @Size(max = 100, message = "子工作项标题不能超过 100 个字符") String> childWorkItemNames; + + @Schema(description = "创建时登记的实际投入工时,单位:小时", example = "2") + @Min(value = 1, message = "实际投入工时必须大于 0") + private Integer actualHours; + + @Schema(description = "创建时登记的剩余工时,单位:小时", example = "6") + @Min(value = 0, message = "剩余工时不能小于 0") + private Integer remainingHours; + + @AssertTrue(message = "工作项结束时间必须晚于开始时间") + public boolean isTimeValid() { + return startTime == null || endTime == null || startTime.isBefore(endTime); + } + + @AssertTrue(message = "填写剩余工时时必须同时填写实际投入工时") + public boolean isWorkLogValid() { + return remainingHours == null || actualHours != null; + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemSortReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemSortReqVO.java new file mode 100644 index 000000000..550ea3fc5 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/workitem/PmsWorkItemSortReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - PMS 工作项看板排序 Request VO") +@Data +public class PmsWorkItemSortReqVO { + + @Schema(description = "看板状态编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "看板状态不能为空") + private Long statusId; + + @Schema(description = "工作项编号列表,顺序即看板显示顺序", requiredMode = Schema.RequiredMode.REQUIRED) + @NotEmpty(message = "工作项编号列表不能为空") + private List<@NotNull(message = "工作项编号不能为空") Long> workItemIds; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/worklog/PmsProjectWorkLogReportReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/worklog/PmsProjectWorkLogReportReqVO.java new file mode 100644 index 000000000..45ab7e8ff --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/worklog/PmsProjectWorkLogReportReqVO.java @@ -0,0 +1,30 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.worklog; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - PMS 项目工时报表 Request VO") +@Data +public class PmsProjectWorkLogReportReqVO { + + @Schema(description = "项目编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "项目编号不能为空") + private Long projectId; + + @Schema(description = "工时登记时间范围", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "工时登记时间范围不能为空") + @Size(min = 2, max = 2, message = "工时登记时间范围必须包含开始时间和结束时间") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + + @Schema(description = "迭代名称", example = "第一迭代") + private String iterationName; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/worklog/PmsProjectWorkLogReportRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/worklog/PmsProjectWorkLogReportRespVO.java new file mode 100644 index 000000000..57132e6b8 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/worklog/PmsProjectWorkLogReportRespVO.java @@ -0,0 +1,64 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.worklog; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +@Schema(description = "管理后台 - PMS 项目工时报表 Response VO") +@Data +public class PmsProjectWorkLogReportRespVO { + + @Schema(description = "报表日期列表") + private List dates; + + @Schema(description = "总工时") + private Integer totalHours; + + @Schema(description = "迭代分组") + private List groups; + + @Data + public static class Group { + + @Schema(description = "迭代编号,未规划事项为空") + private Long iterationId; + + @Schema(description = "迭代名称") + private String iterationName; + + @Schema(description = "分组总工时") + private Integer totalHours; + + @Schema(description = "工作项列表") + private List items; + + } + + @Schema(description = "管理后台 - PMS 项目工时报表工作项明细") + @Data + public static class Item { + + @Schema(description = "工作项编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long workItemId; + + @Schema(description = "项目内工作项序号", requiredMode = Schema.RequiredMode.REQUIRED, example = "12") + private Integer serialNumber; + + @Schema(description = "工作项标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "完成登录接口") + private String name; + + @Schema(description = "工作项类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + private Integer type; + + @Schema(description = "工作项总工时", requiredMode = Schema.RequiredMode.REQUIRED, example = "8") + private Integer totalHours; + + @Schema(description = "每日工时,键为日期,值为小时数", requiredMode = Schema.RequiredMode.REQUIRED, + example = "{\"2026-08-22\": 4}") + private Map dailyHours; + + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/worklog/PmsWorkItemWorkLogRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/worklog/PmsWorkItemWorkLogRespVO.java new file mode 100644 index 000000000..085428649 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/worklog/PmsWorkItemWorkLogRespVO.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.worklog; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - PMS 工作项工时记录 Response VO") +@Data +public class PmsWorkItemWorkLogRespVO { + + @Schema(description = "工时记录编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "工作项编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") + private Long workItemId; + + @Schema(description = "工作项标题", example = "完成登录接口") + private String workItemName; + + @Schema(description = "实际投入工时,单位:小时", requiredMode = Schema.RequiredMode.REQUIRED, example = "4") + private Integer actualHours; + + @Schema(description = "本次登记后的剩余工时,单位:小时", requiredMode = Schema.RequiredMode.REQUIRED, + example = "8") + private Integer remainingHours; + + @Schema(description = "工时说明", example = "完成登录接口联调") + private String description; + + @Schema(description = "登记人用户编号", example = "1") + private Long creatorUserId; + + @Schema(description = "登记人姓名", example = "芋道源码") + private String creatorUserName; + + @Schema(description = "登记时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime updateTime; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/worklog/PmsWorkItemWorkLogSaveReqVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/worklog/PmsWorkItemWorkLogSaveReqVO.java new file mode 100644 index 000000000..07e60d6ae --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/worklog/PmsWorkItemWorkLogSaveReqVO.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.worklog; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - PMS 工作项工时记录新增/修改 Request VO") +@Data +public class PmsWorkItemWorkLogSaveReqVO { + + @Schema(description = "工时记录编号", example = "1024") + private Long id; + + @Schema(description = "工作项编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") + @NotNull(message = "工作项编号不能为空") + private Long workItemId; + + @Schema(description = "实际投入工时,单位:小时", requiredMode = Schema.RequiredMode.REQUIRED, example = "4") + @NotNull(message = "实际投入工时不能为空") + @Min(value = 1, message = "实际投入工时必须大于 0") + private Integer actualHours; + + @Schema(description = "本次登记后的剩余工时,单位:小时", example = "8") + @Min(value = 0, message = "剩余工时不能小于 0") + private Integer remainingHours; + + @Schema(description = "工时说明", example = "完成登录接口联调") + @Size(max = 500, message = "工时说明不能超过 500 个字符") + private String description; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/worklog/PmsWorkItemWorkLogSummaryRespVO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/worklog/PmsWorkItemWorkLogSummaryRespVO.java new file mode 100644 index 000000000..91976aada --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/pm/workitem/vo/worklog/PmsWorkItemWorkLogSummaryRespVO.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.worklog; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - PMS 工作项工时汇总 Response VO") +@Data +public class PmsWorkItemWorkLogSummaryRespVO { + + @Schema(description = "预估工时,单位:小时", example = "16") + private Integer estimatedHours; + + @Schema(description = "已登记工时,单位:小时", requiredMode = Schema.RequiredMode.REQUIRED, example = "8") + private Integer actualHours; + + @Schema(description = "剩余工时,单位:小时", example = "8") + private Integer remainingHours; + + @Schema(description = "工时记录列表", requiredMode = Schema.RequiredMode.REQUIRED) + private List records; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/package-info.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/package-info.java new file mode 100644 index 000000000..117f27385 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/controller/package-info.java @@ -0,0 +1,4 @@ +/** + * PMS 模块的 Controller,提供管理后台和用户端 API + */ +package cn.iocoder.yudao.module.pms.controller; diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/content/PmsKnowledgeContentPermissionDO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/content/PmsKnowledgeContentPermissionDO.java new file mode 100644 index 000000000..607c913e2 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/content/PmsKnowledgeContentPermissionDO.java @@ -0,0 +1,47 @@ +package cn.iocoder.yudao.module.pms.dal.dataobject.kb.content; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryDO; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeContentLevelEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * PMS 知识内容协作权限 DO + * + * @author 芋道源码 + */ +@TableName("pms_knowledge_content_permission") +@KeySequence("pms_knowledge_content_permission_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsKnowledgeContentPermissionDO extends BaseDO { + + /** + * 内容协作权限编号 + */ + @TableId + private Long id; + /** + * 知识库编号 + * + * 关联 {@link PmsKnowledgeLibraryDO#getId()} + */ + private Long libraryId; + /** + * 是否对知识库可访问人公开 + */ + private Boolean openStatus; + /** + * 公开协作等级 + * + * 枚举 {@link PmsKnowledgeContentLevelEnum} + */ + private Integer openLevel; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/content/PmsKnowledgeContentPermissionMemberDO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/content/PmsKnowledgeContentPermissionMemberDO.java new file mode 100644 index 000000000..e1377cbe1 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/content/PmsKnowledgeContentPermissionMemberDO.java @@ -0,0 +1,55 @@ +package cn.iocoder.yudao.module.pms.dal.dataobject.kb.content; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeContentLevelEnum; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * PMS 知识内容协作者 DO + * + * @author 芋道源码 + */ +@TableName("pms_knowledge_content_permission_member") +@KeySequence("pms_knowledge_content_permission_member_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsKnowledgeContentPermissionMemberDO extends BaseDO { + + /** + * 协作者编号 + */ + @TableId + private Long id; + /** + * 内容协作权限编号 + * + * 关联 {@link PmsKnowledgeContentPermissionDO#getId()} + */ + private Long permissionId; + /** + * 后台用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long userId; + /** + * 部门编号 + * + * 关联 {@link DeptRespDTO#getId()} + */ + private Long deptId; + /** + * 协作等级 + * + * 枚举 {@link PmsKnowledgeContentLevelEnum} + */ + private Integer level; +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/content/PmsKnowledgeDocumentDO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/content/PmsKnowledgeDocumentDO.java new file mode 100644 index 000000000..adcedc216 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/content/PmsKnowledgeDocumentDO.java @@ -0,0 +1,117 @@ +package cn.iocoder.yudao.module.pms.dal.dataobject.kb.content; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.framework.mybatis.core.type.LongListTypeHandler; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeContentPermissionDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryDO; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeDocumentStatusEnum; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeDocumentTypeEnum; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * PMS 知识库文档 DO + * + * @author 芋道源码 + */ +@TableName(value = "pms_knowledge_document", autoResultMap = true) +@KeySequence("pms_knowledge_document_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsKnowledgeDocumentDO extends BaseDO { + + /** + * 根文件夹编号 + */ + public static final Long FOLDER_ID_ROOT = 0L; + /** + * 根文档编号 + */ + public static final Long PARENT_ID_ROOT = 0L; + + /** + * 文档编号 + */ + @TableId + private Long id; + /** + * 知识库编号 + * + * 关联 {@link PmsKnowledgeLibraryDO#getId()} + */ + private Long libraryId; + /** + * 内容协作权限编号 + * + * 关联 {@link PmsKnowledgeContentPermissionDO#getId()} + */ + private Long permissionId; + /** + * 文件夹编号,0 表示不在文件夹中 + * + * 关联 {@link PmsKnowledgeFolderDO#getId()} + */ + private Long folderId; + /** + * 父文档编号,0 表示根文档 + * + * 关联 {@link #getId()} + */ + private Long parentId; + /** + * 文档标题 + */ + private String title; + /** + * 文档内容或文件地址 + */ + private String content; + /** + * 文档类型 + * + * 枚举 {@link PmsKnowledgeDocumentTypeEnum} + */ + private Integer type; + /** + * 文件类型 + */ + private String fileType; + /** + * 文件大小,单位:字节 + */ + private Long fileSize; + /** + * 文档状态 + * + * 枚举 {@link PmsKnowledgeDocumentStatusEnum} + */ + private Integer status; + /** + * 标签编号列表 + * + * 关联 {@link PmsKnowledgeDocumentLabelDO#getId()} + */ + @TableField(typeHandler = LongListTypeHandler.class) + private List labelIds; + /** + * 删除人用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long deleteUserId; + /** + * 删除时间 + */ + private LocalDateTime deleteTime; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/content/PmsKnowledgeDocumentLabelDO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/content/PmsKnowledgeDocumentLabelDO.java new file mode 100644 index 000000000..d7cf32258 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/content/PmsKnowledgeDocumentLabelDO.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.pms.dal.dataobject.kb.content; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * PMS 知识库文档标签 DO + * + * @author 芋道源码 + */ +@TableName("pms_knowledge_document_label") +@KeySequence("pms_knowledge_document_label_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsKnowledgeDocumentLabelDO extends BaseDO { + + /** + * 文档标签编号 + */ + @TableId + private Long id; + /** + * 标签名称 + */ + private String name; + /** + * 标签颜色 + */ + private String color; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/content/PmsKnowledgeFolderDO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/content/PmsKnowledgeFolderDO.java new file mode 100644 index 000000000..338424a13 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/content/PmsKnowledgeFolderDO.java @@ -0,0 +1,78 @@ +package cn.iocoder.yudao.module.pms.dal.dataobject.kb.content; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeContentPermissionDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryDO; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeDocumentStatusEnum; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import java.time.LocalDateTime; + +/** + * PMS 知识库文件夹 DO + * + * @author 芋道源码 + */ +@TableName("pms_knowledge_folder") +@KeySequence("pms_knowledge_folder_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsKnowledgeFolderDO extends BaseDO { + + /** + * 根文件夹编号 + */ + public static final Long PARENT_ID_ROOT = 0L; + + /** + * 文件夹编号 + */ + @TableId + private Long id; + /** + * 知识库编号 + * + * 关联 {@link PmsKnowledgeLibraryDO#getId()} + */ + private Long libraryId; + /** + * 内容协作权限编号 + * + * 关联 {@link PmsKnowledgeContentPermissionDO#getId()} + */ + private Long permissionId; + /** + * 父文件夹编号,0 表示根目录 + * + * 关联 {@link #getId()} + */ + private Long parentId; + /** + * 文件夹标题 + */ + private String title; + /** + * 文件夹状态 + * + * 枚举 {@link PmsKnowledgeDocumentStatusEnum} + */ + private Integer status; + /** + * 删除人用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long deleteUserId; + /** + * 删除时间 + */ + private LocalDateTime deleteTime; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/interaction/PmsKnowledgeDocumentCommentDO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/interaction/PmsKnowledgeDocumentCommentDO.java new file mode 100644 index 000000000..b234d94d6 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/interaction/PmsKnowledgeDocumentCommentDO.java @@ -0,0 +1,64 @@ +package cn.iocoder.yudao.module.pms.dal.dataobject.kb.interaction; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeDocumentDO; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * PMS 知识库文档评论 DO + * + * @author 芋道源码 + */ +@TableName("pms_knowledge_document_comment") +@KeySequence("pms_knowledge_document_comment_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsKnowledgeDocumentCommentDO extends BaseDO { + + /** + * 主评论编号 + */ + public static final Long MAIN_ID_ROOT = 0L; + + /** + * 评论编号 + */ + @TableId + private Long id; + /** + * 文档编号 + * + * 关联 {@link PmsKnowledgeDocumentDO#getId()} + */ + private Long documentId; + /** + * 评论人用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long userId; + /** + * 主评论编号,0 表示主评论 + * + * 关联 {@link #getId()} + */ + private Long mainId; + /** + * 回复对象用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long replyUserId; + /** + * 评论内容 + */ + private String content; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/interaction/PmsKnowledgeDocumentLikeDO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/interaction/PmsKnowledgeDocumentLikeDO.java new file mode 100644 index 000000000..1a9fefd5a --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/interaction/PmsKnowledgeDocumentLikeDO.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.pms.dal.dataobject.kb.interaction; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeDocumentDO; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * PMS 知识文档点赞 DO + * + * @author 芋道源码 + */ +@TableName("pms_knowledge_document_like") +@KeySequence("pms_knowledge_document_like_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsKnowledgeDocumentLikeDO extends BaseDO { + + /** + * 点赞编号 + */ + @TableId + private Long id; + /** + * 文档编号 + * + * 关联 {@link PmsKnowledgeDocumentDO#getId()} + */ + private Long documentId; + /** + * 点赞人用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long userId; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/interaction/PmsKnowledgeDocumentShareDO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/interaction/PmsKnowledgeDocumentShareDO.java new file mode 100644 index 000000000..a145d892b --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/interaction/PmsKnowledgeDocumentShareDO.java @@ -0,0 +1,70 @@ +package cn.iocoder.yudao.module.pms.dal.dataobject.kb.interaction; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeDocumentDO; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * PMS 知识库文档分享 DO + * + * @author 芋道源码 + */ +@TableName(value = "pms_knowledge_document_share", autoResultMap = true) +@KeySequence("pms_knowledge_document_share_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsKnowledgeDocumentShareDO extends BaseDO { + + /** + * 分享编号 + */ + @TableId + private Long id; + /** + * 文档编号 + * + * 关联 {@link PmsKnowledgeDocumentDO#getId()} + */ + private Long documentId; + /** + * 内部分享成员用户编号列表 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List shareUserIds; + /** + * 外部查看令牌 + */ + private String token; + /** + * 分享状态 + * + * 枚举 {@link CommonStatusEnum} + */ + private Integer status; + /** + * 关闭人用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long closeUserId; + /** + * 关闭时间 + */ + private LocalDateTime closeTime; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/interaction/PmsKnowledgeFavoriteDO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/interaction/PmsKnowledgeFavoriteDO.java new file mode 100644 index 000000000..4ecb1498f --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/interaction/PmsKnowledgeFavoriteDO.java @@ -0,0 +1,56 @@ +package cn.iocoder.yudao.module.pms.dal.dataobject.kb.interaction; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryDO; +import cn.iocoder.yudao.module.pms.enums.kb.PmsKnowledgeObjectTypeEnum; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * PMS 知识收藏(关注)DO + * + * @author 芋道源码 + */ +@TableName("pms_knowledge_favorite") +@KeySequence("pms_knowledge_favorite_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsKnowledgeFavoriteDO extends BaseDO { + + /** + * 收藏编号 + */ + @TableId + private Long id; + /** + * 知识库编号 + * + * 关联 {@link PmsKnowledgeLibraryDO#getId()} + */ + private Long libraryId; + /** + * 收藏对象类型 + * + * 枚举 {@link PmsKnowledgeObjectTypeEnum} + */ + private Integer type; + /** + * 收藏对象编号 + * + * 根据 {@link #getType()} 关联知识库、文件夹或文档编号 + */ + private Long entityId; + /** + * 收藏人用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long userId; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/interaction/PmsKnowledgeViewRecordDO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/interaction/PmsKnowledgeViewRecordDO.java new file mode 100644 index 000000000..7d0f2447f --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/interaction/PmsKnowledgeViewRecordDO.java @@ -0,0 +1,56 @@ +package cn.iocoder.yudao.module.pms.dal.dataobject.kb.interaction; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryDO; +import cn.iocoder.yudao.module.pms.enums.kb.PmsKnowledgeObjectTypeEnum; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * PMS 知识最近浏览记录 DO + * + * @author 芋道源码 + */ +@TableName("pms_knowledge_view_record") +@KeySequence("pms_knowledge_view_record_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsKnowledgeViewRecordDO extends BaseDO { + + /** + * 浏览记录编号 + */ + @TableId + private Long id; + /** + * 知识库编号 + * + * 关联 {@link PmsKnowledgeLibraryDO#getId()} + */ + private Long libraryId; + /** + * 浏览对象类型 + * + * 枚举 {@link PmsKnowledgeObjectTypeEnum} + */ + private Integer type; + /** + * 浏览对象编号 + * + * 根据 {@link #getType()} 关联文件夹或文档编号 + */ + private Long entityId; + /** + * 浏览人用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long userId; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/library/PmsKnowledgeGroupDO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/library/PmsKnowledgeGroupDO.java new file mode 100644 index 000000000..4b985324f --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/library/PmsKnowledgeGroupDO.java @@ -0,0 +1,51 @@ +package cn.iocoder.yudao.module.pms.dal.dataobject.kb.library; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.pms.enums.kb.library.PmsKnowledgeGroupTypeEnum; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * PMS 个人知识库分组 DO + * + * @author 芋道源码 + */ +@TableName("pms_knowledge_group") +@KeySequence("pms_knowledge_group_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsKnowledgeGroupDO extends BaseDO { + + /** + * 知识库分组编号 + */ + @TableId + private Long id; + /** + * 后台用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long userId; + /** + * 分组名称 + */ + private String name; + /** + * 显示顺序 + */ + private Integer sort; + /** + * 分组类型 + * + * 枚举 {@link PmsKnowledgeGroupTypeEnum} + */ + private Integer type; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/library/PmsKnowledgeGroupRelationDO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/library/PmsKnowledgeGroupRelationDO.java new file mode 100644 index 000000000..e3869ae06 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/library/PmsKnowledgeGroupRelationDO.java @@ -0,0 +1,52 @@ +package cn.iocoder.yudao.module.pms.dal.dataobject.kb.library; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * PMS 个人知识库分组关系 DO + * + * @author 芋道源码 + */ +@TableName("pms_knowledge_group_relation") +@KeySequence("pms_knowledge_group_relation_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsKnowledgeGroupRelationDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 后台用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long userId; + /** + * 知识库分组编号 + * + * 关联 {@link PmsKnowledgeGroupDO#getId()} + */ + private Long groupId; + /** + * 知识库编号 + * + * 关联 {@link PmsKnowledgeLibraryDO#getId()} + */ + private Long libraryId; + /** + * 显示顺序 + */ + private Integer sort; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/library/PmsKnowledgeLibraryDO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/library/PmsKnowledgeLibraryDO.java new file mode 100644 index 000000000..97bd2776b --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/library/PmsKnowledgeLibraryDO.java @@ -0,0 +1,65 @@ +package cn.iocoder.yudao.module.pms.dal.dataobject.kb.library; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeDocumentStatusEnum; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import java.time.LocalDateTime; + +/** + * PMS 知识库 DO + * + * @author 芋道源码 + */ +@TableName("pms_knowledge_library") +@KeySequence("pms_knowledge_library_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsKnowledgeLibraryDO extends BaseDO { + + /** + * 知识库编号 + */ + @TableId + private Long id; + /** + * 知识库名称 + */ + private String name; + /** + * 知识库简介 + */ + private String description; + /** + * 是否公开 + */ + private Boolean openStatus; + /** + * 知识库封面 + */ + private String coverUrl; + /** + * 知识库状态 + * + * 枚举 {@link PmsKnowledgeDocumentStatusEnum} + */ + private Integer status; + /** + * 删除人用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long deleteUserId; + /** + * 删除时间 + */ + private LocalDateTime deleteTime; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/library/PmsKnowledgeLibraryMemberDO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/library/PmsKnowledgeLibraryMemberDO.java new file mode 100644 index 000000000..185e842d4 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/library/PmsKnowledgeLibraryMemberDO.java @@ -0,0 +1,56 @@ +package cn.iocoder.yudao.module.pms.dal.dataobject.kb.library; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.pms.enums.kb.library.PmsKnowledgeLibraryMemberLevelEnum; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * PMS 知识库成员 DO + * + * @author 芋道源码 + */ +@TableName("pms_knowledge_library_member") +@KeySequence("pms_knowledge_library_member_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsKnowledgeLibraryMemberDO extends BaseDO { + + /** + * 成员编号 + */ + @TableId + private Long id; + /** + * 知识库编号 + * + * 关联 {@link PmsKnowledgeLibraryDO#getId()} + */ + private Long libraryId; + /** + * 后台用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long userId; + /** + * 部门编号 + * + * 关联 {@link DeptRespDTO#getId()} + */ + private Long deptId; + /** + * 成员等级 + * + * 枚举 {@link PmsKnowledgeLibraryMemberLevelEnum} + */ + private Integer level; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/library/PmsKnowledgeLibraryTemplateDO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/library/PmsKnowledgeLibraryTemplateDO.java new file mode 100644 index 000000000..53ba3b652 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/library/PmsKnowledgeLibraryTemplateDO.java @@ -0,0 +1,80 @@ +package cn.iocoder.yudao.module.pms.dal.dataobject.kb.library; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import java.util.List; + +/** + * PMS 知识库模板 DO + * + *

模板只保存新建知识库时使用的默认文档,知识库创建后会生成独立的文档内容

+ * + * @author 芋道源码 + */ +@TableName(value = "pms_knowledge_library_template", autoResultMap = true) +@KeySequence("pms_knowledge_library_template_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsKnowledgeLibraryTemplateDO extends BaseDO { + + /** + * 模板编号 + */ + @TableId + private Long id; + /** + * 模板名称 + */ + private String name; + /** + * 模板简介 + */ + private String description; + /** + * 模板封面地址 + */ + private String coverUrl; + /** + * 模板状态 + * + * 枚举 {@link CommonStatusEnum} + */ + private Integer status; + /** + * 显示顺序 + */ + private Integer sort; + /** + * 模板文档列表 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List documents; + + /** + * 模板文档 + */ + @Data + public static class Document { + + /** + * 文档标题 + */ + private String title; + /** + * 文档内容 + */ + private String content; + + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/recycle/PmsKnowledgeRecycleRecordDO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/recycle/PmsKnowledgeRecycleRecordDO.java new file mode 100644 index 000000000..6241e895a --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/kb/recycle/PmsKnowledgeRecycleRecordDO.java @@ -0,0 +1,66 @@ +package cn.iocoder.yudao.module.pms.dal.dataobject.kb.recycle; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryDO; +import cn.iocoder.yudao.module.pms.enums.kb.PmsKnowledgeObjectTypeEnum; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import java.time.LocalDateTime; + +/** + * PMS 知识库回收站记录 DO + * + * @author 芋道源码 + */ +@TableName("pms_knowledge_recycle_record") +@KeySequence("pms_knowledge_recycle_record_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsKnowledgeRecycleRecordDO extends BaseDO { + + /** + * 回收站记录编号 + */ + @TableId + private Long id; + /** + * 知识库编号 + * + * 关联 {@link PmsKnowledgeLibraryDO#getId()} + */ + private Long libraryId; + /** + * 回收对象类型 + * + * 枚举 {@link PmsKnowledgeObjectTypeEnum} + */ + private Integer type; + /** + * 回收对象编号 + * + * 根据 {@link #getType()} 关联知识库、文件夹或文档编号 + */ + private Long entityId; + /** + * 回收对象名称 + */ + private String name; + /** + * 删除人用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long deleteUserId; + /** + * 删除时间 + */ + private LocalDateTime deleteTime; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/iteration/PmsIterationDO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/iteration/PmsIterationDO.java new file mode 100644 index 000000000..e03fa05f1 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/iteration/PmsIterationDO.java @@ -0,0 +1,80 @@ +package cn.iocoder.yudao.module.pms.dal.dataobject.pm.iteration; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectDO; +import cn.iocoder.yudao.module.pms.enums.pm.iteration.PmsIterationStatusEnum; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import java.time.LocalDateTime; + +/** + * PMS 项目迭代 DO + * + * @author 芋道源码 + */ +@TableName("pms_iteration") +@KeySequence("pms_iteration_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsIterationDO extends BaseDO { + + /** + * 迭代编号 + */ + @TableId + private Long id; + /** + * 项目编号 + * + * 关联 {@link PmsProjectDO#getId()} + */ + private Long projectId; + /** + * 迭代名称 + */ + private String name; + /** + * 负责人用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long ownerUserId; + /** + * 迭代状态 + * + * 枚举 {@link PmsIterationStatusEnum} + */ + private Integer status; + /** + * 开始时间 + */ + private LocalDateTime startTime; + /** + * 结束时间 + */ + private LocalDateTime endTime; + /** + * 完成时间 + */ + private LocalDateTime finishTime; + /** + * 迭代目标 + */ + private String target; + /** + * 迭代描述 + */ + private String description; + /** + * 显示顺序 + */ + private Integer sort; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/project/PmsProjectAnnouncementDO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/project/PmsProjectAnnouncementDO.java new file mode 100644 index 000000000..fca831da4 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/project/PmsProjectAnnouncementDO.java @@ -0,0 +1,48 @@ +package cn.iocoder.yudao.module.pms.dal.dataobject.pm.project; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import java.util.List; + +/** + * PMS 项目公告 DO + * + * @author 芋道源码 + */ +@TableName(value = "pms_project_announcement", autoResultMap = true) +@KeySequence("pms_project_announcement_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsProjectAnnouncementDO extends BaseDO { + + /** + * 公告编号 + */ + @TableId + private Long id; + /** + * 项目编号 + * + * 关联 {@link PmsProjectDO#getId()} + */ + private Long projectId; + /** + * 公告内容 + */ + private String content; + /** + * 附件地址列表 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List fileUrls; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/project/PmsProjectDO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/project/PmsProjectDO.java new file mode 100644 index 000000000..60ef75392 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/project/PmsProjectDO.java @@ -0,0 +1,92 @@ +package cn.iocoder.yudao.module.pms.dal.dataobject.pm.project; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.pms.enums.pm.project.PmsProjectLevelEnum; +import cn.iocoder.yudao.module.pms.enums.pm.project.PmsProjectStatusEnum; +import cn.iocoder.yudao.module.pms.enums.pm.project.PmsProjectTypeEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import java.time.LocalDateTime; + +/** + * PMS 项目 DO + * + * @author 芋道源码 + */ +@TableName("pms_project") +@KeySequence("pms_project_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsProjectDO extends BaseDO { + + /** + * 项目编号 + */ + @TableId + private Long id; + /** + * 项目名称 + */ + private String name; + /** + * 项目状态 + * + * 枚举 {@link PmsProjectStatusEnum} + */ + private Integer status; + /** + * 项目类型 + * + * 枚举 {@link PmsProjectTypeEnum} + */ + private Integer type; + /** + * 项目优先级 + * + * 枚举 {@link PmsProjectLevelEnum} + */ + private Integer level; + /** + * 项目描述 + */ + private String description; + /** + * 是否公开 + */ + private Boolean openStatus; + /** + * 项目图标 + */ + private String icon; + /** + * 显示顺序 + */ + private Integer sort; + /** + * 开始时间 + */ + private LocalDateTime startTime; + /** + * 截止时间 + */ + private LocalDateTime endTime; + /** + * 归档时间 + */ + private LocalDateTime archiveTime; + /** + * 移入回收站时间 + */ + private LocalDateTime recycleTime; + /** + * 最近访问时间 + */ + private LocalDateTime accessTime; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/project/PmsProjectFavoriteDO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/project/PmsProjectFavoriteDO.java new file mode 100644 index 000000000..2b4169dcf --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/project/PmsProjectFavoriteDO.java @@ -0,0 +1,42 @@ +package cn.iocoder.yudao.module.pms.dal.dataobject.pm.project; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * PMS 项目收藏 DO + * + * @author 芋道源码 + */ +@TableName("pms_project_favorite") +@KeySequence("pms_project_favorite_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsProjectFavoriteDO extends BaseDO { + + /** + * 收藏编号 + */ + @TableId + private Long id; + /** + * 项目编号 + * + * 关联 {@link PmsProjectDO#getId()} + */ + private Long projectId; + /** + * 收藏人用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long userId; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/project/PmsProjectGroupDO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/project/PmsProjectGroupDO.java new file mode 100644 index 000000000..4c41d4cfb --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/project/PmsProjectGroupDO.java @@ -0,0 +1,53 @@ +package cn.iocoder.yudao.module.pms.dal.dataobject.pm.project; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.pms.enums.pm.project.PmsProjectGroupTypeEnum; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * PMS 项目分组 DO + * + * 项目分组是后台用户个人维护的项目视图,不影响其他成员看到的项目分组 + * + * @author 芋道源码 + */ +@TableName("pms_project_group") +@KeySequence("pms_project_group_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsProjectGroupDO extends BaseDO { + + /** + * 分组编号 + */ + @TableId + private Long id; + /** + * 后台用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long userId; + /** + * 分组名称 + */ + private String name; + /** + * 显示顺序 + */ + private Integer sort; + /** + * 分组类型 + * + * 枚举 {@link PmsProjectGroupTypeEnum} + */ + private Integer type; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/project/PmsProjectGroupRelationDO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/project/PmsProjectGroupRelationDO.java new file mode 100644 index 000000000..216cd1cd2 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/project/PmsProjectGroupRelationDO.java @@ -0,0 +1,52 @@ +package cn.iocoder.yudao.module.pms.dal.dataobject.pm.project; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * PMS 项目分组关系 DO + * + * @author 芋道源码 + */ +@TableName("pms_project_group_relation") +@KeySequence("pms_project_group_relation_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsProjectGroupRelationDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 后台用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long userId; + /** + * 项目分组编号 + * + * 关联 {@link PmsProjectGroupDO#getId()} + */ + private Long groupId; + /** + * 项目编号 + * + * 关联 {@link PmsProjectDO#getId()} + */ + private Long projectId; + /** + * 显示顺序 + */ + private Integer sort; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/project/PmsProjectMemberDO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/project/PmsProjectMemberDO.java new file mode 100644 index 000000000..604ded2de --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/project/PmsProjectMemberDO.java @@ -0,0 +1,49 @@ +package cn.iocoder.yudao.module.pms.dal.dataobject.pm.project; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.pms.enums.pm.project.PmsProjectMemberLevelEnum; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * PMS 项目成员 DO + * + * @author 芋道源码 + */ +@TableName("pms_project_member") +@KeySequence("pms_project_member_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsProjectMemberDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 项目编号 + * + * 关联 {@link PmsProjectDO#getId()} + */ + private Long projectId; + /** + * 后台用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long userId; + /** + * 成员权限级别 + * + * 枚举 {@link PmsProjectMemberLevelEnum} + */ + private Integer level; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/project/PmsProjectTemplateDO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/project/PmsProjectTemplateDO.java new file mode 100644 index 000000000..a1070aa81 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/project/PmsProjectTemplateDO.java @@ -0,0 +1,150 @@ +package cn.iocoder.yudao.module.pms.dal.dataobject.pm.project; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.pms.enums.pm.project.PmsProjectTypeEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemStatusTypeEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemTypeEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import java.util.List; + +/** + * PMS 项目模板 DO + * + *

模板只保存新建项目时使用的默认协作配置,项目创建后会生成自己的运行配置

+ * + * @author 芋道源码 + */ +@TableName(value = "pms_project_template", autoResultMap = true) +@KeySequence("pms_project_template_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsProjectTemplateDO extends BaseDO { + + /** + * 模板编号 + */ + @TableId + private Long id; + /** + * 模板名称 + */ + private String name; + /** + * 模板描述 + */ + private String description; + /** + * 项目类型 + * + * 枚举 {@link PmsProjectTypeEnum} + */ + private Integer projectType; + /** + * 模板状态 + * + * 枚举 {@link CommonStatusEnum} + */ + private Integer status; + /** + * 显示顺序 + */ + private Integer sort; + /** + * 启用的工作项类型列表 + * + * 枚举 {@link PmsWorkItemTypeEnum} + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List itemTypes; + /** + * 工作项状态模板列表 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List statuses; + /** + * 看板列模板列表 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List boards; + + /** + * 工作项状态模板 + */ + @Data + public static class StatusTemplate { + + /** + * 模板内稳定编码 + */ + private String code; + /** + * 状态名称 + */ + private String name; + /** + * 工作项类型 + * + * 枚举 {@link PmsWorkItemTypeEnum} + */ + private Integer workItemType; + /** + * 语义状态 + * + * 枚举 {@link PmsWorkItemStatusTypeEnum} + */ + private Integer statusType; + /** + * 是否为初始状态 + */ + private Boolean defaultStatus; + /** + * 显示顺序 + */ + private Integer sort; + /** + * 所属看板列编码 + */ + private String boardCode; + } + + /** + * 看板列模板 + */ + @Data + public static class BoardTemplate { + + /** + * 模板内稳定编码 + */ + private String code; + /** + * 看板列名称 + */ + private String name; + /** + * 工作项类型 + * + * 枚举 {@link PmsWorkItemTypeEnum} + */ + private Integer workItemType; + /** + * 显示顺序 + */ + private Integer sort; + /** + * 看板列关联的状态编码列表 + */ + private List statusCodes; + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/workitem/PmsWorkItemActivityDO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/workitem/PmsWorkItemActivityDO.java new file mode 100644 index 000000000..a74c5b81a --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/workitem/PmsWorkItemActivityDO.java @@ -0,0 +1,53 @@ +package cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectDO; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * PMS 工作项动态 DO + * + * @author 芋道源码 + */ +@TableName("pms_work_item_activity") +@KeySequence("pms_work_item_activity_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsWorkItemActivityDO extends BaseDO { + + /** + * 动态编号 + */ + @TableId + private Long id; + /** + * 项目编号 + * + * 关联 {@link PmsProjectDO#getId()} + */ + private Long projectId; + /** + * 工作项编号 + * + * 关联 {@link PmsWorkItemDO#getId()} + */ + private Long workItemId; + /** + * 操作人用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long operatorUserId; + /** + * 动态内容 + */ + private String content; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/workitem/PmsWorkItemBoardDO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/workitem/PmsWorkItemBoardDO.java new file mode 100644 index 000000000..d8f84a76e --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/workitem/PmsWorkItemBoardDO.java @@ -0,0 +1,51 @@ +package cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectDO; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemTypeEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * PMS 工作项看板列 DO + * + * @author 芋道源码 + */ +@TableName("pms_work_item_board") +@KeySequence("pms_work_item_board_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsWorkItemBoardDO extends BaseDO { + + /** + * 看板列编号 + */ + @TableId + private Long id; + /** + * 项目编号 + * + * 关联 {@link PmsProjectDO#getId()} + */ + private Long projectId; + /** + * 工作项类型 + * + * 枚举 {@link PmsWorkItemTypeEnum} + */ + private Integer workItemType; + /** + * 看板列名称 + */ + private String name; + /** + * 显示顺序 + */ + private Integer sort; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/workitem/PmsWorkItemCommentDO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/workitem/PmsWorkItemCommentDO.java new file mode 100644 index 000000000..76f96b3fa --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/workitem/PmsWorkItemCommentDO.java @@ -0,0 +1,63 @@ +package cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * PMS 工作项评论 DO + * + * @author 芋道源码 + */ +@TableName("pms_work_item_comment") +@KeySequence("pms_work_item_comment_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsWorkItemCommentDO extends BaseDO { + + /** + * 主评论使用的根编号 + */ + public static final Long MAIN_ID_ROOT = 0L; + + /** + * 评论编号 + */ + @TableId + private Long id; + /** + * 工作项编号 + * + * 关联 {@link PmsWorkItemDO#getId()} + */ + private Long workItemId; + /** + * 评论人用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long userId; + /** + * 主评论编号,{@link #MAIN_ID_ROOT} 表示主评论 + * + * 关联 {@link #getId()} + */ + private Long mainId; + /** + * 回复对象用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long replyUserId; + /** + * 评论内容 + */ + private String content; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/workitem/PmsWorkItemDO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/workitem/PmsWorkItemDO.java new file mode 100644 index 000000000..5dd95d0a7 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/workitem/PmsWorkItemDO.java @@ -0,0 +1,160 @@ +package cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.iteration.PmsIterationDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectDO; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemDefectTypeEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemLifecycleStatusEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemPriorityEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemStatusTypeEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemTypeEnum; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * PMS 工作项 DO + * + * @author 芋道源码 + */ +@TableName(value = "pms_work_item", autoResultMap = true) +@KeySequence("pms_work_item_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsWorkItemDO extends BaseDO { + + /** + * 工作项编号 + */ + @TableId + private Long id; + /** + * 项目编号 + * + * 关联 {@link PmsProjectDO#getId()} + */ + private Long projectId; + /** + * 工作项类型 + * + * 枚举 {@link PmsWorkItemTypeEnum} + */ + private Integer type; + /** + * 项目内工作项序号 + */ + private Integer serialNumber; + /** + * 工作项标题 + */ + private String name; + /** + * 工作项描述 + */ + private String description; + /** + * 优先级 + * + * 枚举 {@link PmsWorkItemPriorityEnum} + */ + private Integer priority; + /** + * 负责人用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long assigneeUserId; + /** + * 看板状态编号 + * + * 关联 {@link PmsWorkItemStatusDO#getId()} + */ + private Long statusId; + /** + * 语义状态 + * + * 枚举 {@link PmsWorkItemStatusTypeEnum} + */ + private Integer status; + /** + * 生命周期状态 + * + * 枚举 {@link PmsWorkItemLifecycleStatusEnum} + */ + private Integer lifecycleStatus; + /** + * 归档时间 + */ + private LocalDateTime archiveTime; + /** + * 移入回收站时间 + */ + private LocalDateTime recycleTime; + /** + * 所属迭代编号 + * + * 关联 {@link PmsIterationDO#getId()} + */ + private Long iterationId; + /** + * 父工作项编号 + * + * 关联 {@link #getId()} + */ + private Long parentId; + /** + * 关联需求编号 + * + * 关联 {@link #getId()} + */ + private Long relatedRequirementId; + /** + * 缺陷类型 + * + * 枚举 {@link PmsWorkItemDefectTypeEnum} + */ + private Integer defectType; + /** + * 开始时间 + */ + private LocalDateTime startTime; + /** + * 截止时间 + */ + private LocalDateTime endTime; + /** + * 预估工时,单位:小时 + */ + private Integer estimatedHours; + /** + * 完成进度,取值范围 0-100 + */ + private Integer progress; + /** + * 附件地址列表 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List fileUrls; + /** + * 标签编号列表 + * + * 关联 {@link PmsWorkItemLabelDO#getId()} + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List labelIds; + /** + * 看板内显示顺序 + */ + private Integer sort; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/workitem/PmsWorkItemLabelDO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/workitem/PmsWorkItemLabelDO.java new file mode 100644 index 000000000..5ee1532fb --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/workitem/PmsWorkItemLabelDO.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * PMS 工作项标签 DO + * + * @author 芋道源码 + */ +@TableName("pms_work_item_label") +@KeySequence("pms_work_item_label_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsWorkItemLabelDO extends BaseDO { + + /** + * 标签编号 + */ + @TableId + private Long id; + /** + * 标签名称 + */ + private String name; + /** + * 标签颜色 + */ + private String color; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/workitem/PmsWorkItemMemberDO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/workitem/PmsWorkItemMemberDO.java new file mode 100644 index 000000000..5b338ea3b --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/workitem/PmsWorkItemMemberDO.java @@ -0,0 +1,49 @@ +package cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectDO; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * PMS 工作项参与人 DO + * + * @author 芋道源码 + */ +@TableName("pms_work_item_member") +@KeySequence("pms_work_item_member_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsWorkItemMemberDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 项目编号 + * + * 关联 {@link PmsProjectDO#getId()} + */ + private Long projectId; + /** + * 工作项编号 + * + * 关联 {@link PmsWorkItemDO#getId()} + */ + private Long workItemId; + /** + * 后台用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long userId; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/workitem/PmsWorkItemStatusDO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/workitem/PmsWorkItemStatusDO.java new file mode 100644 index 000000000..6fb9ea74b --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/workitem/PmsWorkItemStatusDO.java @@ -0,0 +1,74 @@ +package cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectDO; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemStatusTypeEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemTypeEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * PMS 工作项看板状态 DO + * + * @author 芋道源码 + */ +@TableName("pms_work_item_status") +@KeySequence("pms_work_item_status_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsWorkItemStatusDO extends BaseDO { + + /** + * 状态编号 + */ + @TableId + private Long id; + /** + * 项目编号 + * + * 关联 {@link PmsProjectDO#getId()} + */ + private Long projectId; + /** + * 工作项类型 + * + * 枚举 {@link PmsWorkItemTypeEnum} + */ + private Integer workItemType; + /** + * 状态名称 + */ + private String name; + /** + * 语义状态 + * + * 枚举 {@link PmsWorkItemStatusTypeEnum} + */ + private Integer statusType; + /** + * 状态描述 + */ + private String description; + /** + * 所属看板列名称,为空时不在看板展示 + */ + private String boardName; + /** + * 系统状态编码,仅默认状态使用,用于保证并发初始化幂等 + */ + private String systemCode; + /** + * 是否初始状态 + */ + private Boolean defaultStatus; + /** + * 显示顺序 + */ + private Integer sort; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/workitem/PmsWorkItemUserSortDO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/workitem/PmsWorkItemUserSortDO.java new file mode 100644 index 000000000..1c3ad44aa --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/workitem/PmsWorkItemUserSortDO.java @@ -0,0 +1,53 @@ +package cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectDO; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * PMS 工作项个人排序 DO + * + * @author 芋道源码 + */ +@TableName("pms_work_item_user_sort") +@KeySequence("pms_work_item_user_sort_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsWorkItemUserSortDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 项目编号 + * + * 关联 {@link PmsProjectDO#getId()} + */ + private Long projectId; + /** + * 工作项编号 + * + * 关联 {@link PmsWorkItemDO#getId()} + */ + private Long workItemId; + /** + * 用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long userId; + /** + * 显示顺序 + */ + private Integer sort; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/workitem/PmsWorkItemWorkLogDO.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/workitem/PmsWorkItemWorkLogDO.java new file mode 100644 index 000000000..6ae5e53e3 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/dataobject/pm/workitem/PmsWorkItemWorkLogDO.java @@ -0,0 +1,54 @@ +package cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * PMS 工作项工时记录 DO + * + * @author 芋道源码 + */ +@TableName("pms_work_item_work_log") +@KeySequence("pms_work_item_work_log_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class PmsWorkItemWorkLogDO extends BaseDO { + + /** + * 工时记录编号 + */ + @TableId + private Long id; + /** + * 项目编号 + * + * 关联 {@link PmsProjectDO#getId()} + */ + private Long projectId; + /** + * 工作项编号 + * + * 关联 {@link PmsWorkItemDO#getId()} + */ + private Long workItemId; + /** + * 实际投入工时,单位:小时 + */ + private Integer actualHours; + /** + * 本次登记后的剩余工时,单位:小时 + */ + private Integer remainingHours; + /** + * 工时说明 + */ + private String description; + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/content/PmsKnowledgeContentPermissionMapper.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/content/PmsKnowledgeContentPermissionMapper.java new file mode 100644 index 000000000..50b6565f4 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/content/PmsKnowledgeContentPermissionMapper.java @@ -0,0 +1,23 @@ +package cn.iocoder.yudao.module.pms.dal.mysql.kb.content; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeContentPermissionDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +@Mapper +public interface PmsKnowledgeContentPermissionMapper extends BaseMapperX { + + default List selectListByLibraryIds(Collection libraryIds) { + return selectList(new LambdaQueryWrapperX() + .in(PmsKnowledgeContentPermissionDO::getLibraryId, libraryIds)); + } + + default List selectListByLibraryId(Long libraryId) { + return selectList(PmsKnowledgeContentPermissionDO::getLibraryId, libraryId); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/content/PmsKnowledgeContentPermissionMemberMapper.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/content/PmsKnowledgeContentPermissionMemberMapper.java new file mode 100644 index 000000000..87216c364 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/content/PmsKnowledgeContentPermissionMemberMapper.java @@ -0,0 +1,50 @@ +package cn.iocoder.yudao.module.pms.dal.mysql.kb.content; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeContentPermissionMemberDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +@Mapper +public interface PmsKnowledgeContentPermissionMemberMapper extends BaseMapperX { + + default List selectListByPermissionId(Long permissionId) { + return selectList(PmsKnowledgeContentPermissionMemberDO::getPermissionId, permissionId); + } + + default List selectListByPermissionIds(Collection permissionIds) { + return selectList(new LambdaQueryWrapperX() + .in(PmsKnowledgeContentPermissionMemberDO::getPermissionId, permissionIds)); + } + + default PmsKnowledgeContentPermissionMemberDO selectByPermissionIdAndUserId(Long permissionId, Long userId) { + return selectOne(new LambdaQueryWrapperX() + .eq(PmsKnowledgeContentPermissionMemberDO::getPermissionId, permissionId) + .eq(PmsKnowledgeContentPermissionMemberDO::getUserId, userId)); + } + + default void deleteByPermissionId(Long permissionId) { + delete(PmsKnowledgeContentPermissionMemberDO::getPermissionId, permissionId); + } + + default void deleteByPermissionIds(Collection permissionIds) { + delete(new LambdaQueryWrapperX() + .in(PmsKnowledgeContentPermissionMemberDO::getPermissionId, permissionIds)); + } + + default void deleteByPermissionIdsAndUserIds(Collection permissionIds, Collection userIds) { + delete(new LambdaQueryWrapperX() + .in(PmsKnowledgeContentPermissionMemberDO::getPermissionId, permissionIds) + .in(PmsKnowledgeContentPermissionMemberDO::getUserId, userIds)); + } + + default void deleteByPermissionIdsAndDeptIds(Collection permissionIds, Collection deptIds) { + delete(new LambdaQueryWrapperX() + .in(PmsKnowledgeContentPermissionMemberDO::getPermissionId, permissionIds) + .in(PmsKnowledgeContentPermissionMemberDO::getDeptId, deptIds)); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/content/PmsKnowledgeDocumentLabelMapper.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/content/PmsKnowledgeDocumentLabelMapper.java new file mode 100644 index 000000000..2787568d8 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/content/PmsKnowledgeDocumentLabelMapper.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.pms.dal.mysql.kb.content; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeDocumentLabelDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +@Mapper +public interface PmsKnowledgeDocumentLabelMapper extends BaseMapperX { + + default List selectList() { + return selectList(new LambdaQueryWrapperX() + .orderByAsc(PmsKnowledgeDocumentLabelDO::getCreateTime) + .orderByAsc(PmsKnowledgeDocumentLabelDO::getId)); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/content/PmsKnowledgeDocumentMapper.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/content/PmsKnowledgeDocumentMapper.java new file mode 100644 index 000000000..dd7a1a9e6 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/content/PmsKnowledgeDocumentMapper.java @@ -0,0 +1,144 @@ +package cn.iocoder.yudao.module.pms.dal.mysql.kb.content; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.convert.Convert; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.QueryWrapperX; +import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.document.PmsKnowledgeDocumentSearchPageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.label.PmsKnowledgeDocumentLabelPageReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeDocumentDO; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; + +@Mapper +public interface PmsKnowledgeDocumentMapper extends BaseMapperX { + + default Set selectExistingPermissionIdSet(Collection permissionIds) { + return convertSet(selectList(new LambdaQueryWrapperX() + .in(PmsKnowledgeDocumentDO::getPermissionId, permissionIds) + .select(PmsKnowledgeDocumentDO::getPermissionId)), PmsKnowledgeDocumentDO::getPermissionId); + } + + /** + * 统计各知识库下不同文档类型的数量 + * + * @param libraryIds 知识库编号集合 + * @param status 文档状态 + * @return 知识库编号 ->(文档类型 -> 文档数量) + */ + default Map> selectTypeCountMapByLibraryIdsAndStatus( + Collection libraryIds, Integer status) { + if (CollUtil.isEmpty(libraryIds)) { + return Collections.emptyMap(); + } + List> rows = selectMaps(new QueryWrapperX() + .select("library_id AS libraryId", "type", "COUNT(*) AS count") + .in("library_id", libraryIds) + .eq("status", status) + .groupBy("library_id", "type")); + Map> result = new LinkedHashMap<>(); + for (Map row : rows) { + Long libraryId = ((Number) row.get("libraryId")).longValue(); + Integer type = ((Number) row.get("type")).intValue(); + result.computeIfAbsent(libraryId, key -> new LinkedHashMap<>()) + .put(type, ((Number) row.get("count")).longValue()); + } + return result; + } + + default List selectListByLibraryIdAndStatus(Long libraryId, Integer status) { + return selectList(new LambdaQueryWrapperX() + .eq(PmsKnowledgeDocumentDO::getLibraryId, libraryId) + .eq(PmsKnowledgeDocumentDO::getStatus, status) + .orderByAsc(PmsKnowledgeDocumentDO::getCreateTime) + .orderByAsc(PmsKnowledgeDocumentDO::getId)); + } + + default List selectListByLibraryId(Long libraryId) { + return selectList(new LambdaQueryWrapperX() + .eq(PmsKnowledgeDocumentDO::getLibraryId, libraryId) + .orderByAsc(PmsKnowledgeDocumentDO::getId)); + } + + default List selectListByParentIds(Collection parentIds) { + return selectList(new LambdaQueryWrapperX() + .in(PmsKnowledgeDocumentDO::getParentId, parentIds)); + } + + default List selectListByFolderIds(Collection folderIds) { + return selectList(new LambdaQueryWrapperX() + .in(PmsKnowledgeDocumentDO::getFolderId, folderIds)); + } + + default int updateToRestoreByIds(Collection ids, Integer status) { + return update(new LambdaUpdateWrapper() + .set(PmsKnowledgeDocumentDO::getStatus, status) + .set(PmsKnowledgeDocumentDO::getDeleteUserId, null) + .set(PmsKnowledgeDocumentDO::getDeleteTime, null) + .in(PmsKnowledgeDocumentDO::getId, ids)); + } + + /** + * 按标签和状态分页查询文档,并限制在用户可读的知识库和权限范围内 + * + * @param pageReqVO 标签分页查询条件 + * @param readableLibraryIds 用户可读的知识库编号集合 + * @param readablePermissionIds 用户可读的内容权限编号集合 + * @param statuses 文档状态集合 + * @return 文档分页结果 + */ + default PageResult selectPageByLabelIdAndStatuses( + PmsKnowledgeDocumentLabelPageReqVO pageReqVO, Collection readableLibraryIds, + Collection readablePermissionIds, Collection statuses) { + LambdaQueryWrapperX queryWrapper = new LambdaQueryWrapperX<>(); + queryWrapper.in(PmsKnowledgeDocumentDO::getLibraryId, readableLibraryIds) + .in(PmsKnowledgeDocumentDO::getPermissionId, readablePermissionIds) + .in(PmsKnowledgeDocumentDO::getStatus, statuses) + .orderByDesc(PmsKnowledgeDocumentDO::getUpdateTime) + .orderByDesc(PmsKnowledgeDocumentDO::getId); + queryWrapper.apply(MyBatisUtils.findInSet("label_ids"), pageReqVO.getLabelId()); + return selectPage(pageReqVO, queryWrapper); + } + + /** + * 分页查询文档,并限制在用户可读的知识库、权限和状态范围内 + * + * @param pageReqVO 文档分页查询条件 + * @param readableLibraryIds 用户可读的知识库编号集合 + * @param readablePermissionIds 用户可读的内容权限编号集合 + * @param statuses 文档状态集合 + * @return 文档分页结果 + */ + default PageResult selectPage(PmsKnowledgeDocumentSearchPageReqVO pageReqVO, + Collection readableLibraryIds, + Collection readablePermissionIds, + Collection statuses) { + return selectPage(pageReqVO, new LambdaQueryWrapperX() + .in(PmsKnowledgeDocumentDO::getLibraryId, readableLibraryIds) + .in(PmsKnowledgeDocumentDO::getPermissionId, readablePermissionIds) + .in(PmsKnowledgeDocumentDO::getStatus, statuses) + .eqIfPresent(PmsKnowledgeDocumentDO::getLibraryId, pageReqVO.getLibraryId()) + .eqIfPresent(PmsKnowledgeDocumentDO::getCreator, + Convert.toStr(pageReqVO.getCreatorUserId())) + .betweenIfPresent(PmsKnowledgeDocumentDO::getUpdateTime, pageReqVO.getUpdateTime()) + .and(StrUtil.isNotBlank(pageReqVO.getKeyword()), + wrapper -> wrapper.like(PmsKnowledgeDocumentDO::getTitle, pageReqVO.getKeyword()) + .or().like(PmsKnowledgeDocumentDO::getContent, pageReqVO.getKeyword())) + .orderByDesc(PmsKnowledgeDocumentDO::getUpdateTime) + .orderByDesc(PmsKnowledgeDocumentDO::getId)); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/content/PmsKnowledgeFolderMapper.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/content/PmsKnowledgeFolderMapper.java new file mode 100644 index 000000000..0ce2306c8 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/content/PmsKnowledgeFolderMapper.java @@ -0,0 +1,51 @@ +package cn.iocoder.yudao.module.pms.dal.mysql.kb.content; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeFolderDO; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; + +@Mapper +public interface PmsKnowledgeFolderMapper extends BaseMapperX { + + default Set selectExistingPermissionIdSet(Collection permissionIds) { + return convertSet(selectList(new LambdaQueryWrapperX() + .in(PmsKnowledgeFolderDO::getPermissionId, permissionIds) + .select(PmsKnowledgeFolderDO::getPermissionId)), PmsKnowledgeFolderDO::getPermissionId); + } + + default List selectListByLibraryIdAndStatus(Long libraryId, Integer status) { + return selectList(new LambdaQueryWrapperX() + .eq(PmsKnowledgeFolderDO::getLibraryId, libraryId) + .eq(PmsKnowledgeFolderDO::getStatus, status) + .orderByAsc(PmsKnowledgeFolderDO::getCreateTime) + .orderByAsc(PmsKnowledgeFolderDO::getId)); + } + + default List selectListByLibraryId(Long libraryId) { + return selectList(new LambdaQueryWrapperX() + .eq(PmsKnowledgeFolderDO::getLibraryId, libraryId) + .orderByAsc(PmsKnowledgeFolderDO::getId)); + } + + default List selectListByParentIds(Collection parentIds) { + return selectList(new LambdaQueryWrapperX() + .in(PmsKnowledgeFolderDO::getParentId, parentIds)); + } + + default int updateToRestoreByIds(Collection ids, Integer status) { + return update(new LambdaUpdateWrapper() + .set(PmsKnowledgeFolderDO::getStatus, status) + .set(PmsKnowledgeFolderDO::getDeleteUserId, null) + .set(PmsKnowledgeFolderDO::getDeleteTime, null) + .in(PmsKnowledgeFolderDO::getId, ids)); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/interaction/PmsKnowledgeDocumentCommentMapper.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/interaction/PmsKnowledgeDocumentCommentMapper.java new file mode 100644 index 000000000..afb88ca64 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/interaction/PmsKnowledgeDocumentCommentMapper.java @@ -0,0 +1,30 @@ +package cn.iocoder.yudao.module.pms.dal.mysql.kb.interaction; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.interaction.PmsKnowledgeDocumentCommentDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +@Mapper +public interface PmsKnowledgeDocumentCommentMapper extends BaseMapperX { + + default List selectListByDocumentId(Long documentId) { + return selectList(new LambdaQueryWrapperX() + .eq(PmsKnowledgeDocumentCommentDO::getDocumentId, documentId) + .orderByAsc(PmsKnowledgeDocumentCommentDO::getCreateTime) + .orderByAsc(PmsKnowledgeDocumentCommentDO::getId)); + } + + default void deleteByMainId(Long mainId) { + delete(PmsKnowledgeDocumentCommentDO::getMainId, mainId); + } + + default void deleteByDocumentIds(Collection documentIds) { + delete(new LambdaQueryWrapperX() + .in(PmsKnowledgeDocumentCommentDO::getDocumentId, documentIds)); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/interaction/PmsKnowledgeDocumentLikeMapper.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/interaction/PmsKnowledgeDocumentLikeMapper.java new file mode 100644 index 000000000..c44c71ded --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/interaction/PmsKnowledgeDocumentLikeMapper.java @@ -0,0 +1,32 @@ +package cn.iocoder.yudao.module.pms.dal.mysql.kb.interaction; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.interaction.PmsKnowledgeDocumentLikeDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +@Mapper +public interface PmsKnowledgeDocumentLikeMapper extends BaseMapperX { + + default PmsKnowledgeDocumentLikeDO selectByDocumentIdAndUserId(Long documentId, Long userId) { + return selectOne(new LambdaQueryWrapperX() + .eq(PmsKnowledgeDocumentLikeDO::getDocumentId, documentId) + .eq(PmsKnowledgeDocumentLikeDO::getUserId, userId)); + } + + default List selectListByDocumentId(Long documentId) { + return selectList(new LambdaQueryWrapperX() + .eq(PmsKnowledgeDocumentLikeDO::getDocumentId, documentId) + .orderByAsc(PmsKnowledgeDocumentLikeDO::getCreateTime) + .orderByAsc(PmsKnowledgeDocumentLikeDO::getId)); + } + + default void deleteByDocumentIds(Collection documentIds) { + delete(new LambdaQueryWrapperX() + .in(PmsKnowledgeDocumentLikeDO::getDocumentId, documentIds)); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/interaction/PmsKnowledgeDocumentShareMapper.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/interaction/PmsKnowledgeDocumentShareMapper.java new file mode 100644 index 000000000..f76bb23b7 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/interaction/PmsKnowledgeDocumentShareMapper.java @@ -0,0 +1,36 @@ +package cn.iocoder.yudao.module.pms.dal.mysql.kb.interaction; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.interaction.PmsKnowledgeDocumentShareDO; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; + +@Mapper +public interface PmsKnowledgeDocumentShareMapper extends BaseMapperX { + + default PmsKnowledgeDocumentShareDO selectByDocumentId(Long documentId) { + return selectOne(new LambdaQueryWrapperX() + .eq(PmsKnowledgeDocumentShareDO::getDocumentId, documentId)); + } + + default PmsKnowledgeDocumentShareDO selectByToken(String token) { + return selectOne(new LambdaQueryWrapperX() + .eq(PmsKnowledgeDocumentShareDO::getToken, token)); + } + + default int updateToReopen(PmsKnowledgeDocumentShareDO updateObj) { + return update(updateObj, new LambdaUpdateWrapper() + .set(PmsKnowledgeDocumentShareDO::getCloseUserId, null) + .set(PmsKnowledgeDocumentShareDO::getCloseTime, null) + .eq(PmsKnowledgeDocumentShareDO::getId, updateObj.getId())); + } + + default void deleteByDocumentIds(Collection documentIds) { + delete(new LambdaQueryWrapperX() + .in(PmsKnowledgeDocumentShareDO::getDocumentId, documentIds)); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/interaction/PmsKnowledgeFavoriteMapper.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/interaction/PmsKnowledgeFavoriteMapper.java new file mode 100644 index 000000000..b392a2f90 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/interaction/PmsKnowledgeFavoriteMapper.java @@ -0,0 +1,74 @@ +package cn.iocoder.yudao.module.pms.dal.mysql.kb.interaction; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.favorite.PmsKnowledgeFavoritePageReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.interaction.PmsKnowledgeFavoriteDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +@Mapper +public interface PmsKnowledgeFavoriteMapper extends BaseMapperX { + + default PageResult selectPage(PmsKnowledgeFavoritePageReqVO pageReqVO, + Long userId, Collection readableFavoriteIds) { + return selectPage(pageReqVO, new LambdaQueryWrapperX() + .eq(PmsKnowledgeFavoriteDO::getUserId, userId) + .in(PmsKnowledgeFavoriteDO::getId, readableFavoriteIds) + .eqIfPresent(PmsKnowledgeFavoriteDO::getType, pageReqVO.getType()) + .orderByDesc(PmsKnowledgeFavoriteDO::getCreateTime) + .orderByDesc(PmsKnowledgeFavoriteDO::getId)); + } + + default PmsKnowledgeFavoriteDO selectByUserIdAndTypeAndEntityId(Long userId, Integer type, Long entityId) { + return selectOne(new LambdaQueryWrapperX() + .eq(PmsKnowledgeFavoriteDO::getUserId, userId) + .eq(PmsKnowledgeFavoriteDO::getType, type) + .eq(PmsKnowledgeFavoriteDO::getEntityId, entityId)); + } + + default List selectListByUserId(Long userId) { + return selectList(new LambdaQueryWrapperX() + .eq(PmsKnowledgeFavoriteDO::getUserId, userId) + .orderByDesc(PmsKnowledgeFavoriteDO::getCreateTime) + .orderByDesc(PmsKnowledgeFavoriteDO::getId)); + } + + default List selectListByUserIdAndLibraryId(Long userId, Long libraryId) { + return selectList(new LambdaQueryWrapperX() + .eq(PmsKnowledgeFavoriteDO::getUserId, userId) + .eq(PmsKnowledgeFavoriteDO::getLibraryId, libraryId) + .orderByDesc(PmsKnowledgeFavoriteDO::getCreateTime) + .orderByDesc(PmsKnowledgeFavoriteDO::getId)); + } + + default List selectListByUserIdAndTypeAndEntityIds( + Long userId, Integer type, Collection entityIds) { + return selectList(new LambdaQueryWrapperX() + .eq(PmsKnowledgeFavoriteDO::getUserId, userId) + .eq(PmsKnowledgeFavoriteDO::getType, type) + .in(PmsKnowledgeFavoriteDO::getEntityId, entityIds)); + } + + default void deleteByLibraryId(Long libraryId) { + delete(PmsKnowledgeFavoriteDO::getLibraryId, libraryId); + } + + default void deleteByTypeAndEntityIds(Collection types, Collection entityIds) { + delete(new LambdaQueryWrapperX() + .in(PmsKnowledgeFavoriteDO::getType, types) + .in(PmsKnowledgeFavoriteDO::getEntityId, entityIds)); + } + + default void updateLibraryIdByTypeAndEntityIds(Long libraryId, + Collection types, Collection entityIds) { + update(new PmsKnowledgeFavoriteDO().setLibraryId(libraryId), + new LambdaQueryWrapperX() + .in(PmsKnowledgeFavoriteDO::getType, types) + .in(PmsKnowledgeFavoriteDO::getEntityId, entityIds)); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/interaction/PmsKnowledgeViewRecordMapper.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/interaction/PmsKnowledgeViewRecordMapper.java new file mode 100644 index 000000000..32bafde15 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/interaction/PmsKnowledgeViewRecordMapper.java @@ -0,0 +1,53 @@ +package cn.iocoder.yudao.module.pms.dal.mysql.kb.interaction; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.interaction.PmsKnowledgeViewRecordDO; +import org.apache.ibatis.annotations.Mapper; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +@Mapper +public interface PmsKnowledgeViewRecordMapper extends BaseMapperX { + + default List selectListByUserIdAndCreateTimeAfter( + Long userId, LocalDateTime beginTime) { + return selectList(new LambdaQueryWrapperX() + .eq(PmsKnowledgeViewRecordDO::getUserId, userId) + .ge(PmsKnowledgeViewRecordDO::getCreateTime, beginTime) + .orderByDesc(PmsKnowledgeViewRecordDO::getCreateTime) + .orderByDesc(PmsKnowledgeViewRecordDO::getId)); + } + + default int updateLibraryIdAndCreateTimeByUserIdAndTypeAndEntityId( + Long libraryId, LocalDateTime createTime, Long userId, Integer type, Long entityId) { + PmsKnowledgeViewRecordDO updateObj = new PmsKnowledgeViewRecordDO().setLibraryId(libraryId); + updateObj.setCreateTime(createTime); + return update(updateObj, + new LambdaQueryWrapperX() + .eq(PmsKnowledgeViewRecordDO::getUserId, userId) + .eq(PmsKnowledgeViewRecordDO::getType, type) + .eq(PmsKnowledgeViewRecordDO::getEntityId, entityId)); + } + + default void deleteByLibraryId(Long libraryId) { + delete(PmsKnowledgeViewRecordDO::getLibraryId, libraryId); + } + + default void deleteByTypeAndEntityIds(Collection types, Collection entityIds) { + delete(new LambdaQueryWrapperX() + .in(PmsKnowledgeViewRecordDO::getType, types) + .in(PmsKnowledgeViewRecordDO::getEntityId, entityIds)); + } + + default void updateLibraryIdByTypeAndEntityIds(Long libraryId, Collection types, + Collection entityIds) { + update(new PmsKnowledgeViewRecordDO().setLibraryId(libraryId), + new LambdaQueryWrapperX() + .in(PmsKnowledgeViewRecordDO::getType, types) + .in(PmsKnowledgeViewRecordDO::getEntityId, entityIds)); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/library/PmsKnowledgeGroupMapper.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/library/PmsKnowledgeGroupMapper.java new file mode 100644 index 000000000..33d5b2972 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/library/PmsKnowledgeGroupMapper.java @@ -0,0 +1,47 @@ +package cn.iocoder.yudao.module.pms.dal.mysql.kb.library; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeGroupDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +@Mapper +public interface PmsKnowledgeGroupMapper extends BaseMapperX { + + default PmsKnowledgeGroupDO selectByIdAndUserId(Long id, Long userId) { + return selectOne(new LambdaQueryWrapperX() + .eq(PmsKnowledgeGroupDO::getId, id) + .eq(PmsKnowledgeGroupDO::getUserId, userId)); + } + + default PmsKnowledgeGroupDO selectByUserIdAndName(Long userId, String name, Long excludeId) { + return selectOne(new LambdaQueryWrapperX() + .eq(PmsKnowledgeGroupDO::getUserId, userId) + .eq(PmsKnowledgeGroupDO::getName, name) + .neIfPresent(PmsKnowledgeGroupDO::getId, excludeId)); + } + + default List selectListByUserId(Long userId) { + return selectList(new LambdaQueryWrapperX() + .eq(PmsKnowledgeGroupDO::getUserId, userId) + .orderByAsc(PmsKnowledgeGroupDO::getSort) + .orderByAsc(PmsKnowledgeGroupDO::getId)); + } + + default List selectListByIdsAndUserId(Collection ids, Long userId) { + return selectList(new LambdaQueryWrapperX() + .in(PmsKnowledgeGroupDO::getId, ids) + .eq(PmsKnowledgeGroupDO::getUserId, userId)); + } + + default PmsKnowledgeGroupDO selectLastByUserId(Long userId) { + return selectLastOne(new LambdaQueryWrapperX() + .eq(PmsKnowledgeGroupDO::getUserId, userId) + .orderByAsc(PmsKnowledgeGroupDO::getSort) + .orderByAsc(PmsKnowledgeGroupDO::getId)); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/library/PmsKnowledgeGroupRelationMapper.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/library/PmsKnowledgeGroupRelationMapper.java new file mode 100644 index 000000000..ea30845b9 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/library/PmsKnowledgeGroupRelationMapper.java @@ -0,0 +1,89 @@ +package cn.iocoder.yudao.module.pms.dal.mysql.kb.library; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.QueryWrapperX; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeGroupRelationDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Mapper +public interface PmsKnowledgeGroupRelationMapper extends BaseMapperX { + + default PmsKnowledgeGroupRelationDO selectByUserIdAndLibraryId(Long userId, Long libraryId) { + return selectOne(new LambdaQueryWrapperX() + .eq(PmsKnowledgeGroupRelationDO::getUserId, userId) + .eq(PmsKnowledgeGroupRelationDO::getLibraryId, libraryId)); + } + + default List selectListByUserIdAndLibraryIds( + Long userId, Collection libraryIds) { + return selectList(new LambdaQueryWrapperX() + .eq(PmsKnowledgeGroupRelationDO::getUserId, userId) + .in(PmsKnowledgeGroupRelationDO::getLibraryId, libraryIds)); + } + + default List selectListByUserIdAndGroupId(Long userId, Long groupId) { + return selectList(new LambdaQueryWrapperX() + .eq(PmsKnowledgeGroupRelationDO::getUserId, userId) + .eq(PmsKnowledgeGroupRelationDO::getGroupId, groupId)); + } + + /** + * 统计用户在指定知识库集合中各分组的知识库数量,使用 DISTINCT 避免重复关系影响统计 + * + * @param userId 用户编号 + * @param libraryIds 知识库编号集合 + * @return 分组编号 -> 知识库数量 + */ + default Map selectGroupLibraryCountMapByUserIdAndLibraryIds(Long userId, + Collection libraryIds) { + List> rows = selectMaps(new QueryWrapperX() + .select("group_id AS groupId", "COUNT(DISTINCT library_id) AS count") + .eq("user_id", userId) + .in("library_id", libraryIds) + .groupBy("group_id")); + Map groupCountMap = new LinkedHashMap<>(); + for (Map row : rows) { + groupCountMap.put(((Number) row.get("groupId")).longValue(), ((Number) row.get("count")).intValue()); + } + return groupCountMap; + } + + /** + * 统计用户在指定知识库集合中已分组的知识库数量 + * + * @param userId 用户编号 + * @param libraryIds 知识库编号集合 + * @return 已分组知识库数量 + */ + default int selectGroupedLibraryCountByUserIdAndLibraryIds(Long userId, Collection libraryIds) { + Map row = CollUtil.getFirst(selectMaps(new QueryWrapperX() + .select("COUNT(DISTINCT library_id) AS count") + .eq("user_id", userId) + .in("library_id", libraryIds))); + return row == null ? 0 : ((Number) row.get("count")).intValue(); + } + + default void deleteByUserIdAndGroupId(Long userId, Long groupId) { + delete(new LambdaQueryWrapperX() + .eq(PmsKnowledgeGroupRelationDO::getUserId, userId) + .eq(PmsKnowledgeGroupRelationDO::getGroupId, groupId)); + } + + default void deleteByUserIdAndLibraryId(Long userId, Long libraryId) { + delete(new LambdaQueryWrapperX() + .eq(PmsKnowledgeGroupRelationDO::getUserId, userId) + .eq(PmsKnowledgeGroupRelationDO::getLibraryId, libraryId)); + } + + default void deleteByLibraryId(Long libraryId) { + delete(PmsKnowledgeGroupRelationDO::getLibraryId, libraryId); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/library/PmsKnowledgeLibraryMapper.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/library/PmsKnowledgeLibraryMapper.java new file mode 100644 index 000000000..7c7dfee20 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/library/PmsKnowledgeLibraryMapper.java @@ -0,0 +1,74 @@ +package cn.iocoder.yudao.module.pms.dal.mysql.kb.library; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.library.PmsKnowledgeLibraryPageReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryDO; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; + +@Mapper +public interface PmsKnowledgeLibraryMapper extends BaseMapperX { + + default List selectIdList(Boolean openStatus, Integer status) { + return convertList(selectList(new LambdaQueryWrapperX() + .eq(PmsKnowledgeLibraryDO::getStatus, status) + .eqIfPresent(PmsKnowledgeLibraryDO::getOpenStatus, openStatus) + .select(PmsKnowledgeLibraryDO::getId)), PmsKnowledgeLibraryDO::getId); + } + + /** + * 分页查询知识库,并按公开状态和成员关系过滤可见范围 + * + * @param pageReqVO 分页查询条件 + * @param memberLibraryIds 当前用户作为成员可访问的知识库编号集合 + * @param includeAll 是否包含全部知识库 + * @param filterLibraryIds 指定过滤的知识库编号集合 + * @param status 知识库状态 + * @return 知识库分页结果 + */ + default PageResult selectPage(PmsKnowledgeLibraryPageReqVO pageReqVO, + Collection memberLibraryIds, + boolean includeAll, + Collection filterLibraryIds, + Integer status) { + LambdaQueryWrapperX query = new LambdaQueryWrapperX() + .eq(PmsKnowledgeLibraryDO::getStatus, status) + .likeIfPresent(PmsKnowledgeLibraryDO::getName, pageReqVO.getName()); + if (filterLibraryIds != null) { + query.in(PmsKnowledgeLibraryDO::getId, filterLibraryIds); + } else if (!includeAll) { + query.and(wrapper -> { + wrapper.eq(PmsKnowledgeLibraryDO::getOpenStatus, true); + if (CollUtil.isNotEmpty(memberLibraryIds)) { + wrapper.or().in(PmsKnowledgeLibraryDO::getId, memberLibraryIds); + } + }); + } + query.orderByDesc(PmsKnowledgeLibraryDO::getCreateTime).orderByDesc(PmsKnowledgeLibraryDO::getId); + return selectPage(pageReqVO, query); + } + + default int updateForEdit(PmsKnowledgeLibraryDO updateObj) { + return update(updateObj, new LambdaUpdateWrapper() + .set(updateObj.getDescription() == null, PmsKnowledgeLibraryDO::getDescription, null) + .set(updateObj.getCoverUrl() == null, PmsKnowledgeLibraryDO::getCoverUrl, null) + .eq(PmsKnowledgeLibraryDO::getId, updateObj.getId())); + } + + default int updateToRestoreById(Long id, Integer status) { + return update(new LambdaUpdateWrapper() + .set(PmsKnowledgeLibraryDO::getStatus, status) + .set(PmsKnowledgeLibraryDO::getDeleteUserId, null) + .set(PmsKnowledgeLibraryDO::getDeleteTime, null) + .eq(PmsKnowledgeLibraryDO::getId, id)); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/library/PmsKnowledgeLibraryMemberMapper.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/library/PmsKnowledgeLibraryMemberMapper.java new file mode 100644 index 000000000..b976be0d9 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/library/PmsKnowledgeLibraryMemberMapper.java @@ -0,0 +1,71 @@ +package cn.iocoder.yudao.module.pms.dal.mysql.kb.library; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryMemberDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; + +@Mapper +public interface PmsKnowledgeLibraryMemberMapper extends BaseMapperX { + + default PmsKnowledgeLibraryMemberDO selectByLibraryIdAndUserId(Long libraryId, Long userId) { + return selectOne(PmsKnowledgeLibraryMemberDO::getLibraryId, libraryId, + PmsKnowledgeLibraryMemberDO::getUserId, userId); + } + + default PmsKnowledgeLibraryMemberDO selectByLibraryIdAndDeptId(Long libraryId, Long deptId) { + return selectOne(PmsKnowledgeLibraryMemberDO::getLibraryId, libraryId, + PmsKnowledgeLibraryMemberDO::getDeptId, deptId); + } + + default List selectListByDeptIds(Collection deptIds) { + return selectList(new LambdaQueryWrapperX() + .in(PmsKnowledgeLibraryMemberDO::getDeptId, deptIds)); + } + + default List selectListByLibraryId(Long libraryId) { + return selectList(new LambdaQueryWrapperX() + .eq(PmsKnowledgeLibraryMemberDO::getLibraryId, libraryId) + .orderByAsc(PmsKnowledgeLibraryMemberDO::getLevel) + .orderByAsc(PmsKnowledgeLibraryMemberDO::getId)); + } + + default List selectListByLibraryIds(Collection libraryIds) { + return selectList(new LambdaQueryWrapperX() + .in(PmsKnowledgeLibraryMemberDO::getLibraryId, libraryIds) + .orderByAsc(PmsKnowledgeLibraryMemberDO::getLibraryId) + .orderByAsc(PmsKnowledgeLibraryMemberDO::getLevel) + .orderByAsc(PmsKnowledgeLibraryMemberDO::getId)); + } + + default List selectLibraryIdListByUserIdOrDeptId(Long userId, Long deptId) { + LambdaQueryWrapperX query = new LambdaQueryWrapperX<>(); + query.eq(PmsKnowledgeLibraryMemberDO::getUserId, userId); + if (deptId != null) { + query.or().eq(PmsKnowledgeLibraryMemberDO::getDeptId, deptId); + } + return convertList(selectList(query), PmsKnowledgeLibraryMemberDO::getLibraryId); + } + + default void deleteByLibraryIdAndLevels(Long libraryId, Collection levels) { + delete(new LambdaQueryWrapperX() + .eq(PmsKnowledgeLibraryMemberDO::getLibraryId, libraryId) + .in(PmsKnowledgeLibraryMemberDO::getLevel, levels)); + } + + default void deleteByLibraryId(Long libraryId) { + delete(PmsKnowledgeLibraryMemberDO::getLibraryId, libraryId); + } + + default void deleteByLibraryIdAndUserId(Long libraryId, Long userId) { + delete(new LambdaQueryWrapperX() + .eq(PmsKnowledgeLibraryMemberDO::getLibraryId, libraryId) + .eq(PmsKnowledgeLibraryMemberDO::getUserId, userId)); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/library/PmsKnowledgeLibraryTemplateMapper.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/library/PmsKnowledgeLibraryTemplateMapper.java new file mode 100644 index 000000000..e33978c9f --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/library/PmsKnowledgeLibraryTemplateMapper.java @@ -0,0 +1,34 @@ +package cn.iocoder.yudao.module.pms.dal.mysql.kb.library; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.template.PmsKnowledgeLibraryTemplatePageReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryTemplateDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +@Mapper +public interface PmsKnowledgeLibraryTemplateMapper extends BaseMapperX { + + default PageResult selectPage(PmsKnowledgeLibraryTemplatePageReqVO pageReqVO) { + return selectPage(pageReqVO, new LambdaQueryWrapperX() + .likeIfPresent(PmsKnowledgeLibraryTemplateDO::getName, pageReqVO.getName()) + .eq(pageReqVO.getStatus() != null, PmsKnowledgeLibraryTemplateDO::getStatus, pageReqVO.getStatus()) + .orderByAsc(PmsKnowledgeLibraryTemplateDO::getSort) + .orderByDesc(PmsKnowledgeLibraryTemplateDO::getId)); + } + + default List selectListByStatus(Integer status) { + return selectList(new LambdaQueryWrapperX() + .eqIfPresent(PmsKnowledgeLibraryTemplateDO::getStatus, status) + .orderByAsc(PmsKnowledgeLibraryTemplateDO::getSort) + .orderByDesc(PmsKnowledgeLibraryTemplateDO::getId)); + } + + default PmsKnowledgeLibraryTemplateDO selectByName(String name) { + return selectOne(PmsKnowledgeLibraryTemplateDO::getName, name); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/recycle/PmsKnowledgeRecycleRecordMapper.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/recycle/PmsKnowledgeRecycleRecordMapper.java new file mode 100644 index 000000000..5315a6b37 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/kb/recycle/PmsKnowledgeRecycleRecordMapper.java @@ -0,0 +1,52 @@ +package cn.iocoder.yudao.module.pms.dal.mysql.kb.recycle; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.recycle.PmsKnowledgeRecycleRecordDO; +import org.apache.ibatis.annotations.Mapper; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +@Mapper +public interface PmsKnowledgeRecycleRecordMapper extends BaseMapperX { + + default PmsKnowledgeRecycleRecordDO selectByTypeAndEntityId(Integer type, Long entityId) { + return selectOne(PmsKnowledgeRecycleRecordDO::getType, type, + PmsKnowledgeRecycleRecordDO::getEntityId, entityId); + } + + default List selectListByLibraryId(Long libraryId) { + return selectList(new LambdaQueryWrapperX() + .eq(PmsKnowledgeRecycleRecordDO::getLibraryId, libraryId) + .orderByDesc(PmsKnowledgeRecycleRecordDO::getDeleteTime) + .orderByDesc(PmsKnowledgeRecycleRecordDO::getId)); + } + + default List selectListByTypeAndDeleteUserId(Integer type, Long userId) { + return selectList(new LambdaQueryWrapperX() + .eq(PmsKnowledgeRecycleRecordDO::getType, type) + .eq(PmsKnowledgeRecycleRecordDO::getDeleteUserId, userId) + .orderByDesc(PmsKnowledgeRecycleRecordDO::getDeleteTime) + .orderByDesc(PmsKnowledgeRecycleRecordDO::getId)); + } + + default List selectListByDeleteTimeBefore(LocalDateTime deleteTime) { + return selectList(new LambdaQueryWrapperX() + .le(PmsKnowledgeRecycleRecordDO::getDeleteTime, deleteTime) + .orderByAsc(PmsKnowledgeRecycleRecordDO::getDeleteTime) + .orderByAsc(PmsKnowledgeRecycleRecordDO::getId)); + } + + default void deleteByLibraryId(Long libraryId) { + delete(PmsKnowledgeRecycleRecordDO::getLibraryId, libraryId); + } + + default void deleteByTypeAndEntityIds(Collection types, Collection entityIds) { + delete(new LambdaQueryWrapperX() + .in(PmsKnowledgeRecycleRecordDO::getType, types) + .in(PmsKnowledgeRecycleRecordDO::getEntityId, entityIds)); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/package-info.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/package-info.java new file mode 100644 index 000000000..ce46c8e6f --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/package-info.java @@ -0,0 +1,6 @@ +/** + * PMS 模块的数据访问层,提供 MySQL 数据访问实现 + * + * 其中,MySQL 的表以 pms_ 作为前缀 + */ +package cn.iocoder.yudao.module.pms.dal.mysql; diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/iteration/PmsIterationMapper.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/iteration/PmsIterationMapper.java new file mode 100644 index 000000000..8625f0ed0 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/iteration/PmsIterationMapper.java @@ -0,0 +1,122 @@ +package cn.iocoder.yudao.module.pms.dal.mysql.pm.iteration; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.QueryWrapperX; +import cn.iocoder.yudao.module.pms.controller.admin.pm.iteration.vo.PmsIterationPageReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.iteration.PmsIterationDO; +import org.apache.ibatis.annotations.Mapper; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Mapper +public interface PmsIterationMapper extends BaseMapperX { + + default PageResult selectPage(PmsIterationPageReqVO pageReqVO) { + return selectPage(pageReqVO, new LambdaQueryWrapperX() + .eq(PmsIterationDO::getProjectId, pageReqVO.getProjectId()) + .likeIfPresent(PmsIterationDO::getName, pageReqVO.getName()) + .eqIfPresent(PmsIterationDO::getStatus, pageReqVO.getStatus()) + .orderByAsc(PmsIterationDO::getSort) + .orderByDesc(PmsIterationDO::getId)); + } + + default Integer selectMaxSortByProjectId(Long projectId) { + PmsIterationDO iteration = selectLastOne(new LambdaQueryWrapperX() + .select(PmsIterationDO::getSort) + .eq(PmsIterationDO::getProjectId, projectId) + .orderByAsc(PmsIterationDO::getSort) + .orderByAsc(PmsIterationDO::getId)); + return iteration != null ? iteration.getSort() : null; + } + + default List selectListByProjectId(Long projectId) { + return selectList(PmsIterationDO::getProjectId, projectId); + } + + /** + * 分页查询当前用户负责的迭代,用于工作台展示 + * + * @param pageParam 分页参数 + * @param projectIds 当前用户可访问的项目编号集合 + * @param projectId 项目编号,可选 + * @param name 迭代名称,可选 + * @param status 迭代状态,可选 + * @param endTime 结束时间范围,可选 + * @param completedStatus 已完成状态 + * @param ownerUserId 迭代负责人编号 + * @return 迭代分页结果 + */ + default PageResult selectWorkbenchPage(PageParam pageParam, Collection projectIds, + Long projectId, String name, + Integer status, LocalDateTime[] endTime, + Integer completedStatus, Long ownerUserId) { + return selectPage(pageParam, new LambdaQueryWrapperX() + .in(PmsIterationDO::getProjectId, projectIds) + .eqIfPresent(PmsIterationDO::getProjectId, projectId) + .eq(PmsIterationDO::getOwnerUserId, ownerUserId) + .likeIfPresent(PmsIterationDO::getName, name) + .eqIfPresent(PmsIterationDO::getStatus, status) + .betweenIfPresent(PmsIterationDO::getEndTime, endTime) + .ne(status == null, PmsIterationDO::getStatus, completedStatus) + .orderByAsc(PmsIterationDO::getEndTime) + .orderByDesc(PmsIterationDO::getId)); + } + + /** + * 统计当前用户负责的迭代数量,用于工作台统计 + * + * @param projectIds 当前用户可访问的项目编号集合 + * @param projectId 项目编号,可选 + * @param name 迭代名称,可选 + * @param status 迭代状态,可选 + * @param endTime 结束时间范围,可选 + * @param completedStatus 已完成状态 + * @param ownerUserId 迭代负责人编号 + * @return 迭代数量 + */ + default Long selectWorkbenchCount(Collection projectIds, Long projectId, String name, + Integer status, LocalDateTime[] endTime, + Integer completedStatus, Long ownerUserId) { + return selectCount(new LambdaQueryWrapperX() + .in(PmsIterationDO::getProjectId, projectIds) + .eqIfPresent(PmsIterationDO::getProjectId, projectId) + .eq(PmsIterationDO::getOwnerUserId, ownerUserId) + .likeIfPresent(PmsIterationDO::getName, name) + .eqIfPresent(PmsIterationDO::getStatus, status) + .betweenIfPresent(PmsIterationDO::getEndTime, endTime) + .ne(status == null, PmsIterationDO::getStatus, completedStatus)); + } + + /** + * 统计各项目下不同状态的迭代数量 + * + * @param projectIds 项目编号集合 + * @return 项目编号 ->(状态 -> 迭代数量) + */ + default Map> selectStatusCountMapByProjectIds(Collection projectIds) { + List> rows = selectMaps(new QueryWrapperX() + .select("project_id AS projectId", "status", "COUNT(*) AS count") + .in("project_id", projectIds) + .groupBy("project_id", "status")); + Map> projectStatusCountMap = new LinkedHashMap<>(); + for (Map row : rows) { + Long projectId = ((Number) row.get("projectId")).longValue(); + Integer status = ((Number) row.get("status")).intValue(); + projectStatusCountMap.computeIfAbsent(projectId, key -> new LinkedHashMap<>()) + .put(status, ((Number) row.get("count")).longValue()); + } + return projectStatusCountMap; + } + + default void deleteByProjectId(Long projectId) { + delete(PmsIterationDO::getProjectId, projectId); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/project/PmsProjectAnnouncementMapper.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/project/PmsProjectAnnouncementMapper.java new file mode 100644 index 000000000..ae627fff6 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/project/PmsProjectAnnouncementMapper.java @@ -0,0 +1,25 @@ +package cn.iocoder.yudao.module.pms.dal.mysql.pm.project; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectAnnouncementDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +@Mapper +public interface PmsProjectAnnouncementMapper extends BaseMapperX { + + default List selectListByProjectId(Long projectId) { + return selectList(new LambdaQueryWrapperX() + .eq(PmsProjectAnnouncementDO::getProjectId, projectId) + .orderByDesc(PmsProjectAnnouncementDO::getCreateTime) + .orderByDesc(PmsProjectAnnouncementDO::getId)); + } + + default void deleteByProjectId(Long projectId) { + delete(new LambdaQueryWrapperX() + .eq(PmsProjectAnnouncementDO::getProjectId, projectId)); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/project/PmsProjectFavoriteMapper.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/project/PmsProjectFavoriteMapper.java new file mode 100644 index 000000000..c86232a0c --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/project/PmsProjectFavoriteMapper.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.pms.dal.mysql.pm.project; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectFavoriteDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; + +@Mapper +public interface PmsProjectFavoriteMapper extends BaseMapperX { + + default PmsProjectFavoriteDO selectByProjectIdAndUserId(Long projectId, Long userId) { + return selectFirstOne(PmsProjectFavoriteDO::getProjectId, projectId, + PmsProjectFavoriteDO::getUserId, userId); + } + + default List selectProjectIdListByUserId(Long userId) { + return convertList(selectList(new LambdaQueryWrapperX() + .eq(PmsProjectFavoriteDO::getUserId, userId) + .orderByDesc(PmsProjectFavoriteDO::getCreateTime)), PmsProjectFavoriteDO::getProjectId); + } + + default void deleteByProjectIds(Collection projectIds) { + delete(new LambdaQueryWrapperX() + .in(PmsProjectFavoriteDO::getProjectId, projectIds)); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/project/PmsProjectGroupMapper.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/project/PmsProjectGroupMapper.java new file mode 100644 index 000000000..6caf0e01d --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/project/PmsProjectGroupMapper.java @@ -0,0 +1,53 @@ +package cn.iocoder.yudao.module.pms.dal.mysql.pm.project; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectGroupDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +@Mapper +public interface PmsProjectGroupMapper extends BaseMapperX { + + default PmsProjectGroupDO selectByIdAndUserId(Long id, Long userId) { + return selectOne(new LambdaQueryWrapperX() + .eq(PmsProjectGroupDO::getId, id) + .eq(PmsProjectGroupDO::getUserId, userId)); + } + + default PmsProjectGroupDO selectByUserIdAndType(Long userId, Integer type) { + return selectOne(new LambdaQueryWrapperX() + .eq(PmsProjectGroupDO::getUserId, userId) + .eq(PmsProjectGroupDO::getType, type)); + } + + default PmsProjectGroupDO selectByUserIdAndName(Long userId, String name, Long excludeId) { + return selectOne(new LambdaQueryWrapperX() + .eq(PmsProjectGroupDO::getUserId, userId) + .eq(PmsProjectGroupDO::getName, name) + .neIfPresent(PmsProjectGroupDO::getId, excludeId)); + } + + default List selectListByUserId(Long userId) { + return selectList(new LambdaQueryWrapperX() + .eq(PmsProjectGroupDO::getUserId, userId) + .orderByAsc(PmsProjectGroupDO::getSort) + .orderByAsc(PmsProjectGroupDO::getId)); + } + + default List selectListByIdsAndUserId(Collection ids, Long userId) { + return selectList(new LambdaQueryWrapperX() + .in(PmsProjectGroupDO::getId, ids) + .eq(PmsProjectGroupDO::getUserId, userId)); + } + + default PmsProjectGroupDO selectLastByUserId(Long userId) { + return selectLastOne(new LambdaQueryWrapperX() + .eq(PmsProjectGroupDO::getUserId, userId) + .orderByAsc(PmsProjectGroupDO::getSort) + .orderByAsc(PmsProjectGroupDO::getId)); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/project/PmsProjectGroupRelationMapper.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/project/PmsProjectGroupRelationMapper.java new file mode 100644 index 000000000..7e52dc877 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/project/PmsProjectGroupRelationMapper.java @@ -0,0 +1,88 @@ +package cn.iocoder.yudao.module.pms.dal.mysql.pm.project; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.QueryWrapperX; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectGroupRelationDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Mapper +public interface PmsProjectGroupRelationMapper extends BaseMapperX { + + default PmsProjectGroupRelationDO selectByUserIdAndProjectId(Long userId, Long projectId) { + return selectFirstOne(PmsProjectGroupRelationDO::getUserId, userId, + PmsProjectGroupRelationDO::getProjectId, projectId); + } + + default List selectListByUserIdAndProjectIds( + Long userId, Collection projectIds) { + return selectList(new LambdaQueryWrapperX() + .eq(PmsProjectGroupRelationDO::getUserId, userId) + .in(PmsProjectGroupRelationDO::getProjectId, projectIds)); + } + + default List selectListByUserIdAndGroupId(Long userId, Long groupId) { + return selectList(new LambdaQueryWrapperX() + .eq(PmsProjectGroupRelationDO::getUserId, userId) + .eq(PmsProjectGroupRelationDO::getGroupId, groupId)); + } + + /** + * 统计用户在指定项目集合中各分组的项目数量,使用 DISTINCT 避免重复关系影响统计 + * + * @param userId 用户编号 + * @param projectIds 项目编号集合 + * @return 分组编号 -> 项目数量 + */ + default Map selectGroupCountMapByUserIdAndProjectIds(Long userId, Collection projectIds) { + List> rows = selectMaps(new QueryWrapperX() + .select("group_id AS groupId", "COUNT(DISTINCT project_id) AS count") + .eq("user_id", userId) + .in("project_id", projectIds) + .groupBy("group_id")); + Map groupCountMap = new LinkedHashMap<>(); + for (Map row : rows) { + groupCountMap.put(((Number) row.get("groupId")).longValue(), + ((Number) row.get("count")).intValue()); + } + return groupCountMap; + } + + /** + * 统计用户在指定项目集合中已分组的项目数量 + * + * @param userId 用户编号 + * @param projectIds 项目编号集合 + * @return 已分组项目数量 + */ + default int selectProjectCountByUserIdAndProjectIds(Long userId, Collection projectIds) { + Map row = CollUtil.getFirst(selectMaps(new QueryWrapperX() + .select("COUNT(DISTINCT project_id) AS count") + .eq("user_id", userId) + .in("project_id", projectIds))); + return row == null ? 0 : ((Number) row.get("count")).intValue(); + } + + default void deleteByUserIdAndGroupId(Long userId, Long groupId) { + delete(new LambdaQueryWrapperX() + .eq(PmsProjectGroupRelationDO::getUserId, userId) + .eq(PmsProjectGroupRelationDO::getGroupId, groupId)); + } + + default void deleteByUserIdAndProjectId(Long userId, Long projectId) { + delete(new LambdaQueryWrapperX() + .eq(PmsProjectGroupRelationDO::getUserId, userId) + .eq(PmsProjectGroupRelationDO::getProjectId, projectId)); + } + + default void deleteByProjectId(Long projectId) { + delete(PmsProjectGroupRelationDO::getProjectId, projectId); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/project/PmsProjectMapper.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/project/PmsProjectMapper.java new file mode 100644 index 000000000..5b4a5e15b --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/project/PmsProjectMapper.java @@ -0,0 +1,90 @@ +package cn.iocoder.yudao.module.pms.dal.mysql.pm.project; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.project.PmsProjectPageReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectDO; +import cn.iocoder.yudao.module.pms.enums.pm.project.PmsProjectSortTypeEnum; +import cn.iocoder.yudao.module.pms.enums.pm.project.PmsProjectStatusEnum; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.apache.ibatis.annotations.Mapper; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +@Mapper +public interface PmsProjectMapper extends BaseMapperX { + + default List selectListByIdsAndStatus(Collection ids, Integer status) { + return selectList(new LambdaQueryWrapperX() + .in(PmsProjectDO::getId, ids) + .eq(PmsProjectDO::getStatus, status) + .orderByDesc(PmsProjectDO::getAccessTime) + .orderByDesc(PmsProjectDO::getId)); + } + + /** + * 分页查询项目,并按访问范围过滤项目 + * + * @param pageReqVO 分页查询条件 + * @param projectIds 当前用户可访问的项目编号集合 + * @param includeOpenProject 是否包含公开项目 + * @param includeAllProject 是否包含全部项目 + * @return 项目分页结果 + */ + default PageResult selectPage(PmsProjectPageReqVO pageReqVO, Collection projectIds, + boolean includeOpenProject, boolean includeAllProject) { + LambdaQueryWrapperX query = new LambdaQueryWrapperX() + .likeIfPresent(PmsProjectDO::getName, pageReqVO.getName()) + .eq(PmsProjectDO::getStatus, pageReqVO.getStatus()); + if (!includeAllProject) { + if (includeOpenProject) { + query.and(wrapper -> { + wrapper.eq(PmsProjectDO::getOpenStatus, true); + if (CollUtil.isNotEmpty(projectIds)) { + wrapper.or().in(PmsProjectDO::getId, projectIds); + } + }); + } else { + query.in(PmsProjectDO::getId, projectIds); + } + } + if (PmsProjectSortTypeEnum.ACCESS_TIME.getType().equals(pageReqVO.getSortType())) { + query.orderByDesc(PmsProjectDO::getAccessTime); + } else { + query.orderByAsc(PmsProjectDO::getCreateTime); + } + query.orderByDesc(PmsProjectDO::getId); + return selectPage(pageReqVO, query); + } + + default int updateStatusAndArchiveTimeById(Long id, Integer sourceStatus, Integer status, + LocalDateTime archiveTime) { + return update(new LambdaUpdateWrapper().eq(PmsProjectDO::getId, id) + .eq(PmsProjectDO::getStatus, sourceStatus) + .set(PmsProjectDO::getStatus, status) + .set(PmsProjectDO::getArchiveTime, archiveTime)); + } + + default int updateStatusAndRecycleTimeById(Long id, Integer sourceStatus, Integer status, + LocalDateTime recycleTime) { + return update(new LambdaUpdateWrapper().eq(PmsProjectDO::getId, id) + .eq(PmsProjectDO::getStatus, sourceStatus) + .set(PmsProjectDO::getStatus, status) + .set(PmsProjectDO::getRecycleTime, recycleTime)); + } + + default int updateToRestoreById(Long id, Integer status) { + return update(new LambdaUpdateWrapper().eq(PmsProjectDO::getId, id) + .in(PmsProjectDO::getStatus, Arrays.asList(PmsProjectStatusEnum.ARCHIVED.getStatus(), + PmsProjectStatusEnum.RECYCLED.getStatus())) + .set(PmsProjectDO::getStatus, status) + .set(PmsProjectDO::getArchiveTime, null) + .set(PmsProjectDO::getRecycleTime, null)); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/project/PmsProjectMemberMapper.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/project/PmsProjectMemberMapper.java new file mode 100644 index 000000000..0f35d229c --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/project/PmsProjectMemberMapper.java @@ -0,0 +1,61 @@ +package cn.iocoder.yudao.module.pms.dal.mysql.pm.project; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectMemberDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +@Mapper +public interface PmsProjectMemberMapper extends BaseMapperX { + + default List selectListByUserId(Long userId) { + return selectList(PmsProjectMemberDO::getUserId, userId); + } + + default List selectListByProjectId(Long projectId) { + return selectList(PmsProjectMemberDO::getProjectId, projectId); + } + + default PmsProjectMemberDO selectByProjectIdAndUserId(Long projectId, Long userId) { + return selectFirstOne(PmsProjectMemberDO::getProjectId, projectId, + PmsProjectMemberDO::getUserId, userId); + } + + default List selectListByUserIdAndLevels(Long userId, Collection levels) { + return selectList(new LambdaQueryWrapperX() + .eq(PmsProjectMemberDO::getUserId, userId) + .in(PmsProjectMemberDO::getLevel, levels)); + } + + default List selectListByProjectIds(Collection projectIds) { + return selectList(new LambdaQueryWrapperX() + .in(PmsProjectMemberDO::getProjectId, projectIds)); + } + + default List selectListByProjectIdsAndLevels(Collection projectIds, + Collection levels) { + return selectList(new LambdaQueryWrapperX() + .in(PmsProjectMemberDO::getProjectId, projectIds) + .in(PmsProjectMemberDO::getLevel, levels)); + } + + default void deleteByProjectIdAndUserIds(Long projectId, Collection userIds) { + delete(new LambdaQueryWrapperX() + .eq(PmsProjectMemberDO::getProjectId, projectId) + .in(PmsProjectMemberDO::getUserId, userIds)); + } + + default void deleteByProjectIdAndUserId(Long projectId, Long userId) { + delete(new LambdaQueryWrapperX() + .eq(PmsProjectMemberDO::getProjectId, projectId) + .eq(PmsProjectMemberDO::getUserId, userId)); + } + + default void deleteByProjectId(Long projectId) { + delete(PmsProjectMemberDO::getProjectId, projectId); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/project/PmsProjectTemplateMapper.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/project/PmsProjectTemplateMapper.java new file mode 100644 index 000000000..dd9cafe6b --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/project/PmsProjectTemplateMapper.java @@ -0,0 +1,28 @@ +package cn.iocoder.yudao.module.pms.dal.mysql.pm.project; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.template.PmsProjectTemplatePageReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectTemplateDO; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface PmsProjectTemplateMapper extends BaseMapperX { + + default PageResult selectPage(PmsProjectTemplatePageReqVO pageReqVO) { + return selectPage(pageReqVO, new LambdaQueryWrapperX() + .likeIfPresent(PmsProjectTemplateDO::getName, pageReqVO.getName()) + .eqIfPresent(PmsProjectTemplateDO::getProjectType, pageReqVO.getProjectType()) + .eqIfPresent(PmsProjectTemplateDO::getStatus, pageReqVO.getStatus()) + .orderByAsc(PmsProjectTemplateDO::getSort) + .orderByDesc(PmsProjectTemplateDO::getId)); + } + + default PmsProjectTemplateDO selectByNameAndProjectType(String name, Integer projectType) { + return selectOne(new LambdaQueryWrapperX() + .eq(PmsProjectTemplateDO::getName, name) + .eq(PmsProjectTemplateDO::getProjectType, projectType)); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemActivityMapper.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemActivityMapper.java new file mode 100644 index 000000000..9f40a25ac --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemActivityMapper.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.pms.dal.mysql.pm.workitem; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemActivityDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +@Mapper +public interface PmsWorkItemActivityMapper extends BaseMapperX { + + default List selectListByWorkItemId(Long workItemId) { + return selectList(new LambdaQueryWrapperX() + .eq(PmsWorkItemActivityDO::getWorkItemId, workItemId) + .orderByDesc(PmsWorkItemActivityDO::getCreateTime) + .orderByDesc(PmsWorkItemActivityDO::getId)); + } + + default List selectListByWorkItemIds(Collection workItemIds, int limit) { + return selectList(new LambdaQueryWrapperX() + .in(PmsWorkItemActivityDO::getWorkItemId, workItemIds) + .orderByDesc(PmsWorkItemActivityDO::getCreateTime) + .orderByDesc(PmsWorkItemActivityDO::getId) + .last("LIMIT " + limit)); + } + + default void deleteByWorkItemId(Long workItemId) { + delete(PmsWorkItemActivityDO::getWorkItemId, workItemId); + } + + default void deleteByProjectId(Long projectId) { + delete(PmsWorkItemActivityDO::getProjectId, projectId); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemBoardMapper.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemBoardMapper.java new file mode 100644 index 000000000..3c776cab2 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemBoardMapper.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.pms.dal.mysql.pm.workitem; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemBoardDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +@Mapper +public interface PmsWorkItemBoardMapper extends BaseMapperX { + + default List selectListByProjectIdAndWorkItemType(Long projectId, Integer workItemType) { + return selectList(new LambdaQueryWrapperX() + .eq(PmsWorkItemBoardDO::getProjectId, projectId) + .eq(PmsWorkItemBoardDO::getWorkItemType, workItemType) + .orderByAsc(PmsWorkItemBoardDO::getSort) + .orderByAsc(PmsWorkItemBoardDO::getId)); + } + + default void deleteByProjectIdAndWorkItemType(Long projectId, Integer workItemType) { + delete(new LambdaQueryWrapperX() + .eq(PmsWorkItemBoardDO::getProjectId, projectId) + .eq(PmsWorkItemBoardDO::getWorkItemType, workItemType)); + } + + default void deleteByProjectId(Long projectId) { + delete(PmsWorkItemBoardDO::getProjectId, projectId); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemCommentMapper.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemCommentMapper.java new file mode 100644 index 000000000..950994b83 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemCommentMapper.java @@ -0,0 +1,34 @@ +package cn.iocoder.yudao.module.pms.dal.mysql.pm.workitem; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemCommentDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +@Mapper +public interface PmsWorkItemCommentMapper extends BaseMapperX { + + default List selectListByWorkItemId(Long workItemId) { + return selectList(new LambdaQueryWrapperX() + .eq(PmsWorkItemCommentDO::getWorkItemId, workItemId) + .orderByAsc(PmsWorkItemCommentDO::getCreateTime) + .orderByAsc(PmsWorkItemCommentDO::getId)); + } + + default void deleteByMainId(Long mainId) { + delete(PmsWorkItemCommentDO::getMainId, mainId); + } + + default void deleteByWorkItemId(Long workItemId) { + delete(PmsWorkItemCommentDO::getWorkItemId, workItemId); + } + + default void deleteByWorkItemIds(Collection workItemIds) { + delete(new LambdaQueryWrapperX() + .in(PmsWorkItemCommentDO::getWorkItemId, workItemIds)); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemLabelMapper.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemLabelMapper.java new file mode 100644 index 000000000..2dac02713 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemLabelMapper.java @@ -0,0 +1,20 @@ +package cn.iocoder.yudao.module.pms.dal.mysql.pm.workitem; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemLabelDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +@Mapper +public interface PmsWorkItemLabelMapper extends BaseMapperX { + + default List selectListByName(String name) { + return selectList(new LambdaQueryWrapperX() + .likeIfPresent(PmsWorkItemLabelDO::getName, name) + .orderByAsc(PmsWorkItemLabelDO::getCreateTime) + .orderByAsc(PmsWorkItemLabelDO::getId)); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemMapper.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemMapper.java new file mode 100644 index 000000000..ad9927230 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemMapper.java @@ -0,0 +1,394 @@ +package cn.iocoder.yudao.module.pms.dal.mysql.pm.workitem; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.QueryWrapperX; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workbench.vo.PmsWorkbenchPageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemPageReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemDO; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemLifecycleStatusEnum; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.apache.ibatis.annotations.Mapper; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Mapper +public interface PmsWorkItemMapper extends BaseMapperX { + + default PageResult selectPage(PmsWorkItemPageReqVO pageReqVO) { + LambdaQueryWrapperX queryWrapper = buildQueryWrapper(pageReqVO); + if (Boolean.TRUE.equals(pageReqVO.getPlanningOnly())) { + queryWrapper.isNull(PmsWorkItemDO::getParentId) + .orderByAsc(PmsWorkItemDO::getSort) + .orderByDesc(PmsWorkItemDO::getId); + } else { + queryWrapper.orderByDesc(PmsWorkItemDO::getPriority) + .orderByAsc(PmsWorkItemDO::getSerialNumber) + .orderByAsc(PmsWorkItemDO::getId); + } + return selectPage(pageReqVO, queryWrapper); + } + + default List selectRootList(PmsWorkItemPageReqVO pageReqVO) { + return selectList(buildQueryWrapper(pageReqVO) + .isNull(PmsWorkItemDO::getParentId) + .orderByAsc(PmsWorkItemDO::getSort) + .orderByDesc(PmsWorkItemDO::getId)); + } + + default List selectList(PmsWorkItemPageReqVO pageReqVO) { + LambdaQueryWrapperX queryWrapper = buildQueryWrapper(pageReqVO); + if (Boolean.TRUE.equals(pageReqVO.getRootOnly())) { + queryWrapper.isNull(PmsWorkItemDO::getParentId); + } + return selectList(queryWrapper.orderByDesc(PmsWorkItemDO::getPriority) + .orderByAsc(PmsWorkItemDO::getSerialNumber) + .orderByAsc(PmsWorkItemDO::getId)); + } + + default Integer selectMaxSerialNumberByProjectId(Long projectId) { + PmsWorkItemDO workItem = selectLastOne(new LambdaQueryWrapperX() + .select(PmsWorkItemDO::getSerialNumber) + .eq(PmsWorkItemDO::getProjectId, projectId) + .orderByAsc(PmsWorkItemDO::getSerialNumber) + .orderByAsc(PmsWorkItemDO::getId)); + return workItem != null ? workItem.getSerialNumber() : null; + } + + /** + * 统计各项目下不同工作项状态的数量,仅统计生命周期正常的工作项 + * + * @param projectIds 项目编号集合 + * @return 项目编号 ->(状态 -> 工作项数量) + */ + default Map> selectStatusCountMapByProjectIds(Collection projectIds) { + List> rows = selectMaps(new QueryWrapperX() + .select("project_id AS projectId", "status", "COUNT(*) AS count") + .in("project_id", projectIds) + .eq("lifecycle_status", PmsWorkItemLifecycleStatusEnum.ACTIVE.getStatus()) + .groupBy("project_id", "status")); + Map> projectStatusCountMap = new LinkedHashMap<>(); + for (Map row : rows) { + Long projectId = ((Number) row.get("projectId")).longValue(); + Integer status = ((Number) row.get("status")).intValue(); + projectStatusCountMap.computeIfAbsent(projectId, key -> new LinkedHashMap<>()) + .put(status, ((Number) row.get("count")).longValue()); + } + return projectStatusCountMap; + } + + /** + * 统计各迭代下不同语义状态的工作项数量 + * + * @param iterationIds 迭代编号集合 + * @return 迭代编号 ->(语义状态 -> 工作项数量) + */ + default Map> selectStatusCountMapByIterationIds(Collection iterationIds) { + List> rows = selectMaps(new QueryWrapperX() + .select("iteration_id AS iterationId", "status", "COUNT(*) AS count") + .in("iteration_id", iterationIds) + .eq("lifecycle_status", PmsWorkItemLifecycleStatusEnum.ACTIVE.getStatus()) + .groupBy("iteration_id", "status")); + Map> iterationStatusCountMap = new LinkedHashMap<>(); + for (Map row : rows) { + Long iterationId = ((Number) row.get("iterationId")).longValue(); + Integer status = ((Number) row.get("status")).intValue(); + iterationStatusCountMap.computeIfAbsent(iterationId, key -> new LinkedHashMap<>()) + .put(status, ((Number) row.get("count")).longValue()); + } + return iterationStatusCountMap; + } + + default Integer selectMaxSortByStatusId(Long statusId) { + PmsWorkItemDO workItem = selectLastOne(new LambdaQueryWrapperX() + .select(PmsWorkItemDO::getSort) + .eq(PmsWorkItemDO::getStatusId, statusId) + .orderByAsc(PmsWorkItemDO::getSort) + .orderByAsc(PmsWorkItemDO::getId)); + return workItem != null ? workItem.getSort() : null; + } + + default List selectListByStatusId(Long statusId) { + return selectList(new LambdaQueryWrapperX() + .eq(PmsWorkItemDO::getStatusId, statusId) + .eq(PmsWorkItemDO::getLifecycleStatus, PmsWorkItemLifecycleStatusEnum.ACTIVE.getStatus()) + .isNull(PmsWorkItemDO::getParentId) + .orderByAsc(PmsWorkItemDO::getSort) + .orderByAsc(PmsWorkItemDO::getId)); + } + + default List selectListByProjectId(Long projectId) { + return selectList(new LambdaQueryWrapperX() + .eq(PmsWorkItemDO::getProjectId, projectId) + .orderByDesc(PmsWorkItemDO::getId)); + } + + default List selectActiveListByProjectId(Long projectId) { + return selectList(new LambdaQueryWrapperX() + .eq(PmsWorkItemDO::getProjectId, projectId) + .eq(PmsWorkItemDO::getLifecycleStatus, PmsWorkItemLifecycleStatusEnum.ACTIVE.getStatus()) + .orderByDesc(PmsWorkItemDO::getId)); + } + + default List selectListByIterationId(Long iterationId) { + return selectList(new LambdaQueryWrapperX() + .eq(PmsWorkItemDO::getIterationId, iterationId) + .eq(PmsWorkItemDO::getLifecycleStatus, PmsWorkItemLifecycleStatusEnum.ACTIVE.getStatus()) + .orderByDesc(PmsWorkItemDO::getId)); + } + + default List selectPlanningList(Long projectId, Long iterationId) { + LambdaQueryWrapperX queryWrapper = new LambdaQueryWrapperX<>(); + queryWrapper.eq(PmsWorkItemDO::getProjectId, projectId) + .eq(PmsWorkItemDO::getLifecycleStatus, PmsWorkItemLifecycleStatusEnum.ACTIVE.getStatus()) + .isNull(PmsWorkItemDO::getParentId); + if (iterationId == null) { + queryWrapper.isNull(PmsWorkItemDO::getIterationId); + } else { + queryWrapper.eq(PmsWorkItemDO::getIterationId, iterationId); + } + return selectList(queryWrapper.orderByAsc(PmsWorkItemDO::getSort).orderByDesc(PmsWorkItemDO::getId)); + } + + /** + * 查询待规划的根工作项列表 + * + * @param pageReqVO 工作项筛选参数 + * @return 待规划工作项列表 + */ + default List selectListByPlanning(PmsWorkItemPageReqVO pageReqVO) { + return selectList(buildQueryWrapper(pageReqVO) + .isNull(PmsWorkItemDO::getParentId) + .orderByAsc(PmsWorkItemDO::getSort) + .orderByDesc(PmsWorkItemDO::getId)); + } + + /** + * 分页查询当前用户负责的工作项,用于工作台展示 + * + * @param pageParam 分页参数 + * @param projectIds 当前用户可访问的项目编号集合 + * @param userId 当前用户编号 + * @param projectId 项目编号,可选 + * @param type 工作项类型,可选 + * @param name 工作项名称或编号,可选 + * @param status 工作项状态,可选 + * @param priority 优先级,可选 + * @param iterationId 迭代编号,可选 + * @param endTime 结束时间范围,可选 + * @param completedStatus 已完成状态 + * @return 工作项分页结果 + */ + default PageResult selectWorkbenchPage(PageParam pageParam, Collection projectIds, + Long userId, Long projectId, Integer type, String name, + Integer status, Integer priority, Long iterationId, + LocalDateTime[] endTime, + Integer completedStatus) { + LambdaQueryWrapperX queryWrapper = new LambdaQueryWrapperX() + .in(PmsWorkItemDO::getProjectId, projectIds) + .eq(PmsWorkItemDO::getAssigneeUserId, userId) + .eq(PmsWorkItemDO::getLifecycleStatus, PmsWorkItemLifecycleStatusEnum.ACTIVE.getStatus()) + .eqIfPresent(PmsWorkItemDO::getProjectId, projectId) + .eqIfPresent(PmsWorkItemDO::getType, type) + .eqIfPresent(PmsWorkItemDO::getStatus, status) + .eqIfPresent(PmsWorkItemDO::getPriority, priority) + .eqIfPresent(PmsWorkItemDO::getIterationId, iterationId) + .betweenIfPresent(PmsWorkItemDO::getEndTime, endTime); + queryWrapper.ne(status == null, PmsWorkItemDO::getStatus, completedStatus); + appendNameOrSerialNumberQuery(queryWrapper, name); + return selectPage(pageParam, queryWrapper + .orderByAsc(PmsWorkItemDO::getEndTime) + .orderByDesc(PmsWorkItemDO::getId)); + } + + /** + * 统计工作台当前筛选条件下各工作项类型的数量 + * + * @param pageReqVO 工作台筛选条件 + * @param projectIds 可访问的项目编号集合 + * @param userId 当前用户编号 + * @param lifecycleStatus 工作项生命周期状态 + * @param completedStatus 已完成状态 + * @return 工作项类型 -> 工作项数量 + */ + default Map selectWorkbenchTypeCountMap(PmsWorkbenchPageReqVO pageReqVO, + Collection projectIds, Long userId, + Integer lifecycleStatus, Integer completedStatus) { + QueryWrapperX queryWrapper = new QueryWrapperX() + .in("project_id", projectIds) + .eq("assignee_user_id", userId) + .eq("lifecycle_status", lifecycleStatus) + .eqIfPresent("project_id", pageReqVO.getProjectId()) + .eqIfPresent("status", pageReqVO.getStatus()) + .eqIfPresent("priority", pageReqVO.getPriority()) + .eqIfPresent("iteration_id", pageReqVO.getIterationId()) + .betweenIfPresent("end_time", pageReqVO.getEndTime()); + queryWrapper.ne(pageReqVO.getStatus() == null, "status", completedStatus); + appendNameOrSerialNumberQuery(queryWrapper, pageReqVO.getName()); + queryWrapper.select("type", "COUNT(*) AS count").groupBy("type"); + List> rows = selectMaps(queryWrapper); + Map typeCountMap = new LinkedHashMap<>(); + for (Map row : rows) { + typeCountMap.put(((Number) row.get("type")).intValue(), ((Number) row.get("count")).longValue()); + } + return typeCountMap; + } + + default Long selectCountByStatusId(Long statusId) { + return selectCount(PmsWorkItemDO::getStatusId, statusId); + } + + default void updateStatusBySourceStatusId(Long sourceStatusId, Long targetStatusId, Integer targetStatus) { + update(null, new LambdaUpdateWrapper() + .set(PmsWorkItemDO::getStatusId, targetStatusId) + .set(PmsWorkItemDO::getStatus, targetStatus) + .eq(PmsWorkItemDO::getStatusId, sourceStatusId)); + } + + default void updateStatusTypeByStatusId(Long statusId, Integer statusType) { + update(null, new LambdaUpdateWrapper() + .set(PmsWorkItemDO::getStatus, statusType) + .eq(PmsWorkItemDO::getStatusId, statusId)); + } + + default void updateStatusAndSortById(Long id, Long statusId, Integer status, Integer sort) { + update(null, new LambdaUpdateWrapper() + .set(PmsWorkItemDO::getStatusId, statusId) + .set(PmsWorkItemDO::getStatus, status) + .set(PmsWorkItemDO::getSort, sort) + .eq(PmsWorkItemDO::getId, id)); + } + + default int updateForEdit(PmsWorkItemDO updateObj) { + return update(updateObj, new LambdaUpdateWrapper() + .set(updateObj.getDescription() == null, PmsWorkItemDO::getDescription, null) + .set(updateObj.getAssigneeUserId() == null, PmsWorkItemDO::getAssigneeUserId, null) + .set(updateObj.getIterationId() == null, PmsWorkItemDO::getIterationId, null) + .set(updateObj.getParentId() == null, PmsWorkItemDO::getParentId, null) + .set(updateObj.getRelatedRequirementId() == null, PmsWorkItemDO::getRelatedRequirementId, null) + .set(updateObj.getDefectType() == null, PmsWorkItemDO::getDefectType, null) + .set(updateObj.getStartTime() == null, PmsWorkItemDO::getStartTime, null) + .set(updateObj.getEndTime() == null, PmsWorkItemDO::getEndTime, null) + .set(updateObj.getEstimatedHours() == null, PmsWorkItemDO::getEstimatedHours, null) + .set(updateObj.getFileUrls() == null, PmsWorkItemDO::getFileUrls, null) + .set(updateObj.getLabelIds() == null, PmsWorkItemDO::getLabelIds, null) + .eq(PmsWorkItemDO::getId, updateObj.getId())); + } + + default void updateIterationIdToNullByIterationId(Long iterationId) { + update(null, new LambdaUpdateWrapper() + .set(PmsWorkItemDO::getIterationId, null) + .eq(PmsWorkItemDO::getIterationId, iterationId)); + } + + default void updateParentIdToNullByParentId(Long parentId) { + update(null, new LambdaUpdateWrapper() + .set(PmsWorkItemDO::getParentId, null) + .eq(PmsWorkItemDO::getParentId, parentId)); + } + + default void updateRelatedRequirementIdToNullByRelatedRequirementId(Long relatedRequirementId) { + update(null, new LambdaUpdateWrapper() + .set(PmsWorkItemDO::getRelatedRequirementId, null) + .eq(PmsWorkItemDO::getRelatedRequirementId, relatedRequirementId)); + } + + default void deleteByProjectId(Long projectId) { + delete(PmsWorkItemDO::getProjectId, projectId); + } + + static LambdaQueryWrapperX buildQueryWrapper(PmsWorkItemPageReqVO pageReqVO) { + LambdaQueryWrapperX queryWrapper = new LambdaQueryWrapperX() + .eq(PmsWorkItemDO::getProjectId, pageReqVO.getProjectId()) + .eq(PmsWorkItemDO::getLifecycleStatus, pageReqVO.getLifecycleStatus() != null + ? pageReqVO.getLifecycleStatus() : PmsWorkItemLifecycleStatusEnum.ACTIVE.getStatus()) + .eqIfPresent(PmsWorkItemDO::getType, pageReqVO.getType()) + .inIfPresent(PmsWorkItemDO::getType, pageReqVO.getTypes()) + .eqIfPresent(PmsWorkItemDO::getStatus, pageReqVO.getStatus()) + .inIfPresent(PmsWorkItemDO::getStatus, pageReqVO.getStatuses()) + .eqIfPresent(PmsWorkItemDO::getPriority, pageReqVO.getPriority()) + .inIfPresent(PmsWorkItemDO::getPriority, pageReqVO.getPriorities()) + .eqIfPresent(PmsWorkItemDO::getStatusId, pageReqVO.getStatusId()) + .eqIfPresent(PmsWorkItemDO::getIterationId, pageReqVO.getIterationId()) + .inIfPresent(PmsWorkItemDO::getIterationId, pageReqVO.getIterationIds()) + .eqIfPresent(PmsWorkItemDO::getParentId, pageReqVO.getParentId()) + .eqIfPresent(PmsWorkItemDO::getAssigneeUserId, pageReqVO.getAssigneeUserId()) + .inIfPresent(PmsWorkItemDO::getAssigneeUserId, pageReqVO.getAssigneeUserIds()); + appendNameOrSerialNumberQuery(queryWrapper, pageReqVO.getName()); + if (Boolean.TRUE.equals(pageReqVO.getUnplannedOnly())) { + queryWrapper.isNull(PmsWorkItemDO::getIterationId); + } + if (Boolean.TRUE.equals(pageReqVO.getRootOnly())) { + queryWrapper.isNull(PmsWorkItemDO::getParentId); + } + if (CollUtil.isNotEmpty(pageReqVO.getExcludedIterationIds())) { + queryWrapper.and(wrapper -> wrapper.notIn(PmsWorkItemDO::getIterationId, + pageReqVO.getExcludedIterationIds()).or().isNull(PmsWorkItemDO::getIterationId)); + } + // label_ids 为 JSON 数组列,使用 MySQL JSON_CONTAINS 匹配元素,H2 单测不覆盖该分支 + if (CollUtil.isNotEmpty(pageReqVO.getLabelIds())) { + queryWrapper.and(labelWrapper -> { + for (Long labelId : pageReqVO.getLabelIds()) { + labelWrapper.or().apply("JSON_CONTAINS(label_ids, JSON_ARRAY({0}))", labelId); + } + }); + } + return queryWrapper; + } + + /** + * 按工作项名称或编号追加模糊查询条件 + * + * @param queryWrapper 查询条件 + * @param keyword 关键词,可匹配名称或去掉前缀后的编号 + */ + static void appendNameOrSerialNumberQuery(LambdaQueryWrapperX queryWrapper, String keyword) { + if (StrUtil.isBlank(keyword)) { + return; + } + String serialNumberKeyword = StrUtil.removePrefix(keyword, "#"); + queryWrapper.and(wrapper -> { + wrapper.like(PmsWorkItemDO::getName, keyword); + if (StrUtil.isNotBlank(serialNumberKeyword)) { + wrapper.or().like(PmsWorkItemDO::getSerialNumber, serialNumberKeyword); + } + }); + } + + static void appendNameOrSerialNumberQuery(QueryWrapperX queryWrapper, String keyword) { + if (StrUtil.isBlank(keyword)) { + return; + } + String serialNumberKeyword = StrUtil.removePrefix(keyword, "#"); + queryWrapper.and(wrapper -> { + wrapper.like("name", keyword); + if (StrUtil.isNotBlank(serialNumberKeyword)) { + wrapper.or().like("serial_number", serialNumberKeyword); + } + }); + } + + default void updateIterationIdById(Long id, Long iterationId) { + update(null, new LambdaUpdateWrapper() + .set(PmsWorkItemDO::getIterationId, iterationId) + .eq(PmsWorkItemDO::getId, id)); + } + + default void updateLifecycleStatusById(Long id, Integer lifecycleStatus, LocalDateTime archiveTime, + LocalDateTime recycleTime) { + update(null, new LambdaUpdateWrapper() + .set(PmsWorkItemDO::getLifecycleStatus, lifecycleStatus) + .set(PmsWorkItemDO::getArchiveTime, archiveTime) + .set(PmsWorkItemDO::getRecycleTime, recycleTime) + .eq(PmsWorkItemDO::getId, id)); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemMemberMapper.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemMemberMapper.java new file mode 100644 index 000000000..d2872a09c --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemMemberMapper.java @@ -0,0 +1,28 @@ +package cn.iocoder.yudao.module.pms.dal.mysql.pm.workitem; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemMemberDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +@Mapper +public interface PmsWorkItemMemberMapper extends BaseMapperX { + + default List selectListByWorkItemIds(Collection workItemIds) { + return selectList(new LambdaQueryWrapperX() + .in(PmsWorkItemMemberDO::getWorkItemId, workItemIds) + .orderByAsc(PmsWorkItemMemberDO::getId)); + } + + default void deleteByWorkItemId(Long workItemId) { + delete(PmsWorkItemMemberDO::getWorkItemId, workItemId); + } + + default void deleteByProjectId(Long projectId) { + delete(PmsWorkItemMemberDO::getProjectId, projectId); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemStatusMapper.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemStatusMapper.java new file mode 100644 index 000000000..b0d9702cd --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemStatusMapper.java @@ -0,0 +1,68 @@ +package cn.iocoder.yudao.module.pms.dal.mysql.pm.workitem; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemStatusDO; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +@Mapper +public interface PmsWorkItemStatusMapper extends BaseMapperX { + + default List selectListByProjectIdAndWorkItemType(Long projectId, Integer workItemType) { + return selectList(new LambdaQueryWrapperX() + .eq(PmsWorkItemStatusDO::getProjectId, projectId) + .eq(PmsWorkItemStatusDO::getWorkItemType, workItemType) + .orderByAsc(PmsWorkItemStatusDO::getSort) + .orderByAsc(PmsWorkItemStatusDO::getId)); + } + + default PmsWorkItemStatusDO selectDefaultByProjectIdAndWorkItemType(Long projectId, Integer workItemType) { + return selectOne(new LambdaQueryWrapperX() + .eq(PmsWorkItemStatusDO::getProjectId, projectId) + .eq(PmsWorkItemStatusDO::getWorkItemType, workItemType) + .eq(PmsWorkItemStatusDO::getDefaultStatus, true)); + } + + default PmsWorkItemStatusDO selectByProjectIdAndWorkItemTypeAndName(Long projectId, Integer workItemType, + String name) { + return selectOne(new LambdaQueryWrapperX() + .eq(PmsWorkItemStatusDO::getProjectId, projectId) + .eq(PmsWorkItemStatusDO::getWorkItemType, workItemType) + .eq(PmsWorkItemStatusDO::getName, name)); + } + + default void updateDefaultStatusByProjectIdAndWorkItemType(Long projectId, Integer workItemType, + Long defaultStatusId) { + update(null, new LambdaUpdateWrapper() + .set(PmsWorkItemStatusDO::getDefaultStatus, false) + .eq(PmsWorkItemStatusDO::getProjectId, projectId) + .eq(PmsWorkItemStatusDO::getWorkItemType, workItemType)); + update(null, new LambdaUpdateWrapper() + .set(PmsWorkItemStatusDO::getDefaultStatus, true) + .eq(PmsWorkItemStatusDO::getId, defaultStatusId) + .eq(PmsWorkItemStatusDO::getProjectId, projectId) + .eq(PmsWorkItemStatusDO::getWorkItemType, workItemType)); + } + + default void deleteByProjectId(Long projectId) { + delete(PmsWorkItemStatusDO::getProjectId, projectId); + } + + default void updateBoardNameByProjectIdAndWorkItemType(Long projectId, Integer workItemType, String boardName) { + update(null, new LambdaUpdateWrapper() + .set(PmsWorkItemStatusDO::getBoardName, boardName) + .eq(PmsWorkItemStatusDO::getProjectId, projectId) + .eq(PmsWorkItemStatusDO::getWorkItemType, workItemType)); + } + + default void updateBoardNameByIds(Collection ids, String boardName) { + update(null, new LambdaUpdateWrapper() + .set(PmsWorkItemStatusDO::getBoardName, boardName) + .in(PmsWorkItemStatusDO::getId, ids)); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemUserSortMapper.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemUserSortMapper.java new file mode 100644 index 000000000..21c97997e --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemUserSortMapper.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.pms.dal.mysql.pm.workitem; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemUserSortDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +@Mapper +public interface PmsWorkItemUserSortMapper extends BaseMapperX { + + default List selectListByProjectIdAndUserId(Long projectId, Long userId) { + return selectList(new LambdaQueryWrapperX() + .eq(PmsWorkItemUserSortDO::getProjectId, projectId) + .eq(PmsWorkItemUserSortDO::getUserId, userId) + .orderByAsc(PmsWorkItemUserSortDO::getSort) + .orderByAsc(PmsWorkItemUserSortDO::getId)); + } + + default void deleteByProjectIdAndUserId(Long projectId, Long userId) { + delete(new LambdaQueryWrapperX() + .eq(PmsWorkItemUserSortDO::getProjectId, projectId) + .eq(PmsWorkItemUserSortDO::getUserId, userId)); + } + + default void deleteByWorkItemId(Long workItemId) { + delete(new LambdaQueryWrapperX() + .eq(PmsWorkItemUserSortDO::getWorkItemId, workItemId)); + } + + default void deleteByProjectId(Long projectId) { + delete(new LambdaQueryWrapperX() + .eq(PmsWorkItemUserSortDO::getProjectId, projectId)); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemWorkLogMapper.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemWorkLogMapper.java new file mode 100644 index 000000000..7b84a5dcd --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemWorkLogMapper.java @@ -0,0 +1,53 @@ +package cn.iocoder.yudao.module.pms.dal.mysql.pm.workitem; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemWorkLogDO; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.apache.ibatis.annotations.Mapper; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +@Mapper +public interface PmsWorkItemWorkLogMapper extends BaseMapperX { + + default List selectListByWorkItemId(Long workItemId) { + return selectList(new LambdaQueryWrapperX() + .eq(PmsWorkItemWorkLogDO::getWorkItemId, workItemId) + .orderByDesc(PmsWorkItemWorkLogDO::getCreateTime) + .orderByDesc(PmsWorkItemWorkLogDO::getId)); + } + + default List selectListByWorkItemIds(Collection workItemIds) { + return selectList(new LambdaQueryWrapperX() + .in(PmsWorkItemWorkLogDO::getWorkItemId, workItemIds) + .orderByAsc(PmsWorkItemWorkLogDO::getCreateTime) + .orderByAsc(PmsWorkItemWorkLogDO::getId)); + } + + default List selectListByProjectIdAndCreateTimeBetween( + Long projectId, LocalDateTime beginTime, LocalDateTime endTime) { + return selectList(new LambdaQueryWrapperX() + .eq(PmsWorkItemWorkLogDO::getProjectId, projectId) + .between(PmsWorkItemWorkLogDO::getCreateTime, beginTime, endTime) + .orderByAsc(PmsWorkItemWorkLogDO::getCreateTime) + .orderByAsc(PmsWorkItemWorkLogDO::getId)); + } + + default int updateForEdit(PmsWorkItemWorkLogDO updateObj) { + return update(updateObj, new LambdaUpdateWrapper() + .set(updateObj.getDescription() == null, PmsWorkItemWorkLogDO::getDescription, null) + .eq(PmsWorkItemWorkLogDO::getId, updateObj.getId())); + } + + default void deleteByProjectId(Long projectId) { + delete(PmsWorkItemWorkLogDO::getProjectId, projectId); + } + + default void deleteByWorkItemId(Long workItemId) { + delete(PmsWorkItemWorkLogDO::getWorkItemId, workItemId); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/framework/package-info.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/framework/package-info.java new file mode 100644 index 000000000..855cabee6 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/framework/package-info.java @@ -0,0 +1,4 @@ +/** + * PMS 模块的 Framework 封装 + */ +package cn.iocoder.yudao.module.pms.framework; diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/framework/rpc/config/RpcConfiguration.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/framework/rpc/config/RpcConfiguration.java new file mode 100644 index 000000000..b1c7ddd27 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/framework/rpc/config/RpcConfiguration.java @@ -0,0 +1,15 @@ +package cn.iocoder.yudao.module.pms.framework.rpc.config; + +import cn.iocoder.yudao.module.infra.api.file.FileApi; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.notify.NotifyMessageSendApi; +import cn.iocoder.yudao.module.system.api.permission.PermissionApi; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.context.annotation.Configuration; + +@Configuration(value = "pmsRpcConfiguration", proxyBeanMethods = false) +@EnableFeignClients(clients = {AdminUserApi.class, DeptApi.class, PermissionApi.class, + NotifyMessageSendApi.class, FileApi.class}) +public class RpcConfiguration { +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/framework/security/config/SecurityConfiguration.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/framework/security/config/SecurityConfiguration.java new file mode 100644 index 000000000..f0f849e3f --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/framework/security/config/SecurityConfiguration.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.pms.framework.security.config; + +import cn.iocoder.yudao.framework.security.config.AuthorizeRequestsCustomizer; +import cn.iocoder.yudao.module.pms.enums.ApiConstants; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; + +/** + * PMS 模块的 Security 配置 + */ +@Configuration("pmsSecurityConfiguration") +public class SecurityConfiguration { + + @Bean("pmsAuthorizeRequestsCustomizer") + public AuthorizeRequestsCustomizer authorizeRequestsCustomizer() { + return new AuthorizeRequestsCustomizer() { + + @Override + public void customize(AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry registry) { + // Swagger 接口文档 + registry.requestMatchers("/v3/api-docs/**").permitAll() + .requestMatchers("/webjars/**").permitAll() + .requestMatchers("/swagger-ui").permitAll() + .requestMatchers("/swagger-ui/**").permitAll(); + // Spring Boot Actuator 的安全配置 + registry.requestMatchers("/actuator").permitAll() + .requestMatchers("/actuator/**").permitAll(); + // Druid 监控 + registry.requestMatchers("/druid/**").permitAll(); + // RPC 服务的安全配置 + registry.requestMatchers(ApiConstants.PREFIX + "/**").permitAll(); + } + + }; + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/framework/web/config/PmsWebConfiguration.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/framework/web/config/PmsWebConfiguration.java new file mode 100644 index 000000000..288746dfe --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/framework/web/config/PmsWebConfiguration.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.pms.framework.web.config; + +import cn.iocoder.yudao.framework.swagger.config.YudaoSwaggerAutoConfiguration; +import org.springdoc.core.models.GroupedOpenApi; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * PMS 模块的 Web 配置 + * + * @author 芋道源码 + */ +@Configuration(proxyBeanMethods = false) +public class PmsWebConfiguration { + + /** + * 创建 PMS 模块的 OpenAPI 分组 + * + * @return PMS OpenAPI 分组 + */ + @Bean + public GroupedOpenApi pmsGroupedOpenApi() { + return YudaoSwaggerAutoConfiguration.buildGroupedOpenApi("pms"); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/framework/web/package-info.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/framework/web/package-info.java new file mode 100644 index 000000000..f7e65e9f1 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/framework/web/package-info.java @@ -0,0 +1,4 @@ +/** + * PMS 模块的 Web 配置 + */ +package cn.iocoder.yudao.module.pms.framework.web; diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/job/kb/PmsKnowledgeRecycleCleanJob.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/job/kb/PmsKnowledgeRecycleCleanJob.java new file mode 100644 index 000000000..0d270e412 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/job/kb/PmsKnowledgeRecycleCleanJob.java @@ -0,0 +1,35 @@ +package cn.iocoder.yudao.module.pms.job.kb; + +import cn.iocoder.yudao.framework.tenant.core.job.TenantJob; +import cn.iocoder.yudao.module.pms.service.kb.recycle.PmsKnowledgeRecycleService; +import com.xxl.job.core.handler.annotation.XxlJob; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +/** + * PMS 知识库回收站到期清理 Job + * + * @author 芋道源码 + */ +@Component +@Slf4j +public class PmsKnowledgeRecycleCleanJob { + + private static final int RETAIN_DAYS = 30; + + @Resource + private PmsKnowledgeRecycleService recycleService; + + @XxlJob("pmsKnowledgeRecycleCleanJob") + @TenantJob + public String execute() { + LocalDateTime deleteTime = LocalDateTime.now().minusDays(RETAIN_DAYS); + int cleanCount = recycleService.deleteExpiredRecycleRecords(deleteTime); + log.info("[execute][清理知识库到期回收站记录 ({}) 条]", cleanCount); + return String.format("清理知识库到期回收站记录 %s 条", cleanCount); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/job/package-info.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/job/package-info.java new file mode 100644 index 000000000..30d81b4c5 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/job/package-info.java @@ -0,0 +1,4 @@ +/** + * PMS 模块的定时任务 + */ +package cn.iocoder.yudao.module.pms.job; diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/package-info.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/package-info.java new file mode 100644 index 000000000..43fc4323b --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/package-info.java @@ -0,0 +1,10 @@ +/** + * pms 包下,项目管理(Project Management System) + * 例如说:项目、工作事项、迭代、看板、工时和知识库等等 + * + * 1. Controller URL:以 /pms/ 开头,避免和其它 Module 冲突 + * 2. DataObject 表名:以 pms_ 开头,方便在数据库中区分 + * + * 注意,由于 PMS 模块下容易和其它模块重名,所以类名都加 Pms 前缀 + */ +package cn.iocoder.yudao.module.pms; diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeContentPermissionService.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeContentPermissionService.java new file mode 100644 index 000000000..599444aef --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeContentPermissionService.java @@ -0,0 +1,165 @@ +package cn.iocoder.yudao.module.pms.service.kb.content; + +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.permission.PmsKnowledgeContentPermissionUpdateReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeContentPermissionDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeContentPermissionMemberDO; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * PMS 知识内容协作权限 Service 接口 + * + * @author 芋道源码 + */ +public interface PmsKnowledgeContentPermissionService { + + /** + * 创建默认内容权限,公开仅可预览,创建人固定拥有管理权限 + * + * @param libraryId 知识库编号 + * @param userId 用户编号 + * @return 内容权限编号 + */ + Long createDefaultContentPermission(Long libraryId, Long userId); + + /** + * 复制内容权限及其协作者到目标知识库,同一原权限对应同一新权限 + * + * @param permissionIds 内容权限编号集合 + * @param targetLibraryId 目标知识库编号 + * @return 原权限编号到新权限编号的映射 + */ + Map cloneContentPermissions(Collection permissionIds, Long targetLibraryId); + + /** + * 校验当前用户对内容有读取权限 + * + * @param permissionId 内容权限编号 + * @param libraryId 知识库编号 + * @param userId 用户编号 + * @return 内容等级 + */ + Integer validateContentPermissionReadable(Long permissionId, Long libraryId, Long userId); + + /** + * 校验当前用户对内容有编辑权限 + * + * @param permissionId 内容权限编号 + * @param libraryId 知识库编号 + * @param userId 用户编号 + * @return 内容等级 + */ + Integer validateContentPermissionWritable(Long permissionId, Long libraryId, Long userId); + + /** + * 校验当前用户对内容有删除权限 + * + * @param permissionId 内容权限编号 + * @param libraryId 知识库编号 + * @param userId 用户编号 + * @return 内容等级 + */ + Integer validateContentPermissionDeletable(Long permissionId, Long libraryId, Long userId); + + /** + * 校验当前用户对内容有管理权限 + * + * @param permissionId 内容权限编号 + * @param libraryId 知识库编号 + * @param userId 用户编号 + * @return 内容等级 + */ + Integer validateContentPermissionManageable(Long permissionId, Long libraryId, Long userId); + + /** + * 判断当前用户对内容是否有读取权限 + * + * @param permissionId 内容权限编号 + * @param libraryId 知识库编号 + * @param userId 用户编号 + * @return 是否可读 + */ + boolean isContentReadable(Long permissionId, Long libraryId, Long userId); + + /** + * 获得内容权限,校验当前用户有读取权限 + * + * @param permissionId 内容权限编号 + * @param userId 用户编号 + * @return 内容权限 + */ + PmsKnowledgeContentPermissionDO getContentPermission(Long permissionId, Long userId); + + /** + * 获得内容权限的协作者列表,校验当前用户有读取权限 + * + * @param permissionId 内容权限编号 + * @param userId 用户编号 + * @return 协作者列表 + */ + List getContentPermissionMemberList(Long permissionId, Long userId); + + /** + * 获得当前用户对内容的协作等级,无权限时返回 {@code null} + * + * @param permissionId 内容权限编号 + * @param libraryId 知识库编号 + * @param userId 用户编号 + * @return 内容等级 + */ + Integer getCurrentUserContentPermissionLevel(Long permissionId, Long libraryId, Long userId); + + /** + * 获得当前用户对一批内容的协作等级 Map,无权限的权限编号对应 {@code null} + * + * @param permissionIds 内容权限编号集合 + * @param libraryId 知识库编号 + * @param userId 用户编号 + * @return 内容权限编号到等级的映射 + */ + Map getCurrentUserContentPermissionLevelMap(Collection permissionIds, Long libraryId, Long userId); + + /** + * 获得当前用户在知识库中可读的内容权限编号集合 + * + * @param libraryIds 知识库编号集合 + * @param userId 用户编号 + * @return 可读内容权限编号集合 + */ + Set getReadableContentPermissionIdSet(Collection libraryIds, Long userId); + + /** + * 更新内容权限的公开设置和协作者,保留创建人的管理权限 + * + * @param updateReqVO 更新信息 + * @param userId 用户编号 + */ + void updateContentPermission(PmsKnowledgeContentPermissionUpdateReqVO updateReqVO, Long userId); + + /** + * 删除不再被任何文件夹或文档引用的内容权限 + * + * @param permissionIds 内容权限编号集合 + */ + void deleteUnusedContentPermissions(Set permissionIds); + + /** + * 删除知识库的全部内容权限 + * + * @param libraryId 知识库编号 + */ + void deleteContentPermissionsByLibraryId(Long libraryId); + + /** + * 删除知识库内容权限中指定用户和部门的协作者 + * + * @param libraryId 知识库编号 + * @param userIds 用户编号集合 + * @param deptIds 部门编号集合 + */ + void deleteContentPermissionMembersByLibraryId(Long libraryId, Collection userIds, Collection deptIds); + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeContentPermissionServiceImpl.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeContentPermissionServiceImpl.java new file mode 100644 index 000000000..f18dcb580 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeContentPermissionServiceImpl.java @@ -0,0 +1,417 @@ +package cn.iocoder.yudao.module.pms.service.kb.content; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.iocoder.yudao.framework.common.util.number.NumberUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.permission.PmsKnowledgeContentPermissionUpdateReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeContentPermissionDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeContentPermissionMemberDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryMemberDO; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.content.PmsKnowledgeContentPermissionMapper; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.content.PmsKnowledgeContentPermissionMemberMapper; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.library.PmsKnowledgeLibraryMemberMapper; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeContentLevelEnum; +import cn.iocoder.yudao.module.pms.enums.kb.library.PmsKnowledgeLibraryMemberLevelEnum; +import cn.iocoder.yudao.module.pms.service.kb.library.PmsKnowledgeLibraryMemberService; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.permission.PermissionApi; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import cn.iocoder.yudao.module.system.enums.permission.RoleCodeEnum; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.common.util.object.ObjectUtils.equalsAny; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_CONTENT_ACCESS_DENIED; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_CONTENT_DELETE_ACCESS_DENIED; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_CONTENT_PERMISSION_MEMBERS_INVALID; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_CONTENT_PERMISSION_NOT_EXISTS; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_CONTENT_WRITE_ACCESS_DENIED; + +/** + * PMS 知识内容协作权限 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class PmsKnowledgeContentPermissionServiceImpl implements PmsKnowledgeContentPermissionService { + + @Resource + private PmsKnowledgeContentPermissionMapper permissionMapper; + @Resource + private PmsKnowledgeContentPermissionMemberMapper permissionMemberMapper; + + @Resource + @Lazy + private PmsKnowledgeFolderService folderService; + @Resource + @Lazy + private PmsKnowledgeDocumentService documentService; + @Resource + private PmsKnowledgeLibraryMemberMapper libraryMemberMapper; + @Resource + private PmsKnowledgeLibraryMemberService libraryMemberService; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private DeptApi deptApi; + @Resource + private PermissionApi permissionApi; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createDefaultContentPermission(Long libraryId, Long userId) { + // 1. 创建默认公开且仅预览的内容权限 + PmsKnowledgeContentPermissionDO permission = new PmsKnowledgeContentPermissionDO().setLibraryId(libraryId) + .setOpenStatus(true).setOpenLevel(PmsKnowledgeContentLevelEnum.PREVIEW.getLevel()); + permission.setCreator(String.valueOf(userId)); + permissionMapper.insert(permission); + + // 2. 创建人固定拥有管理权限 + permissionMemberMapper.insert(new PmsKnowledgeContentPermissionMemberDO().setPermissionId(permission.getId()).setUserId(userId) + .setLevel(PmsKnowledgeContentLevelEnum.MANAGE.getLevel())); + return permission.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Map cloneContentPermissions(Collection permissionIds, Long targetLibraryId) { + if (CollUtil.isEmpty(permissionIds)) { + return Collections.emptyMap(); + } + // 1. 批量查询原权限及协作者 + List permissions = permissionMapper.selectByIds(permissionIds); + if (permissions.size() != permissionIds.size()) { + throw exception(KNOWLEDGE_CONTENT_PERMISSION_NOT_EXISTS); + } + List members = permissionMemberMapper.selectListByPermissionIds(permissionIds); + + // 2. 逐个复制权限和协作者,保持同一原权限仍对应同一新权限 + Map clonedPermissionIdMap = new LinkedHashMap<>(); + for (PmsKnowledgeContentPermissionDO source : permissions) { + PmsKnowledgeContentPermissionDO target = new PmsKnowledgeContentPermissionDO().setLibraryId(targetLibraryId) + .setOpenStatus(source.getOpenStatus()).setOpenLevel(source.getOpenLevel()); + target.setCreator(source.getCreator()); + permissionMapper.insert(target); + List targetMembers = convertList(members, member -> + new PmsKnowledgeContentPermissionMemberDO().setPermissionId(target.getId()) + .setUserId(member.getUserId()).setDeptId(member.getDeptId()).setLevel(member.getLevel()), + member -> source.getId().equals(member.getPermissionId())); + if (CollUtil.isNotEmpty(targetMembers)) { + permissionMemberMapper.insertBatch(targetMembers); + } + clonedPermissionIdMap.put(source.getId(), target.getId()); + } + return clonedPermissionIdMap; + } + + @Override + public Integer validateContentPermissionReadable(Long permissionId, Long libraryId, Long userId) { + Integer level = getCurrentUserContentPermissionLevel(permissionId, libraryId, userId); + if (level == null) { + throw exception(KNOWLEDGE_CONTENT_ACCESS_DENIED); + } + return level; + } + + @Override + public Integer validateContentPermissionWritable(Long permissionId, Long libraryId, Long userId) { + Integer level = validateContentPermissionReadable(permissionId, libraryId, userId); + if (!PmsKnowledgeContentLevelEnum.canEdit(level)) { + throw exception(KNOWLEDGE_CONTENT_WRITE_ACCESS_DENIED); + } + return level; + } + + @Override + public Integer validateContentPermissionDeletable(Long permissionId, Long libraryId, Long userId) { + Integer level = validateContentPermissionReadable(permissionId, libraryId, userId); + if (!PmsKnowledgeContentLevelEnum.canDelete(level)) { + throw exception(KNOWLEDGE_CONTENT_DELETE_ACCESS_DENIED); + } + return level; + } + + @Override + public Integer validateContentPermissionManageable(Long permissionId, Long libraryId, Long userId) { + Integer level = validateContentPermissionReadable(permissionId, libraryId, userId); + if (ObjectUtil.notEqual(PmsKnowledgeContentLevelEnum.MANAGE.getLevel(), level)) { + throw exception(KNOWLEDGE_CONTENT_WRITE_ACCESS_DENIED); + } + return level; + } + + @Override + public boolean isContentReadable(Long permissionId, Long libraryId, Long userId) { + return getCurrentUserContentPermissionLevel(permissionId, libraryId, userId) != null; + } + + @Override + public PmsKnowledgeContentPermissionDO getContentPermission(Long permissionId, Long userId) { + // 1. 校验内容权限存在 + PmsKnowledgeContentPermissionDO permission = validatePermissionExists(permissionId); + // 2. 校验当前用户可以读取知识库 + libraryMemberService.validateLibraryReadable(permission.getLibraryId(), userId); + // 3. 校验当前用户可以读取该内容 + validateContentPermissionReadable(permissionId, permission.getLibraryId(), userId); + return permission; + } + + @Override + public List getContentPermissionMemberList(Long permissionId, Long userId) { + // 1. 校验内容权限存在、知识库可读且内容可读 + PmsKnowledgeContentPermissionDO permission = validatePermissionExists(permissionId); + libraryMemberService.validateLibraryReadable(permission.getLibraryId(), userId); + validateContentPermissionReadable(permissionId, permission.getLibraryId(), userId); + + // 2. 返回协作者列表 + return permissionMemberMapper.selectListByPermissionId(permissionId); + } + + @Override + public Integer getCurrentUserContentPermissionLevel(Long permissionId, Long libraryId, Long userId) { + return getCurrentUserContentPermissionLevelMap(Collections.singleton(permissionId), libraryId, userId).get(permissionId); + } + + @Override + public Map getCurrentUserContentPermissionLevelMap(Collection permissionIds, Long libraryId, Long userId) { + if (CollUtil.isEmpty(permissionIds)) { + return Collections.emptyMap(); + } + // 1. 批量查询内容权限并校验知识库归属 + List permissions = permissionMapper.selectByIds(permissionIds); + if (permissions.size() != permissionIds.size() + || permissions.stream().anyMatch(permission -> ObjectUtil.notEqual(libraryId, permission.getLibraryId()))) { + throw exception(KNOWLEDGE_CONTENT_PERMISSION_NOT_EXISTS); + } + Map levelMap = new LinkedHashMap<>(); + if (libraryMemberService.isLibraryAdmin(libraryId, userId)) { + permissions.forEach(permission -> levelMap.put( + permission.getId(), PmsKnowledgeContentLevelEnum.MANAGE.getLevel())); + return levelMap; + } + + // 2. 公开权限优先;私有内容再按用户、部门顺序匹配显式权限 + AdminUserRespDTO user = adminUserApi.getUser(userId).getCheckedData(); + Long deptId = user != null ? user.getDeptId() : null; + Map userMemberMap = new LinkedHashMap<>(); + Map deptMemberMap = new LinkedHashMap<>(); + for (PmsKnowledgeContentPermissionMemberDO member : permissionMemberMapper.selectListByPermissionIds(permissionIds)) { + if (userId.equals(member.getUserId())) { + userMemberMap.put(member.getPermissionId(), member); + } else if (deptId != null && deptId.equals(member.getDeptId())) { + deptMemberMap.put(member.getPermissionId(), member); + } + } + for (PmsKnowledgeContentPermissionDO permission : permissions) { + PmsKnowledgeContentPermissionMemberDO userMember = userMemberMap.get(permission.getId()); + PmsKnowledgeContentPermissionMemberDO deptMember = deptMemberMap.get(permission.getId()); + Integer level = Boolean.TRUE.equals(permission.getOpenStatus()) ? permission.getOpenLevel() + : userMember != null ? userMember.getLevel() + : deptMember != null ? deptMember.getLevel() + : null; + levelMap.put(permission.getId(), level); + } + return levelMap; + } + + @Override + public Set getReadableContentPermissionIdSet(Collection libraryIds, Long userId) { + if (CollUtil.isEmpty(libraryIds)) { + return Collections.emptySet(); + } + // 1.1 一次查询全部知识库的内容权限 + List permissions = permissionMapper.selectListByLibraryIds(libraryIds); + if (CollUtil.isEmpty(permissions)) { + return Collections.emptySet(); + } + Set permissionIds = convertSet(permissions, PmsKnowledgeContentPermissionDO::getId); + // 1.2 超级管理员可以读取全部内容 + if (permissionApi.hasAnyRoles(userId, RoleCodeEnum.SUPER_ADMIN.getCode()).getCheckedData()) { + return permissionIds; + } + + // 2.1 一次查询当前用户及其部门的知识库成员身份,用户身份优先于部门身份 + AdminUserRespDTO user = adminUserApi.getUser(userId).getCheckedData(); + Long deptId = user != null ? user.getDeptId() : null; + Map userLibraryMemberMap = new LinkedHashMap<>(); + Map deptLibraryMemberMap = new LinkedHashMap<>(); + List libraryMembers = libraryMemberMapper.selectListByLibraryIds(libraryIds); + for (PmsKnowledgeLibraryMemberDO member : libraryMembers) { + if (userId.equals(member.getUserId())) { + userLibraryMemberMap.putIfAbsent(member.getLibraryId(), member); + } else if (deptId != null && deptId.equals(member.getDeptId())) { + deptLibraryMemberMap.putIfAbsent(member.getLibraryId(), member); + } + } + Set adminLibraryIds = new LinkedHashSet<>(); + for (Long libraryId : libraryIds) { + PmsKnowledgeLibraryMemberDO member = userLibraryMemberMap.get(libraryId); + if (member == null) { + member = deptLibraryMemberMap.get(libraryId); + } + if (member != null && equalsAny(member.getLevel(), + PmsKnowledgeLibraryMemberLevelEnum.CREATOR.getLevel(), + PmsKnowledgeLibraryMemberLevelEnum.ADMIN.getLevel())) { + adminLibraryIds.add(libraryId); + } + } + // 2.2 一次查询当前用户及其部门的内容协作权限,用户权限优先于部门权限 + Map userPermissionMemberMap = new LinkedHashMap<>(); + Map deptPermissionMemberMap = new LinkedHashMap<>(); + for (PmsKnowledgeContentPermissionMemberDO member : permissionMemberMapper.selectListByPermissionIds(permissionIds)) { + if (userId.equals(member.getUserId())) { + userPermissionMemberMap.put(member.getPermissionId(), member); + } else if (deptId != null && deptId.equals(member.getDeptId())) { + deptPermissionMemberMap.put(member.getPermissionId(), member); + } + } + + // 3. 库管理员、本人和部门的显式权限优先于公开权限 + Set readablePermissionIds = new LinkedHashSet<>(); + for (PmsKnowledgeContentPermissionDO permission : permissions) { + if (adminLibraryIds.contains(permission.getLibraryId()) + || userPermissionMemberMap.containsKey(permission.getId()) + || deptPermissionMemberMap.containsKey(permission.getId()) + || Boolean.TRUE.equals(permission.getOpenStatus())) { + readablePermissionIds.add(permission.getId()); + } + } + return readablePermissionIds; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateContentPermission(PmsKnowledgeContentPermissionUpdateReqVO updateReqVO, Long userId) { + // 1.1 校验内容权限存在 + PmsKnowledgeContentPermissionDO permission = validatePermissionExists(updateReqVO.getId()); + // 1.2 校验知识库可访问 + PmsKnowledgeLibraryDO library = libraryMemberService.validateLibraryReadable(permission.getLibraryId(), userId); + // 1.3 校验内容管理权限 + if (!PmsKnowledgeContentLevelEnum.MANAGE.getLevel().equals( + getCurrentUserContentPermissionLevel(permission.getId(), permission.getLibraryId(), userId))) { + throw exception(KNOWLEDGE_CONTENT_WRITE_ACCESS_DENIED); + } + // 1.4 校验协作者唯一 + Set userIds = convertSet(updateReqVO.getMembers(), + PmsKnowledgeContentPermissionUpdateReqVO.Member::getUserId); + Set deptIds = convertSet(updateReqVO.getMembers(), + PmsKnowledgeContentPermissionUpdateReqVO.Member::getDeptId); + if (userIds.size() + deptIds.size() != updateReqVO.getMembers().size()) { + throw exception(KNOWLEDGE_CONTENT_PERMISSION_MEMBERS_INVALID); + } + // 1.5 校验协作者不能包含内容创建人 + if (userIds.contains(NumberUtils.parseLong(permission.getCreator()))) { + throw exception(KNOWLEDGE_CONTENT_PERMISSION_MEMBERS_INVALID); + } + // 1.6 校验用户协作者存在 + adminUserApi.validateUserList(userIds).checkError(); + // 1.7 校验部门协作者存在 + deptApi.validateDeptList(deptIds).checkError(); + // 1.8 私有知识库的用户协作者必须是知识库成员 + if (!library.getOpenStatus()) { + List libraryMembers = libraryMemberMapper.selectListByLibraryId( + library.getId()); + Set libraryUserIds = convertSet(libraryMembers, PmsKnowledgeLibraryMemberDO::getUserId); + Set libraryDeptIds = convertSet(libraryMembers, PmsKnowledgeLibraryMemberDO::getDeptId); + if (!libraryUserIds.containsAll(userIds)) { + throw exception(KNOWLEDGE_CONTENT_PERMISSION_MEMBERS_INVALID); + } + // 1.9 私有知识库的部门协作者必须是知识库成员 + if (!libraryDeptIds.containsAll(deptIds)) { + throw exception(KNOWLEDGE_CONTENT_PERMISSION_MEMBERS_INVALID); + } + } + + // 2. 更新公开权限并重建协作者 + permissionMapper.updateById(BeanUtils.toBean(updateReqVO, PmsKnowledgeContentPermissionDO.class) + .setId(permission.getId()).setLibraryId(null)); + permissionMemberMapper.deleteByPermissionId(permission.getId()); + List members = convertList(updateReqVO.getMembers(), member -> + new PmsKnowledgeContentPermissionMemberDO().setPermissionId(permission.getId()).setUserId(member.getUserId()) + .setDeptId(member.getDeptId()).setLevel(member.getLevel())); + members.add(0, new PmsKnowledgeContentPermissionMemberDO().setPermissionId(permission.getId()) + .setUserId(NumberUtils.parseLong(permission.getCreator())) + .setLevel(PmsKnowledgeContentLevelEnum.MANAGE.getLevel())); + permissionMemberMapper.insertBatch(members); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteUnusedContentPermissions(Set permissionIds) { + if (CollUtil.isEmpty(permissionIds)) { + return; + } + // 仅删除已经不被任何文件夹或文档引用的权限,避免同一权限被兄弟内容复用时误删 + permissionIds.removeAll(folderService.getExistingContentPermissionIdSet(permissionIds)); + permissionIds.removeAll(documentService.getExistingContentPermissionIdSet(permissionIds)); + if (CollUtil.isEmpty(permissionIds)) { + return; + } + + permissionMemberMapper.deleteByPermissionIds(permissionIds); + permissionMapper.deleteByIds(permissionIds); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteContentPermissionsByLibraryId(Long libraryId) { + List permissions = permissionMapper.selectListByLibraryId(libraryId); + Set permissionIds = convertSet(permissions, PmsKnowledgeContentPermissionDO::getId); + if (CollUtil.isEmpty(permissionIds)) { + return; + } + + // 先删除权限成员关系,再删除权限主记录 + permissionMemberMapper.deleteByPermissionIds(permissionIds); + permissionMapper.deleteByIds(permissionIds); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteContentPermissionMembersByLibraryId(Long libraryId, Collection userIds, Collection deptIds) { + if (CollUtil.isEmpty(userIds) && CollUtil.isEmpty(deptIds)) { + return; + } + Set permissionIds = convertSet(permissionMapper.selectListByLibraryId(libraryId), + PmsKnowledgeContentPermissionDO::getId); + if (CollUtil.isEmpty(permissionIds)) { + return; + } + + // 分别清理用户和部门的内容级协作关系 + if (CollUtil.isNotEmpty(userIds)) { + permissionMemberMapper.deleteByPermissionIdsAndUserIds(permissionIds, userIds); + } + if (CollUtil.isNotEmpty(deptIds)) { + permissionMemberMapper.deleteByPermissionIdsAndDeptIds(permissionIds, deptIds); + } + } + + private PmsKnowledgeContentPermissionDO validatePermissionExists(Long permissionId) { + PmsKnowledgeContentPermissionDO permission = permissionMapper.selectById(permissionId); + if (permission == null) { + throw exception(KNOWLEDGE_CONTENT_PERMISSION_NOT_EXISTS); + } + return permission; + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeDocumentLabelService.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeDocumentLabelService.java new file mode 100644 index 000000000..3f7e9e67b --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeDocumentLabelService.java @@ -0,0 +1,73 @@ +package cn.iocoder.yudao.module.pms.service.kb.content; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.label.PmsKnowledgeDocumentLabelPageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.label.PmsKnowledgeDocumentLabelSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeDocumentDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeDocumentLabelDO; + +import java.util.Collection; +import java.util.List; + +/** + * PMS 知识库文档标签 Service 接口 + * + * @author 芋道源码 + */ +public interface PmsKnowledgeDocumentLabelService { + + /** + * 创建文档标签 + * + * @param saveReqVO 保存信息 + * @return 标签编号 + */ + Long createDocumentLabel(PmsKnowledgeDocumentLabelSaveReqVO saveReqVO); + + /** + * 更新文档标签 + * + * @param saveReqVO 保存信息 + */ + void updateDocumentLabel(PmsKnowledgeDocumentLabelSaveReqVO saveReqVO); + + /** + * 删除文档标签 + * + * @param id 标签编号 + */ + void deleteDocumentLabel(Long id); + + /** + * 获得文档标签 + * + * @param id 标签编号 + * @return 文档标签 + */ + PmsKnowledgeDocumentLabelDO getDocumentLabel(Long id); + + /** + * 获得文档标签列表 + * + * @return 标签列表 + */ + List getDocumentLabelList(); + + /** + * 校验文档标签均存在 + * + * @param ids 标签编号集合 + */ + void validateDocumentLabelList(Collection ids); + + /** + * 按标签获得当前用户可读的文档分页 + * + * @param pageReqVO 分页查询 + * @param userId 用户编号 + * @return 文档分页 + */ + PageResult getDocumentPageByLabel( + PmsKnowledgeDocumentLabelPageReqVO pageReqVO, Long userId); + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeDocumentLabelServiceImpl.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeDocumentLabelServiceImpl.java new file mode 100644 index 000000000..4a91d81fd --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeDocumentLabelServiceImpl.java @@ -0,0 +1,125 @@ +package cn.iocoder.yudao.module.pms.service.kb.content; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.label.PmsKnowledgeDocumentLabelPageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.label.PmsKnowledgeDocumentLabelSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeDocumentDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeDocumentLabelDO; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.content.PmsKnowledgeDocumentLabelMapper; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.content.PmsKnowledgeDocumentMapper; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeDocumentStatusEnum; +import cn.iocoder.yudao.module.pms.service.kb.library.PmsKnowledgeLibraryMemberService; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_DOCUMENT_LABEL_NOT_EXISTS; + +/** + * PMS 知识库文档标签 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class PmsKnowledgeDocumentLabelServiceImpl implements PmsKnowledgeDocumentLabelService { + + @Resource + private PmsKnowledgeDocumentLabelMapper documentLabelMapper; + @Resource + private PmsKnowledgeDocumentMapper documentMapper; + + @Resource + private PmsKnowledgeLibraryMemberService libraryMemberService; + @Resource + private PmsKnowledgeContentPermissionService contentPermissionService; + + @Override + public Long createDocumentLabel(PmsKnowledgeDocumentLabelSaveReqVO saveReqVO) { + PmsKnowledgeDocumentLabelDO documentLabel = BeanUtils.toBean(saveReqVO, PmsKnowledgeDocumentLabelDO.class) + .setName(StrUtil.trim(saveReqVO.getName())).setColor(StrUtil.trim(saveReqVO.getColor())); + documentLabelMapper.insert(documentLabel); + return documentLabel.getId(); + } + + @Override + public void updateDocumentLabel(PmsKnowledgeDocumentLabelSaveReqVO saveReqVO) { + // 1. 校验文档标签存在 + validateDocumentLabelExists(saveReqVO.getId()); + + // 2. 更新文档标签 + documentLabelMapper.updateById(BeanUtils.toBean(saveReqVO, PmsKnowledgeDocumentLabelDO.class) + .setName(StrUtil.trim(saveReqVO.getName())).setColor(StrUtil.trim(saveReqVO.getColor()))); + } + + @Override + public void deleteDocumentLabel(Long id) { + // 1. 校验文档标签存在 + validateDocumentLabelExists(id); + + // 2. 删除文档标签 + documentLabelMapper.deleteById(id); + } + + @Override + public PmsKnowledgeDocumentLabelDO getDocumentLabel(Long id) { + return documentLabelMapper.selectById(id); + } + + @Override + public List getDocumentLabelList() { + return documentLabelMapper.selectList(); + } + + @Override + public void validateDocumentLabelList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return; + } + List list = documentLabelMapper.selectByIds(ids); + if (list.size() != ids.size()) { + throw exception(KNOWLEDGE_DOCUMENT_LABEL_NOT_EXISTS); + } + } + + @Override + public PageResult getDocumentPageByLabel( + PmsKnowledgeDocumentLabelPageReqVO pageReqVO, Long userId) { + // 1.1 校验文档标签存在 + validateDocumentLabelExists(pageReqVO.getLabelId()); + // 1.2 获得当前用户可读取的知识库 + List readableLibraryIds = libraryMemberService.getReadableLibraryIdList(userId); + if (CollUtil.isEmpty(readableLibraryIds)) { + return PageResult.empty(); + } + Set readablePermissionIds = contentPermissionService + .getReadableContentPermissionIdSet(readableLibraryIds, userId); + if (CollUtil.isEmpty(readablePermissionIds)) { + return PageResult.empty(); + } + + // 2. 按标签分页查询非回收站文档 + return documentMapper.selectPageByLabelIdAndStatuses( + pageReqVO, readableLibraryIds, readablePermissionIds, + PmsKnowledgeDocumentStatusEnum.ACTIVE_STATUSES); + } + + private PmsKnowledgeDocumentLabelDO validateDocumentLabelExists(Long id) { + PmsKnowledgeDocumentLabelDO documentLabel = documentLabelMapper.selectById(id); + if (documentLabel == null) { + throw exception(KNOWLEDGE_DOCUMENT_LABEL_NOT_EXISTS); + } + return documentLabel; + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeDocumentService.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeDocumentService.java new file mode 100644 index 000000000..4be9ba15a --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeDocumentService.java @@ -0,0 +1,174 @@ +package cn.iocoder.yudao.module.pms.service.kb.content; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.document.PmsKnowledgeDocumentCreateReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.document.PmsKnowledgeDocumentMoveReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.document.PmsKnowledgeDocumentSearchPageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.document.PmsKnowledgeDocumentUpdateReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeDocumentDO; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * PMS 知识库文档 Service 接口 + * + * @author 芋道源码 + */ +public interface PmsKnowledgeDocumentService { + + /** + * 创建文档 + * + * @param createReqVO 创建信息 + * @param userId 用户编号 + * @return 文档编号 + */ + Long createDocument(PmsKnowledgeDocumentCreateReqVO createReqVO, Long userId); + + /** + * 更新文档内容 + * + * @param updateReqVO 更新信息 + * @param userId 用户编号 + */ + void updateDocument(PmsKnowledgeDocumentUpdateReqVO updateReqVO, Long userId); + + /** + * 删除文档,并将文档和全部子文档移入回收站 + * + * @param id 文档编号 + * @param userId 用户编号 + */ + void deleteDocument(Long id, Long userId); + + /** + * 移动文档到目标目录,跨知识库移动时复制内容权限 + * + * @param moveReqVO 移动信息 + * @param userId 用户编号 + */ + void moveDocument(PmsKnowledgeDocumentMoveReqVO moveReqVO, Long userId); + + /** + * 获得文档,校验当前用户有内容读取权限 + * + * @param id 文档编号 + * @param userId 用户编号 + * @return 文档 + */ + PmsKnowledgeDocumentDO getDocument(Long id, Long userId); + + /** + * 获得文档 + * + * @param id 文档编号 + * @return 文档 + */ + PmsKnowledgeDocumentDO getDocument(Long id); + + /** + * 获得文档列表 + * + * @param ids 文档编号集合 + * @return 文档列表 + */ + List getDocumentList(Collection ids); + + /** + * 获得文档 Map + * + * @param ids 文档编号集合 + * @return 文档编号到文档的 Map + */ + default Map getDocumentMap(Collection ids) { + return convertMap(getDocumentList(ids), PmsKnowledgeDocumentDO::getId); + } + + /** + * 获得知识库的全部文档列表 + * + * @param libraryId 知识库编号 + * @return 文档列表 + */ + List getDocumentListByLibraryId(Long libraryId); + + /** + * 批量更新文档 + * + * @param documents 文档列表 + */ + void updateDocumentList(Collection documents); + + /** + * 批量恢复文档 + * + * @param ids 文档编号集合 + */ + void restoreDocumentList(Collection ids); + + /** + * 批量彻底删除文档 + * + * @param ids 文档编号集合 + */ + void deleteDocumentList(Collection ids); + + /** + * 获得文件夹及其子文件夹下的全部文档 + * + * @param folderIds 文件夹编号集合 + * @return 文档及其子文档列表 + */ + List getDocumentListByFolderIds(Collection folderIds); + + /** + * 更新文档移动到其他知识库时的归属和内容权限 + * + * @param documents 文档列表 + * @param targetLibraryId 目标知识库编号 + * @param permissionIdMap 原权限编号到新权限编号的映射 + */ + void moveDocumentList(Collection documents, Long targetLibraryId, + Map permissionIdMap); + + /** + * 使用 MySQL 搜索当前用户可读的文档(用户可按需二次开发,使用 Elasticsearch 检索) + * + * @param pageReqVO 分页搜索条件 + * @param userId 用户编号 + * @return 文档分页 + */ + PageResult getDocumentSearchPage( + PmsKnowledgeDocumentSearchPageReqVO pageReqVO, Long userId); + + /** + * 获得知识库的正常文档列表,仅返回当前用户有内容读取权限的文档 + * + * @param libraryId 知识库编号 + * @param userId 用户编号 + * @return 文档列表 + */ + List getDocumentList(Long libraryId, Long userId); + + /** + * 获得知识库文档类型数量 Map + * + * @param libraryIds 知识库编号集合 + * @return 知识库编号到文档类型数量 Map + */ + Map> getDocumentTypeCountMap(Collection libraryIds); + + /** + * 获得仍被文档引用的内容权限编号集合 + * + * @param permissionIds 内容权限编号集合 + * @return 被引用的内容权限编号集合 + */ + Set getExistingContentPermissionIdSet(Collection permissionIds); + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeDocumentServiceImpl.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeDocumentServiceImpl.java new file mode 100644 index 000000000..c1e4f6890 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeDocumentServiceImpl.java @@ -0,0 +1,462 @@ +package cn.iocoder.yudao.module.pms.service.kb.content; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.document.PmsKnowledgeDocumentCreateReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.document.PmsKnowledgeDocumentMoveReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.document.PmsKnowledgeDocumentSearchPageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.document.PmsKnowledgeDocumentUpdateReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeDocumentDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeFolderDO; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.content.PmsKnowledgeDocumentMapper; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeDocumentStatusEnum; +import cn.iocoder.yudao.module.pms.service.kb.interaction.PmsKnowledgeFavoriteService; +import cn.iocoder.yudao.module.pms.service.kb.interaction.PmsKnowledgeViewRecordService; +import cn.iocoder.yudao.module.pms.service.kb.library.PmsKnowledgeLibraryMemberService; +import cn.iocoder.yudao.module.pms.service.kb.recycle.PmsKnowledgeRecycleService; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Deque; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.hutool.core.util.ObjectUtil.notEqual; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_DOCUMENT_FOLDER_INVALID; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_DOCUMENT_MOVE_INVALID; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_DOCUMENT_MOVE_TARGET_INVALID; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_DOCUMENT_NOT_EXISTS; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_DOCUMENT_PARENT_INVALID; + +/** + * PMS 知识库文档 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class PmsKnowledgeDocumentServiceImpl implements PmsKnowledgeDocumentService { + + @Resource + private PmsKnowledgeDocumentMapper documentMapper; + + @Resource + private PmsKnowledgeFolderService folderService; + @Resource + private PmsKnowledgeLibraryMemberService libraryMemberService; + @Resource + private PmsKnowledgeContentPermissionService contentPermissionService; + @Resource + private PmsKnowledgeDocumentLabelService documentLabelService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private PmsKnowledgeFavoriteService favoriteService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private PmsKnowledgeViewRecordService viewRecordService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private PmsKnowledgeRecycleService recycleService; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createDocument(PmsKnowledgeDocumentCreateReqVO createReqVO, Long userId) { + // 1.1 校验知识库读取权限 + libraryMemberService.validateLibraryReadable(createReqVO.getLibraryId(), userId); + // 1.2 规范并校验目录位置,同时确定文档权限 + PmsKnowledgeDocumentDO document = BeanUtils.toBean(createReqVO, PmsKnowledgeDocumentDO.class); + Long permissionId = normalizeAndValidateDocumentPlacement(document, null, userId); + if (permissionId == null) { + permissionId = contentPermissionService.createDefaultContentPermission(createReqVO.getLibraryId(), userId); + } + + // 2. 创建文档 + document.setPermissionId(permissionId).setType(createReqVO.getType()) + .setStatus(PmsKnowledgeDocumentStatusEnum.NORMAL.getStatus()); + documentMapper.insert(document); + return document.getId(); + } + + @Override + public void updateDocument(PmsKnowledgeDocumentUpdateReqVO updateReqVO, Long userId) { + // 1.1 校验文档存在 + PmsKnowledgeDocumentDO document = validateDocumentExists(updateReqVO.getId()); + // 1.2 校验所属知识库可读 + libraryMemberService.validateLibraryReadable(document.getLibraryId(), userId); + // 1.3 校验内容编辑权限 + contentPermissionService.validateContentPermissionWritable(document.getPermissionId(), document.getLibraryId(), userId); + // 1.4 校验标签均存在 + documentLabelService.validateDocumentLabelList(updateReqVO.getLabelIds()); + + // 2. 更新文档内容 + documentMapper.updateById(BeanUtils.toBean(updateReqVO, PmsKnowledgeDocumentDO.class)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteDocument(Long id, Long userId) { + // 1.1 校验文档存在 + PmsKnowledgeDocumentDO document = validateDocumentExists(id); + // 1.2 校验所属知识库可读 + libraryMemberService.validateLibraryReadable(document.getLibraryId(), userId); + // 1.3 校验文档内容删除权限 + contentPermissionService.validateContentPermissionDeletable(document.getPermissionId(), document.getLibraryId(), userId); + + // 2. 将文档和全部子文档移入回收站 + recycleService.recycleDocument(document, getDocumentDescendantList(document), userId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void moveDocument(PmsKnowledgeDocumentMoveReqVO moveReqVO, Long userId) { + // 1.1 校验源文档存在 + PmsKnowledgeDocumentDO document = validateDocumentExists(moveReqVO.getId()); + // 1.2 校验移动目标只能选择一种容器 + if (notEqual(PmsKnowledgeDocumentDO.FOLDER_ID_ROOT, moveReqVO.getTargetFolderId()) + && notEqual(PmsKnowledgeDocumentDO.PARENT_ID_ROOT, moveReqVO.getTargetParentId())) { + throw exception(KNOWLEDGE_DOCUMENT_MOVE_TARGET_INVALID); + } + // 1.3 校验源知识库和目标知识库可读 + libraryMemberService.validateLibraryReadable(document.getLibraryId(), userId); + libraryMemberService.validateLibraryReadable(moveReqVO.getTargetLibraryId(), userId); + // 1.4 移动会改变目录结构,校验源文档管理权限 + contentPermissionService.validateContentPermissionManageable( + document.getPermissionId(), document.getLibraryId(), userId); + // 1.5 规范并校验目标位置,禁止移动到自身或子文档 + PmsKnowledgeDocumentDO placement = new PmsKnowledgeDocumentDO() + .setLibraryId(moveReqVO.getTargetLibraryId()).setFolderId(moveReqVO.getTargetFolderId()) + .setParentId(moveReqVO.getTargetParentId()); + normalizeAndValidateDocumentPlacement(placement, document, userId); + + // 2.1 获得文档子树 + List documents = getDocumentDescendantList(document); + // 2.2 跨知识库移动时复制权限,避免影响原知识库内容 + Set sourcePermissionIds = convertSet(documents, PmsKnowledgeDocumentDO::getPermissionId); + boolean crossLibrary = notEqual(document.getLibraryId(), moveReqVO.getTargetLibraryId()); + Map clonedPermissionIdMap = !crossLibrary + ? Collections.emptyMap() + : contentPermissionService.cloneContentPermissions(sourcePermissionIds, moveReqVO.getTargetLibraryId()); + + // 3. 更新文档位置,并同步全部子文档所属知识库与权限 + for (PmsKnowledgeDocumentDO item : documents) { + item.setLibraryId(moveReqVO.getTargetLibraryId()); + if (CollUtil.isNotEmpty(clonedPermissionIdMap)) { + item.setPermissionId(clonedPermissionIdMap.get(item.getPermissionId())); + } + if (item.getId().equals(document.getId())) { + item.setFolderId(placement.getFolderId()).setParentId(placement.getParentId()); + } + } + if (CollUtil.isNotEmpty(documents)) { + documentMapper.updateBatch(documents); + } + + // 4. 跨知识库移动时同步关注和最近浏览记录的当前归属 + if (crossLibrary) { + Set documentIds = convertSet(documents, PmsKnowledgeDocumentDO::getId); + favoriteService.updateFavoriteLibraryIdByEntityIds(Collections.emptySet(), documentIds, moveReqVO.getTargetLibraryId()); + viewRecordService.updateViewRecordLibraryIdByEntityIds(Collections.emptySet(), documentIds, moveReqVO.getTargetLibraryId()); + } + if (CollUtil.isNotEmpty(clonedPermissionIdMap)) { + contentPermissionService.deleteUnusedContentPermissions(sourcePermissionIds); + } + } + + @Override + public PmsKnowledgeDocumentDO getDocument(Long id, Long userId) { + // 1. 校验文档存在 + PmsKnowledgeDocumentDO document = validateDocumentExists(id); + + // 2.1 校验知识库读取权限 + libraryMemberService.validateLibraryReadable(document.getLibraryId(), userId); + // 2.2 校验内容读取权限 + contentPermissionService.validateContentPermissionReadable(document.getPermissionId(), document.getLibraryId(), userId); + return document; + } + + @Override + public PmsKnowledgeDocumentDO getDocument(Long id) { + return documentMapper.selectById(id); + } + + @Override + public List getDocumentList(Collection ids) { + return CollUtil.isEmpty(ids) ? Collections.emptyList() : documentMapper.selectByIds(ids); + } + + @Override + public List getDocumentListByLibraryId(Long libraryId) { + return documentMapper.selectListByLibraryId(libraryId); + } + + @Override + public void updateDocumentList(Collection documents) { + if (CollUtil.isEmpty(documents)) { + return; + } + documentMapper.updateBatch(documents); + } + + @Override + public void restoreDocumentList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return; + } + documentMapper.updateToRestoreByIds(ids, PmsKnowledgeDocumentStatusEnum.NORMAL.getStatus()); + } + + @Override + public void deleteDocumentList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return; + } + documentMapper.deleteByIds(ids); + } + + @Override + public List getDocumentListByFolderIds(Collection folderIds) { + // 1. 查询文件夹直接包含的根文档 + if (CollUtil.isEmpty(folderIds)) { + return Collections.emptyList(); + } + List roots = documentMapper.selectListByFolderIds(folderIds); + if (CollUtil.isEmpty(roots)) { + return Collections.emptyList(); + } + List documents = new ArrayList<>(roots); + Set visitedDocumentIds = new LinkedHashSet<>(convertSet(roots, PmsKnowledgeDocumentDO::getId)); + Set parentIds = convertSet(roots, PmsKnowledgeDocumentDO::getId); + + // 2. 逐层查询子文档;使用深度上限和已访问集合共同防御异常循环数据 + for (int depth = 0; depth < Short.MAX_VALUE && CollUtil.isNotEmpty(parentIds); depth++) { + List childDocuments = documentMapper.selectListByParentIds(parentIds); + List newChildDocuments = new ArrayList<>(); + for (PmsKnowledgeDocumentDO childDocument : childDocuments) { + if (visitedDocumentIds.add(childDocument.getId())) { + newChildDocuments.add(childDocument); + } + } + if (CollUtil.isEmpty(newChildDocuments)) { + break; + } + documents.addAll(newChildDocuments); + parentIds = convertSet(newChildDocuments, PmsKnowledgeDocumentDO::getId); + } + return documents; + } + + @Override + public void moveDocumentList(Collection documents, Long targetLibraryId, + Map permissionIdMap) { + if (CollUtil.isEmpty(documents)) { + return; + } + for (PmsKnowledgeDocumentDO document : documents) { + document.setLibraryId(targetLibraryId); + if (CollUtil.isNotEmpty(permissionIdMap)) { + document.setPermissionId(permissionIdMap.get(document.getPermissionId())); + } + } + documentMapper.updateBatch(documents); + } + + @Override + public PageResult getDocumentSearchPage( + PmsKnowledgeDocumentSearchPageReqVO pageReqVO, Long userId) { + // 1. 查询当前用户可读的知识库和内容权限 + List readableLibraryIds = libraryMemberService.getReadableLibraryIdList(userId); + if (CollUtil.isEmpty(readableLibraryIds) + || pageReqVO.getLibraryId() != null && !readableLibraryIds.contains(pageReqVO.getLibraryId())) { + return PageResult.empty(); + } + Set readablePermissionIds = contentPermissionService.getReadableContentPermissionIdSet(readableLibraryIds, userId); + if (CollUtil.isEmpty(readablePermissionIds)) { + return PageResult.empty(); + } + + // 2. 使用 MySQL 对标题和正文进行模糊搜索 + return documentMapper.selectPage(pageReqVO, readableLibraryIds, readablePermissionIds, + PmsKnowledgeDocumentStatusEnum.ACTIVE_STATUSES); + } + + @Override + public List getDocumentList(Long libraryId, Long userId) { + // 1. 校验知识库可读 + libraryMemberService.validateLibraryReadable(libraryId, userId); + + // 2. 查询全部正常文档 + List documents = documentMapper.selectListByLibraryIdAndStatus( + libraryId, PmsKnowledgeDocumentStatusEnum.NORMAL.getStatus()); + if (CollUtil.isEmpty(documents)) { + return Collections.emptyList(); + } + + // 3. 按内容权限过滤,只返回当前用户有读取权限的文档 + Map permissionLevelMap = contentPermissionService.getCurrentUserContentPermissionLevelMap( + convertSet(documents, PmsKnowledgeDocumentDO::getPermissionId), libraryId, userId); + documents.removeIf(document -> permissionLevelMap.get(document.getPermissionId()) == null); + + // 4. 父文档不可见时,整条子文档分支均不可见 + Map> childDocumentIdMap = new LinkedHashMap<>(); + Set visibleDocumentIds = new LinkedHashSet<>(); + Deque pendingDocumentIds = new ArrayDeque<>(); + for (PmsKnowledgeDocumentDO document : documents) { + if (PmsKnowledgeDocumentDO.PARENT_ID_ROOT.equals(document.getParentId())) { + visibleDocumentIds.add(document.getId()); + pendingDocumentIds.add(document.getId()); + } else { + childDocumentIdMap.computeIfAbsent(document.getParentId(), key -> new ArrayList<>()) + .add(document.getId()); + } + } + while (CollUtil.isNotEmpty(pendingDocumentIds)) { + Long parentId = pendingDocumentIds.removeFirst(); + for (Long childId : childDocumentIdMap.getOrDefault(parentId, Collections.emptyList())) { + if (visibleDocumentIds.add(childId)) { + pendingDocumentIds.addLast(childId); + } + } + } + documents.removeIf(document -> !visibleDocumentIds.contains(document.getId())); + return documents; + } + + @Override + public Map> getDocumentTypeCountMap(Collection libraryIds) { + return CollUtil.isEmpty(libraryIds) ? Collections.emptyMap() + : documentMapper.selectTypeCountMapByLibraryIdsAndStatus( + libraryIds, PmsKnowledgeDocumentStatusEnum.NORMAL.getStatus()); + } + + private PmsKnowledgeDocumentDO validateDocumentExists(Long id) { + PmsKnowledgeDocumentDO document = documentMapper.selectById(id); + if (document == null || PmsKnowledgeDocumentStatusEnum.RECYCLED.getStatus().equals(document.getStatus())) { + throw exception(KNOWLEDGE_DOCUMENT_NOT_EXISTS); + } + return document; + } + + /** + * 规范并校验文档目录位置,返回继承的内容权限编号 + * + * @param placement 文档位置 + * @param document 待移动文档;创建时为空 + * @param userId 用户编号 + * @return 继承的内容权限编号;位于知识库根目录时返回 {@code null} + */ + private Long normalizeAndValidateDocumentPlacement(PmsKnowledgeDocumentDO placement, + PmsKnowledgeDocumentDO document, + Long userId) { + // 1. 文件夹优先于父文档,文档放入文件夹根节点并继承文件夹权限 + if (placement.getFolderId() != null + && notEqual(PmsKnowledgeDocumentDO.FOLDER_ID_ROOT, placement.getFolderId())) { + placement.setParentId(PmsKnowledgeDocumentDO.PARENT_ID_ROOT); + PmsKnowledgeFolderDO folder = folderService.getFolder(placement.getFolderId(), userId); + if (notEqual(placement.getLibraryId(), folder.getLibraryId()) + || notEqual(PmsKnowledgeDocumentStatusEnum.NORMAL.getStatus(), folder.getStatus())) { + throw exception(KNOWLEDGE_DOCUMENT_FOLDER_INVALID); + } + if (document == null) { + contentPermissionService.validateContentPermissionWritable( + folder.getPermissionId(), folder.getLibraryId(), userId); + } else { + contentPermissionService.validateContentPermissionManageable( + folder.getPermissionId(), folder.getLibraryId(), userId); + } + return folder.getPermissionId(); + } + + // 2. 没有指定文件夹和父文档时,规范到知识库根目录 + placement.setFolderId(PmsKnowledgeDocumentDO.FOLDER_ID_ROOT); + if (placement.getParentId() == null + || PmsKnowledgeDocumentDO.PARENT_ID_ROOT.equals(placement.getParentId())) { + placement.setParentId(PmsKnowledgeDocumentDO.PARENT_ID_ROOT); + if (document == null) { + libraryMemberService.validateLibraryWritable(placement.getLibraryId(), userId); + } else { + libraryMemberService.validateLibraryAdmin(placement.getLibraryId(), userId); + } + return null; + } + + // 3.1 校验父文档存在 + PmsKnowledgeDocumentDO parent = documentMapper.selectById(placement.getParentId()); + if (parent == null) { + throw exception(KNOWLEDGE_DOCUMENT_PARENT_INVALID); + } + // 3.2 校验父文档属于目标知识库 + if (notEqual(placement.getLibraryId(), parent.getLibraryId())) { + throw exception(KNOWLEDGE_DOCUMENT_PARENT_INVALID); + } + // 3.3 校验父文档处于正常状态 + if (notEqual(PmsKnowledgeDocumentStatusEnum.NORMAL.getStatus(), parent.getStatus())) { + throw exception(KNOWLEDGE_DOCUMENT_PARENT_INVALID); + } + // 3.4 校验文档不能移动到自身或子文档下 + if (document != null) { + List documents = getDocumentDescendantList(document); + Set documentIds = convertSet(documents, PmsKnowledgeDocumentDO::getId); + if (documentIds.contains(placement.getParentId())) { + throw exception(KNOWLEDGE_DOCUMENT_MOVE_INVALID); + } + } + // 3.5 创建时校验可编辑,移动时校验目标父文档管理权限 + if (document == null) { + contentPermissionService.validateContentPermissionWritable( + parent.getPermissionId(), parent.getLibraryId(), userId); + } else { + contentPermissionService.validateContentPermissionManageable( + parent.getPermissionId(), parent.getLibraryId(), userId); + } + return parent.getPermissionId(); + } + + /** + * 获得根文档及全部子文档列表 + * + * @param root 根文档 + * @return 根文档及全部子文档列表 + */ + private List getDocumentDescendantList(PmsKnowledgeDocumentDO root) { + // 1. 初始化结果、已访问文档编号和首层父文档编号 + List documents = new ArrayList<>(); + documents.add(root); + Set visitedDocumentIds = new LinkedHashSet<>(); + visitedDocumentIds.add(root.getId()); + Collection parentIds = Collections.singleton(root.getId()); + + // 2. 逐层查询子文档,并通过已访问文档编号避免异常环路和重复数据 + for (int i = 0; i < Short.MAX_VALUE; i++) { + List childDocuments = documentMapper.selectListByParentIds(parentIds); + childDocuments.removeIf(child -> !visitedDocumentIds.add(child.getId())); + if (CollUtil.isEmpty(childDocuments)) { + break; + } + documents.addAll(childDocuments); + parentIds = convertSet(childDocuments, PmsKnowledgeDocumentDO::getId); + } + return documents; + } + + @Override + public Set getExistingContentPermissionIdSet(Collection permissionIds) { + if (CollUtil.isEmpty(permissionIds)) { + return Collections.emptySet(); + } + return documentMapper.selectExistingPermissionIdSet(permissionIds); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeFolderService.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeFolderService.java new file mode 100644 index 000000000..2e529337b --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeFolderService.java @@ -0,0 +1,122 @@ +package cn.iocoder.yudao.module.pms.service.kb.content; + +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.folder.PmsKnowledgeFolderMoveReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.folder.PmsKnowledgeFolderSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeFolderDO; + +import java.util.Collection; +import java.util.List; +import java.util.Set; + +/** + * PMS 知识库文件夹 Service 接口 + * + * @author 芋道源码 + */ +public interface PmsKnowledgeFolderService { + + /** + * 创建文件夹 + * + * @param saveReqVO 保存信息 + * @param userId 用户编号 + * @return 文件夹编号 + */ + Long createFolder(PmsKnowledgeFolderSaveReqVO saveReqVO, Long userId); + + /** + * 更新文件夹名称 + * + * @param saveReqVO 保存信息 + * @param userId 用户编号 + */ + void updateFolder(PmsKnowledgeFolderSaveReqVO saveReqVO, Long userId); + + /** + * 删除文件夹,并将文件夹子树移入回收站 + * + * @param id 文件夹编号 + * @param userId 用户编号 + */ + void deleteFolder(Long id, Long userId); + + /** + * 移动文件夹到目标目录,跨知识库移动时复制内容权限 + * + * @param moveReqVO 移动信息 + * @param userId 用户编号 + */ + void moveFolder(PmsKnowledgeFolderMoveReqVO moveReqVO, Long userId); + + /** + * 获得文件夹,校验当前用户有内容读取权限 + * + * @param id 文件夹编号 + * @param userId 用户编号 + * @return 文件夹 + */ + PmsKnowledgeFolderDO getFolder(Long id, Long userId); + + /** + * 获得文件夹 + * + * @param id 文件夹编号 + * @return 文件夹 + */ + PmsKnowledgeFolderDO getFolder(Long id); + + /** + * 获得指定编号的文件夹列表 + * + * @param ids 文件夹编号集合 + * @return 文件夹列表 + */ + List getFolderList(Collection ids); + + /** + * 获得知识库的全部文件夹列表 + * + * @param libraryId 知识库编号 + * @return 文件夹列表 + */ + List getFolderListByLibraryId(Long libraryId); + + /** + * 批量更新文件夹 + * + * @param folders 文件夹列表 + */ + void updateFolderList(Collection folders); + + /** + * 批量恢复文件夹 + * + * @param ids 文件夹编号集合 + */ + void restoreFolderList(Collection ids); + + /** + * 批量彻底删除文件夹 + * + * @param ids 文件夹编号集合 + */ + void deleteFolderList(Collection ids); + + /** + * 获得知识库的文件夹列表,仅返回当前用户有内容读取权限的文件夹 + * + * @param libraryId 知识库编号 + * @param userId 用户编号 + * @return 文件夹列表 + */ + List getFolderList(Long libraryId, Long userId); + + /** + * 获得仍被文件夹引用的内容权限编号集合 + * + * @param permissionIds 内容权限编号集合 + * @return 被引用的内容权限编号集合 + */ + Set getExistingContentPermissionIdSet(Collection permissionIds); + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeFolderServiceImpl.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeFolderServiceImpl.java new file mode 100644 index 000000000..3c357535f --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeFolderServiceImpl.java @@ -0,0 +1,319 @@ +package cn.iocoder.yudao.module.pms.service.kb.content; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.folder.PmsKnowledgeFolderMoveReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.folder.PmsKnowledgeFolderSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeDocumentDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeFolderDO; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.content.PmsKnowledgeFolderMapper; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeDocumentStatusEnum; +import cn.iocoder.yudao.module.pms.service.kb.content.PmsKnowledgeContentPermissionService; +import cn.iocoder.yudao.module.pms.service.kb.interaction.PmsKnowledgeFavoriteService; +import cn.iocoder.yudao.module.pms.service.kb.interaction.PmsKnowledgeViewRecordService; +import cn.iocoder.yudao.module.pms.service.kb.library.PmsKnowledgeLibraryMemberService; +import cn.iocoder.yudao.module.pms.service.kb.recycle.PmsKnowledgeRecycleService; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_FOLDER_MOVE_INVALID; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_FOLDER_NOT_EXISTS; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_FOLDER_PARENT_INVALID; + +/** + * PMS 知识库文件夹 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class PmsKnowledgeFolderServiceImpl implements PmsKnowledgeFolderService { + + @Resource + private PmsKnowledgeFolderMapper folderMapper; + + @Resource + @Lazy // 延迟加载,避免循环依赖 + private PmsKnowledgeDocumentService documentService; + @Resource + private PmsKnowledgeLibraryMemberService libraryMemberService; + @Resource + private PmsKnowledgeContentPermissionService contentPermissionService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private PmsKnowledgeFavoriteService favoriteService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private PmsKnowledgeViewRecordService viewRecordService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private PmsKnowledgeRecycleService recycleService; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createFolder(PmsKnowledgeFolderSaveReqVO saveReqVO, Long userId) { + // 1.1 校验知识库读取权限 + libraryMemberService.validateLibraryReadable(saveReqVO.getLibraryId(), userId); + // 1.2 校验父文件夹存在 + PmsKnowledgeFolderDO parent = validateFolderParent(saveReqVO.getLibraryId(), saveReqVO.getParentId()); + Long permissionId; + if (parent == null) { + // 1.3 根目录下校验知识库编辑权限 + libraryMemberService.validateLibraryWritable(saveReqVO.getLibraryId(), userId); + permissionId = contentPermissionService.createDefaultContentPermission(saveReqVO.getLibraryId(), userId); + } else { + // 1.3 子目录下校验父文件夹编辑权限 + contentPermissionService.validateContentPermissionWritable(parent.getPermissionId(), parent.getLibraryId(), userId); + permissionId = parent.getPermissionId(); + } + + // 2. 创建文件夹 + PmsKnowledgeFolderDO folder = BeanUtils.toBean(saveReqVO, PmsKnowledgeFolderDO.class) + .setPermissionId(permissionId) + .setStatus(PmsKnowledgeDocumentStatusEnum.NORMAL.getStatus()); + folderMapper.insert(folder); + return folder.getId(); + } + + @Override + public void updateFolder(PmsKnowledgeFolderSaveReqVO saveReqVO, Long userId) { + // 1.1 校验文件夹存在 + PmsKnowledgeFolderDO folder = validateFolderExists(saveReqVO.getId()); + // 1.2 校验所属知识库可读 + libraryMemberService.validateLibraryReadable(folder.getLibraryId(), userId); + // 1.3 校验内容编辑权限 + contentPermissionService.validateContentPermissionWritable(folder.getPermissionId(), folder.getLibraryId(), userId); + // 1.4 校验文件夹属于请求知识库 + if (ObjectUtil.notEqual(folder.getLibraryId(), saveReqVO.getLibraryId())) { + throw exception(KNOWLEDGE_FOLDER_PARENT_INVALID); + } + + // 2. 更新文件夹名称;目录位置通过移动接口维护 + folderMapper.updateById(BeanUtils.toBean(saveReqVO, PmsKnowledgeFolderDO.class) + .setId(folder.getId()).setLibraryId(null).setParentId(null)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteFolder(Long id, Long userId) { + // 1.1 校验文件夹存在 + PmsKnowledgeFolderDO folder = validateFolderExists(id); + // 1.2 校验所属知识库可读 + libraryMemberService.validateLibraryReadable(folder.getLibraryId(), userId); + // 1.3 校验内容删除权限 + contentPermissionService.validateContentPermissionDeletable(folder.getPermissionId(), folder.getLibraryId(), userId); + + // 2. 获得文件夹、子文件夹及其中的文档 + List folders = getFolderDescendants(folder); + List documents = documentService.getDocumentListByFolderIds( + convertSet(folders, PmsKnowledgeFolderDO::getId)); + + // 3. 将文件夹子树移入回收站 + recycleService.recycleFolder(folder, folders, documents, userId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void moveFolder(PmsKnowledgeFolderMoveReqVO moveReqVO, Long userId) { + // 1.1 校验源文件夹存在 + PmsKnowledgeFolderDO folder = validateFolderExists(moveReqVO.getId()); + // 1.2 校验源知识库和目标知识库可读 + libraryMemberService.validateLibraryReadable(folder.getLibraryId(), userId); + libraryMemberService.validateLibraryReadable(moveReqVO.getTargetLibraryId(), userId); + // 1.3 移动会改变目录结构,校验源文件夹管理权限 + contentPermissionService.validateContentPermissionManageable( + folder.getPermissionId(), folder.getLibraryId(), userId); + // 1.4 校验目标父文件夹存在 + PmsKnowledgeFolderDO targetParent = validateFolderParent(moveReqVO.getTargetLibraryId(), + moveReqVO.getTargetParentId()); + if (targetParent == null) { + // 1.5 根目录下校验目标知识库管理权限 + libraryMemberService.validateLibraryAdmin(moveReqVO.getTargetLibraryId(), userId); + } else { + // 1.5 子目录下校验目标文件夹管理权限 + contentPermissionService.validateContentPermissionManageable( + targetParent.getPermissionId(), targetParent.getLibraryId(), userId); + } + + // 2. 禁止移动到自身或子文件夹 + List folders = getFolderDescendants(folder); + Set folderIds = convertSet(folders, PmsKnowledgeFolderDO::getId); + if (folderIds.contains(moveReqVO.getTargetParentId())) { + throw exception(KNOWLEDGE_FOLDER_MOVE_INVALID); + } + + // 3. 跨知识库移动时复制权限,避免与原知识库中复用同一权限的内容相互影响 + List documents = documentService.getDocumentListByFolderIds(folderIds); + Set sourcePermissionIds = new LinkedHashSet<>(convertSet(folders, PmsKnowledgeFolderDO::getPermissionId)); + sourcePermissionIds.addAll(convertSet(documents, PmsKnowledgeDocumentDO::getPermissionId)); + Map clonedPermissionIdMap = ObjectUtil.equal(folder.getLibraryId(), moveReqVO.getTargetLibraryId()) + ? Collections.emptyMap() + : contentPermissionService.cloneContentPermissions(sourcePermissionIds, moveReqVO.getTargetLibraryId()); + boolean crossLibrary = ObjectUtil.notEqual(folder.getLibraryId(), moveReqVO.getTargetLibraryId()); + + // 4. 更新目录位置,并同步子文件夹和文档所属知识库与权限 + for (PmsKnowledgeFolderDO item : folders) { + item.setLibraryId(moveReqVO.getTargetLibraryId()); + if (CollUtil.isNotEmpty(clonedPermissionIdMap)) { + item.setPermissionId(clonedPermissionIdMap.get(item.getPermissionId())); + } + if (item.getId().equals(folder.getId())) { + item.setParentId(moveReqVO.getTargetParentId()); + } + } + if (CollUtil.isNotEmpty(folders)) { + folderMapper.updateBatch(folders); + } + documentService.moveDocumentList(documents, moveReqVO.getTargetLibraryId(), clonedPermissionIdMap); + + // 5. 跨知识库移动时同步关注和最近浏览记录的当前归属 + if (crossLibrary) { + Set documentIds = convertSet(documents, PmsKnowledgeDocumentDO::getId); + favoriteService.updateFavoriteLibraryIdByEntityIds(folderIds, documentIds, + moveReqVO.getTargetLibraryId()); + viewRecordService.updateViewRecordLibraryIdByEntityIds(folderIds, documentIds, + moveReqVO.getTargetLibraryId()); + } + if (CollUtil.isNotEmpty(clonedPermissionIdMap)) { + contentPermissionService.deleteUnusedContentPermissions(sourcePermissionIds); + } + } + + @Override + public PmsKnowledgeFolderDO getFolder(Long id, Long userId) { + // 1.1 校验文件夹存在 + PmsKnowledgeFolderDO folder = validateFolderExists(id); + // 1.2 校验所属知识库可读 + libraryMemberService.validateLibraryReadable(folder.getLibraryId(), userId); + // 1.3 校验内容读取权限 + contentPermissionService.validateContentPermissionReadable(folder.getPermissionId(), folder.getLibraryId(), userId); + return folder; + } + + @Override + public PmsKnowledgeFolderDO getFolder(Long id) { + return folderMapper.selectById(id); + } + + @Override + public List getFolderList(Collection ids) { + return CollUtil.isEmpty(ids) ? Collections.emptyList() : folderMapper.selectByIds(ids); + } + + @Override + public List getFolderListByLibraryId(Long libraryId) { + return folderMapper.selectListByLibraryId(libraryId); + } + + @Override + public void updateFolderList(Collection folders) { + if (CollUtil.isEmpty(folders)) { + return; + } + folderMapper.updateBatch(folders); + } + + @Override + public void restoreFolderList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return; + } + folderMapper.updateToRestoreByIds(ids, PmsKnowledgeDocumentStatusEnum.NORMAL.getStatus()); + } + + @Override + public void deleteFolderList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return; + } + folderMapper.deleteByIds(ids); + } + + @Override + public List getFolderList(Long libraryId, Long userId) { + // 1. 校验知识库可读 + libraryMemberService.validateLibraryReadable(libraryId, userId); + // 2. 查询全部正常文件夹 + List folders = folderMapper.selectListByLibraryIdAndStatus( + libraryId, PmsKnowledgeDocumentStatusEnum.NORMAL.getStatus()); + // 3. 按内容权限过滤,只返回当前用户有读取权限的文件夹 + Set readablePermissionIds = new LinkedHashSet<>(); + contentPermissionService.getCurrentUserContentPermissionLevelMap(convertSet(folders, PmsKnowledgeFolderDO::getPermissionId), + libraryId, userId).forEach((permissionId, level) -> { + if (level != null) { + readablePermissionIds.add(permissionId); + } + }); + folders.removeIf(folder -> !readablePermissionIds.contains(folder.getPermissionId())); + return folders; + } + + private PmsKnowledgeFolderDO validateFolderExists(Long id) { + PmsKnowledgeFolderDO folder = folderMapper.selectById(id); + if (folder == null || PmsKnowledgeDocumentStatusEnum.RECYCLED.getStatus().equals(folder.getStatus())) { + throw exception(KNOWLEDGE_FOLDER_NOT_EXISTS); + } + return folder; + } + + private PmsKnowledgeFolderDO validateFolderParent(Long libraryId, Long parentId) { + if (parentId == null || PmsKnowledgeFolderDO.PARENT_ID_ROOT.equals(parentId)) { + return null; + } + PmsKnowledgeFolderDO parent = folderMapper.selectById(parentId); + if (parent == null || ObjectUtil.notEqual(libraryId, parent.getLibraryId()) + || !PmsKnowledgeDocumentStatusEnum.NORMAL.getStatus().equals(parent.getStatus())) { + throw exception(KNOWLEDGE_FOLDER_PARENT_INVALID); + } + return parent; + } + + /** + * 获得根文件夹及其全部子文件夹,使用 visited 集合避免异常环形数据导致循环 + */ + private List getFolderDescendants(PmsKnowledgeFolderDO root) { + List folders = new ArrayList<>(); + folders.add(root); + Set visitedFolderIds = new LinkedHashSet<>(); + visitedFolderIds.add(root.getId()); + Set parentIds = Collections.singleton(root.getId()); + while (CollUtil.isNotEmpty(parentIds)) { + List childFolders = folderMapper.selectListByParentIds(parentIds); + List newChildFolders = new ArrayList<>(); + for (PmsKnowledgeFolderDO childFolder : childFolders) { + if (visitedFolderIds.add(childFolder.getId())) { + newChildFolders.add(childFolder); + } + } + if (CollUtil.isEmpty(newChildFolders)) { + break; + } + folders.addAll(newChildFolders); + parentIds = convertSet(newChildFolders, PmsKnowledgeFolderDO::getId); + } + return folders; + } + + @Override + public Set getExistingContentPermissionIdSet(Collection permissionIds) { + if (CollUtil.isEmpty(permissionIds)) { + return Collections.emptySet(); + } + return folderMapper.selectExistingPermissionIdSet(permissionIds); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeDocumentCommentService.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeDocumentCommentService.java new file mode 100644 index 000000000..8469cc3a7 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeDocumentCommentService.java @@ -0,0 +1,49 @@ +package cn.iocoder.yudao.module.pms.service.kb.interaction; + +import cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.comment.PmsKnowledgeDocumentCommentSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.interaction.PmsKnowledgeDocumentCommentDO; + +import java.util.Collection; +import java.util.List; + +/** + * PMS 知识库文档评论 Service 接口 + * + * @author 芋道源码 + */ +public interface PmsKnowledgeDocumentCommentService { + + /** + * 创建文档评论或回复 + * + * @param saveReqVO 保存信息 + * @param userId 用户编号 + * @return 评论编号 + */ + Long createDocumentComment(PmsKnowledgeDocumentCommentSaveReqVO saveReqVO, Long userId); + + /** + * 删除自己的评论,删除主评论时同步删除全部回复 + * + * @param id 评论编号 + * @param userId 用户编号 + */ + void deleteDocumentComment(Long id, Long userId); + + /** + * 获得文档的评论和回复列表 + * + * @param documentId 文档编号 + * @param userId 用户编号 + * @return 评论列表 + */ + List getDocumentCommentList(Long documentId, Long userId); + + /** + * 删除文档的全部评论 + * + * @param documentIds 文档编号集合 + */ + void deleteCommentsByDocumentIds(Collection documentIds); + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeDocumentCommentServiceImpl.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeDocumentCommentServiceImpl.java new file mode 100644 index 000000000..33382d374 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeDocumentCommentServiceImpl.java @@ -0,0 +1,120 @@ +package cn.iocoder.yudao.module.pms.service.kb.interaction; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.comment.PmsKnowledgeDocumentCommentSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeDocumentDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.interaction.PmsKnowledgeDocumentCommentDO; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.interaction.PmsKnowledgeDocumentCommentMapper; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeDocumentTypeEnum; +import cn.iocoder.yudao.module.pms.service.kb.content.PmsKnowledgeDocumentService; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.Collection; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_DOCUMENT_COMMENT_ACCESS_DENIED; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_DOCUMENT_COMMENT_NOT_EXISTS; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_DOCUMENT_COMMENT_REPLY_INVALID; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_DOCUMENT_COMMENT_TYPE_INVALID; + +/** + * PMS 知识库文档评论 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class PmsKnowledgeDocumentCommentServiceImpl implements PmsKnowledgeDocumentCommentService { + + @Resource + private PmsKnowledgeDocumentCommentMapper commentMapper; + + @Resource + @Lazy // 延迟加载,避免与文档回收逻辑形成循环依赖 + private PmsKnowledgeDocumentService documentService; + + @Override + public Long createDocumentComment(PmsKnowledgeDocumentCommentSaveReqVO saveReqVO, Long userId) { + // 1.1 校验文档可评论 + validateDocumentCommentable(saveReqVO.getDocumentId(), userId); + // 1.2 校验回复关系 + Long mainId = saveReqVO.getMainId() != null ? saveReqVO.getMainId() + : PmsKnowledgeDocumentCommentDO.MAIN_ID_ROOT; + if (ObjectUtil.notEqual(PmsKnowledgeDocumentCommentDO.MAIN_ID_ROOT, mainId)) { + PmsKnowledgeDocumentCommentDO mainComment = validateCommentExists(mainId); + if (ObjectUtil.notEqual(saveReqVO.getDocumentId(), mainComment.getDocumentId()) + || ObjectUtil.notEqual(PmsKnowledgeDocumentCommentDO.MAIN_ID_ROOT, mainComment.getMainId())) { + throw exception(KNOWLEDGE_DOCUMENT_COMMENT_REPLY_INVALID); + } + } + + // 2. 创建评论 + PmsKnowledgeDocumentCommentDO comment = BeanUtils.toBean(saveReqVO, PmsKnowledgeDocumentCommentDO.class) + .setUserId(userId).setMainId(mainId); + commentMapper.insert(comment); + return comment.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteDocumentComment(Long id, Long userId) { + // 1.1 校验评论存在 + PmsKnowledgeDocumentCommentDO comment = validateCommentExists(id); + // 1.2 校验文档可评论 + validateDocumentCommentable(comment.getDocumentId(), userId); + // 1.3 校验评论属于当前用户 + validateCommentOwner(comment, userId); + + // 2. 删除主评论时同步删除全部回复 + if (PmsKnowledgeDocumentCommentDO.MAIN_ID_ROOT.equals(comment.getMainId())) { + commentMapper.deleteByMainId(comment.getId()); + } + commentMapper.deleteById(comment.getId()); + } + + @Override + public List getDocumentCommentList(Long documentId, Long userId) { + // 1. 校验文档可访问 + validateDocumentCommentable(documentId, userId); + + // 2. 查询评论和回复 + return commentMapper.selectListByDocumentId(documentId); + } + + @Override + public void deleteCommentsByDocumentIds(Collection documentIds) { + if (CollUtil.isEmpty(documentIds)) { + return; + } + commentMapper.deleteByDocumentIds(documentIds); + } + + private PmsKnowledgeDocumentCommentDO validateCommentExists(Long id) { + PmsKnowledgeDocumentCommentDO comment = commentMapper.selectById(id); + if (comment == null) { + throw exception(KNOWLEDGE_DOCUMENT_COMMENT_NOT_EXISTS); + } + return comment; + } + + private void validateCommentOwner(PmsKnowledgeDocumentCommentDO comment, Long userId) { + if (ObjectUtil.notEqual(userId, comment.getUserId())) { + throw exception(KNOWLEDGE_DOCUMENT_COMMENT_ACCESS_DENIED); + } + } + + private void validateDocumentCommentable(Long documentId, Long userId) { + PmsKnowledgeDocumentDO document = documentService.getDocument(documentId, userId); + if (!PmsKnowledgeDocumentTypeEnum.RICH_TEXT.getType().equals(document.getType())) { + throw exception(KNOWLEDGE_DOCUMENT_COMMENT_TYPE_INVALID); + } + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeDocumentLikeService.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeDocumentLikeService.java new file mode 100644 index 000000000..20140c46b --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeDocumentLikeService.java @@ -0,0 +1,46 @@ +package cn.iocoder.yudao.module.pms.service.kb.interaction; + +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.interaction.PmsKnowledgeDocumentLikeDO; + +import java.util.Collection; +import java.util.List; + +/** + * PMS 知识文档点赞 Service 接口 + * + * @author 芋道源码 + */ +public interface PmsKnowledgeDocumentLikeService { + + /** + * 点赞文档 + * + * @param documentId 文档编号 + * @param userId 用户编号 + */ + void createDocumentLike(Long documentId, Long userId); + + /** + * 取消点赞文档 + * + * @param documentId 文档编号 + * @param userId 用户编号 + */ + void deleteDocumentLike(Long documentId, Long userId); + + /** + * 获得文档的点赞列表 + * + * @param documentId 文档编号 + * @return 点赞列表 + */ + List getDocumentLikeList(Long documentId); + + /** + * 删除文档的点赞关系 + * + * @param documentIds 文档编号集合 + */ + void deleteLikesByDocumentIds(Collection documentIds); + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeDocumentLikeServiceImpl.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeDocumentLikeServiceImpl.java new file mode 100644 index 000000000..de9993ee0 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeDocumentLikeServiceImpl.java @@ -0,0 +1,71 @@ +package cn.iocoder.yudao.module.pms.service.kb.interaction; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.interaction.PmsKnowledgeDocumentLikeDO; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.interaction.PmsKnowledgeDocumentLikeMapper; +import cn.iocoder.yudao.module.pms.enums.kb.PmsKnowledgeObjectTypeEnum; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.Collection; +import java.util.List; + +/** + * PMS 知识文档点赞 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class PmsKnowledgeDocumentLikeServiceImpl implements PmsKnowledgeDocumentLikeService { + + @Resource + private PmsKnowledgeDocumentLikeMapper documentLikeMapper; + @Resource + private PmsKnowledgeInteractionTargetService interactionTargetService; + + @Override + @Transactional(rollbackFor = Exception.class) + public void createDocumentLike(Long documentId, Long userId) { + // 1. 校验文档可读 + interactionTargetService.validateTargetReadable( + PmsKnowledgeObjectTypeEnum.DOCUMENT.getType(), documentId, userId); + + // 2. 新增点赞关系,重复请求保持幂等 + PmsKnowledgeDocumentLikeDO like = documentLikeMapper.selectByDocumentIdAndUserId(documentId, userId); + if (like != null) { + return; + } + documentLikeMapper.insert(new PmsKnowledgeDocumentLikeDO().setDocumentId(documentId).setUserId(userId)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteDocumentLike(Long documentId, Long userId) { + // 1. 校验文档可读 + interactionTargetService.validateTargetReadable( + PmsKnowledgeObjectTypeEnum.DOCUMENT.getType(), documentId, userId); + + // 2. 删除点赞关系,重复请求保持幂等 + PmsKnowledgeDocumentLikeDO like = documentLikeMapper.selectByDocumentIdAndUserId(documentId, userId); + if (like != null) { + documentLikeMapper.deleteById(like.getId()); + } + } + + @Override + public List getDocumentLikeList(Long documentId) { + return documentLikeMapper.selectListByDocumentId(documentId); + } + + @Override + public void deleteLikesByDocumentIds(Collection documentIds) { + if (CollUtil.isEmpty(documentIds)) { + return; + } + documentLikeMapper.deleteByDocumentIds(documentIds); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeDocumentShareService.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeDocumentShareService.java new file mode 100644 index 000000000..dc1514d8e --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeDocumentShareService.java @@ -0,0 +1,75 @@ +package cn.iocoder.yudao.module.pms.service.kb.interaction; + +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeDocumentDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.interaction.PmsKnowledgeDocumentShareDO; + +import java.util.Collection; +import java.util.List; + +/** + * PMS 知识库文档分享 Service 接口 + * + * @author 芋道源码 + */ +public interface PmsKnowledgeDocumentShareService { + + /** + * 开启文档分享,重新开启时生成新的外部令牌,并通知内部分享成员 + * + * @param documentId 文档编号 + * @param shareUserIds 内部分享用户编号列表 + * @param userId 用户编号 + * @return 分享信息 + */ + PmsKnowledgeDocumentShareDO openShare(Long documentId, List shareUserIds, Long userId); + + /** + * 更新文档的内部分享成员,只通知本次新增的成员 + * + * @param documentId 文档编号 + * @param shareUserIds 内部分享用户编号列表 + * @param userId 用户编号 + */ + void updateShareMemberList(Long documentId, List shareUserIds, Long userId); + + /** + * 关闭文档分享 + * + * @param documentId 文档编号 + * @param userId 用户编号 + */ + void closeShare(Long documentId, Long userId); + + /** + * 获得文档处于开启状态的分享 + * + * @param documentId 文档编号 + * @return 分享信息 + */ + PmsKnowledgeDocumentShareDO getActiveDocumentShare(Long documentId); + + /** + * 获得文档处于开启状态的分享,校验当前用户有读取权限 + * + * @param documentId 文档编号 + * @param userId 用户编号 + * @return 分享信息 + */ + PmsKnowledgeDocumentShareDO getActiveDocumentShare(Long documentId, Long userId); + + /** + * 通过分享令牌获得正常状态的文档 + * + * @param token 分享令牌 + * @return 文档 + */ + PmsKnowledgeDocumentDO getDocumentByShareToken(String token); + + /** + * 删除文档的分享记录 + * + * @param documentIds 文档编号集合 + */ + void deleteSharesByDocumentIds(Collection documentIds); + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeDocumentShareServiceImpl.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeDocumentShareServiceImpl.java new file mode 100644 index 000000000..78ff8c835 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeDocumentShareServiceImpl.java @@ -0,0 +1,244 @@ +package cn.iocoder.yudao.module.pms.service.kb.interaction; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeDocumentDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.interaction.PmsKnowledgeDocumentShareDO; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.content.PmsKnowledgeDocumentMapper; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.interaction.PmsKnowledgeDocumentShareMapper; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeDocumentStatusEnum; +import cn.iocoder.yudao.module.pms.service.kb.content.PmsKnowledgeContentPermissionService; +import cn.iocoder.yudao.module.system.api.notify.NotifyMessageSendApi; +import cn.iocoder.yudao.module.system.api.notify.dto.NotifySendSingleToUserReqDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import com.google.common.collect.Maps; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_DOCUMENT_NOT_EXISTS; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_DOCUMENT_SHARE_ALREADY_OPEN; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_DOCUMENT_SHARE_INVALID; +import static cn.iocoder.yudao.module.pms.enums.MessageTemplateConstants.KNOWLEDGE_DOCUMENT_SHARED; + +/** + * PMS 知识库文档分享 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +@Slf4j +public class PmsKnowledgeDocumentShareServiceImpl implements PmsKnowledgeDocumentShareService { + + @Resource + private PmsKnowledgeDocumentShareMapper documentShareMapper; + @Resource + private PmsKnowledgeDocumentMapper documentMapper; + + @Resource + private PmsKnowledgeContentPermissionService contentPermissionService; + @Resource + private NotifyMessageSendApi notifyMessageSendApi; + @Resource + private AdminUserApi adminUserApi; + + @Override + @Transactional(rollbackFor = Exception.class) + public PmsKnowledgeDocumentShareDO openShare(Long documentId, List shareUserIds, Long userId) { + // 1.1 校验文档存在 + PmsKnowledgeDocumentDO document = validateDocumentExists(documentId); + // 1.2 校验文档内容编辑权限 + contentPermissionService.validateContentPermissionWritable( + document.getPermissionId(), document.getLibraryId(), userId); + // 1.3 校验文档尚未开启分享 + PmsKnowledgeDocumentShareDO share = documentShareMapper.selectByDocumentId(documentId); + if (share != null && CommonStatusEnum.isEnable(share.getStatus())) { + throw exception(KNOWLEDGE_DOCUMENT_SHARE_ALREADY_OPEN); + } + + // 1.4 校验并规范分享成员 + List normalizedShareUserIds = normalizeShareUserIds(shareUserIds); + + // 2. 新建或重新开启分享;重新开启时生成新的外部令牌 + if (share == null) { + share = new PmsKnowledgeDocumentShareDO().setDocumentId(documentId); + } + share.setShareUserIds(normalizedShareUserIds).setToken(generateShareToken()) + .setStatus(CommonStatusEnum.ENABLE.getStatus()) + .setCloseUserId(null).setCloseTime(null); + if (share.getId() == null) { + documentShareMapper.insert(share); + } else { + documentShareMapper.updateToReopen(share); + } + + // 3. 通知内部分享成员 + sendKnowledgeDocumentSharedNotify(normalizedShareUserIds, userId, share.getToken(), document.getTitle()); + return share; + } + + @Override + public void updateShareMemberList(Long documentId, List shareUserIds, Long userId) { + // 1.1 校验文档存在 + PmsKnowledgeDocumentDO document = validateDocumentExists(documentId); + // 1.2 校验文档内容编辑权限 + contentPermissionService.validateContentPermissionWritable( + document.getPermissionId(), document.getLibraryId(), userId); + // 1.3 校验文档已开启分享 + PmsKnowledgeDocumentShareDO share = validateActiveShare(documentId); + + // 1.4 校验并规范分享成员 + List normalizedShareUserIds = normalizeShareUserIds(shareUserIds); + + // 2. 更新内部分享成员 + documentShareMapper.updateById(new PmsKnowledgeDocumentShareDO().setId(share.getId()) + .setShareUserIds(normalizedShareUserIds)); + + // 3. 只通知本次新增的分享成员 + Set addedUserIds = new LinkedHashSet<>(normalizedShareUserIds); + if (CollUtil.isNotEmpty(share.getShareUserIds())) { + share.getShareUserIds().forEach(addedUserIds::remove); + } + sendKnowledgeDocumentSharedNotify(addedUserIds, userId, share.getToken(), document.getTitle()); + } + + @Override + public void closeShare(Long documentId, Long userId) { + // 1.1 校验文档存在 + PmsKnowledgeDocumentDO document = validateDocumentExists(documentId); + // 1.2 校验文档内容编辑权限 + contentPermissionService.validateContentPermissionWritable( + document.getPermissionId(), document.getLibraryId(), userId); + // 1.3 校验文档已开启分享 + PmsKnowledgeDocumentShareDO share = validateActiveShare(documentId); + + // 2. 关闭分享 + documentShareMapper.updateById(new PmsKnowledgeDocumentShareDO().setId(share.getId()) + .setStatus(CommonStatusEnum.DISABLE.getStatus()) + .setCloseUserId(userId).setCloseTime(LocalDateTime.now())); + } + + @Override + public PmsKnowledgeDocumentShareDO getActiveDocumentShare(Long documentId) { + PmsKnowledgeDocumentShareDO share = documentShareMapper.selectByDocumentId(documentId); + return share != null && CommonStatusEnum.isEnable(share.getStatus()) + ? share : null; + } + + @Override + public PmsKnowledgeDocumentShareDO getActiveDocumentShare(Long documentId, Long userId) { + // 1.1 校验文档存在 + PmsKnowledgeDocumentDO document = validateDocumentExists(documentId); + // 1.2 校验文档内容读取权限 + contentPermissionService.validateContentPermissionReadable( + document.getPermissionId(), document.getLibraryId(), userId); + // 2. 返回当前仍处于开启状态的分享配置 + return getActiveDocumentShare(documentId); + } + + @Override + public PmsKnowledgeDocumentDO getDocumentByShareToken(String token) { + // 1. 校验分享令牌处于开启状态 + PmsKnowledgeDocumentShareDO share = documentShareMapper.selectByToken(token); + if (share == null || CommonStatusEnum.isDisable(share.getStatus())) { + throw exception(KNOWLEDGE_DOCUMENT_SHARE_INVALID); + } + + // 2. 查询正常状态的分享文档 + PmsKnowledgeDocumentDO document = documentMapper.selectById(share.getDocumentId()); + if (document == null || ObjectUtil.notEqual(PmsKnowledgeDocumentStatusEnum.NORMAL.getStatus(), + document.getStatus())) { + throw exception(KNOWLEDGE_DOCUMENT_SHARE_INVALID); + } + return document; + } + + @Override + public void deleteSharesByDocumentIds(Collection documentIds) { + if (CollUtil.isEmpty(documentIds)) { + return; + } + documentShareMapper.deleteByDocumentIds(documentIds); + } + + private PmsKnowledgeDocumentDO validateDocumentExists(Long documentId) { + PmsKnowledgeDocumentDO document = documentMapper.selectById(documentId); + if (document == null || ObjectUtil.notEqual(PmsKnowledgeDocumentStatusEnum.NORMAL.getStatus(), + document.getStatus())) { + throw exception(KNOWLEDGE_DOCUMENT_NOT_EXISTS); + } + return document; + } + + private PmsKnowledgeDocumentShareDO validateActiveShare(Long documentId) { + PmsKnowledgeDocumentShareDO share = getActiveDocumentShare(documentId); + if (share == null) { + throw exception(KNOWLEDGE_DOCUMENT_SHARE_INVALID); + } + return share; + } + + /** + * 规范并校验分享成员列表,避免重复通知并拒绝不存在或已禁用的用户 + */ + private List normalizeShareUserIds(List shareUserIds) { + if (CollUtil.isEmpty(shareUserIds)) { + adminUserApi.validateUserList(Collections.emptyList()).checkError(); + return Collections.emptyList(); + } + List normalizedShareUserIds = new ArrayList<>(new LinkedHashSet<>(shareUserIds)); + adminUserApi.validateUserList(normalizedShareUserIds).checkError(); + return normalizedShareUserIds; + } + + /** + * 生成外部分享令牌 + * + * @return 新的分享令牌 + */ + private String generateShareToken() { + return IdUtil.fastSimpleUUID(); + } + + /** + * 发送知识文档分享站内信。通知配置异常不能阻断文档分享主业务 + */ + private void sendKnowledgeDocumentSharedNotify(Collection receiverUserIds, Long operatorUserId, + String shareToken, String documentTitle) { + if (CollUtil.isEmpty(receiverUserIds)) { + return; + } + Set uniqueReceiverUserIds = new LinkedHashSet<>(receiverUserIds); + uniqueReceiverUserIds.remove(null); + uniqueReceiverUserIds.remove(operatorUserId); + Map templateParams = Maps.newHashMapWithExpectedSize(2); + templateParams.put("documentTitle", documentTitle); + templateParams.put("route", "/pms/kb/document/share/" + shareToken); + uniqueReceiverUserIds.forEach(receiverUserId -> { + try { + notifyMessageSendApi.sendSingleMessageToAdmin(new NotifySendSingleToUserReqDTO() + .setUserId(receiverUserId).setTemplateCode(KNOWLEDGE_DOCUMENT_SHARED) + .setTemplateParams(templateParams)).checkError(); + } catch (RuntimeException ex) { + log.warn("[sendKnowledgeDocumentSharedNotify][向用户({})发送知识文档分享站内信失败,参数为({})]", + receiverUserId, templateParams, ex); + } + }); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeFavoriteService.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeFavoriteService.java new file mode 100644 index 000000000..07a4a57e3 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeFavoriteService.java @@ -0,0 +1,100 @@ +package cn.iocoder.yudao.module.pms.service.kb.interaction; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.PmsKnowledgeInteractionItemRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.favorite.PmsKnowledgeFavoritePageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.favorite.PmsKnowledgeFavoriteSaveReqVO; + +import java.util.Collection; +import java.util.List; +import java.util.Set; + +/** + * PMS 知识收藏(关注)Service 接口 + * + * @author 芋道源码 + */ +public interface PmsKnowledgeFavoriteService { + + /** + * 关注知识对象 + * + * @param saveReqVO 关注信息 + * @param userId 用户编号 + */ + void createFavorite(PmsKnowledgeFavoriteSaveReqVO saveReqVO, Long userId); + + /** + * 取消关注,未关注时不做任何处理 + * + * @param type 对象类型 + * @param entityId 对象编号 + * @param userId 用户编号 + */ + void deleteFavorite(Integer type, Long entityId, Long userId); + + /** + * 获得当前用户的关注分页 + * + * @param pageReqVO 分页查询 + * @param userId 用户编号 + * @return 关注分页结果 + */ + PageResult getFavoritePage(PmsKnowledgeFavoritePageReqVO pageReqVO, + Long userId); + + /** + * 获得指定知识库内当前用户的关注列表 + * + * @param libraryId 知识库编号 + * @param userId 用户编号 + * @return 关注列表 + */ + List getFavoriteListByLibraryId(Long libraryId, Long userId); + + /** + * 获得当前用户已关注的对象编号集合 + * + * @param type 对象类型 + * @param entityIds 对象编号集合 + * @param userId 用户编号 + * @return 已关注的对象编号集合 + */ + Set getFavoriteEntityIdSet(Integer type, Collection entityIds, Long userId); + + /** + * 判断当前用户是否已关注对象 + * + * @param type 对象类型 + * @param entityId 对象编号 + * @param userId 用户编号 + * @return 是否已关注 + */ + boolean isFavorite(Integer type, Long entityId, Long userId); + + /** + * 删除知识库的全部关注关系 + * + * @param libraryId 知识库编号 + */ + void deleteFavoritesByLibraryId(Long libraryId); + + /** + * 删除文件夹和文档的关注关系 + * + * @param folderIds 文件夹编号集合 + * @param documentIds 文档编号集合 + */ + void deleteFavoritesByEntityIds(Collection folderIds, Collection documentIds); + + /** + * 更新文件夹和文档关注关系的所属知识库 + * + * @param folderIds 文件夹编号集合 + * @param documentIds 文档编号集合 + * @param libraryId 目标知识库编号 + */ + void updateFavoriteLibraryIdByEntityIds(Collection folderIds, Collection documentIds, + Long libraryId); + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeFavoriteServiceImpl.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeFavoriteServiceImpl.java new file mode 100644 index 000000000..b1d9b515e --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeFavoriteServiceImpl.java @@ -0,0 +1,160 @@ +package cn.iocoder.yudao.module.pms.service.kb.interaction; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.PmsKnowledgeInteractionItemRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.favorite.PmsKnowledgeFavoritePageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.favorite.PmsKnowledgeFavoriteSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.interaction.PmsKnowledgeFavoriteDO; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.interaction.PmsKnowledgeFavoriteMapper; +import cn.iocoder.yudao.module.pms.enums.kb.PmsKnowledgeObjectTypeEnum; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_FAVORITE_DUPLICATE; + +/** + * PMS 知识收藏(关注)Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class PmsKnowledgeFavoriteServiceImpl implements PmsKnowledgeFavoriteService { + + @Resource + private PmsKnowledgeFavoriteMapper favoriteMapper; + @Resource + private PmsKnowledgeInteractionTargetService interactionTargetService; + + @Override + public void createFavorite(PmsKnowledgeFavoriteSaveReqVO saveReqVO, Long userId) { + // 1.1 校验关注对象可读 + Long libraryId = interactionTargetService.validateTargetReadable( + saveReqVO.getType(), saveReqVO.getEntityId(), userId); + // 1.2 校验关注关系不存在 + if (favoriteMapper.selectByUserIdAndTypeAndEntityId( + userId, saveReqVO.getType(), saveReqVO.getEntityId()) != null) { + throw exception(KNOWLEDGE_FAVORITE_DUPLICATE); + } + + // 2. 新增关注关系 + favoriteMapper.insert(BeanUtils.toBean(saveReqVO, PmsKnowledgeFavoriteDO.class) + .setLibraryId(libraryId).setUserId(userId)); + } + + @Override + public void deleteFavorite(Integer type, Long entityId, Long userId) { + // 1. 查询当前用户的关注关系 + PmsKnowledgeFavoriteDO favorite = favoriteMapper.selectByUserIdAndTypeAndEntityId(userId, type, entityId); + // 2. 存在时删除关注关系,未关注时不做任何处理 + if (favorite != null) { + favoriteMapper.deleteById(favorite.getId()); + } + } + + @Override + public PageResult getFavoritePage(PmsKnowledgeFavoritePageReqVO pageReqVO, + Long userId) { + // 1. 先批量判定全部关注对象的可读性 + List allItems = getReadableFavoriteList( + favoriteMapper.selectListByUserId(userId), userId); + if (CollUtil.isEmpty(allItems)) { + return PageResult.empty(); + } + Map itemMap = new LinkedHashMap<>(); + allItems.forEach(item -> itemMap.put(item.getId(), item)); + + // 2. 将类型、排序和分页交给数据库,再按页拼接已批量解析的展示对象 + PageResult favoritePage = favoriteMapper.selectPage( + pageReqVO, userId, itemMap.keySet()); + return new PageResult<>(convertList(favoritePage.getList(), favorite -> itemMap.get(favorite.getId())), + favoritePage.getTotal()); + } + + @Override + public List getFavoriteListByLibraryId(Long libraryId, Long userId) { + // 1. 查询指定知识库内当前用户的关注对象 + List favorites = favoriteMapper.selectListByUserIdAndLibraryId(userId, libraryId); + // 2. 复用统一的对象可读校验和展示字段组装 + return getReadableFavoriteList(favorites, userId); + } + + /** + * 将关注记录转换为当前用户可读取的互动对象 + * + * @param favorites 关注记录 + * @param userId 用户编号 + * @return 可读取的互动对象列表 + */ + private List getReadableFavoriteList( + List favorites, Long userId) { + if (CollUtil.isEmpty(favorites)) { + return Collections.emptyList(); + } + List items = convertList(favorites, favorite -> + new PmsKnowledgeInteractionItemRespVO().setId(favorite.getId()).setType(favorite.getType()) + .setEntityId(favorite.getEntityId()).setLibraryId(favorite.getLibraryId()) + .setCreateTime(favorite.getCreateTime())); + return interactionTargetService.getReadableItemList(items, userId); + } + + @Override + public Set getFavoriteEntityIdSet(Integer type, Collection entityIds, Long userId) { + if (CollUtil.isEmpty(entityIds)) { + return Collections.emptySet(); + } + List favorites = favoriteMapper + .selectListByUserIdAndTypeAndEntityIds(userId, type, entityIds); + return convertSet(favorites, PmsKnowledgeFavoriteDO::getEntityId); + } + + @Override + public boolean isFavorite(Integer type, Long entityId, Long userId) { + return favoriteMapper.selectByUserIdAndTypeAndEntityId(userId, type, entityId) != null; + } + + @Override + public void deleteFavoritesByLibraryId(Long libraryId) { + favoriteMapper.deleteByLibraryId(libraryId); + } + + @Override + public void deleteFavoritesByEntityIds(Collection folderIds, Collection documentIds) { + if (CollUtil.isNotEmpty(folderIds)) { + favoriteMapper.deleteByTypeAndEntityIds( + Collections.singleton(PmsKnowledgeObjectTypeEnum.FOLDER.getType()), folderIds); + } + if (CollUtil.isNotEmpty(documentIds)) { + favoriteMapper.deleteByTypeAndEntityIds(Arrays.asList(PmsKnowledgeObjectTypeEnum.DOCUMENT.getType(), + PmsKnowledgeObjectTypeEnum.FILE.getType()), documentIds); + } + } + + @Override + public void updateFavoriteLibraryIdByEntityIds(Collection folderIds, Collection documentIds, + Long libraryId) { + if (CollUtil.isNotEmpty(folderIds)) { + favoriteMapper.updateLibraryIdByTypeAndEntityIds(libraryId, + Collections.singleton(PmsKnowledgeObjectTypeEnum.FOLDER.getType()), folderIds); + } + if (CollUtil.isNotEmpty(documentIds)) { + favoriteMapper.updateLibraryIdByTypeAndEntityIds(libraryId, + Arrays.asList(PmsKnowledgeObjectTypeEnum.DOCUMENT.getType(), PmsKnowledgeObjectTypeEnum.FILE.getType()), documentIds); + } + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeFolderReadService.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeFolderReadService.java new file mode 100644 index 000000000..0cd1f208b --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeFolderReadService.java @@ -0,0 +1,32 @@ +package cn.iocoder.yudao.module.pms.service.kb.interaction; + +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeFolderDO; + +import java.util.Collection; +import java.util.List; + +/** + * PMS 知识库文件夹读取 Service 接口 + * + * @author 芋道源码 + */ +public interface PmsKnowledgeFolderReadService { + + /** + * 获得当前用户可读的文件夹 + * + * @param id 文件夹编号 + * @param userId 用户编号 + * @return 文件夹 + */ + PmsKnowledgeFolderDO getReadableFolder(Long id, Long userId); + + /** + * 获得文件夹列表 + * + * @param ids 文件夹编号集合 + * @return 文件夹列表 + */ + List getFolderList(Collection ids); + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeFolderReadServiceImpl.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeFolderReadServiceImpl.java new file mode 100644 index 000000000..8cb93cbfa --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeFolderReadServiceImpl.java @@ -0,0 +1,61 @@ +package cn.iocoder.yudao.module.pms.service.kb.interaction; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeFolderDO; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.content.PmsKnowledgeFolderMapper; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeDocumentStatusEnum; +import cn.iocoder.yudao.module.pms.service.kb.content.PmsKnowledgeContentPermissionService; +import cn.iocoder.yudao.module.pms.service.kb.library.PmsKnowledgeLibraryMemberService; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_FOLDER_NOT_EXISTS; + +/** + * PMS 知识库文件夹读取 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class PmsKnowledgeFolderReadServiceImpl implements PmsKnowledgeFolderReadService { + + @Resource + private PmsKnowledgeFolderMapper folderMapper; + @Resource + private PmsKnowledgeLibraryMemberService libraryMemberService; + @Resource + private PmsKnowledgeContentPermissionService contentPermissionService; + + @Override + public PmsKnowledgeFolderDO getReadableFolder(Long id, Long userId) { + // 1.1 校验文件夹存在 + PmsKnowledgeFolderDO folder = folderMapper.selectById(id); + if (folder == null) { + throw exception(KNOWLEDGE_FOLDER_NOT_EXISTS); + } + // 1.2 校验文件夹未被回收 + if (ObjectUtil.notEqual(PmsKnowledgeDocumentStatusEnum.NORMAL.getStatus(), folder.getStatus())) { + throw exception(KNOWLEDGE_FOLDER_NOT_EXISTS); + } + + // 2.1 校验知识库读取权限 + libraryMemberService.validateLibraryReadable(folder.getLibraryId(), userId); + // 2.2 校验内容读取权限 + contentPermissionService.validateContentPermissionReadable(folder.getPermissionId(), folder.getLibraryId(), userId); + return folder; + } + + @Override + public List getFolderList(Collection ids) { + return CollUtil.isEmpty(ids) ? Collections.emptyList() : folderMapper.selectByIds(ids); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeInteractionTargetService.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeInteractionTargetService.java new file mode 100644 index 000000000..a1c7394af --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeInteractionTargetService.java @@ -0,0 +1,35 @@ +package cn.iocoder.yudao.module.pms.service.kb.interaction; + +import cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.PmsKnowledgeInteractionItemRespVO; + +import java.util.Collection; +import java.util.List; + +/** + * PMS 知识互动对象 Service 接口 + * + * @author 芋道源码 + */ +public interface PmsKnowledgeInteractionTargetService { + + /** + * 校验互动对象可读 + * + * @param type 对象类型 + * @param entityId 对象编号 + * @param userId 用户编号 + * @return 知识库编号 + */ + Long validateTargetReadable(Integer type, Long entityId, Long userId); + + /** + * 批量获得当前用户可读的互动对象 + * + * @param targets 互动对象集合 + * @param userId 用户编号 + * @return 可读的互动对象列表 + */ + List getReadableItemList( + Collection targets, Long userId); + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeInteractionTargetServiceImpl.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeInteractionTargetServiceImpl.java new file mode 100644 index 000000000..9cdccb124 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeInteractionTargetServiceImpl.java @@ -0,0 +1,171 @@ +package cn.iocoder.yudao.module.pms.service.kb.interaction; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.PmsKnowledgeInteractionItemRespVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeDocumentDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeFolderDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryDO; +import cn.iocoder.yudao.module.pms.enums.kb.PmsKnowledgeObjectTypeEnum; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeDocumentStatusEnum; +import cn.iocoder.yudao.module.pms.service.kb.content.PmsKnowledgeContentPermissionService; +import cn.iocoder.yudao.module.pms.service.kb.content.PmsKnowledgeDocumentService; +import cn.iocoder.yudao.module.pms.service.kb.library.PmsKnowledgeLibraryMemberService; +import cn.iocoder.yudao.module.pms.service.kb.library.PmsKnowledgeLibraryService; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_INTERACTION_OBJECT_INVALID; + +/** + * PMS 知识互动对象 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class PmsKnowledgeInteractionTargetServiceImpl implements PmsKnowledgeInteractionTargetService { + + @Resource + private PmsKnowledgeLibraryMemberService libraryMemberService; + @Resource + private PmsKnowledgeLibraryService libraryService; + @Resource + private PmsKnowledgeFolderReadService folderReadService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private PmsKnowledgeDocumentService documentService; + @Resource + private PmsKnowledgeContentPermissionService contentPermissionService; + + @Override + public Long validateTargetReadable(Integer type, Long entityId, Long userId) { + // 1. 校验对象类型 + PmsKnowledgeObjectTypeEnum objectType = PmsKnowledgeObjectTypeEnum.valueOf(type); + if (objectType == null) { + throw exception(KNOWLEDGE_INTERACTION_OBJECT_INVALID); + } + + // 2. 校验对象可读 + if (PmsKnowledgeObjectTypeEnum.LIBRARY.equals(objectType)) { + return libraryMemberService.validateLibraryReadable(entityId, userId).getId(); + } + if (PmsKnowledgeObjectTypeEnum.FOLDER.equals(objectType)) { + return folderReadService.getReadableFolder(entityId, userId).getLibraryId(); + } + PmsKnowledgeDocumentDO document = documentService.getDocument(entityId, userId); + if (ObjectUtil.notEqual(type, document.getType())) { + throw exception(KNOWLEDGE_INTERACTION_OBJECT_INVALID); + } + return document.getLibraryId(); + } + + @Override + public List getReadableItemList( + Collection targets, Long userId) { + if (CollUtil.isEmpty(targets)) { + return Collections.emptyList(); + } + + // 1. 批量收集知识库、文件夹和文档编号 + Set libraryIds = new LinkedHashSet<>(); + Set folderIds = new LinkedHashSet<>(); + Set documentIds = new LinkedHashSet<>(); + for (PmsKnowledgeInteractionItemRespVO target : targets) { + libraryIds.add(target.getLibraryId()); + if (PmsKnowledgeObjectTypeEnum.FOLDER.getType().equals(target.getType())) { + folderIds.add(target.getEntityId()); + } else if (PmsKnowledgeObjectTypeEnum.DOCUMENT.getType().equals(target.getType()) + || PmsKnowledgeObjectTypeEnum.FILE.getType().equals(target.getType())) { + documentIds.add(target.getEntityId()); + } + } + + // 2. 批量查询对象,并计算当前用户的可读范围 + libraryIds.retainAll(libraryMemberService.getReadableLibraryIdList(userId)); + Map libraryMap = convertMap(libraryService.getLibraryList(libraryIds), + PmsKnowledgeLibraryDO::getId); + Map folderMap = convertMap(folderReadService.getFolderList(folderIds), + PmsKnowledgeFolderDO::getId); + Map documentMap = convertMap(documentService.getDocumentList(documentIds), + PmsKnowledgeDocumentDO::getId); + Set readablePermissionIds = contentPermissionService.getReadableContentPermissionIdSet(libraryIds, userId); + + // 3. 按原始顺序组装可读对象 + List items = new ArrayList<>(); + for (PmsKnowledgeInteractionItemRespVO target : targets) { + PmsKnowledgeInteractionItemRespVO item = buildReadableItem(target, libraryMap, folderMap, documentMap, + readablePermissionIds); + if (item != null) { + items.add(item); + } + } + return items; + } + + /** + * 将互动目标转换为当前用户可读的展示对象 + * + * @param target 互动目标 + * @param libraryMap 知识库 Map + * @param folderMap 文件夹 Map + * @param documentMap 文档 Map + * @param readablePermissionIds 可读内容权限编号集合 + * @return 可读的互动展示对象;不可读时返回 {@code null} + */ + private PmsKnowledgeInteractionItemRespVO buildReadableItem( + PmsKnowledgeInteractionItemRespVO target, Map libraryMap, + Map folderMap, Map documentMap, + Set readablePermissionIds) { + // 1. 仅处理仍处于正常状态的所属知识库 + PmsKnowledgeLibraryDO library = libraryMap.get(target.getLibraryId()); + if (library == null || ObjectUtil.notEqual( + PmsKnowledgeDocumentStatusEnum.NORMAL.getStatus(), library.getStatus())) { + return null; + } + // 2. 组装三类对象共用的互动展示字段 + PmsKnowledgeInteractionItemRespVO item = new PmsKnowledgeInteractionItemRespVO() + .setId(target.getId()).setType(target.getType()).setEntityId(target.getEntityId()) + .setLibraryId(target.getLibraryId()).setLibraryName(library.getName()) + .setCreateTime(target.getCreateTime()); + // 3. 知识库对象必须与互动记录的知识库编号一致 + if (PmsKnowledgeObjectTypeEnum.LIBRARY.getType().equals(target.getType())) { + return target.getEntityId().equals(target.getLibraryId()) + ? item.setName(library.getName()).setDescription(library.getDescription()) + .setTargetUpdateTime(library.getUpdateTime()) : null; + } + // 4. 文件夹对象必须归属当前知识库、状态正常且具有内容读取权限 + if (PmsKnowledgeObjectTypeEnum.FOLDER.getType().equals(target.getType())) { + PmsKnowledgeFolderDO folder = folderMap.get(target.getEntityId()); + return folder != null && target.getLibraryId().equals(folder.getLibraryId()) + && PmsKnowledgeDocumentStatusEnum.NORMAL.getStatus().equals(folder.getStatus()) + && readablePermissionIds.contains(folder.getPermissionId()) + ? item.setName(folder.getTitle()).setFolderId(folder.getId()) + .setTargetUpdateTime(folder.getUpdateTime()) : null; + } + // 5. 文档对象必须归属当前知识库、类型匹配、未回收且具有内容读取权限 + PmsKnowledgeDocumentDO document = documentMap.get(target.getEntityId()); + if (document == null || !target.getLibraryId().equals(document.getLibraryId()) + || PmsKnowledgeDocumentStatusEnum.RECYCLED.getStatus().equals(document.getStatus()) + || !target.getType().equals(document.getType()) + || !readablePermissionIds.contains(document.getPermissionId())) { + return null; + } + return item.setName(document.getTitle()).setFolderId(document.getFolderId()) + .setDocumentId(document.getId()).setFileType(document.getFileType()).setFileSize(document.getFileSize()) + .setTargetUpdateTime(document.getUpdateTime()); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeViewRecordService.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeViewRecordService.java new file mode 100644 index 000000000..c46e7fd1d --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeViewRecordService.java @@ -0,0 +1,58 @@ +package cn.iocoder.yudao.module.pms.service.kb.interaction; + +import cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.viewrecord.PmsKnowledgeRecentListRespVO; + +import java.util.Collection; + +/** + * PMS 知识浏览记录 Service 接口 + * + * @author 芋道源码 + */ +public interface PmsKnowledgeViewRecordService { + + /** + * 记录对象的最近浏览时间 + * + * @param libraryId 知识库编号 + * @param type 对象类型 + * @param entityId 对象编号 + * @param userId 用户编号 + */ + void createViewRecord(Long libraryId, Integer type, Long entityId, Long userId); + + /** + * 获得最近浏览列表 + * + * @param libraryId 知识库编号,为空时查询全部可读知识库 + * @param userId 用户编号 + * @return 最近浏览列表 + */ + PmsKnowledgeRecentListRespVO getRecentViewRecordList(Long libraryId, Long userId); + + /** + * 删除知识库的浏览记录 + * + * @param libraryId 知识库编号 + */ + void deleteViewRecordsByLibraryId(Long libraryId); + + /** + * 删除文件夹和文档的浏览记录 + * + * @param folderIds 文件夹编号集合 + * @param documentIds 文档编号集合 + */ + void deleteViewRecordsByEntityIds(Collection folderIds, Collection documentIds); + + /** + * 更新文件夹和文档浏览记录的所属知识库 + * + * @param folderIds 文件夹编号集合 + * @param documentIds 文档编号集合 + * @param libraryId 目标知识库编号 + */ + void updateViewRecordLibraryIdByEntityIds(Collection folderIds, Collection documentIds, + Long libraryId); + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeViewRecordServiceImpl.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeViewRecordServiceImpl.java new file mode 100644 index 000000000..e390a84fb --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeViewRecordServiceImpl.java @@ -0,0 +1,146 @@ +package cn.iocoder.yudao.module.pms.service.kb.interaction; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.PmsKnowledgeInteractionItemRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.viewrecord.PmsKnowledgeRecentListRespVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.interaction.PmsKnowledgeViewRecordDO; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.interaction.PmsKnowledgeViewRecordMapper; +import cn.iocoder.yudao.module.pms.enums.kb.PmsKnowledgeObjectTypeEnum; +import cn.iocoder.yudao.module.pms.service.kb.library.PmsKnowledgeLibraryMemberService; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; + +/** + * PMS 知识浏览记录 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class PmsKnowledgeViewRecordServiceImpl implements PmsKnowledgeViewRecordService { + + /** + * 今天、昨天之外保留的历史自然日数量 + */ + private static final long RECENT_HISTORY_DAYS = 31L; + + @Resource + private PmsKnowledgeViewRecordMapper viewRecordMapper; + + @Resource + private PmsKnowledgeInteractionTargetService interactionTargetService; + @Resource + private PmsKnowledgeLibraryMemberService libraryMemberService; + + @Override + @Transactional(rollbackFor = Exception.class) + public void createViewRecord(Long libraryId, Integer type, Long entityId, Long userId) { + // 1. 已浏览过同一对象时,更新当前归属和最后访问时间 + LocalDateTime viewTime = LocalDateTime.now(); + if (viewRecordMapper.updateLibraryIdAndCreateTimeByUserIdAndTypeAndEntityId( + libraryId, viewTime, userId, type, entityId) > 0) { + return; + } + + // 2. 首次浏览时创建最近访问记录 + PmsKnowledgeViewRecordDO record = new PmsKnowledgeViewRecordDO() + .setLibraryId(libraryId).setType(type) + .setEntityId(entityId).setUserId(userId); + record.setCreateTime(viewTime); + viewRecordMapper.insert(record); + } + + @Override + public PmsKnowledgeRecentListRespVO getRecentViewRecordList(Long libraryId, Long userId) { + // 1. 校验指定知识库的读取权限 + if (libraryId != null) { + libraryMemberService.validateLibraryReadable(libraryId, userId); + } + + // 2. 查询今天、昨天及此前 30 个自然日的浏览记录(从当天零点回溯 31 天) + LocalDateTime todayBeginTime = LocalDate.now().atStartOfDay(); + LocalDateTime yesterdayBeginTime = todayBeginTime.minusDays(1); + List records = viewRecordMapper.selectListByUserIdAndCreateTimeAfter( + userId, todayBeginTime.minusDays(RECENT_HISTORY_DAYS)); + if (libraryId != null) { + records.removeIf(record -> ObjectUtil.notEqual(libraryId, record.getLibraryId())); + } + List items = interactionTargetService.getReadableItemList( + convertList(records, record -> new PmsKnowledgeInteractionItemRespVO().setId(record.getId()) + .setType(record.getType()).setEntityId(record.getEntityId()) + .setLibraryId(record.getLibraryId()).setCreateTime(record.getCreateTime())), userId); + + // 3. 按今天、昨天和此前 30 天分组,同一分组内每个对象只保留最近一次访问 + List todayItems = new ArrayList<>(); + List yesterdayItems = new ArrayList<>(); + List recent30DayItems = new ArrayList<>(); + Set todayKeys = new LinkedHashSet<>(); + Set yesterdayKeys = new LinkedHashSet<>(); + Set recentKeys = new LinkedHashSet<>(); + for (PmsKnowledgeInteractionItemRespVO item : items) { + String key = item.getType() + ":" + item.getEntityId(); + if (!item.getCreateTime().isBefore(todayBeginTime)) { + addRecentItem(todayItems, todayKeys, key, item); + } else if (!item.getCreateTime().isBefore(yesterdayBeginTime)) { + addRecentItem(yesterdayItems, yesterdayKeys, key, item); + } else { + addRecentItem(recent30DayItems, recentKeys, key, item); + } + } + return new PmsKnowledgeRecentListRespVO().setTodayItems(todayItems).setYesterdayItems(yesterdayItems) + .setRecent30DayItems(recent30DayItems); + } + + @Override + public void deleteViewRecordsByLibraryId(Long libraryId) { + viewRecordMapper.deleteByLibraryId(libraryId); + } + + @Override + public void deleteViewRecordsByEntityIds(Collection folderIds, Collection documentIds) { + if (CollUtil.isNotEmpty(folderIds)) { + viewRecordMapper.deleteByTypeAndEntityIds(Collections.singleton( + PmsKnowledgeObjectTypeEnum.FOLDER.getType()), folderIds); + } + if (CollUtil.isNotEmpty(documentIds)) { + viewRecordMapper.deleteByTypeAndEntityIds(Arrays.asList(PmsKnowledgeObjectTypeEnum.DOCUMENT.getType(), + PmsKnowledgeObjectTypeEnum.FILE.getType()), documentIds); + } + } + + @Override + public void updateViewRecordLibraryIdByEntityIds(Collection folderIds, Collection documentIds, + Long libraryId) { + if (CollUtil.isNotEmpty(folderIds)) { + viewRecordMapper.updateLibraryIdByTypeAndEntityIds(libraryId, + Collections.singleton(PmsKnowledgeObjectTypeEnum.FOLDER.getType()), folderIds); + } + if (CollUtil.isNotEmpty(documentIds)) { + viewRecordMapper.updateLibraryIdByTypeAndEntityIds(libraryId, + Arrays.asList(PmsKnowledgeObjectTypeEnum.DOCUMENT.getType(), PmsKnowledgeObjectTypeEnum.FILE.getType()), documentIds); + } + } + + private void addRecentItem(List items, Set keys, + String key, PmsKnowledgeInteractionItemRespVO item) { + if (keys.add(key)) { + items.add(item); + } + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeGroupService.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeGroupService.java new file mode 100644 index 000000000..dea97b3fd --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeGroupService.java @@ -0,0 +1,103 @@ +package cn.iocoder.yudao.module.pms.service.kb.library; + +import cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.group.PmsKnowledgeGroupSaveReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.group.PmsKnowledgeGroupSortReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.group.PmsKnowledgeLibraryMoveGroupReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeGroupDO; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * PMS 知识库分组 Service 接口 + * + * @author 芋道源码 + */ +public interface PmsKnowledgeGroupService { + + /** + * 创建知识库分组,初始化默认分组并追加到现有分组末尾 + * + * @param saveReqVO 保存信息 + * @param userId 用户编号 + * @return 分组编号 + */ + Long createGroup(PmsKnowledgeGroupSaveReqVO saveReqVO, Long userId); + + /** + * 更新知识库分组名称,默认分组不允许修改 + * + * @param saveReqVO 保存信息 + * @param userId 用户编号 + */ + void updateGroup(PmsKnowledgeGroupSaveReqVO saveReqVO, Long userId); + + /** + * 更新知识库分组显示顺序 + * + * @param sortReqVO 排序信息 + * @param userId 用户编号 + */ + void updateGroupSort(PmsKnowledgeGroupSortReqVO sortReqVO, Long userId); + + /** + * 删除知识库分组及其知识库关系,默认分组不允许删除 + * + * @param id 分组编号 + * @param userId 用户编号 + */ + void deleteGroup(Long id, Long userId); + + /** + * 获得当前用户的知识库分组列表,并统计各分组的知识库数量 + * + * @param userId 用户编号 + * @return 分组列表 + */ + List getGroupList(Long userId); + + /** + * 统计当前用户各分组中的可读知识库数量 + * + * @param userId 用户编号 + * @param groupIds 用户分组编号集合 + * @return 分组编号到知识库数量 + */ + Map getGroupLibraryCountMap(Long userId, Collection groupIds); + + /** + * 获得当前用户的知识库分组 + * + * @param id 分组编号 + * @param userId 用户编号 + * @return 知识库分组 + */ + PmsKnowledgeGroupDO getGroup(Long id, Long userId); + + /** + * 移动知识库到个人分组,目标分组为空或“未分组”时移出分组 + * + * @param moveReqVO 移动信息 + * @param userId 用户编号 + */ + void moveLibraryToGroup(PmsKnowledgeLibraryMoveGroupReqVO moveReqVO, Long userId); + + /** + * 按分组过滤知识库编号列表,“全部知识库”不额外过滤,“未分组”排除已分组知识库 + * + * @param groupId 分组编号 + * @param userId 用户编号 + * @param libraryIds 待过滤的知识库编号集合 + * @return 过滤后的知识库编号列表 + */ + List filterLibraryIdListByGroup(Long groupId, Long userId, Collection libraryIds); + + /** + * 删除知识库的全部个人分组关系 + * + * @param libraryId 知识库编号 + */ + void deleteKnowledgeGroupRelationsByLibraryId(Long libraryId); + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeGroupServiceImpl.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeGroupServiceImpl.java new file mode 100644 index 000000000..25252dbde --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeGroupServiceImpl.java @@ -0,0 +1,277 @@ +package cn.iocoder.yudao.module.pms.service.kb.library; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.group.PmsKnowledgeGroupSaveReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.group.PmsKnowledgeGroupSortReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.group.PmsKnowledgeLibraryMoveGroupReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeGroupDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeGroupRelationDO; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.library.PmsKnowledgeGroupMapper; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.library.PmsKnowledgeGroupRelationMapper; +import cn.iocoder.yudao.module.pms.enums.kb.library.PmsKnowledgeGroupTypeEnum; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.hutool.core.util.ObjectUtil.notEqual; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_GROUP_DEFAULT_CANNOT_DELETE; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_GROUP_DEFAULT_CANNOT_MODIFY; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_GROUP_NAME_DUPLICATE; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_GROUP_NOT_EXISTS; + +/** + * PMS 知识库分组 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class PmsKnowledgeGroupServiceImpl implements PmsKnowledgeGroupService { + + @Resource + private PmsKnowledgeGroupMapper groupMapper; + @Resource + private PmsKnowledgeGroupRelationMapper groupRelationMapper; + + @Resource + private PmsKnowledgeLibraryMemberService libraryMemberService; + + @Override + public Long createGroup(PmsKnowledgeGroupSaveReqVO saveReqVO, Long userId) { + // 1. 校验分组名称唯一 + String name = StrUtil.trim(saveReqVO.getName()); + validateKnowledgeGroupNameDuplicate(userId, name, null); + + // 2. 创建自定义分组,并追加到现有分组末尾 + PmsKnowledgeGroupDO lastGroup = groupMapper.selectLastByUserId(userId); + int sort = lastGroup == null || lastGroup.getSort() == null + ? 2 : Math.max(lastGroup.getSort() + 1, 2); + PmsKnowledgeGroupDO group = BeanUtils.toBean(saveReqVO, PmsKnowledgeGroupDO.class) + .setUserId(userId).setName(name) + .setSort(sort) + .setType(PmsKnowledgeGroupTypeEnum.CUSTOM.getType()); + groupMapper.insert(group); + return group.getId(); + } + + @Override + public void updateGroup(PmsKnowledgeGroupSaveReqVO saveReqVO, Long userId) { + // 1.1 校验分组存在 + PmsKnowledgeGroupDO group = validateKnowledgeGroupExists(saveReqVO.getId(), userId); + // 1.2 校验分组为自定义分组 + if (notEqual(PmsKnowledgeGroupTypeEnum.CUSTOM.getType(), group.getType())) { + throw exception(KNOWLEDGE_GROUP_DEFAULT_CANNOT_MODIFY); + } + // 1.3 校验分组名称唯一 + validateKnowledgeGroupNameDuplicate(userId, StrUtil.trim(saveReqVO.getName()), saveReqVO.getId()); + + // 2. 更新分组名称 + groupMapper.updateById(BeanUtils.toBean(saveReqVO, PmsKnowledgeGroupDO.class) + .setName(StrUtil.trim(saveReqVO.getName())).setUserId(null).setSort(null).setType(null)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateGroupSort(PmsKnowledgeGroupSortReqVO sortReqVO, Long userId) { + // 1. 校验排序列表中的分组都属于当前用户 + List groupIds = convertList(sortReqVO.getItems(), PmsKnowledgeGroupSortReqVO.Item::getId); + if (groupMapper.selectListByIdsAndUserId(groupIds, userId).size() != groupIds.size()) { + throw exception(KNOWLEDGE_GROUP_NOT_EXISTS); + } + + // 2. 更新全部分组的显示顺序 + groupMapper.updateBatch(convertList(sortReqVO.getItems(), + item -> new PmsKnowledgeGroupDO().setId(item.getId()).setSort(item.getSort()))); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteGroup(Long id, Long userId) { + // 1.1 校验分组存在 + PmsKnowledgeGroupDO group = validateKnowledgeGroupExists(id, userId); + // 1.2 校验分组为自定义分组 + if (notEqual(PmsKnowledgeGroupTypeEnum.CUSTOM.getType(), group.getType())) { + throw exception(KNOWLEDGE_GROUP_DEFAULT_CANNOT_DELETE); + } + + // 2. 删除分组及其知识库关系 + groupRelationMapper.deleteByUserIdAndGroupId(userId, id); + groupMapper.deleteById(id); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public List getGroupList(Long userId) { + // 1. 查询当前用户的知识库分组 + List groups = groupMapper.selectListByUserId(userId); + // 2. 仅在默认分组缺失时补齐,避免正常访问产生写操作 + ensureDefaultKnowledgeGroupList(userId, groups); + return groups; + } + + @Override + public Map getGroupLibraryCountMap(Long userId, Collection groupIds) { + if (CollUtil.isEmpty(groupIds)) { + return Collections.emptyMap(); + } + // 1. 只统计当前用户可读的知识库,公开库也应进入“全部知识库”数量 + List readableLibraryIds = libraryMemberService.getReadableLibraryIdList(userId); + Map groupCountMap = CollUtil.isEmpty(readableLibraryIds) + ? new LinkedHashMap<>() + : groupRelationMapper.selectGroupLibraryCountMapByUserIdAndLibraryIds(userId, readableLibraryIds); + int groupedLibraryCount = CollUtil.isEmpty(readableLibraryIds) ? 0 + : groupRelationMapper.selectGroupedLibraryCountByUserIdAndLibraryIds(userId, readableLibraryIds); + + // 2. 补充系统分组数量:全部=可读总数,未分组=可读总数-已分组数 + int readableLibraryCount = CollUtil.size(readableLibraryIds); + List groups = groupMapper.selectByIds(groupIds); + for (PmsKnowledgeGroupDO group : groups) { + if (PmsKnowledgeGroupTypeEnum.ALL.getType().equals(group.getType())) { + groupCountMap.put(group.getId(), readableLibraryCount); + } else if (PmsKnowledgeGroupTypeEnum.UNGROUPED.getType().equals(group.getType())) { + groupCountMap.put(group.getId(), Math.max(readableLibraryCount - groupedLibraryCount, 0)); + } + } + return groupCountMap; + } + + @Override + public PmsKnowledgeGroupDO getGroup(Long id, Long userId) { + return validateKnowledgeGroupExists(id, userId); + } + + /** + * 确保用户拥有“全部知识库”和“未分组”两个系统分组 + * + * @param userId 用户编号 + * @param groups 当前已查询到的分组 + */ + private void ensureDefaultKnowledgeGroupList(Long userId, List groups) { + // 1. 检查两个系统分组是否已经存在 + boolean hasAllGroup = CollUtil.findOne(groups, + group -> PmsKnowledgeGroupTypeEnum.ALL.getType().equals(group.getType())) != null; + boolean hasUngroupedGroup = CollUtil.findOne(groups, + group -> PmsKnowledgeGroupTypeEnum.UNGROUPED.getType().equals(group.getType())) != null; + if (hasAllGroup && hasUngroupedGroup) { + return; + } + + // 2. 构造并插入缺失的系统分组 + List defaultGroups = new ArrayList<>(); + if (!hasAllGroup) { + defaultGroups.add(new PmsKnowledgeGroupDO().setUserId(userId) + .setName(PmsKnowledgeGroupTypeEnum.ALL.getName()).setSort(0) + .setType(PmsKnowledgeGroupTypeEnum.ALL.getType())); + } + if (!hasUngroupedGroup) { + defaultGroups.add(new PmsKnowledgeGroupDO().setUserId(userId) + .setName(PmsKnowledgeGroupTypeEnum.UNGROUPED.getName()).setSort(1) + .setType(PmsKnowledgeGroupTypeEnum.UNGROUPED.getType())); + } + groupMapper.insertBatch(defaultGroups); + + // 3. 直接合并本次新增记录并保持排序 + groups.addAll(defaultGroups); + groups.sort(Comparator.comparing(PmsKnowledgeGroupDO::getSort, + Comparator.nullsLast(Integer::compareTo)).thenComparing(PmsKnowledgeGroupDO::getId)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void moveLibraryToGroup(PmsKnowledgeLibraryMoveGroupReqVO moveReqVO, Long userId) { + // 1. 校验当前用户可以读取知识库;个人分组允许收藏公开知识库 + libraryMemberService.validateLibraryReadable(moveReqVO.getLibraryId(), userId); + + // 2. 移出分组时,删除已有的个人分组关系 + if (moveReqVO.getGroupId() == null) { + groupRelationMapper.deleteByUserIdAndLibraryId(userId, moveReqVO.getLibraryId()); + return; + } + + // 3. 校验目标分组;移动到“未分组”等同于移出分组,“全部知识库”不能作为目标分组 + PmsKnowledgeGroupDO targetGroup = validateKnowledgeGroupExists(moveReqVO.getGroupId(), userId); + if (PmsKnowledgeGroupTypeEnum.UNGROUPED.getType().equals(targetGroup.getType())) { + groupRelationMapper.deleteByUserIdAndLibraryId(userId, moveReqVO.getLibraryId()); + return; + } + if (PmsKnowledgeGroupTypeEnum.ALL.getType().equals(targetGroup.getType())) { + throw exception(KNOWLEDGE_GROUP_DEFAULT_CANNOT_MODIFY); + } + + // 4. 新增或更新个人知识库分组关系 + PmsKnowledgeGroupRelationDO relation = groupRelationMapper.selectByUserIdAndLibraryId( + userId, moveReqVO.getLibraryId()); + if (relation == null) { + groupRelationMapper.insert(BeanUtils.toBean(moveReqVO, PmsKnowledgeGroupRelationDO.class) + .setUserId(userId).setSort(0)); + return; + } + groupRelationMapper.updateById(BeanUtils.toBean(moveReqVO, PmsKnowledgeGroupRelationDO.class) + .setId(relation.getId()).setUserId(null).setLibraryId(null).setSort(null)); + } + + @Override + public List filterLibraryIdListByGroup(Long groupId, Long userId, Collection libraryIds) { + if (CollUtil.isEmpty(libraryIds) || groupId == null) { + return CollUtil.isEmpty(libraryIds) ? Collections.emptyList() : new ArrayList<>(new HashSet<>(libraryIds)); + } + + // 1. 校验分组存在;“全部知识库”不额外过滤 + PmsKnowledgeGroupDO group = validateKnowledgeGroupExists(groupId, userId); + if (PmsKnowledgeGroupTypeEnum.ALL.getType().equals(group.getType())) { + return new ArrayList<>(new HashSet<>(libraryIds)); + } + + // 2. 获得个人分组中的知识库编号 + Set candidateLibraryIds = new HashSet<>(libraryIds); + if (PmsKnowledgeGroupTypeEnum.UNGROUPED.getType().equals(group.getType())) { + Set groupedLibraryIds = convertSet(groupRelationMapper.selectListByUserIdAndLibraryIds( + userId, candidateLibraryIds), PmsKnowledgeGroupRelationDO::getLibraryId); + candidateLibraryIds.removeAll(groupedLibraryIds); + return new ArrayList<>(candidateLibraryIds); + } + // 自定义分组只保留已建立当前分组关系的候选知识库 + List relations = + groupRelationMapper.selectListByUserIdAndGroupId(userId, groupId); + Set groupedLibraryIds = convertSet(relations, PmsKnowledgeGroupRelationDO::getLibraryId); + candidateLibraryIds.retainAll(groupedLibraryIds); + return new ArrayList<>(candidateLibraryIds); + } + + @Override + public void deleteKnowledgeGroupRelationsByLibraryId(Long libraryId) { + groupRelationMapper.deleteByLibraryId(libraryId); + } + + private PmsKnowledgeGroupDO validateKnowledgeGroupExists(Long id, Long userId) { + PmsKnowledgeGroupDO group = groupMapper.selectByIdAndUserId(id, userId); + if (group == null) { + throw exception(KNOWLEDGE_GROUP_NOT_EXISTS); + } + return group; + } + + private void validateKnowledgeGroupNameDuplicate(Long userId, String name, Long excludeId) { + PmsKnowledgeGroupDO group = groupMapper.selectByUserIdAndName(userId, name, excludeId); + if (group != null) { + throw exception(KNOWLEDGE_GROUP_NAME_DUPLICATE); + } + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeLibraryMemberService.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeLibraryMemberService.java new file mode 100644 index 000000000..98abd99f3 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeLibraryMemberService.java @@ -0,0 +1,166 @@ +package cn.iocoder.yudao.module.pms.service.kb.library; + +import cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.member.PmsKnowledgeLibraryUpdateMemberListReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryMemberDO; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * PMS 知识库成员 Service 接口 + * + * @author 芋道源码 + */ +public interface PmsKnowledgeLibraryMemberService { + + /** + * 校验知识库可读 + * + * @param libraryId 知识库编号 + * @param userId 用户编号 + * @return 知识库 + */ + PmsKnowledgeLibraryDO validateLibraryReadable(Long libraryId, Long userId); + + /** + * 校验知识库可写 + * + * @param libraryId 知识库编号 + * @param userId 用户编号 + * @return 知识库 + */ + PmsKnowledgeLibraryDO validateLibraryWritable(Long libraryId, Long userId); + + /** + * 校验知识库管理员权限 + * + * @param libraryId 知识库编号 + * @param userId 用户编号 + * @return 知识库 + */ + PmsKnowledgeLibraryDO validateLibraryAdmin(Long libraryId, Long userId); + + /** + * 校验知识库创建人权限 + * + * @param libraryId 知识库编号 + * @param userId 用户编号 + * @return 知识库 + */ + PmsKnowledgeLibraryDO validateLibraryCreator(Long libraryId, Long userId); + + /** + * 判断知识库是否可写 + * + * @param libraryId 知识库编号 + * @param userId 用户编号 + * @return 是否可写 + */ + boolean isLibraryWritable(Long libraryId, Long userId); + + /** + * 判断是否为知识库管理员 + * + * @param libraryId 知识库编号 + * @param userId 用户编号 + * @return 是否为管理员 + */ + boolean isLibraryAdmin(Long libraryId, Long userId); + + /** + * 获得已加入的有效知识库编号列表 + * + * @param userId 用户编号 + * @return 知识库编号列表 + */ + List getJoinedLibraryIdList(Long userId); + + /** + * 获得可读知识库编号列表 + * + * @param userId 用户编号 + * @return 知识库编号列表 + */ + List getReadableLibraryIdList(Long userId); + + /** + * 创建知识库的创建人和初始成员 + * + * @param libraryId 知识库编号 + * @param creatorUserId 创建人用户编号 + * @param adminUserIds 初始管理员用户编号集合 + * @param memberUserIds 初始普通成员用户编号集合 + */ + void createLibraryMemberList(Long libraryId, Long creatorUserId, + Collection adminUserIds, Collection memberUserIds); + + /** + * 获得知识库成员列表 + * + * @param libraryId 知识库编号 + * @param userId 当前用户编号 + * @return 成员列表 + */ + List getLibraryMemberList(Long libraryId, Long userId); + + /** + * 更新知识库成员,保留创建人并清理已移除成员的内容级协作权限 + * + * @param updateReqVO 更新信息 + * @param userId 当前用户编号 + */ + void updateLibraryMemberList(PmsKnowledgeLibraryUpdateMemberListReqVO updateReqVO, Long userId); + + /** + * 退出知识库,创建人不允许退出 + * + * @param libraryId 知识库编号 + * @param userId 当前用户编号 + */ + void exitLibrary(Long libraryId, Long userId); + + /** + * 删除知识库的全部成员关系 + * + * @param libraryId 知识库编号 + */ + void deleteLibraryMembersByLibraryId(Long libraryId); + + /** + * 获得知识库成员列表 Map + * + * @param libraryIds 知识库编号集合 + * @return 知识库编号到成员列表的映射 + */ + Map> getLibraryMemberListMap(Collection libraryIds); + + /** + * 获得用户在知识库中的直接成员关系 + * + * @param libraryId 知识库编号 + * @param userId 用户编号 + * @return 成员关系 + */ + PmsKnowledgeLibraryMemberDO getMemberByLibraryIdAndUserId(Long libraryId, Long userId); + + /** + * 获得部门在知识库中的成员关系 + * + * @param libraryId 知识库编号 + * @param deptId 部门编号 + * @return 成员关系 + */ + PmsKnowledgeLibraryMemberDO getMemberByLibraryIdAndDeptId(Long libraryId, Long deptId); + + /** + * 获得用户或部门加入的知识库编号列表 + * + * @param userId 用户编号 + * @param deptId 部门编号 + * @return 知识库编号列表 + */ + List getLibraryIdListByUserIdOrDeptId(Long userId, Long deptId); + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeLibraryMemberServiceImpl.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeLibraryMemberServiceImpl.java new file mode 100644 index 000000000..9d6788462 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeLibraryMemberServiceImpl.java @@ -0,0 +1,337 @@ +package cn.iocoder.yudao.module.pms.service.kb.library; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.member.PmsKnowledgeLibraryUpdateMemberListReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryMemberDO; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.library.PmsKnowledgeLibraryMemberMapper; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeDocumentStatusEnum; +import cn.iocoder.yudao.module.pms.enums.kb.library.PmsKnowledgeLibraryMemberLevelEnum; +import cn.iocoder.yudao.module.pms.service.kb.content.PmsKnowledgeContentPermissionService; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.permission.PermissionApi; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import cn.iocoder.yudao.module.system.enums.permission.RoleCodeEnum; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import static cn.hutool.core.util.ObjectUtil.notEqual; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMultiMap; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.common.util.object.ObjectUtils.equalsAny; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_LIBRARY_ACCESS_DENIED; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_LIBRARY_ADMIN_REQUIRED; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_LIBRARY_CREATOR_CANNOT_REMOVE; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_LIBRARY_DIRECT_MEMBER_NOT_EXISTS; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_LIBRARY_MEMBERS_INVALID; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_LIBRARY_NOT_EXISTS; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_LIBRARY_WRITE_ACCESS_DENIED; + +/** + * PMS 知识库成员 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class PmsKnowledgeLibraryMemberServiceImpl implements PmsKnowledgeLibraryMemberService { + + @Resource + private PmsKnowledgeLibraryMemberMapper memberMapper; + + @Resource + @Lazy // 延迟加载,避免循环依赖 + private PmsKnowledgeLibraryService libraryService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private PmsKnowledgeContentPermissionService contentPermissionService; + + @Resource + private PermissionApi permissionApi; + @Resource + private AdminUserApi adminUserApi; + @Resource + private DeptApi deptApi; + + @Override + public PmsKnowledgeLibraryDO validateLibraryReadable(Long libraryId, Long userId) { + PmsKnowledgeLibraryDO library = validateLibraryExists(libraryId); + if (Boolean.FALSE.equals(library.getOpenStatus()) && !isLibraryMember(libraryId, userId)) { + throw exception(KNOWLEDGE_LIBRARY_ACCESS_DENIED); + } + return library; + } + + @Override + public PmsKnowledgeLibraryDO validateLibraryWritable(Long libraryId, Long userId) { + PmsKnowledgeLibraryDO library = validateLibraryExists(libraryId); + if (!isLibraryWritable(libraryId, userId)) { + throw exception(KNOWLEDGE_LIBRARY_WRITE_ACCESS_DENIED); + } + return library; + } + + @Override + public PmsKnowledgeLibraryDO validateLibraryAdmin(Long libraryId, Long userId) { + PmsKnowledgeLibraryDO library = validateLibraryExists(libraryId); + if (!isLibraryAdmin(libraryId, userId)) { + throw exception(KNOWLEDGE_LIBRARY_ADMIN_REQUIRED); + } + return library; + } + + @Override + public PmsKnowledgeLibraryDO validateLibraryCreator(Long libraryId, Long userId) { + PmsKnowledgeLibraryDO library = validateLibraryExists(libraryId); + if (isSuperAdmin(userId)) { + return library; + } + PmsKnowledgeLibraryMemberDO member = getLibraryMember(libraryId, userId); + if (member == null || notEqual(PmsKnowledgeLibraryMemberLevelEnum.CREATOR.getLevel(), member.getLevel())) { + throw exception(KNOWLEDGE_LIBRARY_ADMIN_REQUIRED); + } + return library; + } + + @Override + public boolean isLibraryWritable(Long libraryId, Long userId) { + return isSuperAdmin(userId) || getLibraryMember(libraryId, userId) != null; + } + + @Override + public boolean isLibraryAdmin(Long libraryId, Long userId) { + if (isSuperAdmin(userId)) { + return true; + } + PmsKnowledgeLibraryMemberDO member = getLibraryMember(libraryId, userId); + if (member == null) { + return false; + } + // 公开库也允许管理员参与成员和内容协作;创建人权限单独用于公开状态和删除等高危操作 + return equalsAny(member.getLevel(), PmsKnowledgeLibraryMemberLevelEnum.CREATOR.getLevel(), + PmsKnowledgeLibraryMemberLevelEnum.ADMIN.getLevel()); + } + + @Override + public List getJoinedLibraryIdList(Long userId) { + // 1. 查询当前用户及所在部门加入的知识库编号 + AdminUserRespDTO user = adminUserApi.getUser(userId).getCheckedData(); + List joinedLibraryIds = getLibraryIdListByUserIdOrDeptId( + userId, user != null ? user.getDeptId() : null); + + // 2. 过滤已删除或已进入回收站的知识库 + Set libraryIds = new LinkedHashSet<>(joinedLibraryIds); + libraryIds.retainAll(libraryService.getLibraryIdList(null)); + return convertList(libraryIds, item -> item); + } + + @Override + public List getReadableLibraryIdList(Long userId) { + // 1. 超级管理员可读取全部知识库 + if (isSuperAdmin(userId)) { + return libraryService.getLibraryIdList(null); + } + + // 2. 查询公开知识库和自己加入的知识库 + List openLibraryIds = libraryService.getLibraryIdList(true); + List joinedLibraryIds = getJoinedLibraryIdList(userId); + + // 3. 合并并去重可读知识库编号 + Set libraryIds = new LinkedHashSet<>(openLibraryIds); + libraryIds.addAll(joinedLibraryIds); + return convertList(libraryIds, item -> item); + } + + @Override + public void createLibraryMemberList(Long libraryId, Long creatorUserId, + Collection adminUserIds, Collection memberUserIds) { + // 1.1 整理初始成员并校验重复、创建人保护 + List adminUserIdList = CollUtil.isEmpty(adminUserIds) + ? new ArrayList<>() : new ArrayList<>(adminUserIds); + List memberUserIdList = CollUtil.isEmpty(memberUserIds) + ? new ArrayList<>() : new ArrayList<>(memberUserIds); + Set initialMemberUserIds = new LinkedHashSet<>(); + // 两个角色数组统一校验创建人保护和重复用户,避免维护两套不一致的校验分支 + for (Long userId : adminUserIdList) { + if (userId == null || Objects.equals(creatorUserId, userId) + || !initialMemberUserIds.add(userId)) { + throw exception(KNOWLEDGE_LIBRARY_MEMBERS_INVALID); + } + } + for (Long userId : memberUserIdList) { + if (userId == null || Objects.equals(creatorUserId, userId) + || !initialMemberUserIds.add(userId)) { + throw exception(KNOWLEDGE_LIBRARY_MEMBERS_INVALID); + } + } + // 1.2 校验初始成员账号存在 + adminUserApi.validateUserList(initialMemberUserIds).checkError(); + + // 2. 创建创建人和初始成员 + List members = new ArrayList<>(); + members.addAll(convertList(adminUserIdList, userId -> new PmsKnowledgeLibraryMemberDO() + .setLibraryId(libraryId).setUserId(userId) + .setLevel(PmsKnowledgeLibraryMemberLevelEnum.ADMIN.getLevel()))); + members.addAll(convertList(memberUserIdList, userId -> new PmsKnowledgeLibraryMemberDO() + .setLibraryId(libraryId).setUserId(userId) + .setLevel(PmsKnowledgeLibraryMemberLevelEnum.MEMBER.getLevel()))); + members.add(0, new PmsKnowledgeLibraryMemberDO().setLibraryId(libraryId).setUserId(creatorUserId) + .setLevel(PmsKnowledgeLibraryMemberLevelEnum.CREATOR.getLevel())); + memberMapper.insertBatch(members); + } + + @Override + public List getLibraryMemberList(Long libraryId, Long userId) { + validateLibraryReadable(libraryId, userId); + return memberMapper.selectListByLibraryId(libraryId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateLibraryMemberList(PmsKnowledgeLibraryUpdateMemberListReqVO updateReqVO, Long userId) { + // 1.1 校验知识库管理员权限 + validateLibraryAdmin(updateReqVO.getLibraryId(), userId); + // 1.2 校验成员不重复 + Set memberUserIds = convertSet(updateReqVO.getMembers(), + PmsKnowledgeLibraryUpdateMemberListReqVO.Member::getUserId); + Set memberDeptIds = convertSet(updateReqVO.getMembers(), + PmsKnowledgeLibraryUpdateMemberListReqVO.Member::getDeptId); + List oldMembers = + memberMapper.selectListByLibraryId(updateReqVO.getLibraryId()); + PmsKnowledgeLibraryMemberDO creator = CollUtil.findOne(oldMembers, member -> + PmsKnowledgeLibraryMemberLevelEnum.CREATOR.getLevel().equals(member.getLevel())); + if (memberUserIds.size() + memberDeptIds.size() != updateReqVO.getMembers().size()) { + throw exception(KNOWLEDGE_LIBRARY_MEMBERS_INVALID); + } + // 1.3 校验成员不包含知识库创建人 + if (creator != null && memberUserIds.contains(creator.getUserId())) { + throw exception(KNOWLEDGE_LIBRARY_MEMBERS_INVALID); + } + // 1.4 校验成员不能伪造创建人等级 + if (CollUtil.findOne(updateReqVO.getMembers(), member -> + PmsKnowledgeLibraryMemberLevelEnum.CREATOR.getLevel().equals(member.getLevel())) != null) { + throw exception(KNOWLEDGE_LIBRARY_MEMBERS_INVALID); + } + // 1.5 校验用户成员存在 + adminUserApi.validateUserList(memberUserIds).checkError(); + // 1.6 校验部门成员存在 + deptApi.validateDeptList(memberDeptIds).checkError(); + + // 2. 保留创建人并重建管理员和普通成员 + memberMapper.deleteByLibraryIdAndLevels(updateReqVO.getLibraryId(), Arrays.asList( + PmsKnowledgeLibraryMemberLevelEnum.ADMIN.getLevel(), + PmsKnowledgeLibraryMemberLevelEnum.MEMBER.getLevel())); + if (CollUtil.isNotEmpty(updateReqVO.getMembers())) { + memberMapper.insertBatch(convertList(updateReqVO.getMembers(), member -> + BeanUtils.toBean(member, PmsKnowledgeLibraryMemberDO.class) + .setLibraryId(updateReqVO.getLibraryId()))); + } + + // 3. 移除已退出知识库成员的内容级协作权限 + Set retainedUserIds = new LinkedHashSet<>(memberUserIds); + if (creator != null) { + retainedUserIds.add(creator.getUserId()); + } + Set removedUserIds = convertSet(oldMembers, PmsKnowledgeLibraryMemberDO::getUserId); + removedUserIds.removeAll(retainedUserIds); + Set removedDeptIds = convertSet(oldMembers, PmsKnowledgeLibraryMemberDO::getDeptId); + removedDeptIds.removeAll(memberDeptIds); + contentPermissionService.deleteContentPermissionMembersByLibraryId(updateReqVO.getLibraryId(), removedUserIds, removedDeptIds); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void exitLibrary(Long libraryId, Long userId) { + // 1.1 校验知识库可访问 + validateLibraryReadable(libraryId, userId); + // 1.2 校验当前账号是直接成员 + PmsKnowledgeLibraryMemberDO member = memberMapper.selectByLibraryIdAndUserId(libraryId, userId); + if (member == null) { + throw exception(KNOWLEDGE_LIBRARY_DIRECT_MEMBER_NOT_EXISTS); + } + // 1.3 校验知识库创建人不能主动退出 + if (PmsKnowledgeLibraryMemberLevelEnum.CREATOR.getLevel().equals(member.getLevel())) { + throw exception(KNOWLEDGE_LIBRARY_CREATOR_CANNOT_REMOVE); + } + + // 2. 退出知识库并清理当前账号的内容级协作权限 + memberMapper.deleteByLibraryIdAndUserId(libraryId, userId); + contentPermissionService.deleteContentPermissionMembersByLibraryId(libraryId, Collections.singleton(userId), + Collections.emptySet()); + } + + @Override + public void deleteLibraryMembersByLibraryId(Long libraryId) { + memberMapper.deleteByLibraryId(libraryId); + } + + @Override + public Map> getLibraryMemberListMap(Collection libraryIds) { + if (CollUtil.isEmpty(libraryIds)) { + return Collections.emptyMap(); + } + return convertMultiMap(memberMapper.selectListByLibraryIds(libraryIds), + PmsKnowledgeLibraryMemberDO::getLibraryId); + } + + @Override + public PmsKnowledgeLibraryMemberDO getMemberByLibraryIdAndUserId(Long libraryId, Long userId) { + return memberMapper.selectByLibraryIdAndUserId(libraryId, userId); + } + + @Override + public PmsKnowledgeLibraryMemberDO getMemberByLibraryIdAndDeptId(Long libraryId, Long deptId) { + return memberMapper.selectByLibraryIdAndDeptId(libraryId, deptId); + } + + @Override + public List getLibraryIdListByUserIdOrDeptId(Long userId, Long deptId) { + return memberMapper.selectLibraryIdListByUserIdOrDeptId(userId, deptId); + } + + private PmsKnowledgeLibraryDO validateLibraryExists(Long libraryId) { + PmsKnowledgeLibraryDO library = libraryService.getLibrary(libraryId); + if (library == null || notEqual(PmsKnowledgeDocumentStatusEnum.NORMAL.getStatus(), library.getStatus())) { + throw exception(KNOWLEDGE_LIBRARY_NOT_EXISTS); + } + return library; + } + + private boolean isSuperAdmin(Long userId) { + return permissionApi.hasAnyRoles(userId, RoleCodeEnum.SUPER_ADMIN.getCode()).getCheckedData(); + } + + private boolean isLibraryMember(Long libraryId, Long userId) { + return isSuperAdmin(userId) || getLibraryMember(libraryId, userId) != null; + } + + private PmsKnowledgeLibraryMemberDO getLibraryMember(Long libraryId, Long userId) { + PmsKnowledgeLibraryMemberDO member = getMemberByLibraryIdAndUserId(libraryId, userId); + if (member != null) { + return member; + } + AdminUserRespDTO user = adminUserApi.getUser(userId).getCheckedData(); + if (user == null || user.getDeptId() == null) { + return null; + } + return getMemberByLibraryIdAndDeptId(libraryId, user.getDeptId()); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeLibraryService.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeLibraryService.java new file mode 100644 index 000000000..e6e86e5dd --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeLibraryService.java @@ -0,0 +1,113 @@ +package cn.iocoder.yudao.module.pms.service.kb.library; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.library.PmsKnowledgeLibraryPageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.library.PmsKnowledgeLibrarySaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryDO; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * PMS 知识库 Service 接口 + * + * @author 芋道源码 + */ +public interface PmsKnowledgeLibraryService { + + /** + * 创建知识库,并将创建人和初始成员加入知识库 + * + * @param saveReqVO 保存信息 + * @param userId 用户编号 + * @return 知识库编号 + */ + Long createLibrary(PmsKnowledgeLibrarySaveReqVO saveReqVO, Long userId); + + /** + * 更新知识库基本信息 + * + * @param saveReqVO 保存信息 + * @param userId 用户编号 + */ + void updateLibrary(PmsKnowledgeLibrarySaveReqVO saveReqVO, Long userId); + + /** + * 删除知识库,并将知识库及其内容移入回收站 + * + * @param id 知识库编号 + * @param userId 用户编号 + */ + void deleteLibrary(Long id, Long userId); + + /** + * 将知识库更新为已回收状态 + * + * @param id 知识库编号 + * @param userId 删除用户编号 + * @param deleteTime 删除时间 + */ + void updateLibraryToRecycled(Long id, Long userId, LocalDateTime deleteTime); + + /** + * 恢复知识库 + * + * @param id 知识库编号 + */ + void restoreLibrary(Long id); + + /** + * 彻底删除知识库 + * + * @param id 知识库编号 + */ + void deleteLibraryPermanently(Long id); + + /** + * 获得知识库 + * + * @param id 知识库编号 + * @return 知识库 + */ + PmsKnowledgeLibraryDO getLibrary(Long id); + + /** + * 获得当前用户可见的知识库分页 + * + * @param pageReqVO 分页查询 + * @param userId 用户编号 + * @return 知识库分页 + */ + PageResult getLibraryPage(PmsKnowledgeLibraryPageReqVO pageReqVO, Long userId); + + /** + * 获得知识库列表 + * + * @param ids 知识库编号集合 + * @return 知识库列表 + */ + List getLibraryList(Collection ids); + + /** + * 获得知识库 Map + * + * @param ids 知识库编号集合 + * @return 知识库编号到知识库的映射 + */ + default Map getLibraryMap(Collection ids) { + return convertMap(getLibraryList(ids), PmsKnowledgeLibraryDO::getId); + } + + /** + * 获得符合公开状态的正常知识库编号列表 + * + * @param openStatus 公开状态,为空时查询全部 + * @return 知识库编号列表 + */ + List getLibraryIdList(Boolean openStatus); + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeLibraryServiceImpl.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeLibraryServiceImpl.java new file mode 100644 index 000000000..24d2b13fd --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeLibraryServiceImpl.java @@ -0,0 +1,153 @@ +package cn.iocoder.yudao.module.pms.service.kb.library; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.library.PmsKnowledgeLibraryPageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.library.PmsKnowledgeLibrarySaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryDO; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.library.PmsKnowledgeLibraryMapper; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeDocumentStatusEnum; +import cn.iocoder.yudao.module.pms.service.kb.recycle.PmsKnowledgeRecycleService; +import cn.iocoder.yudao.module.system.api.permission.PermissionApi; +import cn.iocoder.yudao.module.system.enums.permission.RoleCodeEnum; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + + +/** + * PMS 知识库 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class PmsKnowledgeLibraryServiceImpl implements PmsKnowledgeLibraryService { + + @Resource + private PmsKnowledgeLibraryMapper libraryMapper; + + @Resource + @Lazy // 延迟加载,避免循环依赖 + private PmsKnowledgeLibraryMemberService memberService; + @Resource + private PmsKnowledgeGroupService knowledgeGroupService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private PmsKnowledgeRecycleService recycleService; + @Resource + private PmsKnowledgeLibraryTemplateService libraryTemplateService; + + @Resource + private PermissionApi permissionApi; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createLibrary(PmsKnowledgeLibrarySaveReqVO saveReqVO, Long userId) { + // 1. 创建知识库 + PmsKnowledgeLibraryDO library = BeanUtils.toBean(saveReqVO, PmsKnowledgeLibraryDO.class) + .setStatus(PmsKnowledgeDocumentStatusEnum.NORMAL.getStatus()); + libraryMapper.insert(library); + + // 2. 创建人和初始成员加入知识库 + memberService.createLibraryMemberList(library.getId(), userId, + saveReqVO.getAdminUserIds(), saveReqVO.getMemberUserIds()); + + // 3. 使用所选模板创建知识库文档 + if (saveReqVO.getTemplateId() != null) { + libraryTemplateService.createTemplateDocumentList(saveReqVO.getTemplateId(), library.getId(), userId); + } + return library.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateLibrary(PmsKnowledgeLibrarySaveReqVO saveReqVO, Long userId) { + // 1.1 普通库设置由管理员维护 + PmsKnowledgeLibraryDO library = memberService.validateLibraryAdmin(saveReqVO.getId(), userId); + // 1.2 公开库转私有仅限创建人 + if (Boolean.TRUE.equals(library.getOpenStatus()) + && Boolean.FALSE.equals(saveReqVO.getOpenStatus())) { + memberService.validateLibraryCreator(library.getId(), userId); + } + + // 2. 更新知识库基本信息 + libraryMapper.updateForEdit(BeanUtils.toBean(saveReqVO, PmsKnowledgeLibraryDO.class)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteLibrary(Long id, Long userId) { + // 1. 校验当前用户是知识库创建人 + PmsKnowledgeLibraryDO library = memberService.validateLibraryCreator(id, userId); + + // 2. 将知识库及其内容移入回收站 + recycleService.recycleLibrary(library, userId); + } + + @Override + public void updateLibraryToRecycled(Long id, Long userId, LocalDateTime deleteTime) { + libraryMapper.updateById(new PmsKnowledgeLibraryDO().setId(id) + .setStatus(PmsKnowledgeDocumentStatusEnum.RECYCLED.getStatus()) + .setDeleteUserId(userId).setDeleteTime(deleteTime)); + } + + @Override + public void restoreLibrary(Long id) { + libraryMapper.updateToRestoreById(id, PmsKnowledgeDocumentStatusEnum.NORMAL.getStatus()); + } + + @Override + public void deleteLibraryPermanently(Long id) { + libraryMapper.deleteById(id); + } + + @Override + public PmsKnowledgeLibraryDO getLibrary(Long id) { + return libraryMapper.selectById(id); + } + + @Override + public PageResult getLibraryPage(PmsKnowledgeLibraryPageReqVO pageReqVO, Long userId) { + // 1. 获得当前用户参与及可读的知识库,并按个人分组进一步过滤 + List memberLibraryIds = memberService.getJoinedLibraryIdList(userId); + List filterLibraryIds = null; + if (pageReqVO.getGroupId() != null) { + List readableLibraryIds = memberService.getReadableLibraryIdList(userId); + filterLibraryIds = knowledgeGroupService.filterLibraryIdListByGroup( + pageReqVO.getGroupId(), userId, readableLibraryIds); + if (CollUtil.isEmpty(filterLibraryIds)) { + return PageResult.empty(); + } + } + // 2. 超级管理员查询全部知识库,普通用户查询公开或已加入知识库 + boolean includeAll = permissionApi.hasAnyRoles(userId, RoleCodeEnum.SUPER_ADMIN.getCode()).getCheckedData(); + + // 3. 执行分页查询 + return libraryMapper.selectPage(pageReqVO, memberLibraryIds, includeAll, filterLibraryIds, + PmsKnowledgeDocumentStatusEnum.NORMAL.getStatus()); + } + + @Override + public List getLibraryList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + return libraryMapper.selectByIds(ids); + } + + @Override + public List getLibraryIdList(Boolean openStatus) { + return libraryMapper.selectIdList(openStatus, PmsKnowledgeDocumentStatusEnum.NORMAL.getStatus()); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeLibraryTemplateService.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeLibraryTemplateService.java new file mode 100644 index 000000000..4aca58d6d --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeLibraryTemplateService.java @@ -0,0 +1,74 @@ +package cn.iocoder.yudao.module.pms.service.kb.library; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.template.PmsKnowledgeLibraryTemplatePageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.template.PmsKnowledgeLibraryTemplateSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryTemplateDO; +import org.springframework.lang.Nullable; + +import java.util.List; + +/** + * PMS 知识库模板 Service 接口 + * + * @author 芋道源码 + */ +public interface PmsKnowledgeLibraryTemplateService { + + /** + * 创建知识库模板 + * + * @param createReqVO 创建信息 + * @return 模板编号 + */ + Long createLibraryTemplate(PmsKnowledgeLibraryTemplateSaveReqVO createReqVO); + + /** + * 更新知识库模板 + * + * @param updateReqVO 更新信息 + */ + void updateLibraryTemplate(PmsKnowledgeLibraryTemplateSaveReqVO updateReqVO); + + /** + * 删除知识库模板 + * + * @param id 模板编号 + */ + void deleteLibraryTemplate(Long id); + + /** + * 获得知识库模板 + * + * @param id 模板编号 + * @return 知识库模板 + */ + PmsKnowledgeLibraryTemplateDO getLibraryTemplate(Long id); + + /** + * 获得知识库模板分页 + * + * @param pageReqVO 分页查询 + * @return 知识库模板分页 + */ + PageResult getLibraryTemplatePage( + PmsKnowledgeLibraryTemplatePageReqVO pageReqVO); + + /** + * 获得知识库模板列表 + * + * @param status 模板状态,为空时返回全部状态 + * @return 知识库模板列表 + */ + List getLibraryTemplateList(@Nullable Integer status); + + /** + * 根据知识库模板创建知识库文档 + * + * @param templateId 模板编号 + * @param libraryId 知识库编号 + * @param userId 当前用户编号 + */ + void createTemplateDocumentList(Long templateId, Long libraryId, Long userId); + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeLibraryTemplateServiceImpl.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeLibraryTemplateServiceImpl.java new file mode 100644 index 000000000..1c3e1ef71 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeLibraryTemplateServiceImpl.java @@ -0,0 +1,158 @@ +package cn.iocoder.yudao.module.pms.service.kb.library; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.document.PmsKnowledgeDocumentCreateReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.template.PmsKnowledgeLibraryTemplatePageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.template.PmsKnowledgeLibraryTemplateSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeDocumentDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryTemplateDO; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.library.PmsKnowledgeLibraryTemplateMapper; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeDocumentTypeEnum; +import cn.iocoder.yudao.module.pms.service.kb.content.PmsKnowledgeDocumentService; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_LIBRARY_TEMPLATE_INVALID; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_LIBRARY_TEMPLATE_NAME_DUPLICATE; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_LIBRARY_TEMPLATE_NOT_EXISTS; + +/** + * PMS 知识库模板 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class PmsKnowledgeLibraryTemplateServiceImpl implements PmsKnowledgeLibraryTemplateService { + + @Resource + private PmsKnowledgeLibraryTemplateMapper libraryTemplateMapper; + @Resource + private PmsKnowledgeDocumentService documentService; + + @Override + public Long createLibraryTemplate(PmsKnowledgeLibraryTemplateSaveReqVO createReqVO) { + // 1. 校验模板名称和文档配置 + validateLibraryTemplateNameDuplicate(null, createReqVO.getName()); + validateLibraryTemplateDocuments(createReqVO.getDocuments()); + + // 2. 创建知识库模板 + PmsKnowledgeLibraryTemplateDO template = BeanUtils.toBean(createReqVO, PmsKnowledgeLibraryTemplateDO.class) + .setDocuments(BeanUtils.toBean(createReqVO.getDocuments(), PmsKnowledgeLibraryTemplateDO.Document.class)); + libraryTemplateMapper.insert(template); + return template.getId(); + } + + @Override + public void updateLibraryTemplate(PmsKnowledgeLibraryTemplateSaveReqVO updateReqVO) { + // 1. 校验模板存在、名称和文档配置 + validateLibraryTemplateExists(updateReqVO.getId()); + validateLibraryTemplateNameDuplicate(updateReqVO.getId(), updateReqVO.getName()); + validateLibraryTemplateDocuments(updateReqVO.getDocuments()); + + // 2. 更新知识库模板 + libraryTemplateMapper.updateById(BeanUtils.toBean(updateReqVO, PmsKnowledgeLibraryTemplateDO.class) + .setDocuments(BeanUtils.toBean(updateReqVO.getDocuments(), PmsKnowledgeLibraryTemplateDO.Document.class))); + } + + @Override + public void deleteLibraryTemplate(Long id) { + // 1. 校验模板存在 + validateLibraryTemplateExists(id); + + // 2. 删除知识库模板 + libraryTemplateMapper.deleteById(id); + } + + @Override + public PmsKnowledgeLibraryTemplateDO getLibraryTemplate(Long id) { + return validateLibraryTemplateExists(id); + } + + @Override + public PageResult getLibraryTemplatePage( + PmsKnowledgeLibraryTemplatePageReqVO pageReqVO) { + return libraryTemplateMapper.selectPage(pageReqVO); + } + + @Override + public List getLibraryTemplateList(Integer status) { + return libraryTemplateMapper.selectListByStatus(status); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void createTemplateDocumentList(Long templateId, Long libraryId, Long userId) { + // 1. 校验模板存在且处于启用状态 + PmsKnowledgeLibraryTemplateDO template = validateLibraryTemplateExists(templateId); + if (CommonStatusEnum.isDisable(template.getStatus())) { + throw exception(KNOWLEDGE_LIBRARY_TEMPLATE_NOT_EXISTS); + } + if (CollUtil.isEmpty(template.getDocuments())) { + throw exception(KNOWLEDGE_LIBRARY_TEMPLATE_INVALID); + } + + // 2. 在知识库根目录创建模板富文本文档;每篇文档独立创建内容权限 + for (PmsKnowledgeLibraryTemplateDO.Document document : template.getDocuments()) { + documentService.createDocument(new PmsKnowledgeDocumentCreateReqVO().setLibraryId(libraryId) + .setFolderId(PmsKnowledgeDocumentDO.FOLDER_ID_ROOT) + .setParentId(PmsKnowledgeDocumentDO.PARENT_ID_ROOT).setTitle(document.getTitle()) + .setType(PmsKnowledgeDocumentTypeEnum.RICH_TEXT.getType()) + .setContent(document.getContent()), userId); + } + } + + private PmsKnowledgeLibraryTemplateDO validateLibraryTemplateExists(Long id) { + PmsKnowledgeLibraryTemplateDO template = libraryTemplateMapper.selectById(id); + if (template == null) { + throw exception(KNOWLEDGE_LIBRARY_TEMPLATE_NOT_EXISTS); + } + return template; + } + + /** + * 校验知识库模板名称唯一 + * + * @param id 模板编号 + * @param name 模板名称 + */ + private void validateLibraryTemplateNameDuplicate(Long id, String name) { + PmsKnowledgeLibraryTemplateDO template = libraryTemplateMapper.selectByName(name); + if (template == null) { + return; + } + // 如果 id 为空,说明不用比较是否为相同 id 的模板 + if (id == null) { + throw exception(KNOWLEDGE_LIBRARY_TEMPLATE_NAME_DUPLICATE); + } + if (ObjectUtil.notEqual(template.getId(), id)) { + throw exception(KNOWLEDGE_LIBRARY_TEMPLATE_NAME_DUPLICATE); + } + } + + private void validateLibraryTemplateDocuments(List documents) { + if (CollUtil.isEmpty(documents)) { + throw exception(KNOWLEDGE_LIBRARY_TEMPLATE_INVALID); + } + Set titles = new HashSet<>(); + for (PmsKnowledgeLibraryTemplateSaveReqVO.Document document : documents) { + if (document == null || StrUtil.hasBlank(document.getTitle(), document.getContent()) + || !titles.add(document.getTitle())) { + throw exception(KNOWLEDGE_LIBRARY_TEMPLATE_INVALID); + } + } + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/recycle/PmsKnowledgeRecycleService.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/recycle/PmsKnowledgeRecycleService.java new file mode 100644 index 000000000..b99775583 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/recycle/PmsKnowledgeRecycleService.java @@ -0,0 +1,107 @@ +package cn.iocoder.yudao.module.pms.service.kb.recycle; + +import cn.iocoder.yudao.module.pms.controller.admin.kb.recycle.vo.PmsKnowledgeRecycleDetailRespVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeDocumentDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeFolderDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.recycle.PmsKnowledgeRecycleRecordDO; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * PMS 知识库回收站 Service 接口 + * + * @author 芋道源码 + */ +public interface PmsKnowledgeRecycleService { + + /** + * 将知识库及其全部内容移入回收站,只记录本次显式删除的知识库 + * + * @param library 知识库 + * @param userId 用户编号 + */ + void recycleLibrary(PmsKnowledgeLibraryDO library, Long userId); + + /** + * 将文件夹子树移入回收站,只记录本次显式删除的根文件夹 + * + * @param folder 根文件夹 + * @param folders 根文件夹及其子文件夹列表 + * @param documents 文件夹子树中的文档列表 + * @param userId 用户编号 + */ + void recycleFolder(PmsKnowledgeFolderDO folder, List folders, + List documents, Long userId); + + /** + * 将文档及全部子文档移入回收站,只记录本次显式删除的根文档 + * + * @param document 根文档 + * @param documents 根文档及其子文档列表 + * @param userId 用户编号 + */ + void recycleDocument(PmsKnowledgeDocumentDO document, List documents, Long userId); + + /** + * 获得当前用户删除的知识库回收站记录 + * + * @param userId 用户编号 + * @return 回收站记录列表 + */ + List getLibraryRecycleList(Long userId); + + /** + * 获得知识库的内容回收站记录,只返回当前用户有内容删除权限的记录 + * + * @param libraryId 知识库编号 + * @param userId 用户编号 + * @return 回收站记录列表 + */ + List getContentRecycleList(Long libraryId, Long userId); + + /** + * 获得知识库回收站对象及其级联删除内容 + * + * @param recordId 回收站记录编号 + * @param userId 用户编号 + * @return 回收站详情 + */ + PmsKnowledgeRecycleDetailRespVO getContentRecycleDetail(Long recordId, Long userId); + + /** + * 获得回收站内容预览数据 + * + * @param recordId 回收站记录编号 + * @param entityId 预览的级联内容编号,可为空 + * @param userId 用户编号 + * @return 内容预览数据 + */ + PmsKnowledgeDocumentDO getContentRecyclePreview(Long recordId, Long entityId, Long userId); + + /** + * 按类型恢复回收站记录对应的知识库或内容,保留仍被单独删除的子树 + * + * @param recordId 回收站记录编号 + * @param userId 用户编号 + */ + void restoreContentRecycle(Long recordId, Long userId); + + /** + * 按类型彻底删除回收站记录对应的知识库或内容及其关联数据 + * + * @param recordId 回收站记录编号 + * @param userId 用户编号 + */ + void deleteContentRecycle(Long recordId, Long userId); + + /** + * 彻底清理指定时间前的回收站记录,不校验用户权限 + * + * @param deleteTime 删除截止时间 + * @return 处理的回收站记录数量 + */ + int deleteExpiredRecycleRecords(LocalDateTime deleteTime); + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/recycle/PmsKnowledgeRecycleServiceImpl.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/recycle/PmsKnowledgeRecycleServiceImpl.java new file mode 100644 index 000000000..bfc6f9555 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/kb/recycle/PmsKnowledgeRecycleServiceImpl.java @@ -0,0 +1,882 @@ +package cn.iocoder.yudao.module.pms.service.kb.recycle; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.kb.recycle.vo.PmsKnowledgeRecycleDetailRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.recycle.vo.PmsKnowledgeRecycleRespVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeDocumentDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeFolderDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryMemberDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.recycle.PmsKnowledgeRecycleRecordDO; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.recycle.PmsKnowledgeRecycleRecordMapper; +import cn.iocoder.yudao.module.pms.enums.kb.PmsKnowledgeObjectTypeEnum; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeContentLevelEnum; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeDocumentStatusEnum; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeDocumentTypeEnum; +import cn.iocoder.yudao.module.pms.enums.kb.library.PmsKnowledgeLibraryMemberLevelEnum; +import cn.iocoder.yudao.module.pms.service.kb.content.PmsKnowledgeContentPermissionService; +import cn.iocoder.yudao.module.pms.service.kb.content.PmsKnowledgeDocumentService; +import cn.iocoder.yudao.module.pms.service.kb.content.PmsKnowledgeFolderService; +import cn.iocoder.yudao.module.pms.service.kb.interaction.PmsKnowledgeDocumentCommentService; +import cn.iocoder.yudao.module.pms.service.kb.interaction.PmsKnowledgeDocumentLikeService; +import cn.iocoder.yudao.module.pms.service.kb.interaction.PmsKnowledgeDocumentShareService; +import cn.iocoder.yudao.module.pms.service.kb.interaction.PmsKnowledgeFavoriteService; +import cn.iocoder.yudao.module.pms.service.kb.interaction.PmsKnowledgeViewRecordService; +import cn.iocoder.yudao.module.pms.service.kb.library.PmsKnowledgeGroupService; +import cn.iocoder.yudao.module.pms.service.kb.library.PmsKnowledgeLibraryMemberService; +import cn.iocoder.yudao.module.pms.service.kb.library.PmsKnowledgeLibraryService; +import cn.iocoder.yudao.module.system.api.permission.PermissionApi; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import cn.iocoder.yudao.module.system.enums.permission.RoleCodeEnum; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.common.util.object.ObjectUtils.equalsAny; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_LIBRARY_ADMIN_REQUIRED; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_LIBRARY_NOT_EXISTS; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_RECYCLE_RECORD_NOT_EXISTS; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_RECYCLE_TYPE_INVALID; + +/** + * PMS 知识库回收站 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class PmsKnowledgeRecycleServiceImpl implements PmsKnowledgeRecycleService { + + @Resource + @Lazy // 延迟加载,避免循环依赖 + private PmsKnowledgeLibraryService libraryService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private PmsKnowledgeFolderService folderService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private PmsKnowledgeDocumentService documentService; + @Resource + private PmsKnowledgeRecycleRecordMapper recycleRecordMapper; + @Resource + private PmsKnowledgeLibraryMemberService libraryMemberService; + @Resource + private PmsKnowledgeGroupService knowledgeGroupService; + @Resource + private PmsKnowledgeFavoriteService favoriteService; + @Resource + private PmsKnowledgeDocumentLikeService documentLikeService; + @Resource + private PmsKnowledgeViewRecordService viewRecordService; + @Resource + private PmsKnowledgeDocumentShareService documentShareService; + @Resource + private PmsKnowledgeDocumentCommentService documentCommentService; + @Resource + private PmsKnowledgeContentPermissionService contentPermissionService; + + @Resource + private PermissionApi permissionApi; + @Resource + private AdminUserApi adminUserApi; + + @Override + @Transactional(rollbackFor = Exception.class) + public void recycleLibrary(PmsKnowledgeLibraryDO library, Long userId) { + LocalDateTime deleteTime = LocalDateTime.now(); + // 1.1 查询知识库范围内的文件夹和文档 + List folders = folderService.getFolderListByLibraryId(library.getId()); + List documents = documentService.getDocumentListByLibraryId(library.getId()); + + // 2.1 将知识库主记录移入回收站 + libraryService.updateLibraryToRecycled(library.getId(), userId, deleteTime); + // 2.2 将文件夹及文档移入回收站 + if (CollUtil.isNotEmpty(folders)) { + folderService.updateFolderList(convertList(folders, folder -> new PmsKnowledgeFolderDO() + .setId(folder.getId()).setStatus(PmsKnowledgeDocumentStatusEnum.RECYCLED.getStatus()) + .setDeleteUserId(userId).setDeleteTime(deleteTime))); + } + if (CollUtil.isNotEmpty(documents)) { + documentService.updateDocumentList(convertList(documents, document -> new PmsKnowledgeDocumentDO() + .setId(document.getId()).setStatus(PmsKnowledgeDocumentStatusEnum.RECYCLED.getStatus()) + .setDeleteUserId(userId).setDeleteTime(deleteTime))); + } + + // 3. 只记录本次显式删除的知识库,级联内容不重复出现在回收站 + insertRecycleRecord(library.getId(), PmsKnowledgeObjectTypeEnum.LIBRARY.getType(), + library.getId(), library.getName(), userId, deleteTime); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void recycleFolder(PmsKnowledgeFolderDO folder, List folders, + List documents, Long userId) { + LocalDateTime deleteTime = LocalDateTime.now(); + // 1. 将文件夹、子文件夹及其中的文档移入回收站 + if (CollUtil.isNotEmpty(documents)) { + documentService.updateDocumentList(convertList(documents, document -> + new PmsKnowledgeDocumentDO().setId(document.getId()) + .setStatus(PmsKnowledgeDocumentStatusEnum.RECYCLED.getStatus()) + .setDeleteUserId(userId).setDeleteTime(deleteTime))); + } + if (CollUtil.isNotEmpty(folders)) { + folderService.updateFolderList(convertList(folders, item -> + new PmsKnowledgeFolderDO().setId(item.getId()) + .setStatus(PmsKnowledgeDocumentStatusEnum.RECYCLED.getStatus()) + .setDeleteUserId(userId).setDeleteTime(deleteTime))); + } + + // 2. 只记录本次显式删除的根文件夹 + insertRecycleRecord(folder.getLibraryId(), PmsKnowledgeObjectTypeEnum.FOLDER.getType(), + folder.getId(), folder.getTitle(), userId, deleteTime); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void recycleDocument(PmsKnowledgeDocumentDO document, List documents, + Long userId) { + LocalDateTime deleteTime = LocalDateTime.now(); + // 1. 将文档及全部子文档移入回收站 + if (CollUtil.isNotEmpty(documents)) { + documentService.updateDocumentList(convertList(documents, item -> + new PmsKnowledgeDocumentDO().setId(item.getId()) + .setStatus(PmsKnowledgeDocumentStatusEnum.RECYCLED.getStatus()) + .setDeleteUserId(userId).setDeleteTime(deleteTime))); + } + + // 2. 只记录本次显式删除的根文档 + insertRecycleRecord(document.getLibraryId(), getDocumentRecycleType(document), + document.getId(), document.getTitle(), userId, deleteTime); + } + + @Override + public List getLibraryRecycleList(Long userId) { + return recycleRecordMapper.selectListByTypeAndDeleteUserId(PmsKnowledgeObjectTypeEnum.LIBRARY.getType(), userId); + } + + @Override + public List getContentRecycleList(Long libraryId, Long userId) { + // 1.1 校验知识库可读 + libraryMemberService.validateLibraryReadable(libraryId, userId); + // 1.2 查询知识库回收站记录,并排除知识库自身记录 + List records = recycleRecordMapper.selectListByLibraryId(libraryId); + records.removeIf(record -> PmsKnowledgeObjectTypeEnum.LIBRARY.getType().equals(record.getType())); + + // 2.1 批量解析内容权限 + Map recordPermissionIdMap = getRecordPermissionIdMap(records); + Set permissionIds = new LinkedHashSet<>(recordPermissionIdMap.values()); + Map levelMap = contentPermissionService.getCurrentUserContentPermissionLevelMap(permissionIds, libraryId, userId); + // 2.2 只展示当前用户仍可删除的回收站记录 + records.removeIf(record -> + !PmsKnowledgeContentLevelEnum.canDelete(levelMap.get(recordPermissionIdMap.get(record.getId())))); + return records; + } + + @Override + public PmsKnowledgeRecycleDetailRespVO getContentRecycleDetail(Long recordId, Long userId) { + // 1. 复用回收站列表的库级与内容删除权限校验,避免详情接口绕过权限边界 + PmsKnowledgeRecycleRecordDO record = validateContentRecycleRecord(recordId, userId); + + // 2. 查询仍处于回收状态的内容,按根对象关系筛出本次级联删除的子树 + List folders = folderService.getFolderListByLibraryId(record.getLibraryId()); + folders.removeIf(folder -> ObjectUtil.notEqual(folder.getStatus(), + PmsKnowledgeDocumentStatusEnum.RECYCLED.getStatus())); + List documents = documentService.getDocumentListByLibraryId(record.getLibraryId()); + documents.removeIf(document -> ObjectUtil.notEqual(document.getStatus(), + PmsKnowledgeDocumentStatusEnum.RECYCLED.getStatus())); + Set folderIds = collectFolderDescendantIds(record, folders); + Set documentIds = collectDocumentDescendantIds(record, documents, folderIds); + + // 3. 组装平铺详情,前端按 parentId/folderId 渲染递归树 + // 3.1 组装级联文件夹节点,并排除回收站根对象 + List children = convertList(folders, + folder -> new PmsKnowledgeRecycleDetailRespVO.Item() + .setId(folder.getId()).setType(PmsKnowledgeObjectTypeEnum.FOLDER.getType()) + .setName(folder.getTitle()).setParentId(folder.getParentId()) + .setDeleteTime(folder.getDeleteTime()), + folder -> folderIds.contains(folder.getId()) + && ObjectUtil.notEqual(folder.getId(), record.getEntityId())); + // 3.2 组装级联文档节点,并排除回收站根对象 + children.addAll(convertList(documents, + document -> new PmsKnowledgeRecycleDetailRespVO.Item() + .setId(document.getId()).setType(getDocumentRecycleType(document)) + .setName(document.getTitle()).setParentId(document.getParentId()) + .setFolderId(document.getFolderId()).setDeleteTime(document.getDeleteTime()), + document -> documentIds.contains(document.getId()) + && ObjectUtil.notEqual(document.getId(), record.getEntityId()))); + return new PmsKnowledgeRecycleDetailRespVO() + .setRoot(BeanUtils.toBean(record, PmsKnowledgeRecycleRespVO.class)) + .setChildren(children); + } + + @Override + public PmsKnowledgeDocumentDO getContentRecyclePreview(Long recordId, Long entityId, Long userId) { + // 1. 复用回收站内容权限校验,避免通过预览接口读取无权限内容 + PmsKnowledgeRecycleRecordDO record = validateContentRecycleRecord(recordId, userId); + + // 2. 回收站保留原文档数据,正文或文件地址仅用于只读预览 + Long targetEntityId = entityId != null ? entityId : record.getEntityId(); + Integer targetType = record.getType(); + if (entityId != null && ObjectUtil.notEqual(entityId, record.getEntityId())) { + PmsKnowledgeRecycleDetailRespVO detail = getContentRecycleDetail(recordId, userId); + PmsKnowledgeRecycleDetailRespVO.Item item = CollUtil.findOne(detail.getChildren(), child -> child.getId().equals(entityId)); + if (item == null) { + throw exception(KNOWLEDGE_RECYCLE_RECORD_NOT_EXISTS); + } + targetType = item.getType(); + } + if (PmsKnowledgeObjectTypeEnum.FOLDER.getType().equals(targetType)) { + throw exception(KNOWLEDGE_RECYCLE_TYPE_INVALID); + } + PmsKnowledgeDocumentDO document = documentService.getDocument(targetEntityId); + if (document == null) { + throw exception(KNOWLEDGE_RECYCLE_RECORD_NOT_EXISTS); + } + return document; + } + + /** + * 收集回收站记录对应的文件夹及其后代文件夹 + * + * @param record 回收站记录 + * @param folders 知识库文件夹列表 + * @return 文件夹编号集合 + */ + private Set collectFolderDescendantIds(PmsKnowledgeRecycleRecordDO record, + List folders) { + if (ObjectUtil.notEqual(PmsKnowledgeObjectTypeEnum.FOLDER.getType(), record.getType())) { + return Collections.emptySet(); + } + return getFolderDescendantIds(folders, Collections.singleton(record.getEntityId())); + } + + /** + * 收集回收站记录对应的文档及其后代文档 + * + * @param record 回收站记录 + * @param documents 知识库文档列表 + * @param folderIds 回收站文件夹及其后代编号 + * @return 文档编号集合 + */ + private Set collectDocumentDescendantIds(PmsKnowledgeRecycleRecordDO record, + List documents, + Set folderIds) { + // 1. 文档回收记录:收集根文档及其全部子文档 + if (isDocumentRecycleType(record.getType())) { + return getDocumentDescendantIds(documents, Collections.singleton(record.getEntityId())); + } + + // 2. 非文件夹回收记录:没有可关联的级联文档 + if (ObjectUtil.notEqual(PmsKnowledgeObjectTypeEnum.FOLDER.getType(), record.getType())) { + return Collections.emptySet(); + } + // 3. 文件夹回收记录:按级联文件夹编号筛选文档 + if (CollUtil.isEmpty(documents) || CollUtil.isEmpty(folderIds)) { + return Collections.emptySet(); + } + Set result = new LinkedHashSet<>(); + for (PmsKnowledgeDocumentDO document : documents) { + if (folderIds.contains(document.getFolderId())) { + result.add(document.getId()); + } + } + return result; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void restoreContentRecycle(Long recordId, Long userId) { + // 1.1 校验回收站记录存在 + PmsKnowledgeRecycleRecordDO record = validateRecycleRecord(recordId); + validateRecycleType(record.getType()); + + // 2. 按记录类型校验权限并恢复:知识库要求管理员权限,文件夹和文档要求内容删除权限 + if (PmsKnowledgeObjectTypeEnum.LIBRARY.getType().equals(record.getType())) { + validateRecycledLibraryAdmin(record, userId); + restoreLibrary(record); + } else if (PmsKnowledgeObjectTypeEnum.FOLDER.getType().equals(record.getType())) { + validateRecycleContentDeletable(record, userId); + restoreFolder(record); + } else if (isDocumentRecycleType(record.getType())) { + validateRecycleContentDeletable(record, userId); + restoreDocument(record); + } + + // 3. 删除本次回收站记录 + recycleRecordMapper.deleteById(recordId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteContentRecycle(Long recordId, Long userId) { + // 1. 校验回收站记录存在 + PmsKnowledgeRecycleRecordDO record = validateRecycleRecord(recordId); + validateRecycleType(record.getType()); + + // 2.A 知识库类型:要求管理员权限,并彻底删除知识库及其全部关联数据 + if (PmsKnowledgeObjectTypeEnum.LIBRARY.getType().equals(record.getType())) { + validateRecycledLibraryAdmin(record, userId); + permanentDeleteLibrary(record.getLibraryId()); + return; + } + + // 2.B 内容类型:校验内容删除权限后,按文件夹和文档类型分别彻底删除 + validateRecycleContentDeletable(record, userId); + if (PmsKnowledgeObjectTypeEnum.FOLDER.getType().equals(record.getType())) { + permanentDeleteFolder(record); + } else if (isDocumentRecycleType(record.getType())) { + permanentDeleteDocument(record); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int deleteExpiredRecycleRecords(LocalDateTime deleteTime) { + // 1. 按删除时间从早到晚查询到期记录 + List records = recycleRecordMapper.selectListByDeleteTimeBefore(deleteTime); + if (CollUtil.isEmpty(records)) { + return 0; + } + + // 2. 按记录类型执行物理清理;上级对象清理后,跳过已被级联删除的记录 + for (PmsKnowledgeRecycleRecordDO record : records) { + if (recycleRecordMapper.selectById(record.getId()) == null) { + continue; + } + validateRecycleType(record.getType()); + if (PmsKnowledgeObjectTypeEnum.LIBRARY.getType().equals(record.getType())) { + permanentDeleteLibrary(record.getLibraryId()); + } else if (PmsKnowledgeObjectTypeEnum.FOLDER.getType().equals(record.getType())) { + permanentDeleteFolder(record); + } else if (isDocumentRecycleType(record.getType())) { + permanentDeleteDocument(record); + } + } + return records.size(); + } + + /** + * 恢复知识库,并恢复本次知识库删除所级联回收的内容 + * + * @param record 知识库回收站记录 + */ + private void restoreLibrary(PmsKnowledgeRecycleRecordDO record) { + // 1.1 查询并校验仍处于回收站状态的知识库 + PmsKnowledgeLibraryDO library = libraryService.getLibrary(record.getLibraryId()); + if (library == null || ObjectUtil.notEqual(PmsKnowledgeDocumentStatusEnum.RECYCLED.getStatus(), + library.getStatus())) { + throw exception(KNOWLEDGE_LIBRARY_NOT_EXISTS); + } + // 1.2 查询知识库回收站记录,供后续排除独立删除的子树 + List records = recycleRecordMapper.selectListByLibraryId(library.getId()); + + // 2.1 恢复知识库主记录 + libraryService.restoreLibrary(library.getId()); + // 2.2 恢复随知识库级联删除的内容,保留此前被单独删除的子树 + restoreLibraryContent(library.getId(), records, null); + } + + /** + * 恢复文件夹及其子树,并在父文件夹仍被删除时挂载到知识库根目录 + * + * @param record 文件夹回收站记录 + */ + private void restoreFolder(PmsKnowledgeRecycleRecordDO record) { + // 1. 校验根文件夹仍处于回收站状态 + PmsKnowledgeFolderDO root = folderService.getFolder(record.getEntityId()); + if (root == null || ObjectUtil.notEqual(PmsKnowledgeDocumentStatusEnum.RECYCLED.getStatus(), + root.getStatus())) { + throw exception(KNOWLEDGE_RECYCLE_RECORD_NOT_EXISTS); + } + // 1.2 查询知识库回收站记录和原父文件夹 + List records = recycleRecordMapper.selectListByLibraryId(record.getLibraryId()); + PmsKnowledgeFolderDO parent = root.getParentId() != null + && ObjectUtil.notEqual(PmsKnowledgeFolderDO.PARENT_ID_ROOT, root.getParentId()) + ? folderService.getFolder(root.getParentId()) : null; + + // 2.1 恢复当前文件夹树,同时跳过其中独立删除的子树 + restoreLibraryContent(record.getLibraryId(), records, root.getId()); + // 2.2 被删除的父文件夹尚未恢复时,将当前根文件夹恢复到知识库根目录 + if (root.getParentId() != null + && ObjectUtil.notEqual(PmsKnowledgeFolderDO.PARENT_ID_ROOT, root.getParentId())) { + if (parent == null || PmsKnowledgeDocumentStatusEnum.RECYCLED.getStatus().equals(parent.getStatus())) { + folderService.updateFolderList(Collections.singleton(new PmsKnowledgeFolderDO() + .setId(root.getId()).setParentId(PmsKnowledgeFolderDO.PARENT_ID_ROOT))); + } + } + } + + /** + * 恢复文档及其子文档,并修正失效的目录或父文档引用 + * + * @param record 文档回收站记录 + */ + private void restoreDocument(PmsKnowledgeRecycleRecordDO record) { + // 1. 校验根文档仍处于回收站状态 + PmsKnowledgeDocumentDO root = documentService.getDocument(record.getEntityId()); + if (root == null || ObjectUtil.notEqual(PmsKnowledgeDocumentStatusEnum.RECYCLED.getStatus(), + root.getStatus())) { + throw exception(KNOWLEDGE_RECYCLE_RECORD_NOT_EXISTS); + } + // 1.2 查询知识库文档全集和回收站记录 + List documents = documentService.getDocumentListByLibraryId(record.getLibraryId()); + List records = recycleRecordMapper.selectListByLibraryId(record.getLibraryId()); + // 1.3 查询文档原目录和父文档 + PmsKnowledgeFolderDO folder = equalsAny(root.getFolderId(), null, PmsKnowledgeDocumentDO.FOLDER_ID_ROOT) ? null + : folderService.getFolder(root.getFolderId()); + PmsKnowledgeDocumentDO parent = equalsAny(root.getParentId(), null, PmsKnowledgeDocumentDO.PARENT_ID_ROOT) ? null + : documentService.getDocument(root.getParentId()); + + // 2.1 恢复文档树,同时保留独立删除的子文档树 + Set restoreIds = getDocumentDescendantIds(documents, Collections.singleton(root.getId())); + excludeProtectedDocuments(restoreIds, documents, records, record.getId()); + restoreDocuments(restoreIds); + // 2.2 原目录或父文档尚未恢复时,将当前根文档恢复到知识库根目录 + boolean invalidFolder = root.getFolderId() != null + && ObjectUtil.notEqual(PmsKnowledgeDocumentDO.FOLDER_ID_ROOT, root.getFolderId()) + && (folder == null || PmsKnowledgeDocumentStatusEnum.RECYCLED.getStatus().equals(folder.getStatus())); + boolean invalidParent = root.getParentId() != null + && ObjectUtil.notEqual(PmsKnowledgeDocumentDO.PARENT_ID_ROOT, root.getParentId()) + && (parent == null || PmsKnowledgeDocumentStatusEnum.RECYCLED.getStatus().equals(parent.getStatus())); + if (invalidFolder || invalidParent) { + documentService.updateDocumentList(Collections.singleton(new PmsKnowledgeDocumentDO() + .setId(root.getId()).setFolderId(PmsKnowledgeDocumentDO.FOLDER_ID_ROOT) + .setParentId(PmsKnowledgeDocumentDO.PARENT_ID_ROOT))); + } + } + + /** + * 恢复知识库范围内的文件夹和文档,排除仍由其他回收站记录保护的子树 + * + * @param libraryId 知识库编号 + * @param records 知识库回收站记录 + * @param folderRootId 仅恢复指定文件夹子树时的根编号,为 {@code null} 表示整个知识库 + */ + private void restoreLibraryContent(Long libraryId, List records, Long folderRootId) { + // 1.1 查询知识库范围内的文件夹和文档 + List folders = folderService.getFolderListByLibraryId(libraryId); + List documents = documentService.getDocumentListByLibraryId(libraryId); + // 1.2 解析目标文件夹树,并排除被单独删除的文件夹子树 + Set restoreFolderIds = folderRootId == null ? convertSet(folders, PmsKnowledgeFolderDO::getId) + : getFolderDescendantIds(folders, Collections.singleton(folderRootId)); + excludeProtectedFolders(restoreFolderIds, folders, records, folderRootId); + // 1.3 解析需要随文件夹恢复的文档树,并排除被单独删除的文档子树 + Set documentRoots = new LinkedHashSet<>(); + for (PmsKnowledgeDocumentDO document : documents) { + if ((folderRootId == null && equalsAny(document.getFolderId(), null, + PmsKnowledgeDocumentDO.FOLDER_ID_ROOT) + && equalsAny(document.getParentId(), null, PmsKnowledgeDocumentDO.PARENT_ID_ROOT)) + || restoreFolderIds.contains(document.getFolderId())) { + documentRoots.add(document.getId()); + } + } + Set restoreDocumentIds = getDocumentDescendantIds(documents, documentRoots); + excludeProtectedDocuments(restoreDocumentIds, documents, records, null); + + // 2.1 恢复文件夹树 + if (CollUtil.isNotEmpty(restoreFolderIds)) { + folderService.restoreFolderList(restoreFolderIds); + } + // 2.2 恢复文档树 + restoreDocuments(restoreDocumentIds); + } + + /** + * 批量恢复文档状态 + * + * @param restoreIds 待恢复文档编号 + */ + private void restoreDocuments(Set restoreIds) { + if (CollUtil.isEmpty(restoreIds)) { + return; + } + documentService.restoreDocumentList(restoreIds); + } + + /** + * 从恢复集合中排除仍处于独立回收状态的文件夹子树 + * + * @param restoreIds 待恢复文件夹编号集合 + * @param folders 所属知识库文件夹 + * @param records 回收站记录 + * @param currentFolderId 当前正在恢复的根文件夹编号 + */ + private void excludeProtectedFolders(Set restoreIds, List folders, + List records, Long currentFolderId) { + Set protectedRoots = new LinkedHashSet<>(); + for (PmsKnowledgeRecycleRecordDO record : records) { + if (PmsKnowledgeObjectTypeEnum.FOLDER.getType().equals(record.getType()) + && ObjectUtil.notEqual(record.getEntityId(), currentFolderId)) { + protectedRoots.add(record.getEntityId()); + } + } + restoreIds.removeAll(getFolderDescendantIds(folders, protectedRoots)); + } + + /** + * 从恢复集合中排除仍处于独立回收状态的文档子树 + * + * @param restoreIds 待恢复文档编号集合 + * @param documents 所属知识库文档 + * @param records 回收站记录 + * @param currentRecordId 当前正在恢复的回收站记录编号 + */ + private void excludeProtectedDocuments(Set restoreIds, List documents, + List records, Long currentRecordId) { + Set protectedRoots = new LinkedHashSet<>(); + for (PmsKnowledgeRecycleRecordDO record : records) { + if (isDocumentRecycleType(record.getType()) + && ObjectUtil.notEqual(record.getId(), currentRecordId)) { + protectedRoots.add(record.getEntityId()); + } + } + restoreIds.removeAll(getDocumentDescendantIds(documents, protectedRoots)); + } + + /** + * 彻底删除知识库及其全部内容、成员和关联数据 + * + * @param libraryId 知识库编号 + */ + private void permanentDeleteLibrary(Long libraryId) { + // 1.1 查询知识库范围内的文档 + List documents = documentService.getDocumentListByLibraryId(libraryId); + Set documentIds = convertSet(documents, PmsKnowledgeDocumentDO::getId); + // 1.2 查询知识库范围内的文件夹 + Set folderIds = convertSet(folderService.getFolderListByLibraryId(libraryId), + PmsKnowledgeFolderDO::getId); + + // 2.1 删除文档收藏、点赞、浏览、分享和评论 + favoriteService.deleteFavoritesByLibraryId(libraryId); + documentLikeService.deleteLikesByDocumentIds(documentIds); + viewRecordService.deleteViewRecordsByLibraryId(libraryId); + deleteDocuments(documentIds); + // 2.2 删除文件夹主记录 + folderService.deleteFolderList(folderIds); + // 2.3 删除内容权限、回收站记录和知识库分组关系 + contentPermissionService.deleteContentPermissionsByLibraryId(libraryId); + recycleRecordMapper.deleteByLibraryId(libraryId); + knowledgeGroupService.deleteKnowledgeGroupRelationsByLibraryId(libraryId); + + // 3.1 删除知识库成员关系 + libraryMemberService.deleteLibraryMembersByLibraryId(libraryId); + // 3.2 最后删除知识库主记录 + libraryService.deleteLibraryPermanently(libraryId); + } + + /** + * 彻底删除文件夹子树及其中的文档和关联数据 + * + * @param record 文件夹回收站记录 + */ + private void permanentDeleteFolder(PmsKnowledgeRecycleRecordDO record) { + // 1.1 查询文件夹和文档全集 + List folders = folderService.getFolderListByLibraryId(record.getLibraryId()); + List documents = documentService.getDocumentListByLibraryId(record.getLibraryId()); + // 1.2 解析文件夹子树和其中的文档编号 + Set folderIds = getFolderDescendantIds(folders, Collections.singleton(record.getEntityId())); + Set documentRoots = new LinkedHashSet<>(); + for (PmsKnowledgeDocumentDO document : documents) { + if (folderIds.contains(document.getFolderId())) { + documentRoots.add(document.getId()); + } + } + Set documentIds = getDocumentDescendantIds(documents, documentRoots); + // 1.3 收集文件夹和文档对应的内容权限编号 + Set permissionIds = new LinkedHashSet<>(); + for (PmsKnowledgeFolderDO folder : folders) { + if (folderIds.contains(folder.getId())) { + permissionIds.add(folder.getPermissionId()); + } + } + for (PmsKnowledgeDocumentDO document : documents) { + if (documentIds.contains(document.getId())) { + permissionIds.add(document.getPermissionId()); + } + } + + // 2.1 删除收藏、点赞、浏览、分享和评论 + favoriteService.deleteFavoritesByEntityIds(folderIds, documentIds); + documentLikeService.deleteLikesByDocumentIds(documentIds); + viewRecordService.deleteViewRecordsByEntityIds(folderIds, documentIds); + deleteDocuments(documentIds); + // 2.2 删除文档和文件夹主记录 + folderService.deleteFolderList(folderIds); + // 2.3 删除不再被引用的内容权限和回收站记录 + contentPermissionService.deleteUnusedContentPermissions(permissionIds); + recycleRecordMapper.deleteByTypeAndEntityIds( + Collections.singleton(PmsKnowledgeObjectTypeEnum.FOLDER.getType()), folderIds); + if (CollUtil.isNotEmpty(documentIds)) { + recycleRecordMapper.deleteByTypeAndEntityIds(Arrays.asList( + PmsKnowledgeObjectTypeEnum.DOCUMENT.getType(), + PmsKnowledgeObjectTypeEnum.FILE.getType()), documentIds); + } + } + + /** + * 彻底删除文档子树及其互动、权限和回收站记录 + * + * @param record 文档回收站记录 + */ + private void permanentDeleteDocument(PmsKnowledgeRecycleRecordDO record) { + // 1.1 查询知识库文档全集 + List documents = documentService.getDocumentListByLibraryId(record.getLibraryId()); + // 1.2 解析文档子树编号 + Set documentIds = getDocumentDescendantIds(documents, Collections.singleton(record.getEntityId())); + // 1.3 收集文档对应的内容权限编号 + Set permissionIds = new LinkedHashSet<>(); + for (PmsKnowledgeDocumentDO document : documents) { + if (documentIds.contains(document.getId())) { + permissionIds.add(document.getPermissionId()); + } + } + + // 2.1 删除收藏、点赞、浏览、分享和评论 + favoriteService.deleteFavoritesByEntityIds(Collections.emptySet(), documentIds); + documentLikeService.deleteLikesByDocumentIds(documentIds); + viewRecordService.deleteViewRecordsByEntityIds(Collections.emptySet(), documentIds); + deleteDocuments(documentIds); + // 2.2 删除不再被引用的内容权限和回收站记录 + contentPermissionService.deleteUnusedContentPermissions(permissionIds); + recycleRecordMapper.deleteByTypeAndEntityIds(Arrays.asList( + PmsKnowledgeObjectTypeEnum.DOCUMENT.getType(), + PmsKnowledgeObjectTypeEnum.FILE.getType()), documentIds); + } + + /** + * 删除文档及其分享、评论等互动数据 + * + * @param documentIds 文档编号集合 + */ + private void deleteDocuments(Set documentIds) { + if (CollUtil.isEmpty(documentIds)) { + return; + } + // 1. 先删除依赖文档编号的分享和评论 + documentShareService.deleteSharesByDocumentIds(documentIds); + documentCommentService.deleteCommentsByDocumentIds(documentIds); + // 2. 再删除文档主记录 + documentService.deleteDocumentList(documentIds); + } + + /** + * 获得文件夹根节点及其全部后代编号 + * + * @param folders 所属知识库文件夹 + * @param rootIds 根文件夹编号 + * @return 根节点及后代编号集合 + */ + private Set getFolderDescendantIds(List folders, Collection rootIds) { + Set descendantIds = new LinkedHashSet<>(rootIds); + // 层级深度设上限,避免异常循环父子关系导致无限遍历 + for (int level = 0; level < Short.MAX_VALUE; level++) { + boolean changed = false; + for (PmsKnowledgeFolderDO folder : folders) { + if (descendantIds.contains(folder.getParentId()) && descendantIds.add(folder.getId())) { + changed = true; + } + } + if (!changed) { + break; + } + } + return descendantIds; + } + + /** + * 获得文档根节点及其全部后代编号 + * + * @param documents 所属知识库文档 + * @param rootIds 根文档编号 + * @return 根节点及后代编号集合 + */ + private Set getDocumentDescendantIds(List documents, Collection rootIds) { + Set descendantIds = new LinkedHashSet<>(rootIds); + // 层级深度设上限,避免异常循环父子关系导致无限遍历 + for (int level = 0; level < Short.MAX_VALUE; level++) { + boolean changed = false; + for (PmsKnowledgeDocumentDO document : documents) { + if (descendantIds.contains(document.getParentId()) && descendantIds.add(document.getId())) { + changed = true; + } + } + if (!changed) { + break; + } + } + return descendantIds; + } + + private void insertRecycleRecord(Long libraryId, Integer type, Long entityId, String name, + Long userId, LocalDateTime deleteTime) { + recycleRecordMapper.insert(new PmsKnowledgeRecycleRecordDO().setLibraryId(libraryId).setType(type) + .setEntityId(entityId).setName(name).setDeleteUserId(userId).setDeleteTime(deleteTime)); + } + + private PmsKnowledgeRecycleRecordDO validateRecycleRecord(Long id) { + PmsKnowledgeRecycleRecordDO record = recycleRecordMapper.selectById(id); + if (record == null) { + throw exception(KNOWLEDGE_RECYCLE_RECORD_NOT_EXISTS); + } + return record; + } + + /** + * 校验内容回收站记录存在且当前用户仍具备内容删除权限 + * + * @param recordId 回收站记录编号 + * @param userId 当前用户编号 + * @return 已校验的回收站记录 + */ + private PmsKnowledgeRecycleRecordDO validateContentRecycleRecord(Long recordId, Long userId) { + PmsKnowledgeRecycleRecordDO record = validateRecycleRecord(recordId); + if (PmsKnowledgeObjectTypeEnum.LIBRARY.getType().equals(record.getType())) { + throw exception(KNOWLEDGE_RECYCLE_TYPE_INVALID); + } + validateRecycleContentDeletable(record, userId); + return record; + } + + /** + * 批量解析回收站记录对应实体的内容权限编号 + * + * @param records 回收站记录 + * @return 回收站记录编号到权限编号的映射 + */ + private Map getRecordPermissionIdMap(List records) { + // 1. 按实体类型批量收集编号 + Set folderIds = new LinkedHashSet<>(); + Set documentIds = new LinkedHashSet<>(); + for (PmsKnowledgeRecycleRecordDO record : records) { + if (PmsKnowledgeObjectTypeEnum.FOLDER.getType().equals(record.getType())) { + folderIds.add(record.getEntityId()); + } else if (isDocumentRecycleType(record.getType())) { + documentIds.add(record.getEntityId()); + } + } + Map folderPermissionIdMap = new LinkedHashMap<>(); + if (CollUtil.isNotEmpty(folderIds)) { + folderService.getFolderList(folderIds).forEach(folder -> + folderPermissionIdMap.put(folder.getId(), folder.getPermissionId())); + } + Map documentPermissionIdMap = new LinkedHashMap<>(); + if (CollUtil.isNotEmpty(documentIds)) { + documentService.getDocumentList(documentIds).forEach(document -> + documentPermissionIdMap.put(document.getId(), document.getPermissionId())); + } + + // 2. 将实体权限编号映射回回收站记录 + Map recordPermissionIdMap = new LinkedHashMap<>(); + records.forEach(record -> { + Map permissionIdMap = PmsKnowledgeObjectTypeEnum.FOLDER.getType().equals(record.getType()) + ? folderPermissionIdMap : documentPermissionIdMap; + recordPermissionIdMap.put(record.getId(), permissionIdMap.get(record.getEntityId())); + }); + return recordPermissionIdMap; + } + + /** + * 校验当前用户是否拥有回收站内容的删除权限 + * + * @param record 回收站记录 + * @param userId 当前用户编号 + */ + private void validateRecycleContentDeletable(PmsKnowledgeRecycleRecordDO record, Long userId) { + // 1.1 先校验知识库可读 + libraryMemberService.validateLibraryReadable(record.getLibraryId(), userId); + + // 1.2 再解析回收对象对应的内容权限 + Long permissionId; + if (PmsKnowledgeObjectTypeEnum.FOLDER.getType().equals(record.getType())) { + PmsKnowledgeFolderDO folder = folderService.getFolder(record.getEntityId()); + permissionId = folder != null ? folder.getPermissionId() : null; + } else { + PmsKnowledgeDocumentDO document = documentService.getDocument(record.getEntityId()); + permissionId = document != null ? document.getPermissionId() : null; + } + if (permissionId == null) { + throw exception(KNOWLEDGE_RECYCLE_RECORD_NOT_EXISTS); + } + // 2. 校验当前用户具备内容删除权限 + contentPermissionService.validateContentPermissionDeletable(permissionId, record.getLibraryId(), userId); + } + + /** + * 校验当前用户是否为知识库创建人、管理员或系统超级管理员 + * + * @param record 知识库回收站记录 + * @param userId 当前用户编号 + */ + private void validateRecycledLibraryAdmin(PmsKnowledgeRecycleRecordDO record, Long userId) { + if (permissionApi.hasAnyRoles(userId, RoleCodeEnum.SUPER_ADMIN.getCode()).getCheckedData()) { + return; + } + PmsKnowledgeLibraryMemberDO member = + libraryMemberService.getMemberByLibraryIdAndUserId(record.getLibraryId(), userId); + if (member == null) { + AdminUserRespDTO user = adminUserApi.getUser(userId).getCheckedData(); + if (user != null && user.getDeptId() != null) { + member = libraryMemberService.getMemberByLibraryIdAndDeptId(record.getLibraryId(), user.getDeptId()); + } + } + if (member == null || !equalsAny(member.getLevel(), PmsKnowledgeLibraryMemberLevelEnum.CREATOR.getLevel(), + PmsKnowledgeLibraryMemberLevelEnum.ADMIN.getLevel())) { + throw exception(KNOWLEDGE_LIBRARY_ADMIN_REQUIRED); + } + } + + /** + * 将文档类型映射为回收站对象类型 + * + * @param document 文档 + * @return 文件或普通文档对应的回收站类型 + */ + private Integer getDocumentRecycleType(PmsKnowledgeDocumentDO document) { + return PmsKnowledgeDocumentTypeEnum.FILE.getType().equals(document.getType()) + ? PmsKnowledgeObjectTypeEnum.FILE.getType() : PmsKnowledgeObjectTypeEnum.DOCUMENT.getType(); + } + + /** + * 判断回收站记录是否为文档类对象 + * + * @param type 回收站对象类型 + * @return 是否为文档或文件 + */ + private boolean isDocumentRecycleType(Integer type) { + return equalsAny(type, PmsKnowledgeObjectTypeEnum.DOCUMENT.getType(), + PmsKnowledgeObjectTypeEnum.FILE.getType()); + } + + /** + * 校验回收站记录类型,避免未知类型进入内容权限和实体查询分支 + * + * @param type 回收站对象类型 + */ + private void validateRecycleType(Integer type) { + if (equalsAny(type, PmsKnowledgeObjectTypeEnum.LIBRARY.getType(), + PmsKnowledgeObjectTypeEnum.FOLDER.getType(), PmsKnowledgeObjectTypeEnum.DOCUMENT.getType(), + PmsKnowledgeObjectTypeEnum.FILE.getType())) { + return; + } + throw exception(KNOWLEDGE_RECYCLE_TYPE_INVALID); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/iteration/PmsIterationService.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/iteration/PmsIterationService.java new file mode 100644 index 000000000..806540fa3 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/iteration/PmsIterationService.java @@ -0,0 +1,156 @@ +package cn.iocoder.yudao.module.pms.service.pm.iteration; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.pms.controller.admin.pm.iteration.vo.PmsIterationOverviewRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.iteration.vo.PmsIterationPageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.iteration.vo.PmsIterationSaveReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.iteration.vo.PmsIterationStartReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workbench.vo.PmsWorkbenchPageReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.iteration.PmsIterationDO; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * PMS 项目迭代 Service 接口 + * + * @author 芋道源码 + */ +public interface PmsIterationService { + + /** + * 创建项目迭代 + * + * @param saveReqVO 保存信息 + * @param userId 用户编号 + * @return 迭代编号 + */ + Long createIteration(PmsIterationSaveReqVO saveReqVO, Long userId); + + /** + * 更新项目迭代 + * + * @param saveReqVO 保存信息 + * @param userId 用户编号 + */ + void updateIteration(PmsIterationSaveReqVO saveReqVO, Long userId); + + /** + * 开始项目迭代 + * + * @param startReqVO 开始信息 + * @param userId 用户编号 + */ + void startIteration(PmsIterationStartReqVO startReqVO, Long userId); + + /** + * 完成项目迭代 + * + * @param id 迭代编号 + * @param userId 用户编号 + */ + void completeIteration(Long id, Long userId); + + /** + * 删除项目迭代 + * + * @param id 迭代编号 + * @param userId 用户编号 + */ + void deleteIteration(Long id, Long userId); + + /** + * 删除指定项目的全部迭代 + * + * @param projectId 项目编号 + */ + void deleteIterationListByProjectId(Long projectId); + + /** + * 获得项目迭代 + * + * @param id 迭代编号 + * @param userId 用户编号 + * @return 项目迭代 + */ + PmsIterationDO getIteration(Long id, Long userId); + + /** + * 获得项目迭代概况 + * + * @param id 迭代编号 + * @param userId 用户编号 + * @return 迭代概况 + */ + PmsIterationOverviewRespVO getIterationOverview(Long id, Long userId); + + /** + * 获得项目迭代分页 + * + * @param pageReqVO 分页查询 + * @param userId 用户编号 + * @return 项目迭代分页 + */ + PageResult getIterationPage(PmsIterationPageReqVO pageReqVO, Long userId); + + /** + * 获得迭代完成进度 Map + * + * @param ids 迭代编号集合 + * @return 迭代编号到完成进度 Map + */ + Map getIterationProgressMap(Collection ids); + + /** + * 获得指定编号的项目迭代列表 + * + * @param ids 迭代编号集合 + * @return 项目迭代列表 + */ + List getIterationList(Collection ids); + + /** + * 获得项目迭代 Map + * + * @param ids 迭代编号集合 + * @return 项目迭代 Map + */ + default Map getIterationMap(Collection ids) { + return convertMap(getIterationList(ids), PmsIterationDO::getId); + } + + /** + * 获得指定项目范围内的未完成迭代分页 + * + * @param pageReqVO 工作台分页条件 + * @param projectIds 项目编号集合 + * @param ownerUserId 负责人用户编号 + * @return 迭代分页 + */ + PageResult getUncompletedIterationPage(PmsWorkbenchPageReqVO pageReqVO, + Collection projectIds, Long ownerUserId); + + /** + * 获得指定项目范围内的未完成迭代数量 + * + * @param pageReqVO 工作台查询条件 + * @param projectIds 项目编号集合 + * @param ownerUserId 负责人用户编号 + * @return 迭代数量 + */ + Long getUncompletedIterationCount(PmsWorkbenchPageReqVO pageReqVO, + Collection projectIds, Long ownerUserId); + + /** + * 获得指定项目范围内按状态分组的迭代数量 + * + * @param projectIds 项目编号集合 + * @return 项目编号、状态和数量的映射 + */ + Map> getProjectIterationStatusCountMap(Collection projectIds); + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/iteration/PmsIterationServiceImpl.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/iteration/PmsIterationServiceImpl.java new file mode 100644 index 000000000..5753a17ee --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/iteration/PmsIterationServiceImpl.java @@ -0,0 +1,395 @@ +package cn.iocoder.yudao.module.pms.service.pm.iteration; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.pm.iteration.vo.PmsIterationOverviewRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.iteration.vo.PmsIterationPageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.iteration.vo.PmsIterationSaveReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.iteration.vo.PmsIterationStartReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workbench.vo.PmsWorkbenchPageReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.iteration.PmsIterationDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemActivityDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemDO; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.iteration.PmsIterationMapper; +import cn.iocoder.yudao.module.pms.enums.pm.iteration.PmsIterationStatusEnum; +import cn.iocoder.yudao.module.pms.enums.pm.project.PmsProjectStatusEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemStatusTypeEnum; +import cn.iocoder.yudao.module.pms.service.pm.project.PmsProjectMemberService; +import cn.iocoder.yudao.module.pms.service.pm.workitem.PmsWorkItemActivityService; +import cn.iocoder.yudao.module.pms.service.pm.workitem.PmsWorkItemService; +import cn.iocoder.yudao.module.pms.service.pm.workitem.PmsWorkItemWorkLogService; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.sum; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.ITERATION_NOT_EXISTS; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.ITERATION_STATUS_INVALID; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.PROJECT_STATUS_INVALID; + +/** + * PMS 项目迭代 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class PmsIterationServiceImpl implements PmsIterationService { + + /** + * 完成趋势统计天数 + */ + private static final int TREND_DAYS = 14; + /** + * 最近活动最大展示数量 + */ + private static final int RECENT_ACTIVITY_LIMIT = 50; + + @Resource + private PmsIterationMapper iterationMapper; + + @Resource + private PmsProjectMemberService projectMemberService; + @Lazy // 延迟加载,避免循环依赖 + @Resource + private PmsWorkItemService workItemService; + @Resource + private PmsWorkItemActivityService workItemActivityService; + @Lazy // 延迟加载,避免循环依赖 + @Resource + private PmsWorkItemWorkLogService workItemWorkLogService; + + @Override + public Long createIteration(PmsIterationSaveReqVO saveReqVO, Long userId) { + // 1.1 校验项目可编辑 + validateProjectWritable(saveReqVO.getProjectId(), userId); + // 1.2 校验负责人 + validateOwnerUser(saveReqVO.getProjectId(), saveReqVO.getOwnerUserId()); + + // 2. 创建未开始的迭代 + Integer maxSort = iterationMapper.selectMaxSortByProjectId(saveReqVO.getProjectId()); + PmsIterationDO iteration = BeanUtils.toBean(saveReqVO, PmsIterationDO.class) + .setStatus(PmsIterationStatusEnum.PLANNED.getStatus()) + .setSort(maxSort == null ? 1 : maxSort + 1); + iterationMapper.insert(iteration); + return iteration.getId(); + } + + @Override + public void updateIteration(PmsIterationSaveReqVO saveReqVO, Long userId) { + // 1.1 校验迭代存在 + PmsIterationDO iteration = validateIterationExists(saveReqVO.getId()); + // 1.2 校验项目可编辑 + validateProjectWritable(iteration.getProjectId(), userId); + // 1.3 校验负责人 + validateOwnerUser(iteration.getProjectId(), saveReqVO.getOwnerUserId()); + + // 2. 更新迭代基本信息 + iterationMapper.updateById(BeanUtils.toBean(saveReqVO, PmsIterationDO.class) + .setProjectId(null)); + } + + @Override + public void startIteration(PmsIterationStartReqVO startReqVO, Long userId) { + // 1.1 校验迭代存在 + PmsIterationDO iteration = validateIterationExists(startReqVO.getId()); + // 1.2 校验项目可编辑 + validateProjectWritable(iteration.getProjectId(), userId); + // 1.3 校验迭代处于未开始状态 + if (ObjectUtil.notEqual(PmsIterationStatusEnum.PLANNED.getStatus(), iteration.getStatus())) { + throw exception(ITERATION_STATUS_INVALID); + } + + // 2. 开始迭代并保存周期 + iterationMapper.updateById(BeanUtils.toBean(startReqVO, PmsIterationDO.class) + .setId(iteration.getId()).setProjectId(null).setStatus(PmsIterationStatusEnum.ACTIVE.getStatus())); + } + + @Override + public void completeIteration(Long id, Long userId) { + // 1.1 校验迭代存在 + PmsIterationDO iteration = validateIterationExists(id); + // 1.2 校验项目可编辑 + validateProjectWritable(iteration.getProjectId(), userId); + // 1.3 校验迭代处于进行中状态 + if (ObjectUtil.notEqual(PmsIterationStatusEnum.ACTIVE.getStatus(), iteration.getStatus())) { + throw exception(ITERATION_STATUS_INVALID); + } + + // 2. 完成迭代 + iterationMapper.updateById(new PmsIterationDO().setId(id) + .setStatus(PmsIterationStatusEnum.COMPLETED.getStatus()).setFinishTime(LocalDateTime.now())); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteIteration(Long id, Long userId) { + // 1.1 校验迭代存在 + PmsIterationDO iteration = validateIterationExists(id); + // 1.2 校验项目可编辑 + validateProjectWritable(iteration.getProjectId(), userId); + + // 2. 解除工作项的所属迭代关系后删除迭代 + workItemService.clearWorkItemIterationId(id); + iterationMapper.deleteById(id); + } + + @Override + public void deleteIterationListByProjectId(Long projectId) { + iterationMapper.deleteByProjectId(projectId); + } + + @Override + public PmsIterationDO getIteration(Long id, Long userId) { + // 1. 校验迭代存在 + PmsIterationDO iteration = validateIterationExists(id); + + // 2. 校验项目读取权限 + projectMemberService.validateProjectReadable(iteration.getProjectId(), userId); + return iteration; + } + + @Override + public PmsIterationOverviewRespVO getIterationOverview(Long id, Long userId) { + // 1.1 查询可访问的迭代 + PmsIterationDO iteration = getIteration(id, userId); + // 1.2 查询迭代关联的工作项 + List workItems = workItemService.getActiveWorkItemListByIterationId(id); + + // 2. 统计状态、类型和完成趋势 + int pendingCount = CollUtil.count(workItems, item -> + PmsWorkItemStatusTypeEnum.PENDING.getType().equals(item.getStatus())); + int processingCount = CollUtil.count(workItems, item -> + PmsWorkItemStatusTypeEnum.PROCESSING.getType().equals(item.getStatus())); + int completedCount = CollUtil.count(workItems, item -> + PmsWorkItemStatusTypeEnum.COMPLETED.getType().equals(item.getStatus())); + Map typeCountMap = new LinkedHashMap<>(); + Map> typeStatusCountMap = new LinkedHashMap<>(); + workItems.forEach(item -> { + typeCountMap.merge(item.getType(), 1, Integer::sum); + typeStatusCountMap.computeIfAbsent(item.getType(), key -> new LinkedHashMap<>()) + .merge(item.getStatus(), 1, Integer::sum); + }); + + // 3. 计算状态趋势、燃尽数据和最近活动 + int progress = CollUtil.isEmpty(workItems) ? 0 : completedCount * 100 / workItems.size(); + return new PmsIterationOverviewRespVO().setTotalCount(workItems.size()).setPendingCount(pendingCount) + .setProcessingCount(processingCount).setCompletedCount(completedCount).setProgress(progress) + .setTypeCountMap(typeCountMap).setTypeStatusCountMap(typeStatusCountMap) + .setStatusTrends(buildStatusTrends(workItems)) + .setBurnDowns(buildBurnDowns(iteration, workItems)).setRecentActivities(buildRecentActivities(workItems)); + } + + /** + * 构建迭代内工作项的最近活动列表 + * + * @param workItems 工作项列表 + * @return 最近活动列表 + */ + private List buildRecentActivities(List workItems) { + Map workItemMap = convertMap(workItems, PmsWorkItemDO::getId); + List activities = workItemActivityService.getWorkItemActivityListByWorkItemIds( + convertSet(workItems, PmsWorkItemDO::getId), RECENT_ACTIVITY_LIMIT); + return convertList(activities, activity -> { + PmsWorkItemDO workItem = workItemMap.get(activity.getWorkItemId()); + return new PmsIterationOverviewRespVO.ActivityItem().setId(activity.getId()) + .setWorkItemId(activity.getWorkItemId()).setWorkItemSerialNumber(workItem.getSerialNumber()) + .setWorkItemName(workItem.getName()).setOperatorUserId(activity.getOperatorUserId()) + .setContent(activity.getContent()).setCreateTime(activity.getCreateTime()); + }); + } + + /** + * 构建最近固定天数的每日状态趋势 + * + * @param workItems 工作项列表 + * @return 状态趋势列表 + */ + private List buildStatusTrends(List workItems) { + LocalDate beginDate = LocalDate.now().minusDays(TREND_DAYS - 1L); + List statusTrends = new ArrayList<>(); + for (int index = 0; index < TREND_DAYS; index++) { + LocalDate date = beginDate.plusDays(index); + int pendingCount = CollUtil.count(workItems, item -> + PmsWorkItemStatusTypeEnum.PENDING.getType().equals(item.getStatus()) + && item.getUpdateTime() != null && date.equals(item.getUpdateTime().toLocalDate())); + int processingCount = CollUtil.count(workItems, item -> + PmsWorkItemStatusTypeEnum.PROCESSING.getType().equals(item.getStatus()) + && item.getUpdateTime() != null && date.equals(item.getUpdateTime().toLocalDate())); + int completedCount = CollUtil.count(workItems, item -> + PmsWorkItemStatusTypeEnum.COMPLETED.getType().equals(item.getStatus()) + && item.getUpdateTime() != null && date.equals(item.getUpdateTime().toLocalDate())); + statusTrends.add(new PmsIterationOverviewRespVO.TrendItem().setDate(date.toString()) + .setPendingCount(pendingCount).setProcessingCount(processingCount) + .setCompletedCount(completedCount)); + } + return statusTrends; + } + + /** + * 根据迭代周期和工时登记记录构建燃尽数据 + * + * @param iteration 迭代 + * @param workItems 工作项列表 + * @return 燃尽数据列表 + */ + private List buildBurnDowns(PmsIterationDO iteration, + List workItems) { + // 1. 未配置完整迭代周期时,不生成燃尽数据 + if (iteration.getStartTime() == null || iteration.getEndTime() == null) { + return new ArrayList<>(); + } + // 2. 汇总迭代工作项的预估工时和每日实际投入工时 + int totalEstimatedHours = (int) sum(workItems, + item -> item.getEstimatedHours() == null ? 0 : item.getEstimatedHours()); + Map actualHoursByDate = new LinkedHashMap<>(); + List workItemIds = convertList(workItems, PmsWorkItemDO::getId); + workItemWorkLogService.getWorkItemWorkLogListByWorkItemIds(workItemIds).forEach(workLog -> { + if (workLog.getCreateTime() == null || workLog.getActualHours() == null) { + return; + } + LocalDate logDate = workLog.getCreateTime().toLocalDate(); + actualHoursByDate.merge(logDate, workLog.getActualHours(), Integer::sum); + }); + + // 3. 生成限定长度的日期轴,并逐日计算理想和实际剩余工时 + LocalDate beginDate = iteration.getStartTime().toLocalDate(); + LocalDate endDate = iteration.getEndTime().toLocalDate(); + long totalDays = Math.max(1, ChronoUnit.DAYS.between(beginDate, endDate)); + List burnDowns = new ArrayList<>(); + int actualHours = 0; + for (LocalDate date = beginDate; !date.isAfter(endDate); date = date.plusDays(1)) { + long elapsedDays = ChronoUnit.DAYS.between(beginDate, date); + int idealRemaining = (int) Math.ceil(totalEstimatedHours * (totalDays - elapsedDays) * 1.0D / totalDays); + actualHours += actualHoursByDate.getOrDefault(date, 0); + burnDowns.add(new PmsIterationOverviewRespVO.BurnDownItem().setDate(date.toString()) + .setIdealRemaining(idealRemaining).setActualRemaining(totalEstimatedHours - actualHours)); + } + return burnDowns; + } + + @Override + public PageResult getIterationPage(PmsIterationPageReqVO pageReqVO, Long userId) { + // 1. 校验项目读取权限 + projectMemberService.validateProjectReadable(pageReqVO.getProjectId(), userId); + + // 2. 查询迭代分页 + return iterationMapper.selectPage(pageReqVO); + } + + @Override + public Map getIterationProgressMap(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyMap(); + } + // 1. 批量聚合迭代关联的正常工作项状态数量 + Map> iterationStatusCountMap = workItemService.getIterationWorkItemStatusCountMap(ids); + + // 2. 计算每个迭代的完成进度 + Map progressMap = new LinkedHashMap<>(); + ids.forEach(id -> { + Map statusCountMap = iterationStatusCountMap.getOrDefault(id, Collections.emptyMap()); + long totalCount = statusCountMap.values().stream().mapToLong(Long::longValue).sum(); + long completedCount = statusCountMap.getOrDefault(PmsWorkItemStatusTypeEnum.COMPLETED.getType(), 0L); + progressMap.put(id, totalCount == 0 ? 0 : (int) (completedCount * 100 / totalCount)); + }); + return progressMap; + } + + @Override + public List getIterationList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + return iterationMapper.selectByIds(ids); + } + + @Override + public PageResult getUncompletedIterationPage(PmsWorkbenchPageReqVO pageReqVO, + Collection projectIds, Long ownerUserId) { + if (CollUtil.isEmpty(projectIds)) { + return PageResult.empty(); + } + return iterationMapper.selectWorkbenchPage(pageReqVO, projectIds, pageReqVO.getProjectId(), + pageReqVO.getName(), pageReqVO.getStatus(), pageReqVO.getEndTime(), + PmsIterationStatusEnum.COMPLETED.getStatus(), ownerUserId); + } + + @Override + public Long getUncompletedIterationCount(PmsWorkbenchPageReqVO pageReqVO, + Collection projectIds, Long ownerUserId) { + if (CollUtil.isEmpty(projectIds)) { + return 0L; + } + return iterationMapper.selectWorkbenchCount(projectIds, pageReqVO.getProjectId(), pageReqVO.getName(), + pageReqVO.getStatus(), pageReqVO.getEndTime(), + PmsIterationStatusEnum.COMPLETED.getStatus(), ownerUserId); + } + + @Override + public Map> getProjectIterationStatusCountMap(Collection projectIds) { + if (CollUtil.isEmpty(projectIds)) { + return Collections.emptyMap(); + } + return iterationMapper.selectStatusCountMapByProjectIds(projectIds); + } + + /** + * 校验迭代存在 + * + * @param id 迭代编号 + * @return 迭代 + */ + private PmsIterationDO validateIterationExists(Long id) { + PmsIterationDO iteration = iterationMapper.selectById(id); + if (iteration == null) { + throw exception(ITERATION_NOT_EXISTS); + } + return iteration; + } + + /** + * 校验项目可编辑且处于进行中 + * + * @param projectId 项目编号 + * @param userId 后台用户编号 + */ + private void validateProjectWritable(Long projectId, Long userId) { + PmsProjectDO project = projectMemberService.validateProjectWritable(projectId, userId); + if (ObjectUtil.notEqual(PmsProjectStatusEnum.ACTIVE.getStatus(), project.getStatus())) { + throw exception(PROJECT_STATUS_INVALID); + } + } + + /** + * 校验迭代负责人是项目成员 + * + * @param projectId 项目编号 + * @param ownerUserId 负责人用户编号 + */ + private void validateOwnerUser(Long projectId, Long ownerUserId) { + if (ownerUserId != null) { + projectMemberService.validateProjectMemberExists(projectId, ownerUserId); + } + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectAnnouncementService.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectAnnouncementService.java new file mode 100644 index 000000000..376d12133 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectAnnouncementService.java @@ -0,0 +1,65 @@ +package cn.iocoder.yudao.module.pms.service.pm.project; + +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.announcement.PmsProjectAnnouncementSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectAnnouncementDO; + +import java.util.List; + +/** + * PMS 项目公告 Service 接口 + * + * @author 芋道源码 + */ +public interface PmsProjectAnnouncementService { + + /** + * 创建项目公告 + * + * @param saveReqVO 保存信息 + * @param userId 后台用户编号 + * @return 公告编号 + */ + Long createProjectAnnouncement(PmsProjectAnnouncementSaveReqVO saveReqVO, Long userId); + + /** + * 更新项目公告 + * + * @param saveReqVO 保存信息 + * @param userId 后台用户编号 + */ + void updateProjectAnnouncement(PmsProjectAnnouncementSaveReqVO saveReqVO, Long userId); + + /** + * 删除项目公告 + * + * @param id 公告编号 + * @param userId 后台用户编号 + */ + void deleteProjectAnnouncement(Long id, Long userId); + + /** + * 获得项目公告详情 + * + * @param id 公告编号 + * @param userId 后台用户编号 + * @return 项目公告 + */ + PmsProjectAnnouncementDO getProjectAnnouncement(Long id, Long userId); + + /** + * 获得项目公告列表 + * + * @param projectId 项目编号 + * @param userId 后台用户编号 + * @return 公告列表 + */ + List getProjectAnnouncementList(Long projectId, Long userId); + + /** + * 删除项目的全部公告 + * + * @param projectId 项目编号 + */ + void deleteProjectAnnouncementListByProjectId(Long projectId); + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectAnnouncementServiceImpl.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectAnnouncementServiceImpl.java new file mode 100644 index 000000000..3b4975958 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectAnnouncementServiceImpl.java @@ -0,0 +1,106 @@ +package cn.iocoder.yudao.module.pms.service.pm.project; + +import cn.hutool.core.util.ObjectUtil; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.announcement.PmsProjectAnnouncementSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectAnnouncementDO; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.project.PmsProjectAnnouncementMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.PROJECT_ANNOUNCEMENT_NOT_EXISTS; + +/** + * PMS 项目公告 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class PmsProjectAnnouncementServiceImpl implements PmsProjectAnnouncementService { + + @Resource + private PmsProjectAnnouncementMapper announcementMapper; + + @Resource + private PmsProjectMemberService projectMemberService; + + @Override + public Long createProjectAnnouncement(PmsProjectAnnouncementSaveReqVO saveReqVO, Long userId) { + // 1. 校验项目编辑权限 + projectMemberService.validateProjectWritable(saveReqVO.getProjectId(), userId); + + // 2. 创建公告 + PmsProjectAnnouncementDO announcement = BeanUtils.toBean(saveReqVO, PmsProjectAnnouncementDO.class); + announcementMapper.insert(announcement); + return announcement.getId(); + } + + @Override + public void updateProjectAnnouncement(PmsProjectAnnouncementSaveReqVO saveReqVO, Long userId) { + // 1.1 校验公告存在 + PmsProjectAnnouncementDO announcement = validateAnnouncementExists(saveReqVO.getId()); + // 1.2 校验公告属于请求项目 + if (ObjectUtil.notEqual(announcement.getProjectId(), saveReqVO.getProjectId())) { + throw exception(PROJECT_ANNOUNCEMENT_NOT_EXISTS); + } + // 1.3 校验项目编辑权限 + projectMemberService.validateProjectWritable(announcement.getProjectId(), userId); + + // 2. 更新公告 + announcementMapper.updateById(BeanUtils.toBean(saveReqVO, PmsProjectAnnouncementDO.class)); + } + + @Override + public void deleteProjectAnnouncement(Long id, Long userId) { + // 1.1 校验公告存在 + PmsProjectAnnouncementDO announcement = validateAnnouncementExists(id); + // 1.2 校验项目编辑权限 + projectMemberService.validateProjectWritable(announcement.getProjectId(), userId); + + // 2. 删除公告 + announcementMapper.deleteById(id); + } + + @Override + public PmsProjectAnnouncementDO getProjectAnnouncement(Long id, Long userId) { + // 1. 校验公告存在 + PmsProjectAnnouncementDO announcement = validateAnnouncementExists(id); + // 2. 校验项目读取权限 + projectMemberService.validateProjectReadable(announcement.getProjectId(), userId); + return announcement; + } + + @Override + public List getProjectAnnouncementList(Long projectId, Long userId) { + // 1. 校验项目读取权限 + projectMemberService.validateProjectReadable(projectId, userId); + + // 2. 查询项目公告 + return announcementMapper.selectListByProjectId(projectId); + } + + @Override + public void deleteProjectAnnouncementListByProjectId(Long projectId) { + announcementMapper.deleteByProjectId(projectId); + } + + /** + * 校验项目公告存在 + * + * @param id 公告编号 + * @return 项目公告 + */ + private PmsProjectAnnouncementDO validateAnnouncementExists(Long id) { + PmsProjectAnnouncementDO announcement = announcementMapper.selectById(id); + if (announcement == null) { + throw exception(PROJECT_ANNOUNCEMENT_NOT_EXISTS); + } + return announcement; + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectFavoriteService.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectFavoriteService.java new file mode 100644 index 000000000..6a16ae83d --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectFavoriteService.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.pms.service.pm.project; + +import java.util.List; + +/** + * PMS 项目收藏 Service 接口 + * + * @author 芋道源码 + */ +public interface PmsProjectFavoriteService { + + /** + * 收藏项目 + * + * @param projectId 项目编号 + * @param userId 后台用户编号 + */ + void createProjectFavorite(Long projectId, Long userId); + + /** + * 取消收藏项目 + * + * @param projectId 项目编号 + * @param userId 后台用户编号 + */ + void deleteProjectFavorite(Long projectId, Long userId); + + /** + * 获得用户收藏的项目编号列表 + * + * @param userId 后台用户编号 + * @return 项目编号列表 + */ + List getFavoriteProjectIdListByUserId(Long userId); + + /** + * 删除项目的全部收藏关系 + * + * @param projectId 项目编号 + */ + void deleteProjectFavoriteListByProjectId(Long projectId); + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectFavoriteServiceImpl.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectFavoriteServiceImpl.java new file mode 100644 index 000000000..d0713ddb6 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectFavoriteServiceImpl.java @@ -0,0 +1,60 @@ +package cn.iocoder.yudao.module.pms.service.pm.project; + +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectFavoriteDO; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.project.PmsProjectFavoriteMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.util.Collections; +import java.util.List; + +/** + * PMS 项目收藏 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class PmsProjectFavoriteServiceImpl implements PmsProjectFavoriteService { + + @Resource + private PmsProjectFavoriteMapper favoriteMapper; + @Resource + private PmsProjectMemberService projectMemberService; + + @Override + public void createProjectFavorite(Long projectId, Long userId) { + // 1. 校验用户是项目成员 + projectMemberService.validateProjectMember(projectId, userId); + + // 2. 创建收藏关系,重复请求保持幂等 + if (favoriteMapper.selectByProjectIdAndUserId(projectId, userId) != null) { + return; + } + favoriteMapper.insert(new PmsProjectFavoriteDO().setProjectId(projectId).setUserId(userId)); + } + + @Override + public void deleteProjectFavorite(Long projectId, Long userId) { + // 1. 校验用户是项目成员 + projectMemberService.validateProjectMember(projectId, userId); + + // 2. 删除收藏关系,重复请求保持幂等 + PmsProjectFavoriteDO favorite = favoriteMapper.selectByProjectIdAndUserId(projectId, userId); + if (favorite != null) { + favoriteMapper.deleteById(favorite.getId()); + } + } + + @Override + public List getFavoriteProjectIdListByUserId(Long userId) { + return favoriteMapper.selectProjectIdListByUserId(userId); + } + + @Override + public void deleteProjectFavoriteListByProjectId(Long projectId) { + favoriteMapper.deleteByProjectIds(Collections.singleton(projectId)); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectGroupService.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectGroupService.java new file mode 100644 index 000000000..e2949eeef --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectGroupService.java @@ -0,0 +1,95 @@ +package cn.iocoder.yudao.module.pms.service.pm.project; + +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.group.PmsProjectGroupMoveReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.group.PmsProjectGroupSaveReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.group.PmsProjectGroupSortReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectGroupDO; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * PMS 项目分组 Service 接口 + * + * @author 芋道源码 + */ +public interface PmsProjectGroupService { + + /** + * 创建项目分组 + * + * @param createReqVO 创建信息 + * @param userId 后台用户编号 + * @return 项目分组编号 + */ + Long createProjectGroup(PmsProjectGroupSaveReqVO createReqVO, Long userId); + + /** + * 更新项目分组 + * + * @param updateReqVO 更新信息 + * @param userId 后台用户编号 + */ + void updateProjectGroup(PmsProjectGroupSaveReqVO updateReqVO, Long userId); + + /** + * 更新项目分组排序 + * + * @param sortReqVO 排序信息 + * @param userId 后台用户编号 + */ + void updateProjectGroupSort(PmsProjectGroupSortReqVO sortReqVO, Long userId); + + /** + * 删除项目分组 + * + * @param id 项目分组编号 + * @param userId 后台用户编号 + */ + void deleteProjectGroup(Long id, Long userId); + + /** + * 获得当前用户的项目分组列表 + * + * @param userId 后台用户编号 + * @return 项目分组列表 + */ + List getProjectGroupList(Long userId); + + /** + * 获得项目分组的项目数量 Map + * + * @param userId 后台用户编号 + * @param groups 项目分组列表 + * @param projectIds 当前用户参与的进行中项目编号集合 + * @return 项目分组编号与项目数量 Map + */ + Map getProjectGroupCountMap(Long userId, Collection groups, Collection projectIds); + + /** + * 移动项目到个人分组 + * + * @param moveReqVO 移动信息 + * @param userId 后台用户编号 + */ + void moveProjectToGroup(PmsProjectGroupMoveReqVO moveReqVO, Long userId); + + /** + * 按个人项目分组过滤项目编号 + * + * @param groupId 项目分组编号 + * @param userId 后台用户编号 + * @param projectIds 待过滤的项目编号集合 + * @return 过滤后的项目编号列表 + */ + List filterProjectIdListByGroupId(Long groupId, Long userId, Collection projectIds); + + /** + * 删除项目的全部个人分组关系 + * + * @param projectId 项目编号 + */ + void deleteProjectGroupRelationListByProjectId(Long projectId); + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectGroupServiceImpl.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectGroupServiceImpl.java new file mode 100644 index 000000000..a110f9bf3 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectGroupServiceImpl.java @@ -0,0 +1,279 @@ +package cn.iocoder.yudao.module.pms.service.pm.project; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.group.PmsProjectGroupMoveReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.group.PmsProjectGroupSaveReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.group.PmsProjectGroupSortReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectGroupDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectGroupRelationDO; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.project.PmsProjectGroupMapper; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.project.PmsProjectGroupRelationMapper; +import cn.iocoder.yudao.module.pms.enums.pm.project.PmsProjectGroupTypeEnum; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.PROJECT_GROUP_DEFAULT_CANNOT_DELETE; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.PROJECT_GROUP_DEFAULT_CANNOT_MODIFY; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.PROJECT_GROUP_NAME_DUPLICATE; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.PROJECT_GROUP_NOT_EXISTS; + +/** + * PMS 项目分组 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class PmsProjectGroupServiceImpl implements PmsProjectGroupService { + + @Resource + private PmsProjectGroupMapper projectGroupMapper; + @Resource + private PmsProjectGroupRelationMapper projectGroupRelationMapper; + @Resource + private PmsProjectMemberService projectMemberService; + + @Override + public Long createProjectGroup(PmsProjectGroupSaveReqVO createReqVO, Long userId) { + // 1. 校验分组名称唯一 + validateProjectGroupNameDuplicate(userId, createReqVO.getName(), null); + + // 2. 创建自定义分组,并追加到现有分组末尾 + PmsProjectGroupDO lastGroup = projectGroupMapper.selectLastByUserId(userId); + PmsProjectGroupDO group = BeanUtils.toBean(createReqVO, PmsProjectGroupDO.class) + .setUserId(userId) + .setSort(lastGroup == null ? 2 : lastGroup.getSort() + 1) + .setType(PmsProjectGroupTypeEnum.CUSTOM.getType()); + projectGroupMapper.insert(group); + return group.getId(); + } + + @Override + public void updateProjectGroup(PmsProjectGroupSaveReqVO updateReqVO, Long userId) { + // 1.1 校验分组存在 + PmsProjectGroupDO group = validateProjectGroupExists(updateReqVO.getId(), userId); + // 1.2 校验分组为自定义分组 + if (ObjectUtil.notEqual(PmsProjectGroupTypeEnum.CUSTOM.getType(), group.getType())) { + throw exception(PROJECT_GROUP_DEFAULT_CANNOT_MODIFY); + } + // 1.3 校验分组名称唯一 + validateProjectGroupNameDuplicate(userId, updateReqVO.getName(), updateReqVO.getId()); + + // 2. 更新分组名称 + projectGroupMapper.updateById(BeanUtils.toBean(updateReqVO, PmsProjectGroupDO.class) + .setUserId(null).setSort(null).setType(null)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateProjectGroupSort(PmsProjectGroupSortReqVO sortReqVO, Long userId) { + // 1. 校验排序列表中的分组都属于当前用户 + Set groupIds = convertSet(sortReqVO.getItems(), PmsProjectGroupSortReqVO.Item::getId); + if (projectGroupMapper.selectListByIdsAndUserId(groupIds, userId).size() != groupIds.size()) { + throw exception(PROJECT_GROUP_NOT_EXISTS); + } + + // 2. 更新全部分组的显示顺序 + projectGroupMapper.updateBatch(convertList(sortReqVO.getItems(), + item -> new PmsProjectGroupDO().setId(item.getId()).setSort(item.getSort()))); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteProjectGroup(Long id, Long userId) { + // 1.1 校验分组存在 + PmsProjectGroupDO group = validateProjectGroupExists(id, userId); + // 1.2 校验分组为自定义分组 + if (ObjectUtil.notEqual(PmsProjectGroupTypeEnum.CUSTOM.getType(), group.getType())) { + throw exception(PROJECT_GROUP_DEFAULT_CANNOT_DELETE); + } + + // 2. 删除分组及其项目关系 + projectGroupRelationMapper.deleteByUserIdAndGroupId(userId, id); + projectGroupMapper.deleteById(id); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public List getProjectGroupList(Long userId) { + // 1. 查询当前用户的项目分组 + List groups = projectGroupMapper.selectListByUserId(userId); + + // 2. 确保“全部项目”和“未分组”两个系统分组存在 + ensureDefaultProjectGroupList(userId, groups); + return groups; + } + + @Override + public Map getProjectGroupCountMap(Long userId, Collection groups, + Collection projectIds) { + if (CollUtil.isEmpty(groups)) { + return Collections.emptyMap(); + } + // 1. 聚合统计各自定义分组的进行中项目数量 + Map groupCountMap = CollUtil.isEmpty(projectIds) ? new LinkedHashMap<>() + : projectGroupRelationMapper.selectGroupCountMapByUserIdAndProjectIds(userId, projectIds); + int groupedProjectCount = CollUtil.isEmpty(projectIds) ? 0 + : projectGroupRelationMapper.selectProjectCountByUserIdAndProjectIds(userId, projectIds); + // 2. 补充“全部项目”和“未分组”的项目数量 + int projectCount = CollUtil.size(projectIds); + for (PmsProjectGroupDO group : groups) { + if (PmsProjectGroupTypeEnum.ALL.getType().equals(group.getType())) { + groupCountMap.put(group.getId(), projectCount); + } else if (PmsProjectGroupTypeEnum.UNGROUPED.getType().equals(group.getType())) { + groupCountMap.put(group.getId(), projectCount - groupedProjectCount); + } + } + return groupCountMap; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void moveProjectToGroup(PmsProjectGroupMoveReqVO moveReqVO, Long userId) { + // 1. 校验当前用户是项目成员 + projectMemberService.validateProjectMember(moveReqVO.getProjectId(), userId); + + // 2. 移出分组时,删除已有的个人分组关系 + if (moveReqVO.getGroupId() == null) { + projectGroupRelationMapper.deleteByUserIdAndProjectId(userId, moveReqVO.getProjectId()); + return; + } + + // 3. 校验目标分组;移动到“未分组”等同于移出分组,“全部项目”不能作为目标分组 + PmsProjectGroupDO targetGroup = validateProjectGroupExists(moveReqVO.getGroupId(), userId); + if (PmsProjectGroupTypeEnum.UNGROUPED.getType().equals(targetGroup.getType())) { + projectGroupRelationMapper.deleteByUserIdAndProjectId(userId, moveReqVO.getProjectId()); + return; + } + if (PmsProjectGroupTypeEnum.ALL.getType().equals(targetGroup.getType())) { + throw exception(PROJECT_GROUP_DEFAULT_CANNOT_MODIFY); + } + + // 4. 新增或更新个人项目分组关系 + PmsProjectGroupRelationDO relation = projectGroupRelationMapper.selectByUserIdAndProjectId( + userId, moveReqVO.getProjectId()); + if (relation == null) { + projectGroupRelationMapper.insert(BeanUtils.toBean(moveReqVO, PmsProjectGroupRelationDO.class) + .setUserId(userId).setSort(0)); + return; + } + projectGroupRelationMapper.updateById(BeanUtils.toBean(moveReqVO, PmsProjectGroupRelationDO.class) + .setId(relation.getId()).setUserId(null).setProjectId(null).setSort(null)); + } + + @Override + public List filterProjectIdListByGroupId(Long groupId, Long userId, Collection projectIds) { + if (CollUtil.isEmpty(projectIds) || groupId == null) { + return CollUtil.isEmpty(projectIds) ? Collections.emptyList() + : new ArrayList<>(new LinkedHashSet<>(projectIds)); + } + + // 1. 校验分组存在;情况一:“全部项目”不额外过滤 + PmsProjectGroupDO group = validateProjectGroupExists(groupId, userId); + if (PmsProjectGroupTypeEnum.ALL.getType().equals(group.getType())) { + return new ArrayList<>(new LinkedHashSet<>(projectIds)); + } + + // 2. 获得候选项目编号集合 + Set candidateProjectIds = new LinkedHashSet<>(projectIds); + // 情况二:“未分组”排除已经加入任意自定义分组的项目 + if (PmsProjectGroupTypeEnum.UNGROUPED.getType().equals(group.getType())) { + List relations = projectGroupRelationMapper.selectListByUserIdAndProjectIds( + userId, candidateProjectIds); + candidateProjectIds.removeAll(convertSet(relations, PmsProjectGroupRelationDO::getProjectId)); + return new ArrayList<>(candidateProjectIds); + } + // 情况三:自定义分组只保留属于该分组的项目 + List relations = projectGroupRelationMapper.selectListByUserIdAndGroupId(userId, groupId); + candidateProjectIds.retainAll(convertSet(relations, PmsProjectGroupRelationDO::getProjectId)); + return new ArrayList<>(candidateProjectIds); + } + + @Override + public void deleteProjectGroupRelationListByProjectId(Long projectId) { + projectGroupRelationMapper.deleteByProjectId(projectId); + } + + /** + * 确保用户拥有“全部项目”和“未分组”两个系统分组 + * + * @param userId 后台用户编号 + * @param groups 当前已查询到的项目分组 + */ + private void ensureDefaultProjectGroupList(Long userId, List groups) { + // 1. 检查两个系统分组是否已经存在 + boolean hasAllGroup = CollUtil.findOne(groups, + group -> PmsProjectGroupTypeEnum.ALL.getType().equals(group.getType())) != null; + boolean hasUngroupedGroup = CollUtil.findOne(groups, + group -> PmsProjectGroupTypeEnum.UNGROUPED.getType().equals(group.getType())) != null; + if (hasAllGroup && hasUngroupedGroup) { + return; + } + + // 2. 构造并插入缺失的系统分组 + List defaultGroups = new ArrayList<>(); + if (!hasAllGroup) { + defaultGroups.add(new PmsProjectGroupDO().setUserId(userId) + .setName(PmsProjectGroupTypeEnum.ALL.getName()).setSort(0) + .setType(PmsProjectGroupTypeEnum.ALL.getType())); + } + if (!hasUngroupedGroup) { + defaultGroups.add(new PmsProjectGroupDO().setUserId(userId) + .setName(PmsProjectGroupTypeEnum.UNGROUPED.getName()).setSort(1) + .setType(PmsProjectGroupTypeEnum.UNGROUPED.getType())); + } + projectGroupMapper.insertBatch(defaultGroups); + + // 3. 合并本次新增记录并保持稳定排序 + groups.addAll(defaultGroups); + groups.sort(Comparator.comparing(PmsProjectGroupDO::getSort, + Comparator.nullsLast(Integer::compareTo)).thenComparing(PmsProjectGroupDO::getId)); + } + + /** + * 校验项目分组存在,并且属于当前用户 + * + * @param id 项目分组编号 + * @param userId 后台用户编号 + * @return 项目分组 + */ + private PmsProjectGroupDO validateProjectGroupExists(Long id, Long userId) { + PmsProjectGroupDO group = projectGroupMapper.selectByIdAndUserId(id, userId); + if (group == null) { + throw exception(PROJECT_GROUP_NOT_EXISTS); + } + return group; + } + + /** + * 校验当前用户的项目分组名称唯一 + * + * @param userId 后台用户编号 + * @param name 分组名称 + * @param excludeId 排除的项目分组编号 + */ + private void validateProjectGroupNameDuplicate(Long userId, String name, Long excludeId) { + PmsProjectGroupDO group = projectGroupMapper.selectByUserIdAndName(userId, name, excludeId); + if (group != null) { + throw exception(PROJECT_GROUP_NAME_DUPLICATE); + } + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectMemberService.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectMemberService.java new file mode 100644 index 000000000..9af94682d --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectMemberService.java @@ -0,0 +1,187 @@ +package cn.iocoder.yudao.module.pms.service.pm.project; + +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.member.PmsProjectMemberRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.member.PmsProjectMemberSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectDO; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * PMS 项目成员 Service 接口 + * + * @author 芋道源码 + */ +public interface PmsProjectMemberService { + + /** + * 获得用户参与的进行中项目编号 + * + * @param userId 后台用户编号 + * @return 项目编号列表 + */ + List getActiveProjectIdListByUserId(Long userId); + + /** + * 校验用户是指定项目的成员 + * + * @param projectId 项目编号 + * @param userId 后台用户编号 + * @return 项目 + */ + PmsProjectDO validateProjectMember(Long projectId, Long userId); + + /** + * 校验用户可以查看指定项目 + * + * 公开项目允许所有后台用户查看,私有项目只允许项目成员或超级管理员查看 + * + * @param projectId 项目编号 + * @param userId 后台用户编号 + * @return 项目 + */ + PmsProjectDO validateProjectReadable(Long projectId, Long userId); + + /** + * 校验用户可以编辑指定项目的业务数据 + * + * @param projectId 项目编号 + * @param userId 后台用户编号 + * @return 项目 + */ + PmsProjectDO validateProjectWritable(Long projectId, Long userId); + + /** + * 初始化项目成员 + * + * @param projectId 项目编号 + * @param creatorId 创建人编号 + * @param memberUserIds 初始成员编号列表 + */ + void createProjectMemberList(Long projectId, Long creatorId, Collection memberUserIds); + + /** + * 保存项目成员及其权限级别 + * + * @param saveReqVO 保存信息 + * @param operatorUserId 操作人编号 + */ + void updateProjectMemberList(PmsProjectMemberSaveReqVO saveReqVO, Long operatorUserId); + + /** + * 移除项目成员 + * + * @param projectId 项目编号 + * @param userId 成员编号 + * @param operatorUserId 操作人编号 + */ + void deleteProjectMember(Long projectId, Long userId, Long operatorUserId); + + /** + * 主动退出项目 + * + * @param projectId 项目编号 + * @param userId 当前用户编号 + */ + void exitProject(Long projectId, Long userId); + + /** + * 删除项目的全部成员关系 + * + * @param projectId 项目编号 + */ + void deleteProjectMemberListByProjectId(Long projectId); + + /** + * 获得项目成员列表 + * + * @param projectId 项目编号 + * @param userId 当前用户编号 + * @return 项目成员列表 + */ + List getProjectMemberList(Long projectId, Long userId); + + /** + * 获得用户参与的项目编号列表 + * + * @param userId 后台用户编号 + * @return 项目编号列表 + */ + List getProjectIdListByUserId(Long userId); + + /** + * 获得用户拥有的项目编号列表 + * + * @param userId 后台用户编号 + * @return 项目编号列表 + */ + List getOwnerProjectIdListByUserId(Long userId); + + /** + * 获得用户管理的项目编号列表 + * + * @param userId 后台用户编号 + * @return 项目编号列表 + */ + List getManagedProjectIdListByUserId(Long userId); + + /** + * 获得用户可编辑的项目编号列表 + * + * @param userId 后台用户编号 + * @return 项目编号列表 + */ + List getWritableProjectIdListByUserId(Long userId); + + /** + * 获得项目拥有者和管理员用户编号 Map + * + * @param projectIds 项目编号集合 + * @return 项目管理员用户编号 Map + */ + Map> getProjectManagerUserIdListMap(Collection projectIds); + + /** + * 获得项目成员数量 Map + * + * @param projectIds 项目编号集合 + * @return 项目成员数量 Map + */ + Map getProjectMemberCountMap(Collection projectIds); + + /** + * 判断用户是否拥有项目拥有者权限 + * + * @param projectId 项目编号 + * @param userId 后台用户编号 + * @return 是否拥有项目拥有者权限 + */ + boolean hasProjectOwnerPermission(Long projectId, Long userId); + + /** + * 判断用户是否拥有项目管理权限 + * + * @param projectId 项目编号 + * @param userId 后台用户编号 + * @return 是否拥有项目管理权限 + */ + boolean hasProjectManagerPermission(Long projectId, Long userId); + + /** + * 校验用户是项目成员 + * + * @param projectId 项目编号 + * @param userId 后台用户编号 + */ + void validateProjectMemberExists(Long projectId, Long userId); + + /** + * 校验用户列表都是项目成员 + * + * @param projectId 项目编号 + * @param userIds 后台用户编号集合 + */ + void validateProjectMemberList(Long projectId, Collection userIds); + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectMemberServiceImpl.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectMemberServiceImpl.java new file mode 100644 index 000000000..4f5678ffa --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectMemberServiceImpl.java @@ -0,0 +1,352 @@ +package cn.iocoder.yudao.module.pms.service.pm.project; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils; +import cn.iocoder.yudao.framework.common.util.number.NumberUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.member.PmsProjectMemberRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.member.PmsProjectMemberSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectMemberDO; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.project.PmsProjectMemberMapper; +import cn.iocoder.yudao.module.pms.enums.pm.project.PmsProjectMemberLevelEnum; +import cn.iocoder.yudao.module.pms.enums.pm.project.PmsProjectStatusEnum; +import cn.iocoder.yudao.module.system.api.permission.PermissionApi; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.enums.permission.RoleCodeEnum; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertLinkedSet; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMultiMap; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.PROJECT_ACCESS_DENIED; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.PROJECT_ADMIN_REQUIRED; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.PROJECT_MEMBER_CREATOR_CANNOT_REMOVE; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.PROJECT_MEMBER_NOT_EXISTS; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.PROJECT_MEMBER_OWNER_CANNOT_EXIT; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.PROJECT_MEMBER_OWNER_LEVEL_CANNOT_ASSIGN; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.PROJECT_NOT_EXISTS; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.PROJECT_WRITE_ACCESS_DENIED; + +/** + * PMS 项目成员 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class PmsProjectMemberServiceImpl implements PmsProjectMemberService { + + @Resource + private PmsProjectMemberMapper projectMemberMapper; + + @Resource + @Lazy // 延迟加载,避免循环依赖 + private PmsProjectService projectService; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private PermissionApi permissionApi; + + @Override + public List getActiveProjectIdListByUserId(Long userId) { + // 1. 查询用户参与的项目编号并去重 + List members = projectMemberMapper.selectListByUserId(userId); + Set projectIds = convertLinkedSet(members, PmsProjectMemberDO::getProjectId); + if (CollUtil.isEmpty(projectIds)) { + return Collections.emptyList(); + } + + // 2. 只保留进行中的项目 + List projects = projectService.getProjectList(projectIds); + return convertList(projects, PmsProjectDO::getId, + project -> PmsProjectStatusEnum.ACTIVE.getStatus().equals(project.getStatus())); + } + + @Override + public PmsProjectDO validateProjectMember(Long projectId, Long userId) { + // 1. 校验项目存在 + PmsProjectDO project = validateProjectExists(projectId); + + // 2. 校验用户是项目成员 + PmsProjectMemberDO member = projectMemberMapper.selectByProjectIdAndUserId(projectId, userId); + if (member == null) { + throw exception(PROJECT_ACCESS_DENIED); + } + return project; + } + + @Override + public PmsProjectDO validateProjectReadable(Long projectId, Long userId) { + // 1. 校验项目存在 + PmsProjectDO project = validateProjectExists(projectId); + + // 2. 公开项目允许查看,私有项目只允许项目成员或超级管理员查看 + if (Boolean.FALSE.equals(project.getOpenStatus()) + && projectMemberMapper.selectByProjectIdAndUserId(projectId, userId) == null + && !isSuperAdmin(userId)) { + throw exception(PROJECT_ACCESS_DENIED); + } + return project; + } + + @Override + public PmsProjectDO validateProjectWritable(Long projectId, Long userId) { + // 1.1 校验项目存在 + PmsProjectDO project = validateProjectExists(projectId); + // 1.2 超级管理员直接通过 + if (isSuperAdmin(userId)) { + return project; + } + + // 2. 校验项目成员权限级别允许编辑 + PmsProjectMemberDO member = projectMemberMapper.selectByProjectIdAndUserId(projectId, userId); + if (member == null || !PmsProjectMemberLevelEnum.isWritable(member.getLevel())) { + throw exception(PROJECT_WRITE_ACCESS_DENIED); + } + return project; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void createProjectMemberList(Long projectId, Long creatorId, Collection memberUserIds) { + // 1. 校验初始成员 + Set userIds = new LinkedHashSet<>(); + CollectionUtils.addIfNotNull(userIds, creatorId); + if (CollUtil.isNotEmpty(memberUserIds)) { + userIds.addAll(memberUserIds); + } + adminUserApi.validateUserList(userIds).checkError(); + + // 2. 创建人作为项目拥有者,其余成员使用默认编辑级别 + List members = convertList(userIds, userId -> new PmsProjectMemberDO() + .setProjectId(projectId).setUserId(userId) + .setLevel(ObjectUtil.equal(creatorId, userId) ? PmsProjectMemberLevelEnum.OWNER.getLevel() : PmsProjectMemberLevelEnum.WRITE.getLevel())); + projectMemberMapper.insertBatch(members); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateProjectMemberList(PmsProjectMemberSaveReqVO saveReqVO, Long operatorUserId) { + // 1.1 校验操作人可以管理项目 + PmsProjectDO project = validateProjectManager(saveReqVO.getProjectId(), operatorUserId); + // 1.2 校验成员编号不重复 + List userIdList = convertList(saveReqVO.getMembers(), PmsProjectMemberSaveReqVO.Member::getUserId); + Set userIds = new LinkedHashSet<>(userIdList); + if (userIds.size() != userIdList.size()) { + throw exception(PROJECT_MEMBER_NOT_EXISTS); + } + // 1.3 校验成员账号 + adminUserApi.validateUserList(userIds).checkError(); + // 1.4 校验成员权限级别 + Long creatorId = NumberUtils.parseLong(project.getCreator()); + for (PmsProjectMemberSaveReqVO.Member member : saveReqVO.getMembers()) { + if (ObjectUtil.equal(creatorId, member.getUserId())) { + throw exception(PROJECT_MEMBER_CREATOR_CANNOT_REMOVE); + } + if (PmsProjectMemberLevelEnum.isOwner(member.getLevel())) { + throw exception(PROJECT_MEMBER_OWNER_LEVEL_CANNOT_ASSIGN); + } + } + + // 2. 删除后按请求保存每名成员的权限级别 + projectMemberMapper.deleteByProjectIdAndUserIds(saveReqVO.getProjectId(), userIds); + projectMemberMapper.insertBatch(convertList(saveReqVO.getMembers(), member -> + BeanUtils.toBean(member, PmsProjectMemberDO.class).setProjectId(saveReqVO.getProjectId()))); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteProjectMember(Long projectId, Long userId, Long operatorUserId) { + // 1.1 校验操作人可以管理项目 + PmsProjectDO project = validateProjectManager(projectId, operatorUserId); + // 1.2 校验待移除成员存在 + PmsProjectMemberDO member = projectMemberMapper.selectByProjectIdAndUserId(projectId, userId); + if (member == null) { + throw exception(PROJECT_MEMBER_NOT_EXISTS); + } + if (ObjectUtil.equal(NumberUtils.parseLong(project.getCreator()), userId) + || PmsProjectMemberLevelEnum.isOwner(member.getLevel())) { + throw exception(PROJECT_MEMBER_CREATOR_CANNOT_REMOVE); + } + + // 2. 移除项目成员 + projectMemberMapper.deleteByProjectIdAndUserId(projectId, userId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void exitProject(Long projectId, Long userId) { + // 1.1 校验项目可访问 + validateProjectReadable(projectId, userId); + // 1.2 校验当前账号是直接成员 + PmsProjectMemberDO member = projectMemberMapper.selectByProjectIdAndUserId(projectId, userId); + if (member == null) { + throw exception(PROJECT_MEMBER_NOT_EXISTS); + } + // 1.3 校验项目所有者不能主动退出 + if (PmsProjectMemberLevelEnum.isOwner(member.getLevel())) { + throw exception(PROJECT_MEMBER_OWNER_CANNOT_EXIT); + } + + // 2. 退出项目 + projectMemberMapper.deleteByProjectIdAndUserId(projectId, userId); + } + + @Override + public void deleteProjectMemberListByProjectId(Long projectId) { + projectMemberMapper.deleteByProjectId(projectId); + } + + @Override + public List getProjectMemberList(Long projectId, Long userId) { + // 1. 校验当前用户可以访问项目 + PmsProjectDO project = validateProjectReadable(projectId, userId); + + // 2. 转换项目成员 + Long creatorId = NumberUtils.parseLong(project.getCreator()); + List members = projectMemberMapper.selectListByProjectId(projectId); + return convertList(members, member -> new PmsProjectMemberRespVO() + .setUserId(member.getUserId()).setLevel(member.getLevel()) + .setCreatorStatus(ObjectUtil.equal(member.getUserId(), creatorId))); + } + + @Override + public List getProjectIdListByUserId(Long userId) { + List members = projectMemberMapper.selectListByUserId(userId); + return convertList(members, PmsProjectMemberDO::getProjectId); + } + + @Override + public List getOwnerProjectIdListByUserId(Long userId) { + List members = projectMemberMapper.selectListByUserIdAndLevels(userId, + Collections.singleton(PmsProjectMemberLevelEnum.OWNER.getLevel())); + return convertList(members, PmsProjectMemberDO::getProjectId); + } + + @Override + public List getManagedProjectIdListByUserId(Long userId) { + List members = projectMemberMapper.selectListByUserIdAndLevels( + userId, PmsProjectMemberLevelEnum.MANAGER_LEVELS); + return convertList(members, PmsProjectMemberDO::getProjectId); + } + + @Override + public List getWritableProjectIdListByUserId(Long userId) { + List members = projectMemberMapper.selectListByUserIdAndLevels( + userId, PmsProjectMemberLevelEnum.WRITABLE_LEVELS); + return convertList(members, PmsProjectMemberDO::getProjectId); + } + + @Override + public Map> getProjectManagerUserIdListMap(Collection projectIds) { + if (CollUtil.isEmpty(projectIds)) { + return Collections.emptyMap(); + } + List members = projectMemberMapper.selectListByProjectIdsAndLevels( + projectIds, PmsProjectMemberLevelEnum.MANAGER_LEVELS); + return convertMultiMap(members, PmsProjectMemberDO::getProjectId, PmsProjectMemberDO::getUserId); + } + + @Override + public Map getProjectMemberCountMap(Collection projectIds) { + if (CollUtil.isEmpty(projectIds)) { + return Collections.emptyMap(); + } + List members = projectMemberMapper.selectListByProjectIds(projectIds); + return CollUtil.countMap(convertList(members, PmsProjectMemberDO::getProjectId)); + } + + @Override + public boolean hasProjectOwnerPermission(Long projectId, Long userId) { + if (isSuperAdmin(userId)) { + return true; + } + PmsProjectMemberDO member = projectMemberMapper.selectByProjectIdAndUserId(projectId, userId); + return member != null && PmsProjectMemberLevelEnum.isOwner(member.getLevel()); + } + + @Override + public boolean hasProjectManagerPermission(Long projectId, Long userId) { + if (isSuperAdmin(userId)) { + return true; + } + PmsProjectMemberDO member = projectMemberMapper.selectByProjectIdAndUserId(projectId, userId); + return member != null && PmsProjectMemberLevelEnum.isManager(member.getLevel()); + } + + @Override + public void validateProjectMemberExists(Long projectId, Long userId) { + PmsProjectMemberDO member = projectMemberMapper.selectByProjectIdAndUserId(projectId, userId); + if (member == null) { + throw exception(PROJECT_MEMBER_NOT_EXISTS); + } + } + + @Override + public void validateProjectMemberList(Long projectId, Collection userIds) { + if (CollUtil.isEmpty(userIds)) { + return; + } + List members = projectMemberMapper.selectListByProjectId(projectId); + Set projectMemberUserIds = convertSet(members, PmsProjectMemberDO::getUserId); + if (!projectMemberUserIds.containsAll(userIds)) { + throw exception(PROJECT_MEMBER_NOT_EXISTS); + } + } + + /** + * 校验用户可以管理项目 + * + * @param projectId 项目编号 + * @param userId 后台用户编号 + * @return 项目 + */ + private PmsProjectDO validateProjectManager(Long projectId, Long userId) { + PmsProjectDO project = validateProjectReadable(projectId, userId); + if (!hasProjectManagerPermission(projectId, userId)) { + throw exception(PROJECT_ADMIN_REQUIRED); + } + return project; + } + + /** + * 判断用户是否为系统超级管理员 + * + * @param userId 后台用户编号 + * @return 是否为超级管理员 + */ + private boolean isSuperAdmin(Long userId) { + return permissionApi.hasAnyRoles(userId, RoleCodeEnum.SUPER_ADMIN.getCode()).getCheckedData(); + } + + /** + * 校验项目存在 + * + * @param projectId 项目编号 + * @return 项目 + */ + private PmsProjectDO validateProjectExists(Long projectId) { + PmsProjectDO project = projectService.getProject(projectId); + if (project == null) { + throw exception(PROJECT_NOT_EXISTS); + } + return project; + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectService.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectService.java new file mode 100644 index 000000000..560dff724 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectService.java @@ -0,0 +1,132 @@ +package cn.iocoder.yudao.module.pms.service.pm.project; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.project.PmsProjectOverviewRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.project.PmsProjectPageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.project.PmsProjectSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectDO; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * PMS 项目 Service 接口 + * + * @author 芋道源码 + */ +public interface PmsProjectService { + + /** + * 创建项目 + * + * @param saveReqVO 保存信息 + * @param userId 创建人编号 + * @return 项目编号 + */ + Long createProject(PmsProjectSaveReqVO saveReqVO, Long userId); + + /** + * 更新项目 + * + * @param saveReqVO 保存信息 + * @param userId 操作人编号 + */ + void updateProject(PmsProjectSaveReqVO saveReqVO, Long userId); + + /** + * 获得项目 + * + * @param id 项目编号 + * @return 项目 + */ + PmsProjectDO getProject(Long id); + + /** + * 获得项目详情并记录访问时间 + * + * @param id 项目编号 + * @param userId 当前用户编号 + * @return 项目 + */ + PmsProjectDO getProjectAndUpdateVisitTime(Long id, Long userId); + + /** + * 获得项目分页 + * + * @param pageReqVO 分页条件 + * @param userId 当前用户编号 + * @return 项目分页 + */ + PageResult getProjectPage(PmsProjectPageReqVO pageReqVO, Long userId); + + /** + * 获得当前用户的星标项目列表 + * + * @param userId 当前用户编号 + * @return 星标项目列表 + */ + List getFavoriteProjectList(Long userId); + + /** + * 获得项目列表 + * + * @param ids 项目编号集合 + * @return 项目列表 + */ + List getProjectList(Collection ids); + + /** + * 获得项目 Map + * + * @param ids 项目编号集合 + * @return 项目 Map + */ + default Map getProjectMap(Collection ids) { + return convertMap(getProjectList(ids), PmsProjectDO::getId); + } + + /** + * 获得项目概况 + * + * @param id 项目编号 + * @param userId 当前用户编号 + * @return 项目概况 + */ + PmsProjectOverviewRespVO getProjectOverview(Long id, Long userId); + + /** + * 归档项目 + * + * @param id 项目编号 + * @param userId 操作人编号 + */ + void archiveProject(Long id, Long userId); + + /** + * 将项目移入回收站 + * + * @param id 项目编号 + * @param userId 操作人编号 + */ + void recycleProject(Long id, Long userId); + + /** + * 恢复项目 + * + * @param id 项目编号 + * @param userId 操作人编号 + */ + void restoreProject(Long id, Long userId); + + /** + * 彻底删除回收站项目 + * + * @param id 项目编号 + * @param userId 操作人编号 + */ + void deleteProject(Long id, Long userId); + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectServiceImpl.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectServiceImpl.java new file mode 100644 index 000000000..b5775944b --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectServiceImpl.java @@ -0,0 +1,379 @@ +package cn.iocoder.yudao.module.pms.service.pm.project; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.project.PmsProjectOverviewRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.project.PmsProjectPageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.project.PmsProjectSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemDO; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.project.PmsProjectMapper; +import cn.iocoder.yudao.module.pms.enums.pm.project.PmsProjectSceneTypeEnum; +import cn.iocoder.yudao.module.pms.enums.pm.project.PmsProjectStatusEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemStatusTypeEnum; +import cn.iocoder.yudao.module.pms.service.pm.iteration.PmsIterationService; +import cn.iocoder.yudao.module.pms.service.pm.workitem.PmsWorkItemService; +import cn.iocoder.yudao.module.pms.service.pm.workitem.PmsWorkItemStatusService; +import cn.iocoder.yudao.module.pms.service.pm.workitem.PmsWorkItemWorkLogService; +import cn.iocoder.yudao.module.system.api.permission.PermissionApi; +import cn.iocoder.yudao.module.system.enums.permission.RoleCodeEnum; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.filterList; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.PROJECT_ADMIN_REQUIRED; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.PROJECT_NOT_EXISTS; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.PROJECT_OWNER_REQUIRED; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.PROJECT_STATUS_INVALID; + +/** + * PMS 项目 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class PmsProjectServiceImpl implements PmsProjectService { + + /** + * 项目完成趋势统计天数 + */ + private static final int TREND_DAYS = 14; + /** + * 项目概况展示的待办工作项数量上限 + */ + private static final int ASSIGNED_WORK_ITEM_LIMIT = 10; + + @Resource + private PmsProjectMapper projectMapper; + + @Resource + private PmsProjectMemberService projectMemberService; + @Resource + private PmsProjectGroupService projectGroupService; + @Resource + private PmsProjectFavoriteService projectFavoriteService; + @Resource + private PmsProjectAnnouncementService projectAnnouncementService; + @Resource + private PmsIterationService iterationService; + @Resource + private PmsWorkItemService workItemService; + @Resource + private PmsWorkItemStatusService workItemStatusService; + @Resource + private PmsWorkItemWorkLogService workItemWorkLogService; + + @Resource + private PermissionApi permissionApi; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createProject(PmsProjectSaveReqVO saveReqVO, Long userId) { + // 1. 创建进行中的项目 + PmsProjectDO project = BeanUtils.toBean(saveReqVO, PmsProjectDO.class) + .setStatus(PmsProjectStatusEnum.ACTIVE.getStatus()).setSort(0).setAccessTime(LocalDateTime.now()); + projectMapper.insert(project); + + // 2.1 创建人作为项目管理员,并初始化其他项目成员 + projectMemberService.createProjectMemberList(project.getId(), userId, saveReqVO.getMemberUserIds()); + // 2.2 初始化项目支持的工作项看板状态 + workItemStatusService.initProjectWorkItemStatuses(project.getId(), project.getType()); + return project.getId(); + } + + @Override + public void updateProject(PmsProjectSaveReqVO saveReqVO, Long userId) { + // 1.1 校验项目存在 + PmsProjectDO project = validateProjectExists(saveReqVO.getId()); + // 1.2 校验项目处于进行中 + validateActiveProject(project); + // 1.3 校验当前用户可以管理项目 + validateProjectManagerPermission(saveReqVO.getId(), userId); + + // 2. 更新项目基本信息 + PmsProjectDO updateObj = BeanUtils.toBean(saveReqVO, PmsProjectDO.class) + .setType(null); + projectMapper.updateById(updateObj); + } + + @Override + public PmsProjectDO getProject(Long id) { + return projectMapper.selectById(id); + } + + @Override + public PmsProjectDO getProjectAndUpdateVisitTime(Long id, Long userId) { + // 1. 校验当前用户可以查看项目 + PmsProjectDO project = projectMemberService.validateProjectReadable(id, userId); + + // 2. 记录最近访问时间 + LocalDateTime accessTime = LocalDateTime.now(); + projectMapper.updateById(new PmsProjectDO().setId(id).setAccessTime(accessTime)); + project.setAccessTime(accessTime); + return project; + } + + @Override + public PageResult getProjectPage(PmsProjectPageReqVO pageReqVO, Long userId) { + List projectIds; + boolean includeOpenProject = false; + boolean includeAllProject = false; + + // 1. 按项目状态和列表场景确定可查询的项目范围 + if (ObjectUtil.notEqual(PmsProjectStatusEnum.ACTIVE.getStatus(), pageReqVO.getStatus())) { + projectIds = projectMemberService.getManagedProjectIdListByUserId(userId); + } else if (PmsProjectSceneTypeEnum.ALL.getType().equals(pageReqVO.getSceneType())) { + projectIds = projectMemberService.getProjectIdListByUserId(userId); + includeOpenProject = true; + includeAllProject = permissionApi.hasAnyRoles(userId, RoleCodeEnum.SUPER_ADMIN.getCode()).getCheckedData(); + } else if (PmsProjectSceneTypeEnum.MANAGED.getType().equals(pageReqVO.getSceneType())) { + projectIds = projectMemberService.getManagedProjectIdListByUserId(userId); + } else { + projectIds = projectGroupService.filterProjectIdListByGroupId(pageReqVO.getGroupId(), userId, + projectMemberService.getProjectIdListByUserId(userId)); + } + if (!includeOpenProject && CollUtil.isEmpty(projectIds)) { + return PageResult.empty(); + } + + // 2. 查询项目分页 + return projectMapper.selectPage(pageReqVO, projectIds, includeOpenProject, includeAllProject); + } + + @Override + public List getFavoriteProjectList(Long userId) { + // 1. 查询当前用户参与且已星标的项目编号 + List favoriteProjectIds = projectFavoriteService.getFavoriteProjectIdListByUserId(userId); + List memberProjectIds = projectMemberService.getProjectIdListByUserId(userId); + List readableFavoriteProjectIds = filterList(favoriteProjectIds, memberProjectIds::contains); + if (CollUtil.isEmpty(readableFavoriteProjectIds)) { + return Collections.emptyList(); + } + // 2. 查询进行中的星标项目列表 + return projectMapper.selectListByIdsAndStatus(readableFavoriteProjectIds, PmsProjectStatusEnum.ACTIVE.getStatus()); + } + + @Override + public List getProjectList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + return projectMapper.selectByIds(ids); + } + + @Override + public PmsProjectOverviewRespVO getProjectOverview(Long id, Long userId) { + // 1.1 校验项目可访问 + projectMemberService.validateProjectReadable(id, userId); + // 1.2 查询项目的全部工作项 + List workItems = workItemService.getActiveWorkItemListByProjectId(id); + + // 2. 统计工作项状态和类型 + long pendingCount = 0L; + long processingCount = 0L; + long completedCount = 0L; + Map typeCountMap = new LinkedHashMap<>(); + for (PmsWorkItemDO workItem : workItems) { + typeCountMap.put(workItem.getType(), typeCountMap.getOrDefault(workItem.getType(), 0L) + 1); + if (PmsWorkItemStatusTypeEnum.PENDING.getType().equals(workItem.getStatus())) { + pendingCount++; + } else if (PmsWorkItemStatusTypeEnum.PROCESSING.getType().equals(workItem.getStatus())) { + processingCount++; + } else if (PmsWorkItemStatusTypeEnum.COMPLETED.getType().equals(workItem.getStatus())) { + completedCount++; + } + } + + // 3. 统计近十四日完成趋势 + LocalDate beginDate = LocalDate.now().minusDays(TREND_DAYS - 1L); + Map completedCountMap = new LinkedHashMap<>(); + for (int index = 0; index < TREND_DAYS; index++) { + completedCountMap.put(beginDate.plusDays(index), 0L); + } + for (PmsWorkItemDO workItem : workItems) { + if (PmsWorkItemStatusTypeEnum.COMPLETED.getType().equals(workItem.getStatus()) + && workItem.getUpdateTime() != null + && !workItem.getUpdateTime().toLocalDate().isBefore(beginDate)) { + LocalDate completedDate = workItem.getUpdateTime().toLocalDate(); + completedCountMap.computeIfPresent(completedDate, (date, count) -> count + 1); + } + } + List completedTrends = new ArrayList<>(); + completedCountMap.forEach((date, count) -> completedTrends.add( + new PmsProjectOverviewRespVO.TrendPoint().setDate(date.toString()).setCount(count))); + + // 4. 查询分配给当前用户的未完成工作项 + List assignedWorkItems = new ArrayList<>(); + for (PmsWorkItemDO workItem : workItems) { + if (userId.equals(workItem.getAssigneeUserId()) + && ObjectUtil.notEqual(PmsWorkItemStatusTypeEnum.COMPLETED.getType(), workItem.getStatus())) { + assignedWorkItems.add(workItem); + } + } + assignedWorkItems.sort(Comparator.comparing(PmsWorkItemDO::getEndTime, + Comparator.nullsLast(Comparator.naturalOrder())).thenComparing(PmsWorkItemDO::getId)); + if (assignedWorkItems.size() > ASSIGNED_WORK_ITEM_LIMIT) { + assignedWorkItems = assignedWorkItems.subList(0, ASSIGNED_WORK_ITEM_LIMIT); + } + List assignedWorkItemVOs = new ArrayList<>(); + for (PmsWorkItemDO workItem : assignedWorkItems) { + assignedWorkItemVOs.add(new PmsProjectOverviewRespVO.AssignedWorkItem().setId(workItem.getId()) + .setSerialNumber(workItem.getSerialNumber()).setType(workItem.getType()) + .setName(workItem.getName()).setStatus(workItem.getStatus()).setEndTime(workItem.getEndTime()) + .setProgress(workItem.getProgress())); + } + + // 5. 组装项目概况 + return new PmsProjectOverviewRespVO().setTotalCount((long) workItems.size()) + .setPendingCount(pendingCount).setProcessingCount(processingCount) + .setCompletedCount(completedCount).setTypeCountMap(typeCountMap) + .setCompletedTrends(completedTrends).setAssignedWorkItems(assignedWorkItemVOs); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void archiveProject(Long id, Long userId) { + // 1.1 校验项目存在 + PmsProjectDO project = validateProjectExists(id); + // 1.2 校验项目处于进行中 + validateActiveProject(project); + // 1.3 校验当前用户可以管理项目 + validateProjectManagerPermission(id, userId); + + // 2. 归档项目,并清除个人项目分组和星标关系 + int updateCount = projectMapper.updateStatusAndArchiveTimeById(id, PmsProjectStatusEnum.ACTIVE.getStatus(), + PmsProjectStatusEnum.ARCHIVED.getStatus(), LocalDateTime.now()); + if (updateCount == 0) { + throw exception(PROJECT_STATUS_INVALID); + } + projectGroupService.deleteProjectGroupRelationListByProjectId(id); + projectFavoriteService.deleteProjectFavoriteListByProjectId(id); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void recycleProject(Long id, Long userId) { + // 1.1 校验项目存在 + PmsProjectDO project = validateProjectExists(id); + // 1.2 校验项目处于进行中 + validateActiveProject(project); + // 1.3 校验当前用户可以管理项目 + validateProjectManagerPermission(id, userId); + + // 2. 将项目移入回收站,并清除个人项目分组和星标关系 + int updateCount = projectMapper.updateStatusAndRecycleTimeById(id, PmsProjectStatusEnum.ACTIVE.getStatus(), + PmsProjectStatusEnum.RECYCLED.getStatus(), LocalDateTime.now()); + if (updateCount == 0) { + throw exception(PROJECT_STATUS_INVALID); + } + projectGroupService.deleteProjectGroupRelationListByProjectId(id); + projectFavoriteService.deleteProjectFavoriteListByProjectId(id); + } + + @Override + public void restoreProject(Long id, Long userId) { + // 1.1 校验项目存在 + PmsProjectDO project = validateProjectExists(id); + // 1.2 校验项目已归档或位于回收站 + if (PmsProjectStatusEnum.ACTIVE.getStatus().equals(project.getStatus())) { + throw exception(PROJECT_STATUS_INVALID); + } + // 1.3 校验当前用户可以管理项目 + validateProjectManagerPermission(id, userId); + + // 2. 恢复为进行中的项目,并清空生命周期时间 + int updateCount = projectMapper.updateToRestoreById(id, PmsProjectStatusEnum.ACTIVE.getStatus()); + if (updateCount == 0) { + throw exception(PROJECT_STATUS_INVALID); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteProject(Long id, Long userId) { + // 1.1 校验项目存在 + PmsProjectDO project = validateProjectExists(id); + // 1.2 校验项目位于回收站 + if (ObjectUtil.notEqual(PmsProjectStatusEnum.RECYCLED.getStatus(), project.getStatus())) { + throw exception(PROJECT_STATUS_INVALID); + } + // 1.3 校验当前用户拥有项目所有者权限 + validateProjectOwnerPermission(id, userId); + + // 2. 删除项目工时、工作项、迭代、成员、个人分组关系和项目 + workItemWorkLogService.deleteWorkItemWorkLogListByProjectId(id); + workItemService.deleteWorkItemListByProjectId(id); + iterationService.deleteIterationListByProjectId(id); + projectGroupService.deleteProjectGroupRelationListByProjectId(id); + projectMemberService.deleteProjectMemberListByProjectId(id); + projectFavoriteService.deleteProjectFavoriteListByProjectId(id); + projectAnnouncementService.deleteProjectAnnouncementListByProjectId(id); + projectMapper.deleteById(id); + } + + /** + * 校验当前用户可以管理项目 + * + * @param projectId 项目编号 + * @param userId 后台用户编号 + */ + private void validateProjectManagerPermission(Long projectId, Long userId) { + if (!projectMemberService.hasProjectManagerPermission(projectId, userId)) { + throw exception(PROJECT_ADMIN_REQUIRED); + } + } + + /** + * 校验当前用户拥有项目所有者权限 + * + * @param projectId 项目编号 + * @param userId 后台用户编号 + */ + private void validateProjectOwnerPermission(Long projectId, Long userId) { + if (!projectMemberService.hasProjectOwnerPermission(projectId, userId)) { + throw exception(PROJECT_OWNER_REQUIRED); + } + } + + /** + * 校验项目处于进行中 + * + * @param project 项目 + */ + private void validateActiveProject(PmsProjectDO project) { + if (ObjectUtil.notEqual(PmsProjectStatusEnum.ACTIVE.getStatus(), project.getStatus())) { + throw exception(PROJECT_STATUS_INVALID); + } + } + + /** + * 校验项目存在 + * + * @param id 项目编号 + * @return 项目 + */ + private PmsProjectDO validateProjectExists(Long id) { + PmsProjectDO project = projectMapper.selectById(id); + if (project == null) { + throw exception(PROJECT_NOT_EXISTS); + } + return project; + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectTemplateService.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectTemplateService.java new file mode 100644 index 000000000..14d2f9576 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectTemplateService.java @@ -0,0 +1,53 @@ +package cn.iocoder.yudao.module.pms.service.pm.project; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.template.PmsProjectTemplatePageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.template.PmsProjectTemplateSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectTemplateDO; + +/** + * PMS 项目模板 Service 接口 + * + * @author 芋道源码 + */ +public interface PmsProjectTemplateService { + + /** + * 创建项目模板 + * + * @param createReqVO 创建信息 + * @return 模板编号 + */ + Long createProjectTemplate(PmsProjectTemplateSaveReqVO createReqVO); + + /** + * 更新项目模板 + * + * @param updateReqVO 更新信息 + */ + void updateProjectTemplate(PmsProjectTemplateSaveReqVO updateReqVO); + + /** + * 删除项目模板 + * + * @param id 模板编号 + */ + void deleteProjectTemplate(Long id); + + /** + * 获得项目模板 + * + * @param id 模板编号 + * @return 项目模板 + */ + PmsProjectTemplateDO getProjectTemplate(Long id); + + /** + * 获得项目模板分页 + * + * @param pageReqVO 分页查询 + * @return 项目模板分页 + */ + PageResult getProjectTemplatePage(PmsProjectTemplatePageReqVO pageReqVO); + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectTemplateServiceImpl.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectTemplateServiceImpl.java new file mode 100644 index 000000000..0937ef399 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectTemplateServiceImpl.java @@ -0,0 +1,186 @@ +package cn.iocoder.yudao.module.pms.service.pm.project; + +import cn.hutool.core.util.ObjectUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.template.PmsProjectTemplatePageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.template.PmsProjectTemplateSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectTemplateDO; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.project.PmsProjectTemplateMapper; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemTypeEnum; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.PROJECT_TEMPLATE_CONFIG_INVALID; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.PROJECT_TEMPLATE_NAME_DUPLICATE; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.PROJECT_TEMPLATE_NOT_EXISTS; + +/** + * PMS 项目模板 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class PmsProjectTemplateServiceImpl implements PmsProjectTemplateService { + + @Resource + private PmsProjectTemplateMapper projectTemplateMapper; + + @Override + public Long createProjectTemplate(PmsProjectTemplateSaveReqVO createReqVO) { + // 1. 校验模板名称和协作配置 + validateProjectTemplateNameDuplicate(createReqVO.getName(), createReqVO.getProjectType(), null); + validateProjectTemplateConfig(createReqVO); + + // 2. 创建项目模板 + PmsProjectTemplateDO template = buildProjectTemplateDO(createReqVO); + projectTemplateMapper.insert(template); + return template.getId(); + } + + @Override + public void updateProjectTemplate(PmsProjectTemplateSaveReqVO updateReqVO) { + // 1. 校验模板存在、名称和协作配置 + validateProjectTemplateExists(updateReqVO.getId()); + validateProjectTemplateNameDuplicate(updateReqVO.getName(), updateReqVO.getProjectType(), updateReqVO.getId()); + validateProjectTemplateConfig(updateReqVO); + + // 2. 更新项目模板 + projectTemplateMapper.updateById(buildProjectTemplateDO(updateReqVO)); + } + + @Override + public void deleteProjectTemplate(Long id) { + // 1. 校验模板存在 + validateProjectTemplateExists(id); + + // 2. 删除项目模板 + projectTemplateMapper.deleteById(id); + } + + @Override + public PmsProjectTemplateDO getProjectTemplate(Long id) { + return validateProjectTemplateExists(id); + } + + @Override + public PageResult getProjectTemplatePage(PmsProjectTemplatePageReqVO pageReqVO) { + return projectTemplateMapper.selectPage(pageReqVO); + } + + /** + * 构建项目模板 DO + * + *

顶层字段使用 BeanUtils 复制,嵌套状态和看板项分别转换为 DO 内部类型后持久化。

+ * + * @param saveReqVO 保存信息 + * @return 项目模板 DO + */ + private PmsProjectTemplateDO buildProjectTemplateDO(PmsProjectTemplateSaveReqVO saveReqVO) { + return BeanUtils.toBean(saveReqVO, PmsProjectTemplateDO.class) + .setStatuses(convertList(saveReqVO.getStatuses(), status -> + BeanUtils.toBean(status, PmsProjectTemplateDO.StatusTemplate.class))) + .setBoards(convertList(saveReqVO.getBoards(), board -> + BeanUtils.toBean(board, PmsProjectTemplateDO.BoardTemplate.class))); + } + + /** + * 校验项目模板存在 + * + * @param id 模板编号 + * @return 项目模板 + */ + private PmsProjectTemplateDO validateProjectTemplateExists(Long id) { + PmsProjectTemplateDO template = projectTemplateMapper.selectById(id); + if (template == null) { + throw exception(PROJECT_TEMPLATE_NOT_EXISTS); + } + return template; + } + + /** + * 校验同一项目类型下的模板名称唯一 + * + * @param name 模板名称 + * @param projectType 项目类型 + * @param id 排除的模板编号 + */ + private void validateProjectTemplateNameDuplicate(String name, Integer projectType, Long id) { + PmsProjectTemplateDO template = projectTemplateMapper + .selectByNameAndProjectType(name, projectType); + if (template == null) { + return; + } + if (id == null) { + throw exception(PROJECT_TEMPLATE_NAME_DUPLICATE); + } + if (ObjectUtil.notEqual(template.getId(), id)) { + throw exception(PROJECT_TEMPLATE_NAME_DUPLICATE); + } + } + + /** + * 校验工作项类型、状态和看板列之间的模板关系 + * + * @param saveReqVO 保存信息 + */ + private void validateProjectTemplateConfig(PmsProjectTemplateSaveReqVO saveReqVO) { + Set itemTypes = new HashSet<>(saveReqVO.getItemTypes()); + if (itemTypes.size() != saveReqVO.getItemTypes().size() + || itemTypes.stream().anyMatch(type -> PmsWorkItemTypeEnum.valueOf(type) == null)) { + throw exception(PROJECT_TEMPLATE_CONFIG_INVALID, "工作项类型存在重复或无效值"); + } + + // 1. 校验状态编码唯一,并且每种工作项只有一个初始状态 + Set statusCodes = new HashSet<>(); + Map defaultStatusCountMap = new HashMap<>(); + for (PmsProjectTemplateSaveReqVO.StatusTemplate status : saveReqVO.getStatuses()) { + if (!itemTypes.contains(status.getWorkItemType()) || !statusCodes.add(status.getCode())) { + throw exception(PROJECT_TEMPLATE_CONFIG_INVALID, "状态类型不属于模板或状态编码重复"); + } + if (Boolean.TRUE.equals(status.getDefaultStatus())) { + defaultStatusCountMap.merge(status.getWorkItemType(), 1, Integer::sum); + } + } + if (itemTypes.stream().anyMatch(type -> ObjectUtil.notEqual(defaultStatusCountMap.get(type), 1))) { + throw exception(PROJECT_TEMPLATE_CONFIG_INVALID, "每种工作项必须且只能配置一个初始状态"); + } + + // 2. 校验看板列编码唯一,且看板列只关联同类型的有效状态 + Set boardCodes = new HashSet<>(); + Map statusMap = new HashMap<>(); + Map statusBoardCodeMap = new HashMap<>(); + saveReqVO.getStatuses().forEach(status -> statusMap.put(status.getCode(), status)); + for (PmsProjectTemplateSaveReqVO.BoardTemplate board : saveReqVO.getBoards()) { + if (!itemTypes.contains(board.getWorkItemType()) || !boardCodes.add(board.getCode()) + || new HashSet<>(board.getStatusCodes()).size() != board.getStatusCodes().size()) { + throw exception(PROJECT_TEMPLATE_CONFIG_INVALID, "看板列类型无效、编码重复或关联状态重复"); + } + for (String statusCode : board.getStatusCodes()) { + PmsProjectTemplateSaveReqVO.StatusTemplate status = statusMap.get(statusCode); + if (status == null || ObjectUtil.notEqual(status.getWorkItemType(), board.getWorkItemType()) + || ObjectUtil.notEqual(status.getBoardCode(), board.getCode()) + || statusBoardCodeMap.putIfAbsent(statusCode, board.getCode()) != null) { + throw exception(PROJECT_TEMPLATE_CONFIG_INVALID, "看板列与状态的类型或编码关系不一致"); + } + } + } + + // 3. 校验每个状态都归属且只归属一个已配置的看板列 + if (saveReqVO.getStatuses().stream().anyMatch(status -> !boardCodes.contains(status.getBoardCode()) + || ObjectUtil.notEqual(statusBoardCodeMap.get(status.getCode()), status.getBoardCode()))) { + throw exception(PROJECT_TEMPLATE_CONFIG_INVALID, "状态没有关联看板列或关联了多个看板列"); + } + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workbench/PmsWorkbenchService.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workbench/PmsWorkbenchService.java new file mode 100644 index 000000000..bff4c8fe7 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workbench/PmsWorkbenchService.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.pms.service.pm.workbench; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workbench.vo.PmsWorkbenchCountRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workbench.vo.PmsWorkbenchPageReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.iteration.PmsIterationDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemDO; + +/** + * PMS 工作台 Service 接口 + * + * @author 芋道源码 + */ +public interface PmsWorkbenchService { + + /** + * 获得当前用户在活跃项目中被分配的工作项分页 + * + * @param pageReqVO 分页查询 + * @param userId 用户编号 + * @return 工作项分页 + */ + PageResult getWorkbenchWorkItemPage(PmsWorkbenchPageReqVO pageReqVO, Long userId); + + /** + * 获得当前用户参与的活跃项目的未完成迭代分页 + * + * @param pageReqVO 分页查询 + * @param userId 用户编号 + * @return 迭代分页 + */ + PageResult getWorkbenchIterationPage(PmsWorkbenchPageReqVO pageReqVO, Long userId); + + /** + * 获得工作台待办数量,按工作项类型和迭代统计 + * + * @param pageReqVO 查询条件 + * @param userId 用户编号 + * @return 工作台数量 + */ + PmsWorkbenchCountRespVO getWorkbenchCount(PmsWorkbenchPageReqVO pageReqVO, Long userId); + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workbench/PmsWorkbenchServiceImpl.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workbench/PmsWorkbenchServiceImpl.java new file mode 100644 index 000000000..5da2a863a --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workbench/PmsWorkbenchServiceImpl.java @@ -0,0 +1,83 @@ +package cn.iocoder.yudao.module.pms.service.pm.workbench; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workbench.vo.PmsWorkbenchCountRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workbench.vo.PmsWorkbenchPageReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.iteration.PmsIterationDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemDO; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemTypeEnum; +import cn.iocoder.yudao.module.pms.service.pm.iteration.PmsIterationService; +import cn.iocoder.yudao.module.pms.service.pm.project.PmsProjectMemberService; +import cn.iocoder.yudao.module.pms.service.pm.workitem.PmsWorkItemService; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.util.List; +import java.util.Map; + +/** + * PMS 工作台 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class PmsWorkbenchServiceImpl implements PmsWorkbenchService { + + @Resource + private PmsWorkItemService workItemService; + @Resource + private PmsIterationService iterationService; + + @Resource + private PmsProjectMemberService projectMemberService; + + @Override + public PageResult getWorkbenchWorkItemPage(PmsWorkbenchPageReqVO pageReqVO, Long userId) { + // 1. 获得当前用户参与的活跃项目 + List projectIds = projectMemberService.getActiveProjectIdListByUserId(userId); + if (CollUtil.isEmpty(projectIds) || pageReqVO.getProjectId() != null + && !projectIds.contains(pageReqVO.getProjectId())) { + return PageResult.empty(); + } + + // 2. 查询当前用户被分配的未完成工作项分页 + return workItemService.getAssignedWorkItemPage(pageReqVO, projectIds, userId); + } + + @Override + public PageResult getWorkbenchIterationPage(PmsWorkbenchPageReqVO pageReqVO, Long userId) { + // 1. 获得当前用户参与的活跃项目 + List projectIds = projectMemberService.getActiveProjectIdListByUserId(userId); + if (CollUtil.isEmpty(projectIds) || pageReqVO.getProjectId() != null + && !projectIds.contains(pageReqVO.getProjectId())) { + return PageResult.empty(); + } + + // 2. 查询活跃项目的未完成迭代分页 + return iterationService.getUncompletedIterationPage(pageReqVO, projectIds, userId); + } + + @Override + public PmsWorkbenchCountRespVO getWorkbenchCount(PmsWorkbenchPageReqVO pageReqVO, Long userId) { + // 1. 获得当前用户参与的活跃项目 + List projectIds = projectMemberService.getActiveProjectIdListByUserId(userId); + if (CollUtil.isEmpty(projectIds) || pageReqVO.getProjectId() != null + && !projectIds.contains(pageReqVO.getProjectId())) { + return new PmsWorkbenchCountRespVO().setRequirementCount(0).setTaskCount(0) + .setDefectCount(0).setIterationCount(0); + } + + // 2. 按工作项类型和迭代统计当前用户的待办数量 + Map typeCountMap = workItemService.getAssignedWorkItemTypeCountMap(pageReqVO, projectIds, userId); + int iterationCount = iterationService.getUncompletedIterationCount(pageReqVO, projectIds, userId).intValue(); + return new PmsWorkbenchCountRespVO() + .setRequirementCount(typeCountMap.getOrDefault(PmsWorkItemTypeEnum.REQUIREMENT.getType(), 0L).intValue()) + .setTaskCount(typeCountMap.getOrDefault(PmsWorkItemTypeEnum.TASK.getType(), 0L).intValue()) + .setDefectCount(typeCountMap.getOrDefault(PmsWorkItemTypeEnum.DEFECT.getType(), 0L).intValue()) + .setIterationCount(iterationCount); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemActivityService.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemActivityService.java new file mode 100644 index 000000000..bea68fb11 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemActivityService.java @@ -0,0 +1,99 @@ +package cn.iocoder.yudao.module.pms.service.pm.workitem; + +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemActivityDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemDO; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemActivityContentEnum; + +import java.util.Collection; +import java.util.List; + +/** + * PMS 工作项动态 Service 接口 + * + * @author 芋道源码 + */ +public interface PmsWorkItemActivityService { + + /** + * 按字段记录工作项更新动态 + * + * @param oldWorkItem 更新前工作项 + * @param newWorkItem 更新后工作项 + * @param oldMemberUserIds 更新前参与人用户编号 + * @param newMemberUserIds 更新后参与人用户编号 + * @param userId 操作人编号 + */ + void createWorkItemUpdateActivities(PmsWorkItemDO oldWorkItem, PmsWorkItemDO newWorkItem, + Collection oldMemberUserIds, Collection newMemberUserIds, + Long userId); + + /** + * 字段发生变化时记录工作项动态 + * + * @param workItem 工作项 + * @param userId 操作人编号 + * @param fieldName 字段名称 + * @param oldFieldValue 旧字段值 + * @param newFieldValue 新字段值 + * @param oldValue 旧展示值 + * @param newValue 新展示值 + */ + void createWorkItemFieldActivityIfChanged(PmsWorkItemDO workItem, Long userId, String fieldName, + Object oldFieldValue, Object newFieldValue, + String oldValue, String newValue); + + /** + * 按迭代编号变化记录工作项动态 + * + * @param workItem 工作项 + * @param userId 操作人编号 + * @param oldIterationId 更新前迭代编号 + * @param newIterationId 更新后迭代编号 + */ + void createWorkItemIterationActivityIfChanged(PmsWorkItemDO workItem, Long userId, + Long oldIterationId, Long newIterationId); + + /** + * 记录一条工作项动态 + * + * @param projectId 项目编号 + * @param workItemId 工作项编号 + * @param operatorUserId 操作用户编号 + * @param content 动态内容枚举 + * @param arguments 动态内容参数 + */ + void createWorkItemActivity(Long projectId, Long workItemId, Long operatorUserId, + PmsWorkItemActivityContentEnum content, Object... arguments); + + /** + * 获得工作项的动态列表 + * + * @param workItemId 工作项编号 + * @return 动态列表 + */ + List getWorkItemActivityList(Long workItemId); + + /** + * 获得多个工作项的最近动态列表 + * + * @param workItemIds 工作项编号集合 + * @param limit 最大数量 + * @return 工作项动态列表 + */ + List getWorkItemActivityListByWorkItemIds(Collection workItemIds, int limit); + + /** + * 删除工作项的全部动态 + * + * @param workItemId 工作项编号 + */ + void deleteWorkItemActivityListByWorkItemId(Long workItemId); + + /** + * 删除项目的全部动态 + * + * @param projectId 项目编号 + */ + void deleteWorkItemActivityListByProjectId(Long projectId); + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemActivityServiceImpl.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemActivityServiceImpl.java new file mode 100644 index 000000000..98c855745 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemActivityServiceImpl.java @@ -0,0 +1,240 @@ +package cn.iocoder.yudao.module.pms.service.pm.workitem; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.iteration.PmsIterationDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemActivityDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemLabelDO; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.workitem.PmsWorkItemActivityMapper; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemActivityContentEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemDefectTypeEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemPriorityEnum; +import cn.iocoder.yudao.module.pms.service.pm.iteration.PmsIterationService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; + +/** + * PMS 工作项动态 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class PmsWorkItemActivityServiceImpl implements PmsWorkItemActivityService { + + @Resource + private PmsWorkItemActivityMapper activityMapper; + + @Lazy // 延迟加载,避免循环依赖 + @Resource + private PmsWorkItemService workItemService; + @Resource + private PmsWorkItemLabelService workItemLabelService; + @Lazy // 延迟加载,避免循环依赖 + @Resource + private PmsIterationService iterationService; + + @Resource + private AdminUserApi adminUserApi; + + @Override + public void createWorkItemUpdateActivities(PmsWorkItemDO oldWorkItem, PmsWorkItemDO newWorkItem, + Collection oldMemberUserIds, Collection newMemberUserIds, + Long userId) { + // 1.1 收集动态展示需要的用户、标签、迭代和关联工作项编号 + Set userIds = new LinkedHashSet<>(); + userIds.addAll(oldMemberUserIds); + userIds.addAll(newMemberUserIds); + userIds.addAll(convertSet(Arrays.asList(oldWorkItem.getAssigneeUserId(), newWorkItem.getAssigneeUserId()))); + Map userMap = adminUserApi.getUserMap(userIds); + Set labelIds = new LinkedHashSet<>(); + labelIds.addAll(convertSet(oldWorkItem.getLabelIds())); + labelIds.addAll(convertSet(newWorkItem.getLabelIds())); + // 1.2 批量查询动态展示需要的关联数据 + Map labelMap = workItemLabelService.getWorkItemLabelMap(labelIds); + Set iterationIds = convertSet(Arrays.asList(oldWorkItem.getIterationId(), newWorkItem.getIterationId())); + Map iterationMap = iterationService.getIterationMap(iterationIds); + Set relatedWorkItemIds = convertSet(Arrays.asList(oldWorkItem.getParentId(), newWorkItem.getParentId(), + oldWorkItem.getRelatedRequirementId(), newWorkItem.getRelatedRequirementId())); + Map relatedWorkItemMap = convertMap(workItemService.getWorkItemList(relatedWorkItemIds), + PmsWorkItemDO::getId); + + // 2.1 记录每个发生变化的字段,便于动态列表准确还原修改内容 + createWorkItemActivityIfChanged(oldWorkItem, userId, PmsWorkItemActivityContentEnum.WORK_ITEM_NAME_UPDATED, + oldWorkItem.getName(), newWorkItem.getName(), oldWorkItem.getName(), newWorkItem.getName()); + if (ObjectUtil.notEqual(oldWorkItem.getDescription(), newWorkItem.getDescription())) { + createWorkItemActivity(oldWorkItem.getProjectId(), oldWorkItem.getId(), userId, + PmsWorkItemActivityContentEnum.WORK_ITEM_DESCRIPTION_UPDATED); + } + createWorkItemFieldActivityIfChanged(oldWorkItem, userId, "优先级", + oldWorkItem.getPriority(), newWorkItem.getPriority(), + getPriorityName(oldWorkItem.getPriority()), getPriorityName(newWorkItem.getPriority())); + createWorkItemFieldActivityIfChanged(oldWorkItem, userId, "负责人", + oldWorkItem.getAssigneeUserId(), newWorkItem.getAssigneeUserId(), + getActivityValue(oldWorkItem.getAssigneeUserId(), userMap, AdminUserRespDTO::getNickname), + getActivityValue(newWorkItem.getAssigneeUserId(), userMap, AdminUserRespDTO::getNickname)); + createWorkItemActivityIfChanged(oldWorkItem, userId, PmsWorkItemActivityContentEnum.WORK_ITEM_MEMBERS_UPDATED, + oldMemberUserIds, newMemberUserIds, + getActivityValues(oldMemberUserIds, userMap, AdminUserRespDTO::getNickname), + getActivityValues(newMemberUserIds, userMap, AdminUserRespDTO::getNickname)); + createWorkItemFieldActivityIfChanged(oldWorkItem, userId, "所属迭代", + oldWorkItem.getIterationId(), newWorkItem.getIterationId(), + getActivityValue(oldWorkItem.getIterationId(), iterationMap, PmsIterationDO::getName), + getActivityValue(newWorkItem.getIterationId(), iterationMap, PmsIterationDO::getName)); + createWorkItemActivityIfChanged(oldWorkItem, userId, PmsWorkItemActivityContentEnum.WORK_ITEM_PARENT_UPDATED, + oldWorkItem.getParentId(), newWorkItem.getParentId(), + getActivityValue(oldWorkItem.getParentId(), relatedWorkItemMap, PmsWorkItemDO::getName), + getActivityValue(newWorkItem.getParentId(), relatedWorkItemMap, PmsWorkItemDO::getName)); + createWorkItemFieldActivityIfChanged(oldWorkItem, userId, "关联需求", + oldWorkItem.getRelatedRequirementId(), newWorkItem.getRelatedRequirementId(), + getActivityValue(oldWorkItem.getRelatedRequirementId(), relatedWorkItemMap, PmsWorkItemDO::getName), + getActivityValue(newWorkItem.getRelatedRequirementId(), relatedWorkItemMap, PmsWorkItemDO::getName)); + createWorkItemFieldActivityIfChanged(oldWorkItem, userId, "缺陷类型", + oldWorkItem.getDefectType(), newWorkItem.getDefectType(), + getDefectTypeName(oldWorkItem.getDefectType()), getDefectTypeName(newWorkItem.getDefectType())); + createWorkItemFieldActivityIfChanged(oldWorkItem, userId, "开始时间", + oldWorkItem.getStartTime(), newWorkItem.getStartTime(), + formatActivityTime(oldWorkItem.getStartTime()), formatActivityTime(newWorkItem.getStartTime())); + createWorkItemFieldActivityIfChanged(oldWorkItem, userId, "截止时间", + oldWorkItem.getEndTime(), newWorkItem.getEndTime(), + formatActivityTime(oldWorkItem.getEndTime()), formatActivityTime(newWorkItem.getEndTime())); + createWorkItemFieldActivityIfChanged(oldWorkItem, userId, "预估工时", + oldWorkItem.getEstimatedHours(), newWorkItem.getEstimatedHours(), + formatActivityValue(oldWorkItem.getEstimatedHours()), formatActivityValue(newWorkItem.getEstimatedHours())); + createWorkItemFieldActivityIfChanged(oldWorkItem, userId, "进度", + oldWorkItem.getProgress(), newWorkItem.getProgress(), + formatActivityValue(oldWorkItem.getProgress()), formatActivityValue(newWorkItem.getProgress())); + createWorkItemActivityIfChanged(oldWorkItem, userId, PmsWorkItemActivityContentEnum.WORK_ITEM_LABELS_UPDATED, + convertSet(oldWorkItem.getLabelIds()), convertSet(newWorkItem.getLabelIds()), + getActivityValues(oldWorkItem.getLabelIds(), labelMap, PmsWorkItemLabelDO::getName), + getActivityValues(newWorkItem.getLabelIds(), labelMap, PmsWorkItemLabelDO::getName)); + createWorkItemActivityIfChanged(oldWorkItem, userId, PmsWorkItemActivityContentEnum.WORK_ITEM_ATTACHMENTS_UPDATED, + oldWorkItem.getFileUrls(), newWorkItem.getFileUrls(), + getActivityFileNames(oldWorkItem.getFileUrls()), getActivityFileNames(newWorkItem.getFileUrls())); + } + + @Override + public void createWorkItemFieldActivityIfChanged(PmsWorkItemDO workItem, Long userId, String fieldName, + Object oldFieldValue, Object newFieldValue, + String oldValue, String newValue) { + if (Objects.equals(oldFieldValue, newFieldValue)) { + return; + } + createWorkItemActivity(workItem.getProjectId(), workItem.getId(), userId, + PmsWorkItemActivityContentEnum.WORK_ITEM_FIELD_UPDATED, fieldName, oldValue, newValue); + } + + @Override + public void createWorkItemIterationActivityIfChanged(PmsWorkItemDO workItem, Long userId, + Long oldIterationId, Long newIterationId) { + Map iterationMap = iterationService.getIterationMap( + convertSet(Arrays.asList(oldIterationId, newIterationId))); + createWorkItemFieldActivityIfChanged(workItem, userId, "所属迭代", oldIterationId, newIterationId, + getActivityValue(oldIterationId, iterationMap, PmsIterationDO::getName), + getActivityValue(newIterationId, iterationMap, PmsIterationDO::getName)); + } + + private void createWorkItemActivityIfChanged(PmsWorkItemDO workItem, Long userId, + PmsWorkItemActivityContentEnum content, + Object oldFieldValue, Object newFieldValue, + String oldValue, String newValue) { + if (Objects.equals(oldFieldValue, newFieldValue)) { + return; + } + createWorkItemActivity(workItem.getProjectId(), workItem.getId(), userId, content, oldValue, newValue); + } + + private static String getPriorityName(Integer priority) { + PmsWorkItemPriorityEnum priorityEnum = PmsWorkItemPriorityEnum.valueOf(priority); + return priorityEnum == null ? formatActivityValue(priority) : priorityEnum.getName(); + } + + private static String getDefectTypeName(Integer defectType) { + PmsWorkItemDefectTypeEnum defectTypeEnum = PmsWorkItemDefectTypeEnum.valueOf(defectType); + return defectTypeEnum == null ? formatActivityValue(defectType) : defectTypeEnum.getName(); + } + + private static String formatActivityTime(LocalDateTime time) { + return time == null ? "无" : time.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")); + } + + private static String formatActivityValue(Object value) { + return value == null ? "无" : String.valueOf(value); + } + + private static String getActivityValue(Long id, Map itemMap, Function nameFunction) { + if (id == null) { + return "无"; + } + T item = itemMap.get(id); + return item == null ? "#" + id : nameFunction.apply(item); + } + + private static String getActivityValues(Collection ids, Map itemMap, + Function nameFunction) { + if (CollUtil.isEmpty(ids)) { + return "无"; + } + return CollUtil.join(convertList(ids, id -> getActivityValue(id, itemMap, nameFunction)), "、"); + } + + private static String getActivityFileNames(List fileUrls) { + if (CollUtil.isEmpty(fileUrls)) { + return "无"; + } + return CollUtil.join(convertList(fileUrls, fileUrl -> StrUtil.subAfter(fileUrl, '/', true)), "、"); + } + + @Override + public void createWorkItemActivity(Long projectId, Long workItemId, Long operatorUserId, + PmsWorkItemActivityContentEnum content, Object... arguments) { + activityMapper.insert(new PmsWorkItemActivityDO().setProjectId(projectId).setWorkItemId(workItemId) + .setOperatorUserId(operatorUserId).setContent(content.format(arguments))); + } + + @Override + public List getWorkItemActivityList(Long workItemId) { + return activityMapper.selectListByWorkItemId(workItemId); + } + + @Override + public List getWorkItemActivityListByWorkItemIds(Collection workItemIds, int limit) { + if (CollUtil.isEmpty(workItemIds)) { + return Collections.emptyList(); + } + return activityMapper.selectListByWorkItemIds(workItemIds, limit); + } + + @Override + public void deleteWorkItemActivityListByWorkItemId(Long workItemId) { + activityMapper.deleteByWorkItemId(workItemId); + } + + @Override + public void deleteWorkItemActivityListByProjectId(Long projectId) { + activityMapper.deleteByProjectId(projectId); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemBoardService.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemBoardService.java new file mode 100644 index 000000000..f64087eb5 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemBoardService.java @@ -0,0 +1,59 @@ +package cn.iocoder.yudao.module.pms.service.pm.workitem; + +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemBoardDO; + +import java.util.Collection; +import java.util.List; + +/** + * PMS 工作项看板列 Service 接口 + * + * @author 芋道源码 + */ +public interface PmsWorkItemBoardService { + + /** + * 获得项目指定工作项类型的看板列 + * + * @param projectId 项目编号 + * @param workItemType 工作项类型 + * @return 看板列列表 + */ + List getWorkItemBoardList(Long projectId, Integer workItemType); + + /** + * 创建看板列 + * + * @param board 看板列 + */ + void createWorkItemBoard(PmsWorkItemBoardDO board); + + /** + * 批量创建看板列 + * + * @param boards 看板列列表 + */ + void createWorkItemBoardList(Collection boards); + + /** + * 批量更新看板列 + * + * @param boards 看板列列表 + */ + void updateWorkItemBoardList(Collection boards); + + /** + * 删除指定看板列 + * + * @param ids 看板列编号集合 + */ + void deleteWorkItemBoardList(Collection ids); + + /** + * 删除项目的全部看板列 + * + * @param projectId 项目编号 + */ + void deleteWorkItemBoardListByProjectId(Long projectId); + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemBoardServiceImpl.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemBoardServiceImpl.java new file mode 100644 index 000000000..f022d0aa3 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemBoardServiceImpl.java @@ -0,0 +1,64 @@ +package cn.iocoder.yudao.module.pms.service.pm.workitem; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemBoardDO; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.workitem.PmsWorkItemBoardMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.util.Collection; +import java.util.List; + +/** + * PMS 工作项看板列 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class PmsWorkItemBoardServiceImpl implements PmsWorkItemBoardService { + + @Resource + private PmsWorkItemBoardMapper workItemBoardMapper; + + @Override + public List getWorkItemBoardList(Long projectId, Integer workItemType) { + return workItemBoardMapper.selectListByProjectIdAndWorkItemType(projectId, workItemType); + } + + @Override + public void createWorkItemBoard(PmsWorkItemBoardDO board) { + workItemBoardMapper.insert(board); + } + + @Override + public void createWorkItemBoardList(Collection boards) { + if (CollUtil.isEmpty(boards)) { + return; + } + workItemBoardMapper.insertBatch(boards); + } + + @Override + public void updateWorkItemBoardList(Collection boards) { + if (CollUtil.isEmpty(boards)) { + return; + } + workItemBoardMapper.updateBatch(boards); + } + + @Override + public void deleteWorkItemBoardList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return; + } + workItemBoardMapper.deleteByIds(ids); + } + + @Override + public void deleteWorkItemBoardListByProjectId(Long projectId) { + workItemBoardMapper.deleteByProjectId(projectId); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemCommentService.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemCommentService.java new file mode 100644 index 000000000..ee31f298c --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemCommentService.java @@ -0,0 +1,64 @@ +package cn.iocoder.yudao.module.pms.service.pm.workitem; + +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.comment.PmsWorkItemCommentSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemCommentDO; + +import java.util.Collection; +import java.util.List; + +/** + * PMS 工作项评论 Service 接口 + * + * @author 芋道源码 + */ +public interface PmsWorkItemCommentService { + + /** + * 创建工作项评论或回复 + * + * @param saveReqVO 保存信息 + * @param userId 用户编号 + * @return 评论编号 + */ + Long createWorkItemComment(PmsWorkItemCommentSaveReqVO saveReqVO, Long userId); + + /** + * 更新自己的工作项评论内容 + * + * @param saveReqVO 保存信息 + * @param userId 用户编号 + */ + void updateWorkItemComment(PmsWorkItemCommentSaveReqVO saveReqVO, Long userId); + + /** + * 删除自己的工作项评论,删除主评论时同步删除全部回复 + * + * @param id 评论编号 + * @param userId 用户编号 + */ + void deleteWorkItemComment(Long id, Long userId); + + /** + * 删除工作项的全部评论,用于工作项删除时级联清理 + * + * @param workItemId 工作项编号 + */ + void deleteWorkItemCommentListByWorkItemId(Long workItemId); + + /** + * 批量删除工作项的评论,用于项目删除时级联清理 + * + * @param workItemIds 工作项编号列表 + */ + void deleteWorkItemCommentListByWorkItemIds(Collection workItemIds); + + /** + * 获得工作项的评论和回复列表 + * + * @param workItemId 工作项编号 + * @param userId 用户编号 + * @return 评论列表 + */ + List getWorkItemCommentList(Long workItemId, Long userId); + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemCommentServiceImpl.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemCommentServiceImpl.java new file mode 100644 index 000000000..415bce153 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemCommentServiceImpl.java @@ -0,0 +1,153 @@ +package cn.iocoder.yudao.module.pms.service.pm.workitem; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.comment.PmsWorkItemCommentSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemCommentDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemDO; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.workitem.PmsWorkItemCommentMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.Collection; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.WORK_ITEM_COMMENT_ACCESS_DENIED; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.WORK_ITEM_COMMENT_NOT_EXISTS; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.WORK_ITEM_COMMENT_REPLY_INVALID; +import static cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemActivityContentEnum.COMMENT_CREATED; +import static cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemActivityContentEnum.COMMENT_DELETED; +import static cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemActivityContentEnum.COMMENT_REPLIED; +import static cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemActivityContentEnum.COMMENT_UPDATED; + +/** + * PMS 工作项评论 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class PmsWorkItemCommentServiceImpl implements PmsWorkItemCommentService { + + @Resource + private PmsWorkItemCommentMapper commentMapper; + + @Resource + private PmsWorkItemService workItemService; + @Resource + private PmsWorkItemActivityService workItemActivityService; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createWorkItemComment(PmsWorkItemCommentSaveReqVO saveReqVO, Long userId) { + // 1.1 校验工作项可编辑 + PmsWorkItemDO workItem = workItemService.getWritableWorkItem(saveReqVO.getWorkItemId(), userId); + // 1.2 校验回复关系 + Long mainId = saveReqVO.getMainId() != null ? saveReqVO.getMainId() : PmsWorkItemCommentDO.MAIN_ID_ROOT; + if (ObjectUtil.notEqual(PmsWorkItemCommentDO.MAIN_ID_ROOT, mainId)) { + PmsWorkItemCommentDO mainComment = validateCommentExists(mainId); + if (ObjectUtil.notEqual(saveReqVO.getWorkItemId(), mainComment.getWorkItemId()) + || ObjectUtil.notEqual(PmsWorkItemCommentDO.MAIN_ID_ROOT, mainComment.getMainId())) { + throw exception(WORK_ITEM_COMMENT_REPLY_INVALID); + } + } + + // 2.1 创建评论 + PmsWorkItemCommentDO comment = BeanUtils.toBean(saveReqVO, PmsWorkItemCommentDO.class) + .setUserId(userId).setMainId(mainId); + commentMapper.insert(comment); + // 2.2 记录评论动态 + workItemActivityService.createWorkItemActivity(workItem.getProjectId(), workItem.getId(), userId, + ObjectUtil.equal(PmsWorkItemCommentDO.MAIN_ID_ROOT, mainId) ? COMMENT_CREATED : COMMENT_REPLIED); + return comment.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateWorkItemComment(PmsWorkItemCommentSaveReqVO saveReqVO, Long userId) { + // 1.1 校验评论存在 + PmsWorkItemCommentDO comment = validateCommentExists(saveReqVO.getId()); + // 1.2 校验工作项可编辑 + PmsWorkItemDO workItem = workItemService.getWritableWorkItem(comment.getWorkItemId(), userId); + // 1.3 校验评论属于当前用户 + validateCommentOwner(comment, userId); + + // 2.1 更新评论内容 + commentMapper.updateById(BeanUtils.toBean(saveReqVO, PmsWorkItemCommentDO.class).setId(comment.getId())); + // 2.2 记录评论更新动态 + workItemActivityService.createWorkItemActivity( + workItem.getProjectId(), workItem.getId(), userId, COMMENT_UPDATED); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteWorkItemComment(Long id, Long userId) { + // 1.1 校验评论存在 + PmsWorkItemCommentDO comment = validateCommentExists(id); + // 1.2 校验工作项可编辑 + PmsWorkItemDO workItem = workItemService.getWritableWorkItem(comment.getWorkItemId(), userId); + // 1.3 校验评论属于当前用户 + validateCommentOwner(comment, userId); + + // 2.1 删除主评论时同步删除全部回复 + if (PmsWorkItemCommentDO.MAIN_ID_ROOT.equals(comment.getMainId())) { + commentMapper.deleteByMainId(comment.getId()); + } + commentMapper.deleteById(comment.getId()); + // 2.2 记录评论删除动态 + workItemActivityService.createWorkItemActivity( + workItem.getProjectId(), workItem.getId(), userId, COMMENT_DELETED); + } + + @Override + public List getWorkItemCommentList(Long workItemId, Long userId) { + // 1. 校验工作项可访问 + workItemService.getWorkItem(workItemId, userId); + + // 2. 查询评论和回复 + return commentMapper.selectListByWorkItemId(workItemId); + } + + /** + * 校验工作项评论存在 + * + * @param id 评论编号 + * @return 工作项评论 + */ + private PmsWorkItemCommentDO validateCommentExists(Long id) { + PmsWorkItemCommentDO comment = commentMapper.selectById(id); + if (comment == null) { + throw exception(WORK_ITEM_COMMENT_NOT_EXISTS); + } + return comment; + } + + /** + * 校验当前用户是评论创建人 + * + * @param comment 工作项评论 + * @param userId 后台用户编号 + */ + private void validateCommentOwner(PmsWorkItemCommentDO comment, Long userId) { + if (ObjectUtil.notEqual(userId, comment.getUserId())) { + throw exception(WORK_ITEM_COMMENT_ACCESS_DENIED); + } + } + + @Override + public void deleteWorkItemCommentListByWorkItemId(Long workItemId) { + commentMapper.deleteByWorkItemId(workItemId); + } + + @Override + public void deleteWorkItemCommentListByWorkItemIds(Collection workItemIds) { + if (CollUtil.isEmpty(workItemIds)) { + return; + } + commentMapper.deleteByWorkItemIds(workItemIds); + } +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemLabelService.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemLabelService.java new file mode 100644 index 000000000..b0f5c2742 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemLabelService.java @@ -0,0 +1,74 @@ +package cn.iocoder.yudao.module.pms.service.pm.workitem; + +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.label.PmsWorkItemLabelSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemLabelDO; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * PMS 工作项标签 Service 接口 + * + * @author 芋道源码 + */ +public interface PmsWorkItemLabelService { + + /** + * 创建工作项标签 + * + * @param saveReqVO 保存信息 + * @return 标签编号 + */ + Long createWorkItemLabel(PmsWorkItemLabelSaveReqVO saveReqVO); + + /** + * 更新工作项标签 + * + * @param saveReqVO 保存信息 + */ + void updateWorkItemLabel(PmsWorkItemLabelSaveReqVO saveReqVO); + + /** + * 删除工作项标签,工作项中的历史编号在展示时自动忽略 + * + * @param id 标签编号 + */ + void deleteWorkItemLabel(Long id); + + /** + * 获得匹配名称的工作项标签列表 + * + * @param name 标签名称,模糊匹配 + * @return 标签列表 + */ + List getWorkItemLabelList(String name); + + /** + * 获得工作项标签列表 + * + * @param ids 标签编号集合 + * @return 标签列表 + */ + List getWorkItemLabelList(Collection ids); + + /** + * 获得工作项标签 Map + * + * @param ids 标签编号集合 + * @return 工作项标签 Map + */ + default Map getWorkItemLabelMap(Collection ids) { + return convertMap(getWorkItemLabelList(ids), PmsWorkItemLabelDO::getId); + } + + /** + * 校验工作项标签编号都存在 + * + * @param ids 标签编号集合 + */ + void validateWorkItemLabelIds(Collection ids); + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemLabelServiceImpl.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemLabelServiceImpl.java new file mode 100644 index 000000000..4e8b70e79 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemLabelServiceImpl.java @@ -0,0 +1,95 @@ +package cn.iocoder.yudao.module.pms.service.pm.workitem; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.label.PmsWorkItemLabelSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemLabelDO; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.workitem.PmsWorkItemLabelMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.WORK_ITEM_LABELS_INVALID; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.WORK_ITEM_LABEL_NOT_EXISTS; + +/** + * PMS 工作项标签 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class PmsWorkItemLabelServiceImpl implements PmsWorkItemLabelService { + + @Resource + private PmsWorkItemLabelMapper labelMapper; + + @Override + public Long createWorkItemLabel(PmsWorkItemLabelSaveReqVO saveReqVO) { + PmsWorkItemLabelDO label = BeanUtils.toBean(saveReqVO, PmsWorkItemLabelDO.class) + .setName(StrUtil.trim(saveReqVO.getName())).setColor(StrUtil.trim(saveReqVO.getColor())); + labelMapper.insert(label); + return label.getId(); + } + + @Override + public void updateWorkItemLabel(PmsWorkItemLabelSaveReqVO saveReqVO) { + // 1. 校验标签存在 + validateLabelExists(saveReqVO.getId()); + + // 2. 更新标签 + labelMapper.updateById(BeanUtils.toBean(saveReqVO, PmsWorkItemLabelDO.class) + .setName(StrUtil.trim(saveReqVO.getName())).setColor(StrUtil.trim(saveReqVO.getColor()))); + } + + @Override + public void deleteWorkItemLabel(Long id) { + // 1. 校验标签存在 + validateLabelExists(id); + + // 2. 删除标签;工作项中的历史编号在展示时自动忽略 + labelMapper.deleteById(id); + } + + @Override + public List getWorkItemLabelList(String name) { + return labelMapper.selectListByName(name); + } + + @Override + public List getWorkItemLabelList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + return labelMapper.selectByIds(ids); + } + + @Override + public void validateWorkItemLabelIds(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return; + } + if (labelMapper.selectByIds(ids).size() != ids.size()) { + throw exception(WORK_ITEM_LABELS_INVALID); + } + } + + /** + * 校验工作项标签存在 + * + * @param id 标签编号 + */ + private void validateLabelExists(Long id) { + if (labelMapper.selectById(id) == null) { + throw exception(WORK_ITEM_LABEL_NOT_EXISTS); + } + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemService.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemService.java new file mode 100644 index 000000000..d26248d84 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemService.java @@ -0,0 +1,296 @@ +package cn.iocoder.yudao.module.pms.service.pm.workitem; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workbench.vo.PmsWorkbenchPageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status.PmsWorkItemStatusUpdateReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemBoardReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemBoardRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemImportExcelVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemImportRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemIterationUpdateReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemNameUpdateReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemPageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemPlanningSortReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemSaveReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemSortReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemDO; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * PMS 工作项 Service 接口 + * + * @author 芋道源码 + */ +public interface PmsWorkItemService { + + /** + * 创建工作项 + * + * @param saveReqVO 保存信息 + * @param userId 用户编号 + * @return 工作项编号 + */ + Long createWorkItem(PmsWorkItemSaveReqVO saveReqVO, Long userId); + + /** + * 更新工作项 + * + * @param saveReqVO 保存信息 + * @param userId 用户编号 + */ + void updateWorkItem(PmsWorkItemSaveReqVO saveReqVO, Long userId); + + /** + * 更新工作项名称 + * + * @param updateReqVO 名称更新信息 + * @param userId 用户编号 + */ + void updateWorkItemName(PmsWorkItemNameUpdateReqVO updateReqVO, Long userId); + + /** + * 更新工作项状态 + * + * @param updateReqVO 状态更新信息 + * @param userId 用户编号 + */ + void updateWorkItemStatus(PmsWorkItemStatusUpdateReqVO updateReqVO, Long userId); + + /** + * 更新指定状态下全部工作项的语义状态 + * + * @param statusId 状态编号 + * @param statusType 语义状态 + */ + void updateWorkItemStatusTypeByStatusId(Long statusId, Integer statusType); + + /** + * 获得指定状态下的工作项数量 + * + * @param statusId 状态编号 + * @return 工作项数量 + */ + Long getWorkItemCountByStatusId(Long statusId); + + /** + * 将指定状态下的工作项迁移到目标状态 + * + * @param sourceStatusId 原状态编号 + * @param targetStatusId 目标状态编号 + * @param targetStatus 目标语义状态 + */ + void transferWorkItemStatus(Long sourceStatusId, Long targetStatusId, Integer targetStatus); + + /** + * 规划工作项到迭代,或移回待规划 + * + * @param updateReqVO 所属迭代更新信息 + * @param userId 用户编号 + */ + void updateWorkItemIteration(PmsWorkItemIterationUpdateReqVO updateReqVO, Long userId); + + /** + * 更新看板列内工作项顺序 + * + * @param sortReqVO 排序信息 + * @param userId 用户编号 + */ + void updateWorkItemSort(PmsWorkItemSortReqVO sortReqVO, Long userId); + + /** + * 更新待规划或迭代内工作项顺序 + * + * @param sortReqVO 排序信息 + * @param userId 用户编号 + */ + void updateWorkItemPlanningSort(PmsWorkItemPlanningSortReqVO sortReqVO, Long userId); + + /** + * 归档工作项 + * + * @param id 工作项编号 + * @param userId 用户编号 + */ + void archiveWorkItem(Long id, Long userId); + + /** + * 将工作项移入回收站 + * + * @param id 工作项编号 + * @param userId 用户编号 + */ + void recycleWorkItem(Long id, Long userId); + + /** + * 恢复工作项 + * + * @param id 工作项编号 + * @param userId 用户编号 + */ + void restoreWorkItem(Long id, Long userId); + + /** + * 彻底删除回收站中的工作项 + * + * @param id 工作项编号 + * @param userId 用户编号 + */ + void deleteWorkItem(Long id, Long userId); + + /** + * 删除项目的全部工作项、参与人及状态 + * + * @param projectId 项目编号 + */ + void deleteWorkItemListByProjectId(Long projectId); + + /** + * 清空工作项的所属迭代 + * + * @param iterationId 迭代编号 + */ + void clearWorkItemIterationId(Long iterationId); + + /** + * 获得工作项 + * + * @param id 工作项编号 + * @param userId 用户编号 + * @return 工作项 + */ + PmsWorkItemDO getWorkItem(Long id, Long userId); + + /** + * 获得可编辑的正常工作项 + * + * @param id 工作项编号 + * @param userId 用户编号 + * @return 工作项 + */ + PmsWorkItemDO getWritableWorkItem(Long id, Long userId); + + /** + * 获得工作项分页 + * + * @param pageReqVO 分页查询 + * @param userId 用户编号 + * @return 工作项分页 + */ + PageResult getWorkItemPage(PmsWorkItemPageReqVO pageReqVO, Long userId); + + /** + * 获得项目工作项语义状态数量 Map + * + * @param projectIds 项目编号集合 + * @return 项目编号到状态数量 Map + */ + Map> getProjectWorkItemStatusCountMap(Collection projectIds); + + /** + * 获得工作项列表 + * + * @param pageReqVO 查询条件 + * @param userId 用户编号 + * @return 工作项列表 + */ + List getWorkItemList(PmsWorkItemPageReqVO pageReqVO, Long userId); + + /** + * 获得项目的正常工作项列表 + * + * @param projectId 项目编号 + * @return 工作项列表 + */ + List getActiveWorkItemListByProjectId(Long projectId); + + /** + * 获得迭代的正常工作项列表 + * + * @param iterationId 迭代编号 + * @return 工作项列表 + */ + List getActiveWorkItemListByIterationId(Long iterationId); + + /** + * 获得迭代工作项语义状态数量 Map + * + * @param iterationIds 迭代编号集合 + * @return 迭代编号到状态数量 Map + */ + Map> getIterationWorkItemStatusCountMap(Collection iterationIds); + + /** + * 获得分配给用户的未完成工作项分页 + * + * @param pageReqVO 工作台分页条件 + * @param projectIds 可访问项目编号集合 + * @param userId 用户编号 + * @return 工作项分页 + */ + PageResult getAssignedWorkItemPage(PmsWorkbenchPageReqVO pageReqVO, + Collection projectIds, Long userId); + + /** + * 获得分配给用户的未完成工作项类型数量 Map + * + * @param pageReqVO 工作台查询条件 + * @param projectIds 可访问项目编号集合 + * @param userId 用户编号 + * @return 工作项类型与数量 Map + */ + Map getAssignedWorkItemTypeCountMap(PmsWorkbenchPageReqVO pageReqVO, + Collection projectIds, Long userId); + + /** + * 导入工作项 + * + * @param projectId 项目编号 + * @param workItemType 工作项类型 + * @param importRows 导入数据 + * @param userId 用户编号 + * @return 导入结果 + */ + PmsWorkItemImportRespVO importWorkItemList(Long projectId, Integer workItemType, + List importRows, Long userId); + + /** + * 获得工作项看板 + * + * @param queryReqVO 看板查询 + * @param userId 用户编号 + * @return 看板列列表 + */ + List getWorkItemBoard(PmsWorkItemBoardReqVO queryReqVO, Long userId); + + /** + * 获得工作项参与人用户编号 Map + * + * @param workItemIds 工作项编号集合 + * @return 工作项参与人用户编号 Map + */ + Map> getWorkItemMemberUserIdListMap(Collection workItemIds); + + /** + * 获得指定编号的工作项列表 + * + * @param ids 工作项编号集合 + * @return 工作项列表 + */ + List getWorkItemList(Collection ids); + + /** + * 获得工作项 Map + * + * @param ids 工作项编号集合 + * @return 工作项 Map + */ + default Map getWorkItemMap(Collection ids) { + return convertMap(getWorkItemList(ids), PmsWorkItemDO::getId); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemServiceImpl.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemServiceImpl.java new file mode 100644 index 000000000..73825968d --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemServiceImpl.java @@ -0,0 +1,955 @@ +package cn.iocoder.yudao.module.pms.service.pm.workitem; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.common.util.validation.ValidationUtils; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workbench.vo.PmsWorkbenchPageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status.PmsWorkItemStatusRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status.PmsWorkItemStatusUpdateReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemBoardReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemBoardRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemImportExcelVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemImportRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemIterationUpdateReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemNameUpdateReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemPageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemPlanningSortReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemSaveReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemSortReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.worklog.PmsWorkItemWorkLogSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.iteration.PmsIterationDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemBoardDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemLabelDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemMemberDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemStatusDO; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.workitem.PmsWorkItemMapper; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.workitem.PmsWorkItemMemberMapper; +import cn.iocoder.yudao.module.pms.enums.pm.project.PmsProjectStatusEnum; +import cn.iocoder.yudao.module.pms.enums.pm.project.PmsProjectTypeEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemActivityContentEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemLifecycleStatusEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemPriorityEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemStatusTypeEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemTypeEnum; +import cn.iocoder.yudao.module.pms.service.pm.iteration.PmsIterationService; +import cn.iocoder.yudao.module.pms.service.pm.project.PmsProjectMemberService; +import cn.iocoder.yudao.module.system.api.notify.NotifyMessageSendApi; +import cn.iocoder.yudao.module.system.api.notify.dto.NotifySendSingleToUserReqDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import com.google.common.collect.Maps; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMultiMap; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.PROJECT_STATUS_INVALID; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.WORK_ITEM_DEFECT_TYPE_INVALID; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.WORK_ITEM_DELETE_STATUS_INVALID; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.WORK_ITEM_ITERATION_INVALID; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.WORK_ITEM_LIFECYCLE_STATUS_INVALID; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.WORK_ITEM_NOT_EXISTS; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.WORK_ITEM_PARENT_INVALID; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.WORK_ITEM_RELATED_REQUIREMENT_INVALID; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.WORK_ITEM_SORT_INVALID; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.WORK_ITEM_TYPE_INVALID; +import static cn.iocoder.yudao.module.pms.enums.MessageTemplateConstants.WORK_ITEM_ASSIGNED; + +/** + * PMS 工作项 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +@Slf4j +public class PmsWorkItemServiceImpl implements PmsWorkItemService { + + @Resource + private PmsWorkItemMapper workItemMapper; + + @Resource + private PmsWorkItemMemberMapper workItemMemberMapper; + @Lazy // 延迟加载,避免循环依赖 + @Resource + private PmsWorkItemCommentService workItemCommentService; + @Lazy // 延迟加载,避免循环依赖 + @Resource + private PmsWorkItemWorkLogService workItemWorkLogService; + @Resource + private PmsWorkItemUserSortService workItemUserSortService; + @Resource + private PmsProjectMemberService projectMemberService; + @Resource + private PmsIterationService iterationService; + @Lazy // 延迟加载,避免循环依赖 + @Resource + private PmsWorkItemStatusService workItemStatusService; + @Resource + private PmsWorkItemLabelService workItemLabelService; + @Lazy // 延迟加载,避免循环依赖 + @Resource + private PmsWorkItemActivityService workItemActivityService; + + @Resource + private NotifyMessageSendApi notifyMessageSendApi; + @Resource + private AdminUserApi adminUserApi; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createWorkItem(PmsWorkItemSaveReqVO saveReqVO, Long userId) { + // 1.1 校验项目可编辑 + PmsProjectDO project = projectMemberService.validateProjectWritable(saveReqVO.getProjectId(), userId); + // 1.2 校验项目处于进行中 + validateActiveProject(project); + // 1.3 校验工作项类型可用 + validateWorkItemType(project, saveReqVO.getType()); + // 1.4 校验工作项关联关系 + validateWorkItemRelations(null, project, saveReqVO.getType(), saveReqVO, userId); + // 1.5 校验工作项参与人 + validateWorkItemMembers(project.getId(), saveReqVO, userId); + // 1.6 校验工作项标签 + workItemLabelService.validateWorkItemLabelIds(saveReqVO.getLabelIds()); + + // 2. 创建工作项,生命周期初始状态由后端控制 + PmsWorkItemStatusDO defaultStatus = workItemStatusService + .getDefaultWorkItemStatus(project.getId(), saveReqVO.getType()); + Integer maxSerialNumber = workItemMapper.selectMaxSerialNumberByProjectId(project.getId()); + Integer maxSort = workItemMapper.selectMaxSortByStatusId(defaultStatus.getId()); + PmsWorkItemDO workItem = BeanUtils.toBean(saveReqVO, PmsWorkItemDO.class) + .setName(saveReqVO.getName().trim()) + .setSerialNumber(maxSerialNumber == null ? 1 : maxSerialNumber + 1) + .setStatusId(defaultStatus.getId()).setStatus(defaultStatus.getStatusType()) + .setLifecycleStatus(PmsWorkItemLifecycleStatusEnum.ACTIVE.getStatus()) + .setSort(maxSort == null ? 1 : maxSort + 1); + workItemMapper.insert(workItem); + + // 3.1 保存工作项参与人 + saveWorkItemMembers(workItem.getId(), project.getId(), getMemberUserIds(saveReqVO, userId)); + // 3.2 记录工作项创建动态 + workItemActivityService.createWorkItemActivity( + project.getId(), workItem.getId(), userId, PmsWorkItemActivityContentEnum.WORK_ITEM_CREATED); + + // 4.1 创建内联子工作项,沿用父项类型、迭代和看板状态 + List childWorkItemNames = saveReqVO.getChildWorkItemNames(); + if (CollUtil.isNotEmpty(childWorkItemNames)) { + for (String childWorkItemName : childWorkItemNames) { + PmsWorkItemSaveReqVO childSaveReqVO = new PmsWorkItemSaveReqVO().setProjectId(project.getId()) + .setType(workItem.getType()).setName(childWorkItemName) + .setPriority(PmsWorkItemPriorityEnum.MEDIUM.getPriority()).setProgress(0) + .setIterationId(workItem.getIterationId()).setParentId(workItem.getId()); + Long childWorkItemId = createWorkItem(childSaveReqVO, userId); + // 子工作项创建后同步父项状态和排序,保持看板中的位置一致 + workItemMapper.updateById(new PmsWorkItemDO().setId(childWorkItemId) + .setStatusId(workItem.getStatusId()).setStatus(workItem.getStatus()).setSort(workItem.getSort())); + } + } + // 4.2 创建时登记工时 + if (saveReqVO.getActualHours() != null) { + workItemWorkLogService.createWorkItemWorkLog(new PmsWorkItemWorkLogSaveReqVO() + .setWorkItemId(workItem.getId()).setActualHours(saveReqVO.getActualHours()) + .setRemainingHours(saveReqVO.getRemainingHours()), userId); + } + + // 5. 通知工作项负责人 + sendWorkItemAssignedNotify(workItem.getAssigneeUserId(), userId, project.getId(), + project.getName(), workItem.getSerialNumber(), workItem.getName()); + return workItem.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateWorkItem(PmsWorkItemSaveReqVO saveReqVO, Long userId) { + // 1.1 校验工作项存在 + PmsWorkItemDO workItem = validateWorkItemExists(saveReqVO.getId()); + // 1.2 校验工作项处于正常状态 + validateActiveWorkItem(workItem); + // 1.3 校验项目可编辑 + PmsProjectDO project = projectMemberService.validateProjectWritable(workItem.getProjectId(), userId); + // 1.4 校验项目处于进行中 + validateActiveProject(project); + // 1.5 校验工作项关联关系 + validateWorkItemRelations(workItem.getId(), project, workItem.getType(), saveReqVO, userId); + // 1.6 校验工作项参与人 + validateWorkItemMembers(project.getId(), saveReqVO, userId); + // 1.7 校验工作项标签 + workItemLabelService.validateWorkItemLabelIds(saveReqVO.getLabelIds()); + Set oldMemberUserIds = convertSet(workItemMemberMapper.selectListByWorkItemIds( + Collections.singleton(workItem.getId())), PmsWorkItemMemberDO::getUserId); + Set newMemberUserIds = getMemberUserIds(saveReqVO, userId); + + // 2. 更新工作项业务字段,不允许表单修改类型和生命周期状态 + PmsWorkItemDO updateObj = BeanUtils.toBean(saveReqVO, PmsWorkItemDO.class) + .setProjectId(null).setType(null).setName(saveReqVO.getName().trim()); + workItemMapper.updateForEdit(updateObj); + + // 3.1 重建工作项参与人关系 + workItemMemberMapper.deleteByWorkItemId(workItem.getId()); + saveWorkItemMembers(workItem.getId(), project.getId(), newMemberUserIds); + // 3.2 按字段记录工作项更新动态 + workItemActivityService.createWorkItemUpdateActivities(workItem, updateObj, + oldMemberUserIds, newMemberUserIds, userId); + + // 4. 负责人发生变化时通知新负责人 + if (ObjectUtil.notEqual(workItem.getAssigneeUserId(), saveReqVO.getAssigneeUserId())) { + sendWorkItemAssignedNotify(saveReqVO.getAssigneeUserId(), userId, project.getId(), + project.getName(), workItem.getSerialNumber(), updateObj.getName()); + } + } + + /** + * 发送工作项指派站内信。通知配置异常不能阻断工作项主业务 + */ + private void sendWorkItemAssignedNotify(Long receiverUserId, Long operatorUserId, Long projectId, + String projectName, Integer serialNumber, String workItemName) { + if (receiverUserId == null || receiverUserId.equals(operatorUserId)) { + return; + } + Map templateParams = Maps.newHashMapWithExpectedSize(4); + templateParams.put("projectName", projectName); + templateParams.put("serialNumber", serialNumber); + templateParams.put("workItemName", workItemName); + // 工作项通知跳转到项目详情的任务页,与前端项目详情路由及 tabs 参数保持一致 + templateParams.put("route", "/pms/pm/project/detail/" + projectId + "?tabs=task"); + try { + notifyMessageSendApi.sendSingleMessageToAdmin(new NotifySendSingleToUserReqDTO() + .setUserId(receiverUserId).setTemplateCode(WORK_ITEM_ASSIGNED) + .setTemplateParams(templateParams)).checkError(); + } catch (RuntimeException ex) { + log.warn("[sendWorkItemAssignedNotify][向用户({})发送工作项指派站内信失败,参数为({})]", + receiverUserId, templateParams, ex); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateWorkItemName(PmsWorkItemNameUpdateReqVO updateReqVO, Long userId) { + // 1. 校验工作项可编辑 + PmsWorkItemDO workItem = getWritableWorkItem(updateReqVO.getId(), userId); + + // 2.1 更新工作项名称 + String name = updateReqVO.getName().trim(); + workItemMapper.updateById(BeanUtils.toBean(updateReqVO, PmsWorkItemDO.class) + .setId(workItem.getId()).setName(name)); + // 2.2 记录工作项名称更新动态 + workItemActivityService.createWorkItemActivity( + workItem.getProjectId(), workItem.getId(), userId, PmsWorkItemActivityContentEnum.WORK_ITEM_NAME_UPDATED, + workItem.getName(), name); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateWorkItemStatus(PmsWorkItemStatusUpdateReqVO updateReqVO, Long userId) { + // 1.1 校验工作项存在 + PmsWorkItemDO workItem = validateWorkItemExists(updateReqVO.getId()); + // 1.2 校验工作项处于正常状态 + validateActiveWorkItem(workItem); + // 1.3 校验项目可编辑 + PmsProjectDO project = projectMemberService.validateProjectWritable(workItem.getProjectId(), userId); + // 1.4 校验项目处于进行中 + validateActiveProject(project); + // 1.5 校验目标状态属于当前项目和工作项类型 + PmsWorkItemStatusDO status = workItemStatusService.validateWorkItemStatus(updateReqVO.getStatusId(), + workItem.getProjectId(), workItem.getType()); + + // 2.1 更新看板状态、语义状态和显示顺序 + Integer maxSort = workItemMapper.selectMaxSortByStatusId(status.getId()); + workItemMapper.updateStatusAndSortById(workItem.getId(), status.getId(), status.getStatusType(), + maxSort == null ? 1 : maxSort + 1); + // 2.2 记录工作项状态更新动态 + workItemActivityService.createWorkItemActivity( + project.getId(), workItem.getId(), userId, PmsWorkItemActivityContentEnum.WORK_ITEM_STATUS_UPDATED, + workItemStatusService.getWorkItemStatus(workItem.getStatusId()).getName(), status.getName()); + } + + @Override + public void updateWorkItemStatusTypeByStatusId(Long statusId, Integer statusType) { + workItemMapper.updateStatusTypeByStatusId(statusId, statusType); + } + + @Override + public Long getWorkItemCountByStatusId(Long statusId) { + return workItemMapper.selectCountByStatusId(statusId); + } + + @Override + public void transferWorkItemStatus(Long sourceStatusId, Long targetStatusId, Integer targetStatus) { + workItemMapper.updateStatusBySourceStatusId(sourceStatusId, targetStatusId, targetStatus); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateWorkItemIteration(PmsWorkItemIterationUpdateReqVO updateReqVO, Long userId) { + // 1.1 校验工作项存在 + PmsWorkItemDO workItem = validateWorkItemExists(updateReqVO.getId()); + // 1.2 校验工作项处于正常状态 + validateActiveWorkItem(workItem); + // 1.3 校验项目可编辑 + PmsProjectDO project = projectMemberService.validateProjectWritable(workItem.getProjectId(), userId); + // 1.4 校验项目处于进行中 + validateActiveProject(project); + // 1.5 校验项目支持迭代 + if (ObjectUtil.notEqual(PmsProjectTypeEnum.AGILE.getType(), project.getType())) { + throw exception(WORK_ITEM_ITERATION_INVALID); + } + // 1.6 校验目标迭代属于同一项目 + PmsIterationDO iteration = null; + if (updateReqVO.getIterationId() != null) { + iteration = iterationService.getIteration(updateReqVO.getIterationId(), userId); + if (ObjectUtil.notEqual(project.getId(), iteration.getProjectId())) { + throw exception(WORK_ITEM_ITERATION_INVALID); + } + } + + // 2.1 更新所属迭代 + workItemMapper.updateIterationIdById(workItem.getId(), updateReqVO.getIterationId()); + // 2.2 记录工作项迭代更新动态 + workItemActivityService.createWorkItemIterationActivityIfChanged(workItem, userId, + workItem.getIterationId(), updateReqVO.getIterationId()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateWorkItemSort(PmsWorkItemSortReqVO sortReqVO, Long userId) { + // 1.1 校验目标状态存在 + PmsWorkItemStatusDO status = workItemStatusService.getWorkItemStatus(sortReqVO.getStatusId()); + // 1.2 校验目标状态所属项目可编辑 + PmsProjectDO project = projectMemberService.validateProjectWritable(status.getProjectId(), userId); + // 1.3 校验项目处于进行中 + validateActiveProject(project); + + // 2.1 校验排序列表中的工作项编号不重复 + Set workItemIds = new LinkedHashSet<>(sortReqVO.getWorkItemIds()); + List workItems = workItemMapper.selectListByStatusId(status.getId()); + if (workItemIds.size() != sortReqVO.getWorkItemIds().size()) { + throw exception(WORK_ITEM_SORT_INVALID); + } + // 2.2 校验排序列表包含目标状态下的全部工作项 + if (workItems.size() != workItemIds.size() + || ObjectUtil.notEqual(workItemIds, convertSet(workItems, PmsWorkItemDO::getId))) { + throw exception(WORK_ITEM_SORT_INVALID); + } + for (PmsWorkItemDO workItem : workItems) { + // 2.3 校验工作项属于目标项目 + if (ObjectUtil.notEqual(status.getProjectId(), workItem.getProjectId())) { + throw exception(WORK_ITEM_SORT_INVALID); + } + // 2.4 校验工作项类型与目标状态一致 + if (ObjectUtil.notEqual(status.getWorkItemType(), workItem.getType())) { + throw exception(WORK_ITEM_SORT_INVALID); + } + // 2.5 校验工作项属于目标状态 + if (ObjectUtil.notEqual(status.getId(), workItem.getStatusId())) { + throw exception(WORK_ITEM_SORT_INVALID); + } + } + + // 3. 按请求顺序更新列内显示顺序 + Map sortMap = new LinkedHashMap<>(); + for (int index = 0; index < sortReqVO.getWorkItemIds().size(); index++) { + sortMap.put(sortReqVO.getWorkItemIds().get(index), index + 1); + } + List sortedWorkItemIds = new ArrayList<>(workItemIds); + Collections.sort(sortedWorkItemIds); + workItemMapper.updateBatch(convertList(sortedWorkItemIds, + workItemId -> new PmsWorkItemDO().setId(workItemId).setSort(sortMap.get(workItemId)))); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateWorkItemPlanningSort(PmsWorkItemPlanningSortReqVO sortReqVO, Long userId) { + // 1.1 校验项目可编辑 + PmsProjectDO project = projectMemberService.validateProjectWritable(sortReqVO.getProjectId(), userId); + // 1.2 校验项目处于进行中 + validateActiveProject(project); + // 1.3 校验目标迭代属于当前项目 + if (sortReqVO.getIterationId() != null) { + PmsIterationDO iteration = iterationService.getIteration(sortReqVO.getIterationId(), userId); + if (ObjectUtil.notEqual(project.getId(), iteration.getProjectId())) { + throw exception(WORK_ITEM_SORT_INVALID); + } + } + + // 1.4 校验请求包含目标区域内的全部一级工作项 + Set workItemIds = new LinkedHashSet<>(sortReqVO.getWorkItemIds()); + List workItems = workItemMapper.selectPlanningList( + sortReqVO.getProjectId(), sortReqVO.getIterationId()); + if (workItemIds.size() != sortReqVO.getWorkItemIds().size() || workItems.size() != workItemIds.size() + || ObjectUtil.notEqual(workItemIds, convertSet(workItems, PmsWorkItemDO::getId))) { + throw exception(WORK_ITEM_SORT_INVALID); + } + + // 3. Backlog 按当前用户保存个人顺序 + if (sortReqVO.getIterationId() == null) { + workItemUserSortService.updateWorkItemUserSort( + sortReqVO.getProjectId(), sortReqVO.getWorkItemIds(), userId); + return; + } + + // 4. 迭代内顺序由项目成员共享 + Map sortMap = new LinkedHashMap<>(); + for (int index = 0; index < sortReqVO.getWorkItemIds().size(); index++) { + sortMap.put(sortReqVO.getWorkItemIds().get(index), index + 1); + } + List sortedWorkItemIds = new ArrayList<>(workItemIds); + Collections.sort(sortedWorkItemIds); + workItemMapper.updateBatch(convertList(sortedWorkItemIds, + workItemId -> new PmsWorkItemDO().setId(workItemId).setSort(sortMap.get(workItemId)))); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void archiveWorkItem(Long id, Long userId) { + // 1.1 校验工作项存在 + PmsWorkItemDO workItem = validateWorkItemExists(id); + // 1.2 校验工作项处于正常状态 + validateActiveWorkItem(workItem); + // 1.3 校验项目可编辑 + PmsProjectDO project = projectMemberService.validateProjectWritable(workItem.getProjectId(), userId); + // 1.4 校验项目处于进行中 + validateActiveProject(project); + + // 2.1 归档工作项 + workItemMapper.updateLifecycleStatusById(id, PmsWorkItemLifecycleStatusEnum.ARCHIVED.getStatus(), + LocalDateTime.now(), null); + // 2.2 记录工作项归档动态 + workItemActivityService.createWorkItemActivity( + project.getId(), workItem.getId(), userId, PmsWorkItemActivityContentEnum.WORK_ITEM_ARCHIVED); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void recycleWorkItem(Long id, Long userId) { + // 1.1 校验工作项存在 + PmsWorkItemDO workItem = validateWorkItemExists(id); + // 1.2 校验工作项不在回收站 + if (PmsWorkItemLifecycleStatusEnum.RECYCLED.getStatus().equals(workItem.getLifecycleStatus())) { + throw exception(WORK_ITEM_LIFECYCLE_STATUS_INVALID); + } + // 1.3 校验项目可编辑 + PmsProjectDO project = projectMemberService.validateProjectWritable(workItem.getProjectId(), userId); + // 1.4 校验项目处于进行中 + validateActiveProject(project); + + // 2.1 将工作项移入回收站 + workItemMapper.updateLifecycleStatusById(id, PmsWorkItemLifecycleStatusEnum.RECYCLED.getStatus(), + workItem.getArchiveTime(), LocalDateTime.now()); + // 2.2 记录工作项回收动态 + workItemActivityService.createWorkItemActivity( + project.getId(), workItem.getId(), userId, PmsWorkItemActivityContentEnum.WORK_ITEM_RECYCLED); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void restoreWorkItem(Long id, Long userId) { + // 1.1 校验工作项存在 + PmsWorkItemDO workItem = validateWorkItemExists(id); + // 1.2 校验工作项已归档或位于回收站 + if (PmsWorkItemLifecycleStatusEnum.ACTIVE.getStatus().equals(workItem.getLifecycleStatus())) { + throw exception(WORK_ITEM_LIFECYCLE_STATUS_INVALID); + } + // 1.3 校验项目可编辑 + PmsProjectDO project = projectMemberService.validateProjectWritable(workItem.getProjectId(), userId); + // 1.4 校验项目处于进行中 + validateActiveProject(project); + + // 2.1 恢复工作项 + workItemMapper.updateLifecycleStatusById( + id, PmsWorkItemLifecycleStatusEnum.ACTIVE.getStatus(), null, null); + // 2.2 记录工作项恢复动态 + workItemActivityService.createWorkItemActivity( + project.getId(), workItem.getId(), userId, PmsWorkItemActivityContentEnum.WORK_ITEM_RESTORED); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteWorkItem(Long id, Long userId) { + // 1.1 校验工作项存在 + PmsWorkItemDO workItem = validateWorkItemExists(id); + // 1.2 校验工作项位于回收站 + if (ObjectUtil.notEqual(PmsWorkItemLifecycleStatusEnum.RECYCLED.getStatus(), workItem.getLifecycleStatus())) { + throw exception(WORK_ITEM_DELETE_STATUS_INVALID); + } + // 1.3 校验项目可编辑 + PmsProjectDO project = projectMemberService.validateProjectWritable(workItem.getProjectId(), userId); + // 1.4 校验项目处于进行中 + validateActiveProject(project); + + // 2.1 解除子工作项和关联需求关系 + workItemMapper.updateParentIdToNullByParentId(id); + if (PmsWorkItemTypeEnum.REQUIREMENT.getType().equals(workItem.getType())) { + workItemMapper.updateRelatedRequirementIdToNullByRelatedRequirementId(id); + } + // 2.2 删除参与人、个人排序、评论、工时和动态 + workItemMemberMapper.deleteByWorkItemId(id); + workItemUserSortService.deleteWorkItemUserSortByWorkItemId(id); + workItemCommentService.deleteWorkItemCommentListByWorkItemId(id); + workItemWorkLogService.deleteWorkItemWorkLogListByWorkItemId(id); + workItemActivityService.deleteWorkItemActivityListByWorkItemId(id); + // 2.3 删除工作项主记录 + workItemMapper.deleteById(id); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteWorkItemListByProjectId(Long projectId) { + // 1. 删除项目工作项动态、评论、个人排序和参与人 + workItemActivityService.deleteWorkItemActivityListByProjectId(projectId); + List workItems = workItemMapper.selectListByProjectId(projectId); + workItemCommentService.deleteWorkItemCommentListByWorkItemIds(convertSet(workItems, PmsWorkItemDO::getId)); + workItemWorkLogService.deleteWorkItemWorkLogListByProjectId(projectId); + workItemMemberMapper.deleteByProjectId(projectId); + workItemUserSortService.deleteWorkItemUserSortByProjectId(projectId); + + // 2. 删除项目工作项和看板状态 + workItemMapper.deleteByProjectId(projectId); + workItemStatusService.deleteWorkItemStatusListByProjectId(projectId); + } + + @Override + public void clearWorkItemIterationId(Long iterationId) { + workItemMapper.updateIterationIdToNullByIterationId(iterationId); + } + + @Override + public PmsWorkItemDO getWorkItem(Long id, Long userId) { + // 1. 校验工作项存在 + PmsWorkItemDO workItem = validateWorkItemExists(id); + + // 2. 校验当前用户可以访问项目 + projectMemberService.validateProjectReadable(workItem.getProjectId(), userId); + return workItem; + } + + @Override + public PmsWorkItemDO getWritableWorkItem(Long id, Long userId) { + // 1.1 校验工作项存在 + PmsWorkItemDO workItem = validateWorkItemExists(id); + // 1.2 校验工作项处于正常状态 + validateActiveWorkItem(workItem); + + // 2.1 校验项目可编辑 + PmsProjectDO project = projectMemberService.validateProjectWritable(workItem.getProjectId(), userId); + // 2.2 校验项目处于进行中 + validateActiveProject(project); + return workItem; + } + + @Override + public PageResult getWorkItemPage(PmsWorkItemPageReqVO pageReqVO, Long userId) { + // 1.1 校验当前用户可以访问项目 + PmsProjectDO project = projectMemberService.validateProjectReadable(pageReqVO.getProjectId(), userId); + // 1.2 校验工作项类型可用 + if (pageReqVO.getType() != null) { + validateWorkItemType(project, pageReqVO.getType()); + } + if (CollUtil.isNotEmpty(pageReqVO.getTypes())) { + pageReqVO.getTypes().forEach(type -> validateWorkItemType(project, type)); + } + + // 2. 待规划页面按当前用户的个人顺序分页; + // 【特殊】个人排序存在"无排序记录时保持默认顺序"的短路语义,难以在 SQL 中等价表达,待规划区数据量为项目内未规划根工作项,因此内存排序后分页 + if (Boolean.TRUE.equals(pageReqVO.getPlanningOnly()) && Boolean.TRUE.equals(pageReqVO.getUnplannedOnly())) { + List workItems = workItemMapper.selectListByPlanning(pageReqVO); + workItemUserSortService.sortWorkItemList(workItems, pageReqVO.getProjectId(), userId); + int fromIndex = Math.min((pageReqVO.getPageNo() - 1) * pageReqVO.getPageSize(), workItems.size()); + int toIndex = Math.min(fromIndex + pageReqVO.getPageSize(), workItems.size()); + return new PageResult<>(new ArrayList<>(workItems.subList(fromIndex, toIndex)), (long) workItems.size()); + } + + // 3. 查询其他工作项分页 + return workItemMapper.selectPage(pageReqVO); + } + + @Override + public Map> getProjectWorkItemStatusCountMap(Collection projectIds) { + if (CollUtil.isEmpty(projectIds)) { + return Collections.emptyMap(); + } + return workItemMapper.selectStatusCountMapByProjectIds(projectIds); + } + + @Override + public List getWorkItemList(PmsWorkItemPageReqVO pageReqVO, Long userId) { + // 1. 校验当前用户可以访问项目 + projectMemberService.validateProjectReadable(pageReqVO.getProjectId(), userId); + + // 2. 查询全部符合条件的工作项,工作项类型由查询条件直接过滤 + return workItemMapper.selectList(pageReqVO); + } + + @Override + public List getActiveWorkItemListByProjectId(Long projectId) { + return workItemMapper.selectActiveListByProjectId(projectId); + } + + @Override + public List getActiveWorkItemListByIterationId(Long iterationId) { + return workItemMapper.selectListByIterationId(iterationId); + } + + @Override + public Map> getIterationWorkItemStatusCountMap(Collection iterationIds) { + if (CollUtil.isEmpty(iterationIds)) { + return Collections.emptyMap(); + } + return workItemMapper.selectStatusCountMapByIterationIds(iterationIds); + } + + @Override + public PageResult getAssignedWorkItemPage(PmsWorkbenchPageReqVO pageReqVO, + Collection projectIds, Long userId) { + if (CollUtil.isEmpty(projectIds)) { + return PageResult.empty(); + } + return workItemMapper.selectWorkbenchPage(pageReqVO, projectIds, userId, pageReqVO.getProjectId(), + pageReqVO.getType(), pageReqVO.getName(), pageReqVO.getStatus(), pageReqVO.getPriority(), + pageReqVO.getIterationId(), pageReqVO.getEndTime(), + PmsWorkItemStatusTypeEnum.COMPLETED.getType()); + } + + @Override + public Map getAssignedWorkItemTypeCountMap(PmsWorkbenchPageReqVO pageReqVO, + Collection projectIds, Long userId) { + if (CollUtil.isEmpty(projectIds)) { + return Collections.emptyMap(); + } + return workItemMapper.selectWorkbenchTypeCountMap(pageReqVO, projectIds, userId, + PmsWorkItemLifecycleStatusEnum.ACTIVE.getStatus(), + PmsWorkItemStatusTypeEnum.COMPLETED.getType()); + } + + @Override + @Transactional(rollbackFor = Exception.class) // 添加事务,异常则回滚所有导入 + public PmsWorkItemImportRespVO importWorkItemList(Long projectId, Integer workItemType, + List importRows, Long userId) { + // 1. 参数校验 + PmsProjectDO project = projectMemberService.validateProjectWritable(projectId, userId); + validateActiveProject(project); + validateWorkItemType(project, workItemType); + + // 2. 查询状态和标签 + Map statusIdMap = convertMap(workItemStatusService.getWorkItemStatusList(projectId, workItemType), + PmsWorkItemStatusDO::getName, PmsWorkItemStatusDO::getId); + Map labelIdMap = convertMap(workItemLabelService.getWorkItemLabelList((String) null), + PmsWorkItemLabelDO::getName, PmsWorkItemLabelDO::getId); + + // 3. 遍历,逐个导入 + int successCount = 0; + Map failureReasons = new LinkedHashMap<>(); + for (int index = 0; index < importRows.size(); index++) { + PmsWorkItemImportExcelVO importItem = importRows.get(index); + int rowNumber = index + 2; + try { + // 3.1 转换并校验工作项数据 + PmsWorkItemSaveReqVO saveReqVO = parseImportData(importItem, projectId, workItemType, + statusIdMap, labelIdMap); + Long workItemId = createWorkItem(saveReqVO, userId); + // 3.2 更新指定状态 + if (importItem.getStatusId() != null) { + updateWorkItemStatus(new PmsWorkItemStatusUpdateReqVO() + .setId(workItemId).setStatusId(importItem.getStatusId()), userId); + } + // 3.3 记录工时 + if (importItem.getActualHours() != null) { + workItemWorkLogService.createWorkItemWorkLog(new PmsWorkItemWorkLogSaveReqVO() + .setWorkItemId(workItemId).setActualHours(importItem.getActualHours()) + .setRemainingHours(importItem.getRemainingHours() == null + ? 0 : importItem.getRemainingHours()), userId); + } + successCount++; + } catch (Exception ex) { + failureReasons.put(rowNumber, StrUtil.blankToDefault(ex.getMessage(), "数据校验失败")); + } + } + return PmsWorkItemImportRespVO.builder().successCount(successCount).failureReasons(failureReasons).build(); + } + + /** + * 将 Excel 导入行转换为工作项保存数据 + */ + private PmsWorkItemSaveReqVO parseImportData(PmsWorkItemImportExcelVO importItem, Long projectId, Integer workItemType, + Map statusIdMap, Map labelIdMap) { + // 1.1 解析导入字段 + importItem.setPriority(importItem.getPriority() == null ? PmsWorkItemPriorityEnum.NONE.getPriority() : importItem.getPriority()) + .setProgress(importItem.getProgress() == null ? 0 : importItem.getProgress()) + .setEstimatedHours(importItem.getEstimatedHours() == null ? 0 : importItem.getEstimatedHours()) + .setWorkItemType(workItemType); + // 1.2 解析状态 + if (StrUtil.isNotBlank(importItem.getStatusName())) { + Long statusId = statusIdMap.get(importItem.getStatusName()); + Assert.notNull(statusId, "状态不存在或不属于当前工作项类型:" + importItem.getStatusName()); + importItem.setStatusId(statusId); + } + // 1.3 解析标签 + importItem.setLabelIds(new ArrayList<>(convertSet(importItem.getLabels(), labelName -> { + Long labelId = labelIdMap.get(labelName); + Assert.notNull(labelId, "标签不存在:" + labelName); + return labelId; + }))); + // 1.4 校验导入数据 + ValidationUtils.validate(importItem); + + // 2. 构造并校验工作项 + PmsWorkItemSaveReqVO saveReqVO = BeanUtils.toBean(importItem, PmsWorkItemSaveReqVO.class) + .setProjectId(projectId).setType(workItemType); + ValidationUtils.validate(saveReqVO); + return saveReqVO; + } + + @Override + public List getWorkItemBoard(PmsWorkItemBoardReqVO queryReqVO, Long userId) { + // 1.1 校验当前用户可以访问项目 + projectMemberService.validateProjectReadable(queryReqVO.getProjectId(), userId); + // 1.2 查询看板状态和符合条件的工作项 + List statuses = workItemStatusService + .getWorkItemStatusList(queryReqVO.getProjectId(), queryReqVO.getType()); + List boards = workItemStatusService + .getWorkItemBoardList(queryReqVO.getProjectId(), queryReqVO.getType()); + Map> workItemListMap = new LinkedHashMap<>(); + for (PmsWorkItemDO workItem : workItemMapper.selectRootList(queryReqVO)) { + workItemListMap.computeIfAbsent(workItem.getStatusId(), key -> new ArrayList<>()).add(workItem); + } + + // 3. 按独立看板列顺序组装状态和工作项,未放入看板的状态不展示 + Map> statusListMap = new LinkedHashMap<>(); + for (PmsWorkItemStatusDO status : statuses) { + // 空看板状态不属于任何列,不参与看板展示 + if (StrUtil.isBlank(status.getBoardName())) { + continue; + } + statusListMap.computeIfAbsent(status.getBoardName(), key -> new ArrayList<>()).add(status); + } + List result = new ArrayList<>(); + for (PmsWorkItemBoardDO board : boards) { + PmsWorkItemBoardRespVO column = new PmsWorkItemBoardRespVO().setId(board.getId()) + .setName(board.getName()).setStatuses(new ArrayList<>()).setItems(new ArrayList<>()); + for (PmsWorkItemStatusDO status : statusListMap.getOrDefault(board.getName(), Collections.emptyList())) { + column.getStatuses().add(BeanUtils.toBean(status, PmsWorkItemStatusRespVO.class)); + column.getItems().addAll(BeanUtils.toBean(workItemListMap.getOrDefault( + status.getId(), Collections.emptyList()), PmsWorkItemRespVO.class)); + } + result.add(column); + } + return result; + } + + @Override + public Map> getWorkItemMemberUserIdListMap(Collection workItemIds) { + if (CollUtil.isEmpty(workItemIds)) { + return Collections.emptyMap(); + } + List members = workItemMemberMapper.selectListByWorkItemIds(workItemIds); + return convertMultiMap(members, PmsWorkItemMemberDO::getWorkItemId, PmsWorkItemMemberDO::getUserId); + } + + @Override + public List getWorkItemList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + return workItemMapper.selectByIds(ids); + } + + /** + * 校验并获得工作项 + * + * @param id 工作项编号 + * @return 工作项 + */ + private PmsWorkItemDO validateWorkItemExists(Long id) { + PmsWorkItemDO workItem = workItemMapper.selectById(id); + if (workItem == null) { + throw exception(WORK_ITEM_NOT_EXISTS); + } + return workItem; + } + + /** + * 校验工作项处于正常状态 + * + * @param workItem 工作项 + */ + private void validateActiveWorkItem(PmsWorkItemDO workItem) { + if (ObjectUtil.notEqual(PmsWorkItemLifecycleStatusEnum.ACTIVE.getStatus(), workItem.getLifecycleStatus())) { + throw exception(WORK_ITEM_LIFECYCLE_STATUS_INVALID); + } + } + + /** + * 校验项目处于进行中 + * + * @param project 项目 + */ + private void validateActiveProject(PmsProjectDO project) { + if (ObjectUtil.notEqual(PmsProjectStatusEnum.ACTIVE.getStatus(), project.getStatus())) { + throw exception(PROJECT_STATUS_INVALID); + } + } + + /** + * 校验项目支持工作项类型 + * + * @param project 项目 + * @param workItemType 工作项类型 + */ + private void validateWorkItemType(PmsProjectDO project, Integer workItemType) { + boolean valid = PmsProjectTypeEnum.GENERAL.getType().equals(project.getType()) + ? PmsWorkItemTypeEnum.TASK.getType().equals(workItemType) + : PmsWorkItemTypeEnum.valueOf(workItemType) != null; + if (!valid) { + throw exception(WORK_ITEM_TYPE_INVALID); + } + } + + /** + * 校验工作项的迭代、父工作项、关联需求和缺陷类型 + * + * @param id 当前工作项编号 + * @param project 项目 + * @param workItemType 工作项类型 + * @param reqVO 工作项信息 + * @param userId 用户编号 + */ + private void validateWorkItemRelations(Long id, PmsProjectDO project, Integer workItemType, + PmsWorkItemSaveReqVO reqVO, Long userId) { + if (reqVO.getIterationId() != null) { + PmsIterationDO iteration = iterationService.getIteration(reqVO.getIterationId(), userId); + if (ObjectUtil.notEqual(project.getId(), iteration.getProjectId()) + || ObjectUtil.notEqual(PmsProjectTypeEnum.AGILE.getType(), project.getType())) { + throw exception(WORK_ITEM_ITERATION_INVALID); + } + } + validateParentWorkItem(id, project.getId(), workItemType, reqVO.getParentId()); + validateRelatedRequirement(project.getId(), workItemType, reqVO.getRelatedRequirementId()); + boolean defectWorkItem = PmsWorkItemTypeEnum.DEFECT.getType().equals(workItemType); + if (defectWorkItem && reqVO.getDefectType() == null + || !defectWorkItem && reqVO.getDefectType() != null) { + throw exception(WORK_ITEM_DEFECT_TYPE_INVALID); + } + } + + /** + * 校验父工作项属于同一项目和类型,且不会形成循环 + * + * @param id 当前工作项编号 + * @param projectId 项目编号 + * @param workItemType 工作项类型 + * @param parentId 父工作项编号 + */ + private void validateParentWorkItem(Long id, Long projectId, Integer workItemType, Long parentId) { + Long currentParentId = parentId; + Set visitedIds = new HashSet<>(); + for (int index = 0; index < Short.MAX_VALUE && currentParentId != null; index++) { + if (ObjectUtil.equal(currentParentId, id) || !visitedIds.add(currentParentId)) { + throw exception(WORK_ITEM_PARENT_INVALID); + } + PmsWorkItemDO parent = workItemMapper.selectById(currentParentId); + if (parent == null || ObjectUtil.notEqual(projectId, parent.getProjectId()) + || ObjectUtil.notEqual(workItemType, parent.getType())) { + throw exception(WORK_ITEM_PARENT_INVALID); + } + // 工作项详情只展示单级子项,父工作项不能再挂在其他工作项下 + if (parent.getParentId() != null) { + throw exception(WORK_ITEM_PARENT_INVALID); + } + validateActiveWorkItem(parent); + currentParentId = parent.getParentId(); + } + if (currentParentId != null) { + throw exception(WORK_ITEM_PARENT_INVALID); + } + } + + /** + * 校验关联需求属于同一项目,且当前工作项不是需求 + * + * @param projectId 项目编号 + * @param workItemType 工作项类型 + * @param requirementId 需求编号 + */ + private void validateRelatedRequirement(Long projectId, Integer workItemType, Long requirementId) { + if (requirementId == null) { + return; + } + PmsWorkItemDO requirement = workItemMapper.selectById(requirementId); + if (PmsWorkItemTypeEnum.REQUIREMENT.getType().equals(workItemType) || requirement == null + || ObjectUtil.notEqual(projectId, requirement.getProjectId()) + || ObjectUtil.notEqual(PmsWorkItemLifecycleStatusEnum.ACTIVE.getStatus(), requirement.getLifecycleStatus()) + || ObjectUtil.notEqual(PmsWorkItemTypeEnum.REQUIREMENT.getType(), requirement.getType())) { + throw exception(WORK_ITEM_RELATED_REQUIREMENT_INVALID); + } + } + + /** + * 校验负责人和参与人都是项目成员 + * + * @param projectId 项目编号 + * @param reqVO 工作项信息 + * @param userId 操作人用户编号 + */ + private void validateWorkItemMembers(Long projectId, PmsWorkItemSaveReqVO reqVO, Long userId) { + Set userIds = new LinkedHashSet<>(); + if (reqVO.getAssigneeUserId() != null) { + userIds.add(reqVO.getAssigneeUserId()); + } + userIds.addAll(getMemberUserIds(reqVO, userId)); + projectMemberService.validateProjectMemberList(projectId, userIds); + } + + /** + * 获得工作项参与人,未选择参与人时默认使用操作人 + * + * @param reqVO 工作项信息 + * @param userId 操作人用户编号 + * @return 参与人用户编号集合 + */ + private Set getMemberUserIds(PmsWorkItemSaveReqVO reqVO, Long userId) { + return CollUtil.isEmpty(reqVO.getMemberUserIds()) ? Collections.singleton(userId) + : new LinkedHashSet<>(reqVO.getMemberUserIds()); + } + + /** + * 保存工作项参与人关系 + * + * @param workItemId 工作项编号 + * @param projectId 项目编号 + * @param memberUserIds 参与人用户编号列表 + */ + private void saveWorkItemMembers(Long workItemId, Long projectId, Collection memberUserIds) { + if (CollUtil.isEmpty(memberUserIds)) { + return; + } + workItemMemberMapper.insertBatch(convertList(memberUserIds, + memberUserId -> new PmsWorkItemMemberDO().setProjectId(projectId) + .setWorkItemId(workItemId).setUserId(memberUserId))); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemStatusService.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemStatusService.java new file mode 100644 index 000000000..41288b135 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemStatusService.java @@ -0,0 +1,158 @@ +package cn.iocoder.yudao.module.pms.service.pm.workitem; + +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status.PmsWorkItemBoardConfigSaveReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status.PmsWorkItemStatusConfigUpdateReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status.PmsWorkItemStatusCreateReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status.PmsWorkItemStatusDeleteReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status.PmsWorkItemStatusSortReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemBoardDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemStatusDO; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * PMS 工作项看板状态 Service 接口 + * + * @author 芋道源码 + */ +public interface PmsWorkItemStatusService { + + /** + * 创建自定义看板状态,追加到项目工作项类型的状态末尾 + * + * @param createReqVO 创建信息 + * @param userId 用户编号 + * @return 状态编号 + */ + Long createWorkItemStatus(PmsWorkItemStatusCreateReqVO createReqVO, Long userId); + + /** + * 更新看板状态配置,并同步其全部工作项的语义状态 + * + * @param updateReqVO 更新信息 + * @param userId 用户编号 + */ + void updateWorkItemStatusConfig(PmsWorkItemStatusConfigUpdateReqVO updateReqVO, Long userId); + + /** + * 切换当前工作项类型的初始状态 + * + * @param id 状态编号 + * @param userId 用户编号 + */ + void updateDefaultWorkItemStatus(Long id, Long userId); + + /** + * 更新项目工作项类型的状态顺序,要求排序列表完整覆盖全部状态 + * + * @param sortReqVO 排序信息 + * @param userId 用户编号 + */ + void updateWorkItemStatusSort(PmsWorkItemStatusSortReqVO sortReqVO, Long userId); + + /** + * 保存工作项看板列及其状态映射 + * + * @param saveReqVO 看板配置 + * @param userId 用户编号 + */ + void updateWorkItemBoardConfig(PmsWorkItemBoardConfigSaveReqVO saveReqVO, Long userId); + + /** + * 删除看板状态,存在工作项时先迁移到目标状态,初始状态不允许删除 + * + * @param deleteReqVO 删除信息 + * @param userId 用户编号 + */ + void deleteWorkItemStatus(PmsWorkItemStatusDeleteReqVO deleteReqVO, Long userId); + + /** + * 初始化项目支持的工作项状态 + * + * @param projectId 项目编号 + * @param projectType 项目类型 + */ + void initProjectWorkItemStatuses(Long projectId, Integer projectType); + + /** + * 获得工作项状态,不存在时抛出异常 + * + * @param id 状态编号 + * @return 工作项状态 + */ + PmsWorkItemStatusDO getWorkItemStatus(Long id); + + /** + * 获得用户可读的工作项状态 + * + * @param id 状态编号 + * @param userId 用户编号 + * @return 工作项状态 + */ + PmsWorkItemStatusDO getWorkItemStatus(Long id, Long userId); + + /** + * 获得项目指定工作项类型的已保存状态列表 + * + * @param projectId 项目编号 + * @param workItemType 工作项类型 + * @return 已存在的状态列表 + */ + List getWorkItemStatusList(Long projectId, Integer workItemType); + + /** + * 获得项目指定工作项类型的已保存看板列 + * + * @param projectId 项目编号 + * @param workItemType 工作项类型 + * @return 看板列列表 + */ + List getWorkItemBoardList(Long projectId, Integer workItemType); + + /** + * 获得工作项的初始状态 + * + * @param projectId 项目编号 + * @param workItemType 工作项类型 + * @return 初始状态 + */ + PmsWorkItemStatusDO getDefaultWorkItemStatus(Long projectId, Integer workItemType); + + /** + * 获得项目指定工作项类型的看板状态列表 + * + * @param projectId 项目编号 + * @param workItemType 工作项类型 + * @param userId 用户编号 + * @return 状态列表 + */ + List getWorkItemStatusList(Long projectId, Integer workItemType, Long userId); + + /** + * 校验并获得看板状态 + * + * @param id 状态编号 + * @param projectId 项目编号 + * @param workItemType 工作项类型 + * @return 看板状态 + */ + PmsWorkItemStatusDO validateWorkItemStatus(Long id, Long projectId, Integer workItemType); + + /** + * 获得工作项状态 Map + * + * @param ids 状态编号集合 + * @return 工作项状态 Map + */ + Map getWorkItemStatusMap(Collection ids); + + /** + * 删除项目的全部工作项状态 + * + * @param projectId 项目编号 + */ + void deleteWorkItemStatusListByProjectId(Long projectId); + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemStatusServiceImpl.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemStatusServiceImpl.java new file mode 100644 index 000000000..fa24a748b --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemStatusServiceImpl.java @@ -0,0 +1,462 @@ +package cn.iocoder.yudao.module.pms.service.pm.workitem; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status.PmsWorkItemBoardConfigSaveReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status.PmsWorkItemStatusConfigUpdateReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status.PmsWorkItemStatusCreateReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status.PmsWorkItemStatusDeleteReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status.PmsWorkItemStatusSortReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemBoardDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemStatusDO; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.workitem.PmsWorkItemStatusMapper; +import cn.iocoder.yudao.module.pms.enums.pm.project.PmsProjectStatusEnum; +import cn.iocoder.yudao.module.pms.enums.pm.project.PmsProjectTypeEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemStatusTypeEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemTypeEnum; +import cn.iocoder.yudao.module.pms.service.pm.project.PmsProjectMemberService; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Lazy; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.diffList; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.PROJECT_STATUS_INVALID; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.WORK_ITEM_STATUS_DEFAULT_CANNOT_DELETE; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.WORK_ITEM_STATUS_INVALID; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.WORK_ITEM_STATUS_NAME_DUPLICATE; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.WORK_ITEM_STATUS_NOT_EXISTS; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.WORK_ITEM_STATUS_SORT_INVALID; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.WORK_ITEM_STATUS_TRANSFER_INVALID; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.WORK_ITEM_TYPE_INVALID; + +/** + * PMS 工作项看板状态 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class PmsWorkItemStatusServiceImpl implements PmsWorkItemStatusService { + + @Resource + private PmsWorkItemStatusMapper workItemStatusMapper; + + @Resource + private PmsWorkItemBoardService workItemBoardService; + @Lazy // 延迟加载,避免循环依赖 + @Resource + private PmsWorkItemService workItemService; + @Resource + private PmsProjectMemberService projectMemberService; + + @Override + public Long createWorkItemStatus(PmsWorkItemStatusCreateReqVO createReqVO, Long userId) { + // 1.1 校验项目可编辑 + PmsProjectDO project = projectMemberService.validateProjectWritable(createReqVO.getProjectId(), userId); + // 1.2 校验项目处于进行中 + validateActiveProject(project); + // 1.3 校验工作项类型可用 + validateWorkItemType(project, createReqVO.getWorkItemType()); + List statuses = getWorkItemStatusList(project.getId(), createReqVO.getWorkItemType()); + if (CollUtil.isEmpty(statuses)) { + throw exception(WORK_ITEM_STATUS_NOT_EXISTS); + } + String name = createReqVO.getName(); + // 1.4 校验状态名称唯一 + validateStatusNameUnique(null, project.getId(), createReqVO.getWorkItemType(), name); + + // 2. 追加自定义状态 + PmsWorkItemStatusDO status = BeanUtils.toBean(createReqVO, PmsWorkItemStatusDO.class) + .setProjectId(project.getId()).setName(name) + .setBoardName(null) + .setDefaultStatus(false).setSort(CollUtil.getLast(statuses).getSort() + 1); + workItemStatusMapper.insert(status); + return status.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateWorkItemStatusConfig(PmsWorkItemStatusConfigUpdateReqVO updateReqVO, Long userId) { + // 1.1 校验状态存在 + PmsWorkItemStatusDO status = getWorkItemStatus(updateReqVO.getId()); + // 1.2 校验状态所属项目可编辑 + PmsProjectDO project = projectMemberService.validateProjectWritable(status.getProjectId(), userId); + // 1.3 校验项目处于进行中 + validateActiveProject(project); + String name = updateReqVO.getName(); + // 1.4 校验状态名称唯一 + validateStatusNameUnique(status.getId(), status.getProjectId(), status.getWorkItemType(), name); + + // 2. 更新状态及其全部工作项的语义状态 + workItemStatusMapper.updateById(BeanUtils.toBean(updateReqVO, PmsWorkItemStatusDO.class) + .setId(status.getId()).setName(name).setBoardName(status.getBoardName())); + workItemService.updateWorkItemStatusTypeByStatusId(status.getId(), updateReqVO.getStatusType()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateDefaultWorkItemStatus(Long id, Long userId) { + // 1.1 校验状态存在 + PmsWorkItemStatusDO status = getWorkItemStatus(id); + // 1.2 校验状态所属项目可编辑 + PmsProjectDO project = projectMemberService.validateProjectWritable(status.getProjectId(), userId); + // 1.3 校验项目处于进行中 + validateActiveProject(project); + + // 2. 切换当前工作项类型的初始状态 + workItemStatusMapper.updateDefaultStatusByProjectIdAndWorkItemType( + status.getProjectId(), status.getWorkItemType(), id); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateWorkItemStatusSort(PmsWorkItemStatusSortReqVO sortReqVO, Long userId) { + // 1.1 校验排序列表中的状态无重复且全部存在 + List statusIds = sortReqVO.getStatusIds(); + List statuses = workItemStatusMapper.selectByIds(statusIds); + if (statuses.size() != statusIds.size()) { + throw exception(WORK_ITEM_STATUS_SORT_INVALID); + } + PmsWorkItemStatusDO firstStatus = CollUtil.getFirst(statuses); + // 1.2 校验状态所属项目可编辑 + PmsProjectDO project = projectMemberService.validateProjectWritable(firstStatus.getProjectId(), userId); + // 1.3 校验项目处于进行中 + validateActiveProject(project); + // 1.4 校验排序列表完整且属于同一项目和工作项类型 + List allStatuses = workItemStatusMapper.selectListByProjectIdAndWorkItemType( + firstStatus.getProjectId(), firstStatus.getWorkItemType()); + if (ObjectUtil.notEqual(convertSet(statusIds), convertSet(allStatuses, PmsWorkItemStatusDO::getId))) { + throw exception(WORK_ITEM_STATUS_SORT_INVALID); + } + + // 2. 按状态编号稳定加锁并写入请求顺序 + Map sortMap = new LinkedHashMap<>(); + for (int index = 0; index < sortReqVO.getStatusIds().size(); index++) { + sortMap.put(sortReqVO.getStatusIds().get(index), index + 1); + } + List sortedStatusIds = new ArrayList<>(statusIds); + Collections.sort(sortedStatusIds); + workItemStatusMapper.updateBatch(convertList(sortedStatusIds, + statusId -> new PmsWorkItemStatusDO().setId(statusId).setSort(sortMap.get(statusId)))); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateWorkItemBoardConfig(PmsWorkItemBoardConfigSaveReqVO saveReqVO, Long userId) { + // 1.1 校验项目和工作项类型可编辑 + PmsProjectDO project = projectMemberService.validateProjectWritable(saveReqVO.getProjectId(), userId); + validateActiveProject(project); + validateWorkItemType(project, saveReqVO.getWorkItemType()); + // 1.2 校验看板列名称和状态映射不重复,且状态全部属于当前项目和工作项类型 + Set boardNames = new LinkedHashSet<>(); + Set statusIds = new LinkedHashSet<>(); + for (PmsWorkItemBoardConfigSaveReqVO.Board board : saveReqVO.getBoards()) { + if (!boardNames.add(board.getName())) { + throw exception(WORK_ITEM_STATUS_SORT_INVALID); + } + for (Long statusId : board.getStatusIds()) { + if (!statusIds.add(statusId)) { + throw exception(WORK_ITEM_STATUS_SORT_INVALID); + } + } + } + Map statusMap = CollUtil.isEmpty(statusIds) ? Collections.emptyMap() + : convertMap(workItemStatusMapper.selectByIds(statusIds), PmsWorkItemStatusDO::getId); + if (statusMap.size() != statusIds.size() || statusMap.values().stream().anyMatch(status -> + ObjectUtil.notEqual(status.getProjectId(), project.getId()) + || ObjectUtil.notEqual(status.getWorkItemType(), saveReqVO.getWorkItemType()))) { + throw exception(WORK_ITEM_STATUS_SORT_INVALID); + } + + // 2.1 读取原看板列,并校验请求中的列编号属于当前项目和工作项类型 + List oldBoards = workItemBoardService + .getWorkItemBoardList(project.getId(), saveReqVO.getWorkItemType()); + Set oldBoardIds = convertSet(oldBoards, PmsWorkItemBoardDO::getId); + Set newBoardIds = convertSet(saveReqVO.getBoards(), PmsWorkItemBoardConfigSaveReqVO.Board::getId); + if (newBoardIds.size() != saveReqVO.getBoards().stream().filter(board -> board.getId() != null).count() + || !oldBoardIds.containsAll(newBoardIds)) { + throw exception(WORK_ITEM_STATUS_SORT_INVALID); + } + + // 2.2 对比看板列,保留已有编号,仅增删改实际发生变化的列 + List newBoards = convertList(saveReqVO.getBoards(), board -> + new PmsWorkItemBoardDO().setId(board.getId()).setProjectId(project.getId()) + .setWorkItemType(saveReqVO.getWorkItemType()).setName(board.getName()) + .setSort(saveReqVO.getBoards().indexOf(board) + 1)); + List> boardDiff = diffList(oldBoards, newBoards, + (oldBoard, newBoard) -> oldBoard.getId() != null && oldBoard.getId().equals(newBoard.getId())); + if (CollUtil.isNotEmpty(boardDiff.get(0))) { + workItemBoardService.createWorkItemBoardList(boardDiff.get(0)); + } + if (CollUtil.isNotEmpty(boardDiff.get(1))) { + workItemBoardService.updateWorkItemBoardList(boardDiff.get(1)); + } + if (CollUtil.isNotEmpty(boardDiff.get(2))) { + workItemBoardService.deleteWorkItemBoardList(convertList(boardDiff.get(2), PmsWorkItemBoardDO::getId)); + } + + // 2.3 先清除当前类型的旧状态映射,再按请求批量写入新映射 + workItemStatusMapper.updateBoardNameByProjectIdAndWorkItemType( + project.getId(), saveReqVO.getWorkItemType(), null); + for (PmsWorkItemBoardConfigSaveReqVO.Board board : saveReqVO.getBoards()) { + if (CollUtil.isNotEmpty(board.getStatusIds())) { + workItemStatusMapper.updateBoardNameByIds(board.getStatusIds(), board.getName()); + } + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteWorkItemStatus(PmsWorkItemStatusDeleteReqVO deleteReqVO, Long userId) { + // 1.1 校验待删除状态存在 + PmsWorkItemStatusDO sourceStatus = getWorkItemStatus(deleteReqVO.getId()); + // 1.2 校验状态所属项目可编辑 + PmsProjectDO project = projectMemberService.validateProjectWritable(sourceStatus.getProjectId(), userId); + // 1.3 校验项目处于进行中 + validateActiveProject(project); + // 1.4 校验默认状态不能删除 + if (sourceStatus.getDefaultStatus()) { + throw exception(WORK_ITEM_STATUS_DEFAULT_CANNOT_DELETE); + } + + // 2. 存在工作项时迁移到同项目同类型的目标状态 + Long workItemCount = workItemService.getWorkItemCountByStatusId(sourceStatus.getId()); + if (workItemCount > 0 || deleteReqVO.getTransferStatusId() != null) { + if (deleteReqVO.getTransferStatusId() == null + || ObjectUtil.equal(sourceStatus.getId(), deleteReqVO.getTransferStatusId())) { + throw exception(WORK_ITEM_STATUS_TRANSFER_INVALID); + } + PmsWorkItemStatusDO targetStatus = validateWorkItemStatus( + deleteReqVO.getTransferStatusId(), sourceStatus.getProjectId(), sourceStatus.getWorkItemType()); + workItemService.transferWorkItemStatus( + sourceStatus.getId(), targetStatus.getId(), targetStatus.getStatusType()); + } + + // 3. 删除空状态 + workItemStatusMapper.deleteById(sourceStatus.getId()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void initProjectWorkItemStatuses(Long projectId, Integer projectType) { + // 1. 通用项目初始化任务状态和看板列 + Integer taskType = PmsWorkItemTypeEnum.TASK.getType(); + initWorkItemStatusList(projectId, taskType); + initWorkItemBoardList(projectId, taskType); + if (PmsProjectTypeEnum.GENERAL.getType().equals(projectType)) { + return; + } + + // 2. 敏捷开发项目同时初始化需求、缺陷状态和对应看板列 + Integer requirementType = PmsWorkItemTypeEnum.REQUIREMENT.getType(); + initWorkItemStatusList(projectId, requirementType); + initWorkItemBoardList(projectId, requirementType); + Integer defectType = PmsWorkItemTypeEnum.DEFECT.getType(); + initWorkItemStatusList(projectId, defectType); + initWorkItemBoardList(projectId, defectType); + } + + @Override + public PmsWorkItemStatusDO getWorkItemStatus(Long id) { + PmsWorkItemStatusDO status = workItemStatusMapper.selectById(id); + if (status == null) { + throw exception(WORK_ITEM_STATUS_NOT_EXISTS); + } + return status; + } + + @Override + public PmsWorkItemStatusDO getWorkItemStatus(Long id, Long userId) { + // 1. 查询工作项状态 + PmsWorkItemStatusDO status = getWorkItemStatus(id); + // 2. 校验项目可读 + projectMemberService.validateProjectReadable(status.getProjectId(), userId); + return status; + } + + private List initWorkItemStatusList(Long projectId, Integer workItemType) { + // 1. 查询项目工作项状态和已有系统状态编码 + List statuses = workItemStatusMapper + .selectListByProjectIdAndWorkItemType(projectId, workItemType); + List defaultStatuses = Arrays.asList(PmsWorkItemStatusTypeEnum.PENDING, + PmsWorkItemStatusTypeEnum.PROCESSING, PmsWorkItemStatusTypeEnum.COMPLETED); + Set existingSystemCodes = convertSet(statuses, PmsWorkItemStatusDO::getSystemCode); + if (existingSystemCodes.containsAll(convertSet(defaultStatuses, Enum::name))) { + return statuses; + } + + // 2. 补充缺失的系统状态,并重新查询 + initMissingSystemWorkItemStatuses(projectId, workItemType, statuses, defaultStatuses, existingSystemCodes); + return workItemStatusMapper.selectListByProjectIdAndWorkItemType(projectId, workItemType); + } + + private void initWorkItemBoardList(Long projectId, Integer workItemType) { + // 1. 项目创建时已完成初始化,重复初始化直接结束 + List boards = workItemBoardService.getWorkItemBoardList(projectId, workItemType); + if (CollUtil.isNotEmpty(boards)) { + return; + } + + // 2. 按初始化后的状态所属列创建独立看板列 + Set boardNames = new LinkedHashSet<>(); + for (PmsWorkItemStatusDO status : getWorkItemStatusList(projectId, workItemType)) { + if (StrUtil.isBlank(status.getBoardName()) + || !boardNames.add(status.getBoardName())) { + continue; + } + workItemBoardService.createWorkItemBoard(new PmsWorkItemBoardDO().setProjectId(projectId) + .setWorkItemType(workItemType).setName(status.getBoardName()).setSort(boardNames.size())); + } + } + + @Override + public List getWorkItemStatusList(Long projectId, Integer workItemType) { + return workItemStatusMapper.selectListByProjectIdAndWorkItemType(projectId, workItemType); + } + + @Override + public List getWorkItemBoardList(Long projectId, Integer workItemType) { + return workItemBoardService.getWorkItemBoardList(projectId, workItemType); + } + + /** + * 初始化缺失的未开始、进行中和已完成系统状态 + * + * @param projectId 项目编号 + * @param workItemType 工作项类型 + * @param statuses 已有状态列表 + * @param defaultStatuses 系统状态类型列表 + * @param existingSystemCodes 已有系统状态编码集合 + */ + private void initMissingSystemWorkItemStatuses(Long projectId, Integer workItemType, + List statuses, + List defaultStatuses, + Set existingSystemCodes) { + boolean hasDefaultStatus = CollUtil.findOne( + statuses, status -> Boolean.TRUE.equals(status.getDefaultStatus())) != null; + for (int index = 0; index < defaultStatuses.size(); index++) { + PmsWorkItemStatusTypeEnum statusType = defaultStatuses.get(index); + if (existingSystemCodes.contains(statusType.name())) { + continue; + } + try { + workItemStatusMapper.insert(new PmsWorkItemStatusDO().setProjectId(projectId) + .setWorkItemType(workItemType).setName(statusType.getName()) + .setStatusType(statusType.getType()).setSystemCode(statusType.name()) + .setBoardName(statusType.getName()) + .setDefaultStatus(!hasDefaultStatus && PmsWorkItemStatusTypeEnum.PENDING.equals(statusType)) + .setSort(index + 1)); + if (PmsWorkItemStatusTypeEnum.PENDING.equals(statusType)) { + hasDefaultStatus = true; + } + } catch (DuplicateKeyException ignored) { + // 其他请求已完成同一默认状态的初始化,继续查询即可 + } + } + } + + @Override + public PmsWorkItemStatusDO getDefaultWorkItemStatus(Long projectId, Integer workItemType) { + PmsWorkItemStatusDO status = workItemStatusMapper + .selectDefaultByProjectIdAndWorkItemType(projectId, workItemType); + if (status == null) { + throw exception(WORK_ITEM_STATUS_NOT_EXISTS); + } + return status; + } + + @Override + public List getWorkItemStatusList(Long projectId, Integer workItemType, Long userId) { + // 1. 校验当前用户可以访问项目 + projectMemberService.validateProjectReadable(projectId, userId); + // 2. 查询已保存的工作项状态列表,不在读取接口初始化数据 + return getWorkItemStatusList(projectId, workItemType); + } + + @Override + public PmsWorkItemStatusDO validateWorkItemStatus(Long id, Long projectId, Integer workItemType) { + PmsWorkItemStatusDO status = getWorkItemStatus(id); + if (ObjectUtil.notEqual(projectId, status.getProjectId()) + || ObjectUtil.notEqual(workItemType, status.getWorkItemType())) { + throw exception(WORK_ITEM_STATUS_INVALID); + } + return status; + } + + @Override + public Map getWorkItemStatusMap(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyMap(); + } + return convertMap(workItemStatusMapper.selectByIds(ids), PmsWorkItemStatusDO::getId); + } + + @Override + public void deleteWorkItemStatusListByProjectId(Long projectId) { + workItemBoardService.deleteWorkItemBoardListByProjectId(projectId); + workItemStatusMapper.deleteByProjectId(projectId); + } + + /** + * 校验项目处于进行中 + * + * @param project 项目 + */ + private void validateActiveProject(PmsProjectDO project) { + if (ObjectUtil.notEqual(PmsProjectStatusEnum.ACTIVE.getStatus(), project.getStatus())) { + throw exception(PROJECT_STATUS_INVALID); + } + } + + /** + * 校验项目支持工作项类型 + * + * @param project 项目 + * @param workItemType 工作项类型 + */ + private void validateWorkItemType(PmsProjectDO project, Integer workItemType) { + boolean valid = PmsProjectTypeEnum.GENERAL.getType().equals(project.getType()) + ? PmsWorkItemTypeEnum.TASK.getType().equals(workItemType) + : PmsWorkItemTypeEnum.valueOf(workItemType) != null; + if (!valid) { + throw exception(WORK_ITEM_TYPE_INVALID); + } + } + + /** + * 校验同一项目和工作项类型下状态名称唯一 + * + * @param id 状态编号 + * @param projectId 项目编号 + * @param workItemType 工作项类型 + * @param name 状态名称 + */ + private void validateStatusNameUnique(Long id, Long projectId, Integer workItemType, String name) { + PmsWorkItemStatusDO status = workItemStatusMapper + .selectByProjectIdAndWorkItemTypeAndName(projectId, workItemType, name); + if (status != null && ObjectUtil.notEqual(status.getId(), id)) { + throw exception(WORK_ITEM_STATUS_NAME_DUPLICATE); + } + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemUserSortService.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemUserSortService.java new file mode 100644 index 000000000..0fa68e9ab --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemUserSortService.java @@ -0,0 +1,46 @@ +package cn.iocoder.yudao.module.pms.service.pm.workitem; + +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemDO; + +import java.util.List; + +/** + * PMS 工作项个人排序 Service 接口 + * + * @author 芋道源码 + */ +public interface PmsWorkItemUserSortService { + + /** + * 更新用户的待规划工作项顺序 + * + * @param projectId 项目编号 + * @param workItemIds 工作项编号列表 + * @param userId 用户编号 + */ + void updateWorkItemUserSort(Long projectId, List workItemIds, Long userId); + + /** + * 按用户保存的顺序排列待规划工作项 + * + * @param workItems 工作项列表 + * @param projectId 项目编号 + * @param userId 用户编号 + */ + void sortWorkItemList(List workItems, Long projectId, Long userId); + + /** + * 删除工作项的个人排序 + * + * @param workItemId 工作项编号 + */ + void deleteWorkItemUserSortByWorkItemId(Long workItemId); + + /** + * 删除项目的个人排序 + * + * @param projectId 项目编号 + */ + void deleteWorkItemUserSortByProjectId(Long projectId); + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemUserSortServiceImpl.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemUserSortServiceImpl.java new file mode 100644 index 000000000..c40f3db0f --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemUserSortServiceImpl.java @@ -0,0 +1,71 @@ +package cn.iocoder.yudao.module.pms.service.pm.workitem; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemUserSortDO; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.workitem.PmsWorkItemUserSortMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * PMS 工作项个人排序 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class PmsWorkItemUserSortServiceImpl implements PmsWorkItemUserSortService { + + @Resource + private PmsWorkItemUserSortMapper workItemUserSortMapper; + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateWorkItemUserSort(Long projectId, List workItemIds, Long userId) { + // 1. 删除原有个人排序 + workItemUserSortMapper.deleteByProjectIdAndUserId(projectId, userId); + + // 2. 按请求顺序保存个人排序 + for (int index = 0; index < workItemIds.size(); index++) { + workItemUserSortMapper.insert(new PmsWorkItemUserSortDO().setProjectId(projectId) + .setWorkItemId(workItemIds.get(index)).setUserId(userId).setSort(index + 1)); + } + } + + @Override + public void sortWorkItemList(List workItems, Long projectId, Long userId) { + // 1. 查询用户保存的个人排序 + List userSorts = + workItemUserSortMapper.selectListByProjectIdAndUserId(projectId, userId); + if (CollUtil.isEmpty(userSorts)) { + return; + } + + // 2. 转换个人排序 Map,并排列工作项 + Map userSortMap = convertMap(userSorts, + PmsWorkItemUserSortDO::getWorkItemId, PmsWorkItemUserSortDO::getSort); + workItems.sort(Comparator + .comparing((PmsWorkItemDO item) -> userSortMap.containsKey(item.getId()) ? 0 : 1) + .thenComparing(item -> userSortMap.getOrDefault(item.getId(), 0)) + .thenComparing(PmsWorkItemDO::getId, Comparator.reverseOrder())); + } + + @Override + public void deleteWorkItemUserSortByWorkItemId(Long workItemId) { + workItemUserSortMapper.deleteByWorkItemId(workItemId); + } + + @Override + public void deleteWorkItemUserSortByProjectId(Long projectId) { + workItemUserSortMapper.deleteByProjectId(projectId); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemWorkLogService.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemWorkLogService.java new file mode 100644 index 000000000..84a6c04a1 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemWorkLogService.java @@ -0,0 +1,85 @@ +package cn.iocoder.yudao.module.pms.service.pm.workitem; + +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.worklog.PmsProjectWorkLogReportReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.worklog.PmsProjectWorkLogReportRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.worklog.PmsWorkItemWorkLogSaveReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.worklog.PmsWorkItemWorkLogSummaryRespVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemWorkLogDO; + +import java.util.Collection; +import java.util.List; + +/** + * PMS 工作项工时记录 Service 接口 + * + * @author 芋道源码 + */ +public interface PmsWorkItemWorkLogService { + + /** + * 创建工时记录 + * + * @param saveReqVO 保存信息 + * @param userId 用户编号 + * @return 工时记录编号 + */ + Long createWorkItemWorkLog(PmsWorkItemWorkLogSaveReqVO saveReqVO, Long userId); + + /** + * 更新工时记录 + * + * @param saveReqVO 保存信息 + * @param userId 用户编号 + */ + void updateWorkItemWorkLog(PmsWorkItemWorkLogSaveReqVO saveReqVO, Long userId); + + /** + * 获得工时记录 + * + * @param id 工时记录编号 + * @param userId 用户编号 + * @return 工时记录 + */ + PmsWorkItemWorkLogDO getWorkItemWorkLog(Long id, Long userId); + + /** + * 获得工作项工时汇总 + * + * @param workItemId 工作项编号 + * @param userId 用户编号 + * @return 工时汇总 + */ + PmsWorkItemWorkLogSummaryRespVO getWorkItemWorkLogSummary(Long workItemId, Long userId); + + /** + * 获得多个工作项的工时记录,供迭代燃尽统计使用 + * + * @param workItemIds 工作项编号集合 + * @return 工时记录 + */ + List getWorkItemWorkLogListByWorkItemIds(Collection workItemIds); + + /** + * 获得项目工时报表,按迭代和工作项汇总日期范围内的每日工时 + * + * @param reqVO 报表查询条件 + * @param userId 用户编号 + * @return 工时报表 + */ + PmsProjectWorkLogReportRespVO getProjectWorkItemWorkLogReport(PmsProjectWorkLogReportReqVO reqVO, Long userId); + + /** + * 删除项目的全部工时记录 + * + * @param projectId 项目编号 + */ + void deleteWorkItemWorkLogListByProjectId(Long projectId); + + /** + * 删除工作项的全部工时,用于工作项删除时级联清理 + * + * @param workItemId 工作项编号 + */ + void deleteWorkItemWorkLogListByWorkItemId(Long workItemId); + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemWorkLogServiceImpl.java b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemWorkLogServiceImpl.java new file mode 100644 index 000000000..e58d3daf5 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemWorkLogServiceImpl.java @@ -0,0 +1,239 @@ +package cn.iocoder.yudao.module.pms.service.pm.workitem; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.util.number.NumberUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.worklog.PmsProjectWorkLogReportReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.worklog.PmsProjectWorkLogReportRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.worklog.PmsWorkItemWorkLogRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.worklog.PmsWorkItemWorkLogSaveReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.worklog.PmsWorkItemWorkLogSummaryRespVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.iteration.PmsIterationDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemWorkLogDO; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.workitem.PmsWorkItemWorkLogMapper; +import cn.iocoder.yudao.module.pms.service.pm.iteration.PmsIterationService; +import cn.iocoder.yudao.module.pms.service.pm.project.PmsProjectMemberService; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMultiMap; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.WORK_ITEM_WORK_LOG_INVALID; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.WORK_ITEM_WORK_LOG_NOT_EXISTS; +import static cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemActivityContentEnum.WORK_LOG_CREATED; +import static cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemActivityContentEnum.WORK_LOG_UPDATED; + +/** + * PMS 工作项工时记录 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class PmsWorkItemWorkLogServiceImpl implements PmsWorkItemWorkLogService { + + @Resource + private PmsWorkItemWorkLogMapper workLogMapper; + + @Resource + private PmsWorkItemService workItemService; + @Resource + private PmsProjectMemberService projectMemberService; + @Resource + private PmsWorkItemActivityService workItemActivityService; + @Resource + private PmsIterationService iterationService; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createWorkItemWorkLog(PmsWorkItemWorkLogSaveReqVO saveReqVO, Long userId) { + // 1. 校验工作项可编辑 + PmsWorkItemDO workItem = workItemService.getWritableWorkItem(saveReqVO.getWorkItemId(), userId); + + // 2.1 创建工时记录 + PmsWorkItemWorkLogDO workLog = BeanUtils.toBean(saveReqVO, PmsWorkItemWorkLogDO.class) + .setProjectId(workItem.getProjectId()).setDescription(StrUtil.trim(saveReqVO.getDescription())); + workLogMapper.insert(workLog); + // 2.2 记录工时创建动态 + workItemActivityService.createWorkItemActivity( + workItem.getProjectId(), workItem.getId(), userId, WORK_LOG_CREATED, saveReqVO.getActualHours()); + return workLog.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateWorkItemWorkLog(PmsWorkItemWorkLogSaveReqVO saveReqVO, Long userId) { + // 1.1 校验工时记录存在 + PmsWorkItemWorkLogDO workLog = validateWorkLogExists(saveReqVO.getId()); + // 1.2 校验工时记录属于请求工作项 + if (ObjectUtil.notEqual(workLog.getWorkItemId(), saveReqVO.getWorkItemId())) { + throw exception(WORK_ITEM_WORK_LOG_INVALID); + } + // 1.3 校验工作项可编辑 + PmsWorkItemDO workItem = workItemService.getWritableWorkItem(workLog.getWorkItemId(), userId); + + // 2.1 更新投入工时和说明 + workLogMapper.updateForEdit(BeanUtils.toBean(saveReqVO, PmsWorkItemWorkLogDO.class) + .setId(workLog.getId()).setDescription(StrUtil.trim(saveReqVO.getDescription()))); + // 2.2 记录工时更新动态 + workItemActivityService.createWorkItemActivity( + workItem.getProjectId(), workItem.getId(), userId, WORK_LOG_UPDATED); + } + + @Override + public PmsWorkItemWorkLogDO getWorkItemWorkLog(Long id, Long userId) { + // 1. 校验工时记录存在 + PmsWorkItemWorkLogDO workLog = validateWorkLogExists(id); + // 2. 校验工作项可查看 + workItemService.getWorkItem(workLog.getWorkItemId(), userId); + return workLog; + } + + @Override + public PmsWorkItemWorkLogSummaryRespVO getWorkItemWorkLogSummary(Long workItemId, Long userId) { + // 1.1 校验工作项可查看 + PmsWorkItemDO workItem = workItemService.getWorkItem(workItemId, userId); + // 1.2 查询工作项工时记录 + List workLogs = workLogMapper.selectListByWorkItemId(workItemId); + + // 2. 汇总已登记工时,剩余工时取最新一条登记记录 + int actualHours = workLogs.stream().mapToInt(PmsWorkItemWorkLogDO::getActualHours).sum(); + Integer remainingHours = CollUtil.isNotEmpty(workLogs) + ? CollUtil.getFirst(workLogs).getRemainingHours() : workItem.getEstimatedHours(); + if (remainingHours == null && workItem.getEstimatedHours() != null) { + remainingHours = Math.max(workItem.getEstimatedHours() - actualHours, 0); + } + List workLogVOs = convertList(workLogs, workLog -> + BeanUtils.toBean(workLog, PmsWorkItemWorkLogRespVO.class) + .setCreatorUserId(NumberUtils.parseLong(workLog.getCreator()))); + return new PmsWorkItemWorkLogSummaryRespVO().setEstimatedHours(workItem.getEstimatedHours()) + .setActualHours(actualHours).setRemainingHours(remainingHours).setRecords(workLogVOs); + } + + @Override + public List getWorkItemWorkLogListByWorkItemIds(Collection workItemIds) { + if (CollUtil.isEmpty(workItemIds)) { + return Collections.emptyList(); + } + return workLogMapper.selectListByWorkItemIds(workItemIds); + } + + @Override + public PmsProjectWorkLogReportRespVO getProjectWorkItemWorkLogReport( + PmsProjectWorkLogReportReqVO reqVO, Long userId) { + // 1.1 校验项目可访问 + projectMemberService.validateProjectReadable(reqVO.getProjectId(), userId); + // 1.2 校验工时登记时间范围 + LocalDateTime beginTime = reqVO.getCreateTime()[0]; + LocalDateTime endTime = reqVO.getCreateTime()[1]; + if (endTime.isBefore(beginTime)) { + throw exception(WORK_ITEM_WORK_LOG_INVALID); + } + + // 2.1 查询范围内工时和关联工作项 + List workLogs = workLogMapper.selectListByProjectIdAndCreateTimeBetween( + reqVO.getProjectId(), beginTime, endTime); + Map workItemMap = convertMap(workItemService.getWorkItemList( + convertSet(workLogs, PmsWorkItemWorkLogDO::getWorkItemId)), PmsWorkItemDO::getId); + // 2.2 查询迭代并按迭代名称筛选 + Set iterationIds = convertSet(workItemMap.values(), PmsWorkItemDO::getIterationId); + iterationIds.remove(null); + Map iterationMap = convertMap(iterationService.getIterationList(iterationIds), + PmsIterationDO::getId); + if (StrUtil.isNotBlank(reqVO.getIterationName())) { + workItemMap.entrySet().removeIf(entry -> { + PmsIterationDO iteration = entry.getValue().getIterationId() != null + ? iterationMap.get(entry.getValue().getIterationId()) : null; + String currentIterationName = iteration != null ? iteration.getName() : "未规划事项"; + return !StrUtil.containsIgnoreCase(currentIterationName, reqVO.getIterationName()); + }); + workLogs.removeIf(workLog -> !workItemMap.containsKey(workLog.getWorkItemId())); + } + + // 3. 按迭代和工作项汇总每日工时 + Map> groupedWorkItems = convertMultiMap(workItemMap.values(), + item -> item.getIterationId() != null ? item.getIterationId() : 0L); + Map> groupedWorkLogs = convertMultiMap( + workLogs, PmsWorkItemWorkLogDO::getWorkItemId); + List groups = new ArrayList<>(); + for (Map.Entry> entry : groupedWorkItems.entrySet()) { + Long iterationId = entry.getKey() != 0L ? entry.getKey() : null; + List items = new ArrayList<>(); + for (PmsWorkItemDO workItem : entry.getValue()) { + Map dailyHours = new LinkedHashMap<>(); + for (PmsWorkItemWorkLogDO workLog : groupedWorkLogs.getOrDefault( + workItem.getId(), Collections.emptyList())) { + dailyHours.merge(workLog.getCreateTime().toLocalDate().toString(), + workLog.getActualHours(), Integer::sum); + } + int totalHours = dailyHours.values().stream().mapToInt(Integer::intValue).sum(); + items.add(new PmsProjectWorkLogReportRespVO.Item().setWorkItemId(workItem.getId()) + .setSerialNumber(workItem.getSerialNumber()).setName(workItem.getName()) + .setType(workItem.getType()).setTotalHours(totalHours).setDailyHours(dailyHours)); + } + items.sort(Comparator.comparingInt(PmsProjectWorkLogReportRespVO.Item::getSerialNumber)); + PmsIterationDO iteration = iterationId != null ? iterationMap.get(iterationId) : null; + groups.add(new PmsProjectWorkLogReportRespVO.Group().setIterationId(iterationId) + .setIterationName(iteration != null ? iteration.getName() : "未规划事项") + .setTotalHours(items.stream().mapToInt(PmsProjectWorkLogReportRespVO.Item::getTotalHours).sum()) + .setItems(items)); + } + groups.sort((first, second) -> first.getIterationId() == null ? 1 + : second.getIterationId() == null ? -1 : first.getIterationName().compareTo(second.getIterationName())); + + // 4. 返回连续日期轴和总工时 + LocalDate beginDate = beginTime.toLocalDate(); + LocalDate endDate = endTime.toLocalDate(); + List dates = new ArrayList<>(); + for (LocalDate date = beginDate; !date.isAfter(endDate); date = date.plusDays(1)) { + dates.add(date.toString()); + } + return new PmsProjectWorkLogReportRespVO().setDates(dates) + .setTotalHours(groups.stream().mapToInt(PmsProjectWorkLogReportRespVO.Group::getTotalHours).sum()) + .setGroups(groups); + } + + @Override + public void deleteWorkItemWorkLogListByProjectId(Long projectId) { + workLogMapper.deleteByProjectId(projectId); + } + + @Override + public void deleteWorkItemWorkLogListByWorkItemId(Long workItemId) { + workLogMapper.deleteByWorkItemId(workItemId); + } + + /** + * 校验工时记录存在 + * + * @param id 工时记录编号 + * @return 工时记录 + */ + private PmsWorkItemWorkLogDO validateWorkLogExists(Long id) { + PmsWorkItemWorkLogDO workLog = workLogMapper.selectById(id); + if (workLog == null) { + throw exception(WORK_ITEM_WORK_LOG_NOT_EXISTS); + } + return workLog; + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/resources/application-dev.yaml b/yudao-module-pms/yudao-module-pms-server/src/main/resources/application-dev.yaml new file mode 100644 index 000000000..522c0145e --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/resources/application-dev.yaml @@ -0,0 +1,120 @@ +--- #################### 注册中心 + 配置中心相关配置 #################### + +spring: + cloud: + nacos: + server-addr: 127.0.0.1:8848 # Nacos 服务器地址 + username: # Nacos 账号 + password: # Nacos 密码 + discovery: # 【配置中心】配置项 + namespace: dev # 命名空间。这里使用 dev 开发环境 + group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP + metadata: + version: 1.0.0 # 服务实例的版本号,可用于灰度发布 + config: # 【注册中心】配置项 + namespace: dev # 命名空间。这里使用 dev 开发环境 + group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP + +--- #################### 数据库相关配置 #################### +spring: + # 数据源配置项 + autoconfigure: + exclude: + datasource: + druid: # Druid 【监控】相关的全局配置 + web-stat-filter: + enabled: true + stat-view-servlet: + enabled: true + allow: # 设置白名单,不填则允许所有访问 + url-pattern: /druid/* + login-username: # 控制台管理用户名和密码 + login-password: + filter: + stat: + enabled: true + log-slow-sql: true # 慢 SQL 记录 + slow-sql-millis: 100 + merge-sql: true + wall: + config: + multi-statement-allow: true + dynamic: # 多数据源配置 + druid: # Druid 【连接池】相关的全局配置 + initial-size: 5 # 初始连接数 + min-idle: 10 # 最小连接池数量 + max-active: 20 # 最大连接池数量 + max-wait: 60000 # 配置获取连接等待超时的时间,单位:毫秒(1 分钟) + time-between-eviction-runs-millis: 60000 # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位:毫秒 + min-evictable-idle-time-millis: 600000 # 配置一个连接在池中最小生存的时间,单位:毫秒(10 分钟) + max-evictable-idle-time-millis: 1800000 # 配置一个连接在池中最大生存的时间,单位:毫秒(30 分钟) + validation-query: SELECT 1 FROM DUAL # 配置检测连接是否有效 + test-while-idle: true + test-on-borrow: false + test-on-return: false + pool-prepared-statements: true # 是否开启 PreparedStatement 缓存 + max-pool-prepared-statement-per-connection-size: 20 # 每个连接缓存的 PreparedStatement 数量 + primary: master + datasource: + master: + url: jdbc:mysql://127.0.0.1:3306/ruoyi-vue-pro?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X 连接的示例 + username: root + password: 123456 + slave: # 模拟从库,可根据自己需要修改 # 模拟从库,可根据自己需要修改 + lazy: true # 开启懒加载,保证启动速度 + url: jdbc:mysql://127.0.0.1:3306/ruoyi-vue-pro?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X 连接的示例 + username: root + password: 123456 + + # Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优 + data: + redis: + host: 400-infra.server.iocoder.cn # 地址 + port: 6379 # 端口 + database: 1 # 数据库索引 +# password: 123456 # 密码,建议生产环境开启 + +--- #################### MQ 消息队列相关配置 #################### + +--- #################### 定时任务相关配置 #################### +xxl: + job: + enabled: false # 是否开启调度中心,默认为 true 开启 + admin: + addresses: http://127.0.0.1:9090/xxl-job-admin # 调度中心部署跟地址 + +--- #################### 服务保障相关配置 #################### + +# Lock4j 配置项 +lock4j: + acquire-timeout: 3000 # 获取分布式锁超时时间,默认为 3000 毫秒 + expire: 30000 # 分布式锁的超时时间,默认为 30000 毫秒 + +--- #################### 监控相关配置 #################### + +# Actuator 监控端点的配置项 +management: + endpoints: + web: + base-path: /actuator # Actuator 提供的 API 接口的根目录。默认为 /actuator + exposure: + include: '*' # 需要开放的端点。默认值只打开 health 和 info 两个端点。通过设置 * ,可以开放所有端点。 + +# Spring Boot Admin 配置项 +spring: + boot: + admin: + # Spring Boot Admin Client 客户端的相关配置 + client: + instance: + service-host-type: IP # 注册实例时,优先使用 IP [IP, HOST_NAME, CANONICAL_HOST_NAME] + username: admin + password: admin + +--- #################### 芋道相关配置 #################### + +# 芋道配置项,设置当前项目所有自定义的配置 +yudao: + access-log: # 访问日志的配置项 + enable: false + demo: false # 关闭演示模式 diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/resources/application-local.yaml b/yudao-module-pms/yudao-module-pms-server/src/main/resources/application-local.yaml new file mode 100644 index 000000000..1f3360add --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/resources/application-local.yaml @@ -0,0 +1,142 @@ +--- #################### 注册中心 + 配置中心相关配置 #################### + +spring: + cloud: + nacos: + server-addr: 127.0.0.1:8848 # Nacos 服务器地址 + username: # Nacos 账号 + password: # Nacos 密码 + discovery: # 【配置中心】配置项 + namespace: dev # 命名空间。这里使用 dev 开发环境 + group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP + metadata: + version: 1.0.0 # 服务实例的版本号,可用于灰度发布 + config: # 【注册中心】配置项 + namespace: dev # 命名空间。这里使用 dev 开发环境 + group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP + +--- #################### 数据库相关配置 #################### +spring: + # 数据源配置项 + autoconfigure: + exclude: + datasource: + druid: # Druid 【监控】相关的全局配置 + web-stat-filter: + enabled: true + stat-view-servlet: + enabled: true + allow: # 设置白名单,不填则允许所有访问 + url-pattern: /druid/* + login-username: # 控制台管理用户名和密码 + login-password: + filter: + stat: + enabled: true + log-slow-sql: true # 慢 SQL 记录 + slow-sql-millis: 100 + merge-sql: true + wall: + config: + multi-statement-allow: true + dynamic: # 多数据源配置 + druid: # Druid 【连接池】相关的全局配置 + initial-size: 1 # 初始连接数 + min-idle: 1 # 最小连接池数量 + max-active: 20 # 最大连接池数量 + max-wait: 60000 # 配置获取连接等待超时的时间,单位:毫秒(1 分钟) + time-between-eviction-runs-millis: 60000 # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位:毫秒 + min-evictable-idle-time-millis: 600000 # 配置一个连接在池中最小生存的时间,单位:毫秒(10 分钟) + max-evictable-idle-time-millis: 1800000 # 配置一个连接在池中最大生存的时间,单位:毫秒(30 分钟) + validation-query: SELECT 1 FROM DUAL # 配置检测连接是否有效 + test-while-idle: true + test-on-borrow: false + test-on-return: false + pool-prepared-statements: true # 是否开启 PreparedStatement 缓存 + max-pool-prepared-statement-per-connection-size: 20 # 每个连接缓存的 PreparedStatement 数量 + primary: master + datasource: + master: + url: jdbc:mysql://127.0.0.1:3306/ruoyi-vue-pro?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X 连接的示例 + # url: jdbc:mysql://127.0.0.1:3306/ruoyi-vue-pro?useSSL=true&allowPublicKeyRetrieval=true&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true # MySQL Connector/J 5.X 连接的示例 + # url: jdbc:postgresql://127.0.0.1:5432/ruoyi-vue-pro # PostgreSQL 连接的示例 + # url: jdbc:postgresql://127.0.0.1:5866/ruoyi-vue-pro # 瀚高 HighGo 连接的示例 + # url: jdbc:oracle:thin:@127.0.0.1:1521:xe # Oracle 连接的示例 + # url: jdbc:sqlserver://127.0.0.1:1433;DatabaseName=ruoyi-vue-pro # SQLServer 连接的示例 + # url: jdbc:dm://10.211.55.4:5236?schema=RUOYI_VUE_PRO # DM 连接的示例 + username: root + password: 123456 + # username: sa # SQL Server 连接的示例 + # password: JSm:g(*%lU4ZAkz06cd52KqT3)i1?H7W # SQL Server 连接的示例 + # username: SYSDBA # DM 连接的示例 + # password: SYSDBA # DM 连接的示例 + # username: root # 瀚高 HighGo 连接的示例 + # password: 123456 # 瀚高 HighGo 连接的示例 + slave: # 模拟从库,可根据自己需要修改 + lazy: true # 开启懒加载,保证启动速度 + url: jdbc:mysql://127.0.0.1:3306/ruoyi-vue-pro?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true + username: root + password: 123456 + + # Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优 + data: + redis: + host: 127.0.0.1 # 地址 + port: 6379 # 端口 + database: 0 # 数据库索引 +# password: 123456 # 密码,建议生产环境开启 + +--- #################### MQ 消息队列相关配置 #################### + +--- #################### 定时任务相关配置 #################### +xxl: + job: + enabled: false # 是否开启调度中心,默认为 true 开启 + admin: + addresses: http://127.0.0.1:9090/xxl-job-admin # 调度中心部署跟地址 + +--- #################### 服务保障相关配置 #################### + +# Lock4j 配置项 +lock4j: + acquire-timeout: 3000 # 获取分布式锁超时时间,默认为 3000 毫秒 + expire: 30000 # 分布式锁的超时时间,默认为 30000 毫秒 + +--- #################### 监控相关配置 #################### + +# Actuator 监控端点的配置项 +management: + endpoints: + web: + base-path: /actuator # Actuator 提供的 API 接口的根目录。默认为 /actuator + exposure: + include: '*' # 需要开放的端点。默认值只打开 health 和 info 两个端点。通过设置 * ,可以开放所有端点。 + +# Spring Boot Admin 配置项 +spring: + boot: + admin: + # Spring Boot Admin Client 客户端的相关配置 + client: + instance: + service-host-type: IP # 注册实例时,优先使用 IP [IP, HOST_NAME, CANONICAL_HOST_NAME] + username: admin + password: admin + +# 日志文件配置 +logging: + level: + # 配置自己写的 MyBatis Mapper 打印日志 + cn.iocoder.yudao.module.pms.dal.mysql: debug + org.springframework.context.support.PostProcessorRegistrationDelegate: ERROR # TODO 芋艿:先禁用,Spring Boot 3.X 存在部分错误的 WARN 提示 + +--- #################### 芋道相关配置 #################### + +# 芋道配置项,设置当前项目所有自定义的配置 +yudao: + env: # 多环境的配置项 + tag: ${HOSTNAME} + security: + mock-enable: true + access-log: # 访问日志的配置项 + enable: false diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/resources/application.yaml b/yudao-module-pms/yudao-module-pms-server/src/main/resources/application.yaml new file mode 100644 index 000000000..50c12c106 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/resources/application.yaml @@ -0,0 +1,124 @@ +spring: + application: + name: pms-server + + profiles: + active: local + + main: + allow-circular-references: true # 允许循环依赖,因为项目是三层架构,无法避免这个情况。 + allow-bean-definition-overriding: true # 允许 Bean 覆盖,例如说 Feign 等会存在重复定义的服务 + + config: + import: + - optional:classpath:application-${spring.profiles.active}.yaml # 加载【本地】配置 + - optional:nacos:${spring.application.name}-${spring.profiles.active}.yaml # 加载【Nacos】的配置 + + # Servlet 配置 + servlet: + # 文件上传相关配置项 + multipart: + max-file-size: 16MB # 单个文件大小 + max-request-size: 32MB # 设置总上传的文件大小 + + # Jackson 配置项 + jackson: + serialization: + write-dates-as-timestamps: true # 设置 LocalDateTime 的格式,使用时间戳 + write-date-timestamps-as-nanoseconds: false # 设置不使用 nanoseconds 的格式。例如说 1611460870.401,而是直接 1611460870401 + write-durations-as-timestamps: true # 设置 Duration 的格式,使用时间戳 + fail-on-empty-beans: false # 允许序列化无属性的 Bean + + # Cache 配置项 + cache: + type: REDIS + redis: + time-to-live: 1h # 设置过期时间为 1 小时 + +server: + port: 48096 + +logging: + file: + name: ${user.home}/logs/${spring.application.name}.log # 日志文件名,全路径 + +--- #################### 接口文档配置 #################### + +springdoc: + api-docs: + enabled: true # 1. 是否开启 Swagger 接文档的元数据 + path: /v3/api-docs + swagger-ui: + enabled: true # 2.1 是否开启 Swagger 文档的官方 UI 界面 + path: /swagger-ui + default-flat-param-object: true # 参见 https://doc.xiaominfo.com/docs/faq/v4/knife4j-parameterobject-flat-param 文档 + +knife4j: + enable: true + setting: + language: zh_cn + +# MyBatis Plus 的配置项 +mybatis-plus: + configuration: + map-underscore-to-camel-case: true # 虽然默认为 true ,但是还是显示去指定下。 + global-config: + db-config: + id-type: NONE # "智能"模式,基于 IdTypeEnvironmentPostProcessor + 数据源的类型,自动适配成 AUTO、INPUT 模式。 + # id-type: AUTO # 自增 ID,适合 MySQL 等直接自增的数据库 + # id-type: INPUT # 用户输入 ID,适合 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库 + # id-type: ASSIGN_ID # 分配 ID,默认使用雪花算法。注意,Oracle、PostgreSQL、Kingbase、DB2、H2 数据库时,需要去除实体类上的 @KeySequence 注解 + logic-delete-value: 1 # 逻辑已删除值(默认为 1) + logic-not-delete-value: 0 # 逻辑未删除值(默认为 0) + banner: false # 关闭控制台的 Banner 打印 + type-aliases-package: ${yudao.info.base-package}.dal.dataobject + encryptor: + password: XDV71a+xqStEA3WH # 加解密的秘钥,可使用 https://www.imaegoo.com/2020/aes-key-generator/ 网站生成 + +mybatis-plus-join: + banner: false # 关闭控制台的 Banner 打印 + +# Spring Data Redis 配置 +spring: + data: + redis: + repositories: + enabled: false # 项目未使用到 Spring Data Redis 的 Repository,所以直接禁用,保证启动速度 + +# VO 转换(数据翻译)相关 +easy-trans: + is-enable-global: false # 【默认禁用,对性能确认压力大】启用全局翻译(拦截所有 SpringMVC ResponseBody 进行自动翻译 )。如果对于性能要求很高可关闭此配置,或通过 @IgnoreTrans 忽略某个接口 + +--- #################### MQ 消息队列相关配置 #################### + +--- #################### 定时任务相关配置 #################### + +xxl: + job: + executor: + appname: ${spring.application.name} # 执行器 AppName + logpath: ${user.home}/logs/xxl-job/${spring.application.name} # 执行器运行日志文件存储磁盘路径 + accessToken: default_token # 执行器通讯TOKEN + +--- #################### 芋道相关配置 #################### + +yudao: + info: + version: 1.0.0 + base-package: cn.iocoder.yudao.module.pms + web: + admin-ui: + url: http://dashboard.yudao.iocoder.cn # Admin 管理后台 UI 的地址 + xss: + enable: false + exclude-urls: # 如下 url,仅仅是为了演示,去掉配置也没关系 + - ${management.endpoints.web.base-path}/** # 不处理 Actuator 的请求 + swagger: + title: 管理后台 + description: 提供管理员管理的所有功能 + version: ${yudao.info.version} + tenant: # 多租户相关配置项 + enable: true + ignore-urls: + +debug: false diff --git a/yudao-module-pms/yudao-module-pms-server/src/main/resources/logback-spring.xml b/yudao-module-pms/yudao-module-pms-server/src/main/resources/logback-spring.xml new file mode 100644 index 000000000..15b28cfdf --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/main/resources/logback-spring.xml @@ -0,0 +1,56 @@ + + + + + + + + +       + + ${CONSOLE_LOG_PATTERN} + + + + + + + + ${FILE_LOG_PATTERN} + + + ${LOG_FILE} + + + ${LOG_FILE}.%d{yyyy-MM-dd}.%i.log + 30 + 10MB + + + + + 0 + 512 + + + + + + + + + + + + + + + diff --git a/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/PmsKnowledgeDocumentShareControllerTest.java b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/PmsKnowledgeDocumentShareControllerTest.java new file mode 100644 index 000000000..30e927988 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/controller/admin/kb/interaction/PmsKnowledgeDocumentShareControllerTest.java @@ -0,0 +1,50 @@ +package cn.iocoder.yudao.module.pms.controller.admin.kb.interaction; + +import cn.iocoder.yudao.module.infra.api.file.FileApi; +import cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.share.PmsKnowledgeDocumentSharePublicRespVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeDocumentDO; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeDocumentTypeEnum; +import cn.iocoder.yudao.module.pms.service.kb.interaction.PmsKnowledgeDocumentShareService; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link PmsKnowledgeDocumentShareController} 的单元测试类 + * + * @author 芋道源码 + */ +public class PmsKnowledgeDocumentShareControllerTest { + + @Test + public void testGetDocumentByToken_fileUsesDefaultPreviewUrl() { + // mock 数据 + String token = "share-token"; + String rawUrl = "https://file.example.com/private/demo.pdf"; + String previewUrl = "https://file.example.com/private/demo.pdf?signature=temporary"; + PmsKnowledgeDocumentDO document = new PmsKnowledgeDocumentDO().setId(1L).setTitle("测试文件") + .setType(PmsKnowledgeDocumentTypeEnum.FILE.getType()).setContent(rawUrl).setFileType("pdf"); + PmsKnowledgeDocumentShareService shareService = mock(PmsKnowledgeDocumentShareService.class); + FileApi fileApi = mock(FileApi.class); + when(shareService.getDocumentByShareToken(token)).thenReturn(document); + when(fileApi.presignGetUrl(rawUrl, null)).thenReturn(success(previewUrl)); + PmsKnowledgeDocumentShareController controller = new PmsKnowledgeDocumentShareController(); + ReflectionTestUtils.setField(controller, "documentShareService", shareService); + ReflectionTestUtils.setField(controller, "fileApi", fileApi); + + // 调用 + PmsKnowledgeDocumentSharePublicRespVO result = controller.getDocumentByToken(token).getData(); + + // 断言 + assertNull(result.getContent()); + assertEquals(previewUrl, result.getPreviewUrl()); + verify(fileApi).presignGetUrl(rawUrl, null); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeContentPermissionServiceImplTest.java b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeContentPermissionServiceImplTest.java new file mode 100644 index 000000000..59f10c095 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeContentPermissionServiceImplTest.java @@ -0,0 +1,280 @@ +package cn.iocoder.yudao.module.pms.service.kb.content; + +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeContentPermissionDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeContentPermissionMemberDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeFolderDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryMemberDO; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.content.PmsKnowledgeContentPermissionMapper; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.content.PmsKnowledgeContentPermissionMemberMapper; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.content.PmsKnowledgeFolderMapper; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.library.PmsKnowledgeLibraryMemberMapper; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeContentLevelEnum; +import cn.iocoder.yudao.module.pms.enums.kb.library.PmsKnowledgeLibraryMemberLevelEnum; +import cn.iocoder.yudao.module.pms.service.kb.library.PmsKnowledgeLibraryMemberService; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.permission.PermissionApi; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link PmsKnowledgeContentPermissionServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(PmsKnowledgeContentPermissionServiceImpl.class) +public class PmsKnowledgeContentPermissionServiceImplTest extends BaseDbUnitTest { + + @Resource + private PmsKnowledgeContentPermissionServiceImpl permissionService; + + @Resource + private PmsKnowledgeContentPermissionMapper permissionMapper; + @Resource + private PmsKnowledgeContentPermissionMemberMapper permissionMemberMapper; + @Resource + private PmsKnowledgeFolderMapper folderMapper; + @Resource + private PmsKnowledgeLibraryMemberMapper libraryMemberMapper; + + @MockitoBean + private PmsKnowledgeLibraryMemberService libraryMemberService; + @MockitoBean + private PmsKnowledgeFolderService folderService; + @MockitoBean + private PmsKnowledgeDocumentService documentService; + @MockitoBean + private AdminUserApi adminUserApi; + @MockitoBean + private DeptApi deptApi; + @MockitoBean + private PermissionApi permissionApi; + + @BeforeEach + public void setUpCloudApiMocks() { + lenient().when(permissionApi.hasAnyRoles(anyLong(), any(String[].class))).thenReturn(success(false)); + lenient().when(adminUserApi.getUser(anyLong())).thenReturn(success(null)); + lenient().when(adminUserApi.validateUserList(anyCollection())).thenReturn(success(true)); + lenient().when(deptApi.validateDeptList(anyCollection())).thenReturn(success(true)); + } + + @Test + public void testCreateDefaultPermission() { + // 准备参数 + Long libraryId = randomLongId(); + Long userId = randomLongId(); + + // 调用 + Long permissionId = permissionService.createDefaultContentPermission(libraryId, userId); + + // 断言 + PmsKnowledgeContentPermissionDO permission = permissionMapper.selectById(permissionId); + assertNotNull(permission); + assertEquals(libraryId, permission.getLibraryId()); + assertTrue(permission.getOpenStatus()); + assertEquals(PmsKnowledgeContentLevelEnum.PREVIEW.getLevel(), permission.getOpenLevel()); + PmsKnowledgeContentPermissionMemberDO owner = permissionMemberMapper.selectByPermissionIdAndUserId(permissionId, userId); + assertNotNull(owner); + assertEquals(PmsKnowledgeContentLevelEnum.MANAGE.getLevel(), owner.getLevel()); + } + + @Test + public void testGetCurrentUserLevel_openBeforeUserAndDept() { + // mock 数据 + Long libraryId = randomLongId(); + Long userId = randomLongId(); + Long deptId = randomLongId(); + PmsKnowledgeContentPermissionDO permission = randomPermissionDO(libraryId, true, + PmsKnowledgeContentLevelEnum.PREVIEW.getLevel()); + permissionMapper.insert(permission); + permissionMemberMapper.insert(new PmsKnowledgeContentPermissionMemberDO().setPermissionId(permission.getId()).setDeptId(deptId) + .setLevel(PmsKnowledgeContentLevelEnum.DOWNLOAD.getLevel())); + permissionMemberMapper.insert(new PmsKnowledgeContentPermissionMemberDO().setPermissionId(permission.getId()).setUserId(userId) + .setLevel(PmsKnowledgeContentLevelEnum.EDIT.getLevel())); + + // mock 方法 + when(libraryMemberService.isLibraryAdmin(libraryId, userId)).thenReturn(false); + when(adminUserApi.getUser(userId)).thenReturn(success( + new AdminUserRespDTO().setId(userId).setDeptId(deptId))); + + // 调用 + Integer level = permissionService.getCurrentUserContentPermissionLevel(permission.getId(), libraryId, userId); + + // 断言 + assertEquals(PmsKnowledgeContentLevelEnum.PREVIEW.getLevel(), level); + } + + @Test + public void testGetContentPermissionMemberList_readableUser() { + // mock 数据 + Long libraryId = randomLongId(); + Long userId = randomLongId(); + Long collaboratorUserId = randomLongId(); + PmsKnowledgeContentPermissionDO permission = randomPermissionDO(libraryId, true, + PmsKnowledgeContentLevelEnum.PREVIEW.getLevel()); + permissionMapper.insert(permission); + permissionMemberMapper.insert(new PmsKnowledgeContentPermissionMemberDO() + .setPermissionId(permission.getId()).setUserId(collaboratorUserId) + .setLevel(PmsKnowledgeContentLevelEnum.EDIT.getLevel())); + + // mock 方法 + when(libraryMemberService.isLibraryAdmin(libraryId, userId)).thenReturn(false); + when(adminUserApi.getUser(userId)).thenReturn(success(new AdminUserRespDTO().setId(userId))); + + // 调用 + Set memberUserIds = new LinkedHashSet<>(); + permissionService.getContentPermissionMemberList(permission.getId(), userId) + .forEach(member -> memberUserIds.add(member.getUserId())); + + // 断言 + assertEquals(Collections.singleton(collaboratorUserId), memberUserIds); + verify(libraryMemberService).validateLibraryReadable(libraryId, userId); + } + + @Test + public void testGetCurrentUserLevel_privateDenied() { + // mock 数据 + Long libraryId = randomLongId(); + Long userId = randomLongId(); + PmsKnowledgeContentPermissionDO permission = randomPermissionDO(libraryId, false, + PmsKnowledgeContentLevelEnum.PREVIEW.getLevel()); + permissionMapper.insert(permission); + + // mock 方法 + when(libraryMemberService.isLibraryAdmin(libraryId, userId)).thenReturn(false); + when(adminUserApi.getUser(userId)).thenReturn(success(new AdminUserRespDTO().setId(userId))); + + // 调用 + Integer level = permissionService.getCurrentUserContentPermissionLevel(permission.getId(), libraryId, userId); + + // 断言 + assertNull(level); + } + + @Test + public void testClonePermissions_copiesMembersAndChangesLibrary() { + // mock 数据 + Long sourceLibraryId = randomLongId(); + Long targetLibraryId = randomLongId(); + Long userId = randomLongId(); + PmsKnowledgeContentPermissionDO source = randomPermissionDO(sourceLibraryId, false, + PmsKnowledgeContentLevelEnum.PREVIEW.getLevel()); + permissionMapper.insert(source); + permissionMemberMapper.insert(new PmsKnowledgeContentPermissionMemberDO().setPermissionId(source.getId()).setUserId(userId) + .setLevel(PmsKnowledgeContentLevelEnum.MANAGE.getLevel())); + + // 调用 + Map clonedPermissionIdMap = permissionService.cloneContentPermissions( + Collections.singleton(source.getId()), targetLibraryId); + + // 断言 + Long targetPermissionId = clonedPermissionIdMap.get(source.getId()); + assertNotNull(targetPermissionId); + assertNotEquals(source.getId(), targetPermissionId); + PmsKnowledgeContentPermissionDO target = permissionMapper.selectById(targetPermissionId); + assertEquals(targetLibraryId, target.getLibraryId()); + assertFalse(target.getOpenStatus()); + assertNotNull(permissionMemberMapper.selectByPermissionIdAndUserId(targetPermissionId, userId)); + } + + @Test + public void testGetReadablePermissionIds_batchAcrossLibraries() { + // mock 数据 + Long userId = randomLongId(); + Long deptId = randomLongId(); + Long adminLibraryId = randomLongId(); + Long collaboratorLibraryId = randomLongId(); + Long deniedLibraryId = randomLongId(); + PmsKnowledgeContentPermissionDO adminPermission = randomPermissionDO(adminLibraryId, false, + PmsKnowledgeContentLevelEnum.PREVIEW.getLevel()); + permissionMapper.insert(adminPermission); + PmsKnowledgeContentPermissionDO collaboratorPermission = randomPermissionDO(collaboratorLibraryId, false, + PmsKnowledgeContentLevelEnum.PREVIEW.getLevel()); + permissionMapper.insert(collaboratorPermission); + PmsKnowledgeContentPermissionDO deniedPermission = randomPermissionDO(deniedLibraryId, false, + PmsKnowledgeContentLevelEnum.PREVIEW.getLevel()); + permissionMapper.insert(deniedPermission); + libraryMemberMapper.insert(new PmsKnowledgeLibraryMemberDO().setLibraryId(adminLibraryId).setUserId(userId) + .setLevel(PmsKnowledgeLibraryMemberLevelEnum.ADMIN.getLevel())); + permissionMemberMapper.insert(new PmsKnowledgeContentPermissionMemberDO() + .setPermissionId(collaboratorPermission.getId()).setDeptId(deptId) + .setLevel(PmsKnowledgeContentLevelEnum.PREVIEW.getLevel())); + + // mock 方法 + when(adminUserApi.getUser(userId)).thenReturn(success( + new AdminUserRespDTO().setId(userId).setDeptId(deptId))); + + // 调用 + Set permissionIds = permissionService.getReadableContentPermissionIdSet( + Arrays.asList(adminLibraryId, collaboratorLibraryId, deniedLibraryId), userId); + + // 断言 + assertEquals(new LinkedHashSet<>(Arrays.asList(adminPermission.getId(), collaboratorPermission.getId())), + permissionIds); + verify(libraryMemberService, never()).isLibraryAdmin(anyLong(), anyLong()); + } + + @Test + public void testDeleteUnusedPermissions_preservesReferencedPermission() { + // mock 数据 + Long libraryId = randomLongId(); + PmsKnowledgeContentPermissionDO referenced = randomPermissionDO(libraryId, true, + PmsKnowledgeContentLevelEnum.PREVIEW.getLevel()); + permissionMapper.insert(referenced); + PmsKnowledgeContentPermissionDO unused = randomPermissionDO(libraryId, true, + PmsKnowledgeContentLevelEnum.PREVIEW.getLevel()); + permissionMapper.insert(unused); + folderMapper.insert(new PmsKnowledgeFolderDO().setLibraryId(libraryId).setPermissionId(referenced.getId()) + .setParentId(0L).setTitle("测试目录").setStatus(1)); + when(folderService.getExistingContentPermissionIdSet(anyCollection())) + .thenReturn(Collections.singleton(referenced.getId())); + when(documentService.getExistingContentPermissionIdSet(anyCollection())) + .thenReturn(Collections.emptySet()); + + // 调用 + permissionService.deleteUnusedContentPermissions(new LinkedHashSet<>( + Arrays.asList(referenced.getId(), unused.getId()))); + + // 断言 + assertNotNull(permissionMapper.selectById(referenced.getId())); + assertNull(permissionMapper.selectById(unused.getId())); + } + + // ========== 随机对象 ========== + + private PmsKnowledgeContentPermissionDO randomPermissionDO(Long libraryId, boolean openStatus, + Integer openLevel) { + return randomPojo(PmsKnowledgeContentPermissionDO.class, permission -> permission.setId(null) + .setLibraryId(libraryId).setOpenStatus(openStatus).setOpenLevel(openLevel) + .setCreator(String.valueOf(randomLongId()))); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeDocumentLabelServiceImplTest.java b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeDocumentLabelServiceImplTest.java new file mode 100644 index 000000000..daa10a767 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeDocumentLabelServiceImplTest.java @@ -0,0 +1,158 @@ +package cn.iocoder.yudao.module.pms.service.kb.content; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.label.PmsKnowledgeDocumentLabelPageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.label.PmsKnowledgeDocumentLabelSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeDocumentDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeDocumentLabelDO; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.content.PmsKnowledgeDocumentLabelMapper; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.content.PmsKnowledgeDocumentMapper; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeDocumentStatusEnum; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeDocumentTypeEnum; +import cn.iocoder.yudao.module.pms.service.kb.library.PmsKnowledgeLibraryMemberService; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_DOCUMENT_LABEL_NOT_EXISTS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.when; + +/** + * {@link PmsKnowledgeDocumentLabelServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(PmsKnowledgeDocumentLabelServiceImpl.class) +public class PmsKnowledgeDocumentLabelServiceImplTest extends BaseDbUnitTest { + + @Resource + private PmsKnowledgeDocumentLabelServiceImpl documentLabelService; + + @Resource + private PmsKnowledgeDocumentLabelMapper documentLabelMapper; + @Resource + private PmsKnowledgeDocumentMapper documentMapper; + + @MockitoBean + private PmsKnowledgeLibraryMemberService libraryMemberService; + @MockitoBean + private PmsKnowledgeContentPermissionService contentPermissionService; + + @Test + public void testCreateDocumentLabel_success() { + // 准备参数 + PmsKnowledgeDocumentLabelSaveReqVO reqVO = new PmsKnowledgeDocumentLabelSaveReqVO() + .setName(" 产品 ").setColor(" #409EFF "); + + // 调用 + Long id = documentLabelService.createDocumentLabel(reqVO); + + // 断言 + PmsKnowledgeDocumentLabelDO documentLabel = documentLabelMapper.selectById(id); + assertEquals("产品", documentLabel.getName()); + assertEquals("#409EFF", documentLabel.getColor()); + } + + @Test + public void testUpdateDocumentLabel_notExists() { + // 准备参数 + PmsKnowledgeDocumentLabelSaveReqVO reqVO = new PmsKnowledgeDocumentLabelSaveReqVO() + .setId(randomLongId()).setName("产品").setColor("#409EFF"); + + // 调用,并断言异常 + assertServiceException(() -> documentLabelService.updateDocumentLabel(reqVO), + KNOWLEDGE_DOCUMENT_LABEL_NOT_EXISTS); + } + + @Test + public void testDeleteDocumentLabel_success() { + // mock 数据 + PmsKnowledgeDocumentLabelDO documentLabel = randomDocumentLabelDO(); + documentLabelMapper.insert(documentLabel); + + // 调用 + documentLabelService.deleteDocumentLabel(documentLabel.getId()); + + // 断言 + assertNull(documentLabelMapper.selectById(documentLabel.getId())); + } + + @Test + public void testGetDocumentLabel_success() { + // mock 数据 + PmsKnowledgeDocumentLabelDO documentLabel = randomDocumentLabelDO(); + documentLabelMapper.insert(documentLabel); + + // 调用 + PmsKnowledgeDocumentLabelDO result = documentLabelService.getDocumentLabel(documentLabel.getId()); + + // 断言 + assertEquals(documentLabel.getId(), result.getId()); + assertEquals(documentLabel.getName(), result.getName()); + assertEquals(documentLabel.getColor(), result.getColor()); + } + + @Test + public void testGetDocumentPageByLabel_success() { + // mock 数据 + Long userId = randomLongId(); + Long libraryId = randomLongId(); + PmsKnowledgeDocumentLabelDO targetLabel = randomDocumentLabelDO(); + documentLabelMapper.insert(targetLabel); + PmsKnowledgeDocumentLabelDO otherLabel = randomDocumentLabelDO(); + documentLabelMapper.insert(otherLabel); + PmsKnowledgeDocumentDO targetDocument = randomDocumentDO(libraryId, + Arrays.asList(targetLabel.getId(), otherLabel.getId())); + PmsKnowledgeDocumentDO otherDocument = randomDocumentDO(libraryId, + Collections.singletonList(otherLabel.getId())); + PmsKnowledgeDocumentDO unreadableDocument = randomDocumentDO(libraryId, + Collections.singletonList(targetLabel.getId())); + documentMapper.insert(targetDocument); + documentMapper.insert(otherDocument); + documentMapper.insert(unreadableDocument); + when(libraryMemberService.getReadableLibraryIdList(userId)).thenReturn(Collections.singletonList(libraryId)); + when(contentPermissionService.getReadableContentPermissionIdSet(Collections.singletonList(libraryId), userId)) + .thenReturn(new HashSet<>(Arrays.asList(targetDocument.getPermissionId(), otherDocument.getPermissionId()))); + // 准备参数 + PmsKnowledgeDocumentLabelPageReqVO pageReqVO = new PmsKnowledgeDocumentLabelPageReqVO() + .setLabelId(targetLabel.getId()); + pageReqVO.setPageNo(1).setPageSize(10); + + // 调用 + PageResult pageResult = + documentLabelService.getDocumentPageByLabel(pageReqVO, userId); + + // 断言 + assertEquals(1, pageResult.getTotal()); + assertEquals(targetLabel.getId(), CollUtil.getFirst(CollUtil.getFirst(pageResult.getList()).getLabelIds())); + } + + // ========== 随机对象 ========== + + private PmsKnowledgeDocumentLabelDO randomDocumentLabelDO() { + return randomPojo(PmsKnowledgeDocumentLabelDO.class, + documentLabel -> documentLabel.setId(null).setName("产品").setColor("#409EFF")); + } + + private PmsKnowledgeDocumentDO randomDocumentDO(Long libraryId, List labelIds) { + return randomPojo(PmsKnowledgeDocumentDO.class, document -> document.setId(null).setLibraryId(libraryId) + .setPermissionId(randomLongId()) + .setFolderId(0L).setParentId(0L).setTitle("测试文档") + .setType(PmsKnowledgeDocumentTypeEnum.RICH_TEXT.getType()) + .setStatus(PmsKnowledgeDocumentStatusEnum.NORMAL.getStatus()).setLabelIds(labelIds)); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeDocumentServiceImplTest.java b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeDocumentServiceImplTest.java new file mode 100644 index 000000000..08bb98692 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeDocumentServiceImplTest.java @@ -0,0 +1,498 @@ +package cn.iocoder.yudao.module.pms.service.kb.content; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.document.PmsKnowledgeDocumentCreateReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.document.PmsKnowledgeDocumentMoveReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.document.PmsKnowledgeDocumentSearchPageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.document.PmsKnowledgeDocumentUpdateReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeDocumentDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeFolderDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryMemberDO; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.content.PmsKnowledgeDocumentMapper; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.content.PmsKnowledgeFolderMapper; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.library.PmsKnowledgeLibraryMapper; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.library.PmsKnowledgeLibraryMemberMapper; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeDocumentStatusEnum; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeDocumentTypeEnum; +import cn.iocoder.yudao.module.pms.enums.kb.library.PmsKnowledgeLibraryMemberLevelEnum; +import cn.iocoder.yudao.module.pms.service.kb.interaction.PmsKnowledgeDocumentCommentService; +import cn.iocoder.yudao.module.pms.service.kb.interaction.PmsKnowledgeDocumentLikeService; +import cn.iocoder.yudao.module.pms.service.kb.interaction.PmsKnowledgeDocumentShareService; +import cn.iocoder.yudao.module.pms.service.kb.interaction.PmsKnowledgeFavoriteService; +import cn.iocoder.yudao.module.pms.service.kb.interaction.PmsKnowledgeViewRecordService; +import cn.iocoder.yudao.module.pms.service.kb.library.PmsKnowledgeGroupService; +import cn.iocoder.yudao.module.pms.service.kb.library.PmsKnowledgeLibraryMemberService; +import cn.iocoder.yudao.module.pms.service.kb.library.PmsKnowledgeLibraryService; +import cn.iocoder.yudao.module.pms.service.kb.recycle.PmsKnowledgeRecycleServiceImpl; +import cn.iocoder.yudao.module.system.api.permission.PermissionApi; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_DOCUMENT_MOVE_INVALID; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_DOCUMENT_MOVE_TARGET_INVALID; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_DOCUMENT_NOT_EXISTS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link PmsKnowledgeDocumentServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import({PmsKnowledgeDocumentServiceImpl.class, PmsKnowledgeRecycleServiceImpl.class}) +public class PmsKnowledgeDocumentServiceImplTest extends BaseDbUnitTest { + + @Resource + private PmsKnowledgeDocumentServiceImpl documentService; + + @Resource + private PmsKnowledgeLibraryMapper libraryMapper; + @Resource + private PmsKnowledgeLibraryMemberMapper memberMapper; + @Resource + private PmsKnowledgeFolderMapper folderMapper; + @Resource + private PmsKnowledgeDocumentMapper documentMapper; + @MockitoBean + private PermissionApi permissionApi; + @MockitoBean + private AdminUserApi adminUserApi; + @MockitoBean + private PmsKnowledgeLibraryService libraryService; + @MockitoBean + private PmsKnowledgeLibraryMemberService memberService; + @MockitoBean + private PmsKnowledgeGroupService knowledgeGroupService; + @MockitoBean + private PmsKnowledgeDocumentShareService knowledgeDocumentShareService; + @MockitoBean + private PmsKnowledgeDocumentCommentService knowledgeDocumentCommentService; + @MockitoBean + private PmsKnowledgeContentPermissionService contentPermissionService; + @MockitoBean + private PmsKnowledgeDocumentLabelService documentLabelService; + @MockitoBean + private PmsKnowledgeFolderService folderService; + @MockitoBean + private PmsKnowledgeFavoriteService favoriteService; + @MockitoBean + private PmsKnowledgeDocumentLikeService documentLikeService; + @MockitoBean + private PmsKnowledgeViewRecordService viewRecordService; + + @Test + public void testCreateDocument_folderWinsParent() { + // mock 数据 + Long userId = randomLongId(); + PmsKnowledgeLibraryDO library = randomLibraryDO(); + libraryMapper.insert(library); + memberMapper.insert(randomMemberDO(library.getId(), userId)); + when(memberService.validateLibraryReadable(library.getId(), userId)).thenReturn(library); + when(memberService.validateLibraryWritable(library.getId(), userId)).thenReturn(library); + PmsKnowledgeFolderDO folder = randomFolderDO(library.getId(), 0L); + folderMapper.insert(folder); + PmsKnowledgeDocumentDO parent = randomDocumentDO(library.getId(), 0L, 0L); + documentMapper.insert(parent); + when(folderService.getFolder(folder.getId(), userId)).thenReturn(folder); + // 准备参数 + PmsKnowledgeDocumentCreateReqVO reqVO = new PmsKnowledgeDocumentCreateReqVO() + .setLibraryId(library.getId()).setFolderId(folder.getId()).setParentId(parent.getId()) + .setTitle("说明文档").setType(PmsKnowledgeDocumentTypeEnum.RICH_TEXT.getType()); + + // 调用 + Long documentId = documentService.createDocument(reqVO, userId); + + // 断言 + PmsKnowledgeDocumentDO document = documentMapper.selectById(documentId); + assertEquals(folder.getId(), document.getFolderId()); + assertEquals(0L, document.getParentId()); + assertEquals(PmsKnowledgeDocumentTypeEnum.RICH_TEXT.getType(), document.getType()); + assertEquals(PmsKnowledgeDocumentStatusEnum.NORMAL.getStatus(), document.getStatus()); + verify(folderService).getFolder(folder.getId(), userId); + } + + @Test + public void testCreateDocument_fileDocument() { + // mock 数据 + Long userId = randomLongId(); + PmsKnowledgeLibraryDO library = randomLibraryDO(); + libraryMapper.insert(library); + memberMapper.insert(randomMemberDO(library.getId(), userId)); + when(memberService.validateLibraryReadable(library.getId(), userId)).thenReturn(library); + when(folderService.getFolder(0L, userId)).thenReturn(null); + // 准备参数 + PmsKnowledgeDocumentCreateReqVO reqVO = new PmsKnowledgeDocumentCreateReqVO() + .setLibraryId(library.getId()).setFolderId(0L).setParentId(0L).setTitle("产品附件") + .setType(PmsKnowledgeDocumentTypeEnum.FILE.getType()).setContent("https://example.com/a.pdf") + .setFileType("pdf").setFileSize(2048L); + + // 调用 + Long documentId = documentService.createDocument(reqVO, userId); + + // 断言 + PmsKnowledgeDocumentDO document = documentMapper.selectById(documentId); + assertEquals(PmsKnowledgeDocumentTypeEnum.FILE.getType(), document.getType()); + assertEquals("https://example.com/a.pdf", document.getContent()); + assertEquals("pdf", document.getFileType()); + assertEquals(2048L, document.getFileSize()); + } + + @Test + public void testUpdateDocument_success() { + // mock 数据 + Long userId = randomLongId(); + PmsKnowledgeLibraryDO library = randomLibraryDO(); + libraryMapper.insert(library); + memberMapper.insert(randomMemberDO(library.getId(), userId)); + when(memberService.validateLibraryReadable(library.getId(), userId)).thenReturn(library); + when(memberService.validateLibraryWritable(library.getId(), userId)).thenReturn(library); + PmsKnowledgeDocumentDO document = randomDocumentDO(library.getId(), 0L, 0L).setContent("旧正文"); + documentMapper.insert(document); + // 准备参数 + PmsKnowledgeDocumentUpdateReqVO reqVO = new PmsKnowledgeDocumentUpdateReqVO() + .setId(document.getId()).setContent("新正文"); + + // 调用 + documentService.updateDocument(reqVO, userId); + + // 断言 + PmsKnowledgeDocumentDO updatedDocument = documentMapper.selectById(document.getId()); + assertEquals("新正文", updatedDocument.getContent()); + assertEquals(document.getTitle(), updatedDocument.getTitle()); + assertEquals(document.getType(), updatedDocument.getType()); + assertEquals(document.getStatus(), updatedDocument.getStatus()); + verify(memberService).validateLibraryReadable(document.getLibraryId(), userId); + verify(contentPermissionService).validateContentPermissionWritable( + document.getPermissionId(), document.getLibraryId(), userId); + } + + @Test + public void testUpdateDocument_titleAndContent() { + // mock 数据 + Long userId = randomLongId(); + PmsKnowledgeLibraryDO library = randomLibraryDO(); + libraryMapper.insert(library); + memberMapper.insert(randomMemberDO(library.getId(), userId)); + PmsKnowledgeDocumentDO document = randomDocumentDO(library.getId(), 0L, 0L).setContent("旧正文"); + documentMapper.insert(document); + // 准备参数 + PmsKnowledgeDocumentUpdateReqVO reqVO = new PmsKnowledgeDocumentUpdateReqVO() + .setId(document.getId()).setTitle("新标题").setContent("新正文"); + + // 调用 + documentService.updateDocument(reqVO, userId); + + // 断言 + PmsKnowledgeDocumentDO updatedDocument = documentMapper.selectById(document.getId()); + assertEquals("新标题", updatedDocument.getTitle()); + assertEquals("新正文", updatedDocument.getContent()); + } + + @Test + public void testDeleteDocument_recursive() { + // mock 数据 + Long userId = randomLongId(); + PmsKnowledgeLibraryDO library = randomLibraryDO(); + libraryMapper.insert(library); + memberMapper.insert(randomMemberDO(library.getId(), userId)); + when(memberService.validateLibraryReadable(library.getId(), userId)).thenReturn(library); + when(memberService.validateLibraryWritable(library.getId(), userId)).thenReturn(library); + PmsKnowledgeDocumentDO parent = randomDocumentDO(library.getId(), 0L, 0L); + documentMapper.insert(parent); + PmsKnowledgeDocumentDO child = randomDocumentDO(library.getId(), 0L, parent.getId()); + documentMapper.insert(child); + + // 调用 + documentService.deleteDocument(parent.getId(), userId); + + // 断言 + assertEquals(PmsKnowledgeDocumentStatusEnum.RECYCLED.getStatus(), + documentMapper.selectById(parent.getId()).getStatus()); + assertEquals(PmsKnowledgeDocumentStatusEnum.RECYCLED.getStatus(), + documentMapper.selectById(child.getId()).getStatus()); + verify(memberService).validateLibraryReadable(library.getId(), userId); + } + + @Test + public void testMoveDocument_toDescendant() { + // mock 数据 + Long userId = randomLongId(); + PmsKnowledgeLibraryDO library = randomLibraryDO(); + libraryMapper.insert(library); + memberMapper.insert(randomMemberDO(library.getId(), userId)); + when(memberService.validateLibraryReadable(library.getId(), userId)).thenReturn(library); + when(memberService.validateLibraryWritable(library.getId(), userId)).thenReturn(library); + PmsKnowledgeDocumentDO parent = randomDocumentDO(library.getId(), 0L, 0L); + documentMapper.insert(parent); + PmsKnowledgeDocumentDO child = randomDocumentDO(library.getId(), 0L, parent.getId()); + documentMapper.insert(child); + // 准备参数 + PmsKnowledgeDocumentMoveReqVO reqVO = new PmsKnowledgeDocumentMoveReqVO().setId(parent.getId()) + .setTargetLibraryId(library.getId()).setTargetFolderId(0L).setTargetParentId(child.getId()); + + // 调用,并断言异常 + assertServiceException(() -> documentService.moveDocument(reqVO, userId), KNOWLEDGE_DOCUMENT_MOVE_INVALID); + } + + @Test + public void testMoveDocument_targetFolderAndParentBothSet() { + // mock 数据 + Long userId = randomLongId(); + PmsKnowledgeLibraryDO library = randomLibraryDO(); + libraryMapper.insert(library); + PmsKnowledgeDocumentDO document = randomDocumentDO(library.getId(), 0L, 0L); + documentMapper.insert(document); + // 准备参数 + PmsKnowledgeDocumentMoveReqVO reqVO = new PmsKnowledgeDocumentMoveReqVO().setId(document.getId()) + .setTargetLibraryId(library.getId()).setTargetFolderId(randomLongId()).setTargetParentId(randomLongId()); + + // 调用,并断言异常 + assertServiceException(() -> documentService.moveDocument(reqVO, userId), KNOWLEDGE_DOCUMENT_MOVE_TARGET_INVALID); + } + + @Test + public void testMoveDocument_crossLibraryClonesPermission() { + // mock 数据 + Long userId = randomLongId(); + PmsKnowledgeLibraryDO sourceLibrary = randomLibraryDO(); + libraryMapper.insert(sourceLibrary); + memberMapper.insert(randomMemberDO(sourceLibrary.getId(), userId)); + when(memberService.validateLibraryReadable(sourceLibrary.getId(), userId)).thenReturn(sourceLibrary); + when(memberService.validateLibraryWritable(sourceLibrary.getId(), userId)).thenReturn(sourceLibrary); + PmsKnowledgeLibraryDO targetLibrary = randomLibraryDO(); + libraryMapper.insert(targetLibrary); + memberMapper.insert(randomMemberDO(targetLibrary.getId(), userId)); + when(memberService.validateLibraryReadable(targetLibrary.getId(), userId)).thenReturn(targetLibrary); + when(memberService.validateLibraryWritable(targetLibrary.getId(), userId)).thenReturn(targetLibrary); + PmsKnowledgeDocumentDO document = randomDocumentDO(sourceLibrary.getId(), 0L, 0L); + documentMapper.insert(document); + Long sourcePermissionId = document.getPermissionId(); + Long targetPermissionId = randomLongId(); + // 准备参数 + PmsKnowledgeDocumentMoveReqVO reqVO = new PmsKnowledgeDocumentMoveReqVO().setId(document.getId()) + .setTargetLibraryId(targetLibrary.getId()).setTargetFolderId(0L).setTargetParentId(0L); + // mock 方法 + when(contentPermissionService.cloneContentPermissions(Collections.singleton(sourcePermissionId), targetLibrary.getId())) + .thenReturn(Collections.singletonMap(sourcePermissionId, targetPermissionId)); + + // 调用 + documentService.moveDocument(reqVO, userId); + + // 断言 + PmsKnowledgeDocumentDO movedDocument = documentMapper.selectById(document.getId()); + assertEquals(targetLibrary.getId(), movedDocument.getLibraryId()); + assertEquals(targetPermissionId, movedDocument.getPermissionId()); + verify(memberService).validateLibraryReadable(sourceLibrary.getId(), userId); + verify(memberService).validateLibraryReadable(targetLibrary.getId(), userId); + verify(memberService).validateLibraryAdmin(targetLibrary.getId(), userId); + verify(contentPermissionService).validateContentPermissionManageable( + sourcePermissionId, sourceLibrary.getId(), userId); + verify(favoriteService).updateFavoriteLibraryIdByEntityIds(Collections.emptySet(), + Collections.singleton(document.getId()), targetLibrary.getId()); + verify(viewRecordService).updateViewRecordLibraryIdByEntityIds(Collections.emptySet(), + Collections.singleton(document.getId()), targetLibrary.getId()); + verify(contentPermissionService).deleteUnusedContentPermissions(Collections.singleton(sourcePermissionId)); + } + + @Test + public void testGetDocumentList_hidesChildWhenParentInvisible() { + // mock 数据 + Long userId = randomLongId(); + PmsKnowledgeLibraryDO library = randomLibraryDO(); + libraryMapper.insert(library); + PmsKnowledgeDocumentDO parent = randomDocumentDO(library.getId(), 0L, 0L); + documentMapper.insert(parent); + PmsKnowledgeDocumentDO child = randomDocumentDO(library.getId(), 0L, parent.getId()); + documentMapper.insert(child); + + // mock 方法:只有子文档可读,父文档不可读 + when(contentPermissionService.getCurrentUserContentPermissionLevelMap( + anyCollection(), eq(library.getId()), eq(userId))) + .thenReturn(Collections.singletonMap(child.getPermissionId(), 3)); + + // 调用并断言 + assertTrue(documentService.getDocumentList(library.getId(), userId).isEmpty()); + verify(memberService).validateLibraryReadable(library.getId(), userId); + } + + @Test + public void testGetDocument_success() { + // mock 数据 + Long userId = randomLongId(); + PmsKnowledgeLibraryDO library = randomLibraryDO(); + libraryMapper.insert(library); + memberMapper.insert(randomMemberDO(library.getId(), userId)); + when(memberService.validateLibraryReadable(library.getId(), userId)).thenReturn(library); + when(memberService.validateLibraryWritable(library.getId(), userId)).thenReturn(library); + PmsKnowledgeDocumentDO document = randomDocumentDO(library.getId(), 0L, 0L); + documentMapper.insert(document); + + // 调用 + PmsKnowledgeDocumentDO foundDocument = documentService.getDocument(document.getId(), userId); + + // 断言 + assertEquals(document.getId(), foundDocument.getId()); + verify(contentPermissionService).validateContentPermissionReadable(document.getPermissionId(), library.getId(), userId); + } + + @Test + public void testGetDocument_recycled() { + // mock 数据 + Long userId = randomLongId(); + PmsKnowledgeLibraryDO library = randomLibraryDO(); + libraryMapper.insert(library); + memberMapper.insert(randomMemberDO(library.getId(), userId)); + when(memberService.validateLibraryReadable(library.getId(), userId)).thenReturn(library); + when(memberService.validateLibraryWritable(library.getId(), userId)).thenReturn(library); + PmsKnowledgeDocumentDO document = randomDocumentDO(library.getId(), 0L, 0L); + documentMapper.insert(document); + documentMapper.updateById(new PmsKnowledgeDocumentDO().setId(document.getId()) + .setStatus(PmsKnowledgeDocumentStatusEnum.RECYCLED.getStatus())); + + // 调用,并断言异常 + assertServiceException(() -> documentService.getDocument(document.getId(), userId), + KNOWLEDGE_DOCUMENT_NOT_EXISTS); + } + + @Test + public void testGetDocumentTypeCountMap_success() { + // mock 数据 + Long libraryId = randomLongId(); + Long otherLibraryId = randomLongId(); + documentMapper.insert(randomDocumentDO(libraryId, 0L, 0L)); + documentMapper.insert(randomDocumentDO(libraryId, 0L, 0L)); + documentMapper.insert(randomDocumentDO(libraryId, 0L, 0L) + .setType(PmsKnowledgeDocumentTypeEnum.FILE.getType())); + documentMapper.insert(randomDocumentDO(libraryId, 0L, 0L) + .setStatus(PmsKnowledgeDocumentStatusEnum.RECYCLED.getStatus())); + documentMapper.insert(randomDocumentDO(otherLibraryId, 0L, 0L)); + + // 调用 + Map> typeCountMap = documentService.getDocumentTypeCountMap( + Arrays.asList(libraryId, otherLibraryId)); + + // 断言 + assertEquals(2L, typeCountMap.get(libraryId).get(PmsKnowledgeDocumentTypeEnum.RICH_TEXT.getType())); + assertEquals(1L, typeCountMap.get(libraryId).get(PmsKnowledgeDocumentTypeEnum.FILE.getType())); + assertEquals(1L, typeCountMap.get(otherLibraryId).get(PmsKnowledgeDocumentTypeEnum.RICH_TEXT.getType())); + } + + @Test + public void testGetDocumentSearchPage_mysqlAndAccessScope() { + // mock 数据 + Long userId = randomLongId(); + Long libraryId = randomLongId(); + Long otherLibraryId = randomLongId(); + Long readablePermissionId = randomLongId(); + documentMapper.insert(randomSearchDocumentDO(libraryId, readablePermissionId, "产品说明", "普通正文", 1)); + documentMapper.insert(randomSearchDocumentDO(libraryId, readablePermissionId, + "普通标题", "包含产品设计方案", 1)); + documentMapper.insert(randomSearchDocumentDO(libraryId, randomLongId(), "产品私密文档", "不可见", 1)); + documentMapper.insert(randomSearchDocumentDO(otherLibraryId, readablePermissionId, + "产品已删除文档", "回收站", -1)); + when(memberService.getReadableLibraryIdList(userId)).thenReturn(Arrays.asList(libraryId, otherLibraryId)); + when(contentPermissionService.getReadableContentPermissionIdSet(Arrays.asList(libraryId, otherLibraryId), userId)) + .thenReturn(Collections.singleton(readablePermissionId)); + // 准备参数 + PmsKnowledgeDocumentSearchPageReqVO pageReqVO = new PmsKnowledgeDocumentSearchPageReqVO() + .setKeyword("产品"); + pageReqVO.setPageNo(1).setPageSize(10); + + // 调用 + PageResult pageResult = documentService.getDocumentSearchPage(pageReqVO, userId); + + // 断言 + assertEquals(2, pageResult.getTotal()); + assertEquals("产品", pageReqVO.getKeyword()); + } + + @Test + public void testGetDocumentSearchPage_libraryDenied() { + // mock 数据 + Long userId = randomLongId(); + Long readableLibraryId = randomLongId(); + when(memberService.getReadableLibraryIdList(userId)) + .thenReturn(Collections.singletonList(readableLibraryId)); + // 准备参数 + PmsKnowledgeDocumentSearchPageReqVO pageReqVO = new PmsKnowledgeDocumentSearchPageReqVO() + .setKeyword("产品").setLibraryId(randomLongId()); + + // 调用 + PageResult pageResult = documentService.getDocumentSearchPage(pageReqVO, userId); + + // 断言 + assertEquals(0, pageResult.getTotal()); + } + + @Test + public void testGetDocumentSearchPage_withoutKeyword() { + // mock 数据 + Long userId = randomLongId(); + Long libraryId = randomLongId(); + Long readablePermissionId = randomLongId(); + documentMapper.insert(randomSearchDocumentDO(libraryId, readablePermissionId, "产品说明", "普通正文", 1)); + documentMapper.insert(randomSearchDocumentDO(libraryId, readablePermissionId, "研发规范", "技术方案", 1)); + when(memberService.getReadableLibraryIdList(userId)).thenReturn(Collections.singletonList(libraryId)); + when(contentPermissionService.getReadableContentPermissionIdSet( + Collections.singletonList(libraryId), userId)) + .thenReturn(Collections.singleton(readablePermissionId)); + // 准备参数 + PmsKnowledgeDocumentSearchPageReqVO pageReqVO = new PmsKnowledgeDocumentSearchPageReqVO(); + pageReqVO.setPageNo(1).setPageSize(10); + + // 调用 + PageResult pageResult = documentService.getDocumentSearchPage(pageReqVO, userId); + + // 断言:空关键词按当前产品语义返回可读文档 + assertEquals(2, pageResult.getTotal()); + } + + // ========== 随机对象 ========== + + private PmsKnowledgeLibraryDO randomLibraryDO() { + return randomPojo(PmsKnowledgeLibraryDO.class, library -> library.setId(null).setName("测试知识库") + .setDescription("测试").setOpenStatus(false)); + } + + private PmsKnowledgeLibraryMemberDO randomMemberDO(Long libraryId, Long userId) { + return randomPojo(PmsKnowledgeLibraryMemberDO.class, member -> member.setId(null).setLibraryId(libraryId) + .setUserId(userId).setLevel(PmsKnowledgeLibraryMemberLevelEnum.CREATOR.getLevel())); + } + + private PmsKnowledgeFolderDO randomFolderDO(Long libraryId, Long parentId) { + return randomPojo(PmsKnowledgeFolderDO.class, folder -> folder.setId(null).setLibraryId(libraryId) + .setPermissionId(randomLongId()).setParentId(parentId).setTitle("测试目录").setStatus(1)); + } + + private PmsKnowledgeDocumentDO randomDocumentDO(Long libraryId, Long folderId, Long parentId) { + return randomPojo(PmsKnowledgeDocumentDO.class, document -> document.setId(null).setLibraryId(libraryId) + .setPermissionId(randomLongId()).setFolderId(folderId).setParentId(parentId).setTitle("测试文档") + .setType(PmsKnowledgeDocumentTypeEnum.RICH_TEXT.getType()) + .setStatus(PmsKnowledgeDocumentStatusEnum.NORMAL.getStatus())); + } + + private PmsKnowledgeDocumentDO randomSearchDocumentDO(Long libraryId, Long permissionId, String title, + String content, Integer status) { + return randomPojo(PmsKnowledgeDocumentDO.class, document -> document.setId(null).setLibraryId(libraryId) + .setPermissionId(permissionId) + .setFolderId(0L).setParentId(0L).setTitle(title).setContent(content) + .setType(PmsKnowledgeDocumentTypeEnum.RICH_TEXT.getType()).setStatus(status)); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeFolderServiceImplTest.java b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeFolderServiceImplTest.java new file mode 100644 index 000000000..d990a04d0 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/content/PmsKnowledgeFolderServiceImplTest.java @@ -0,0 +1,247 @@ +package cn.iocoder.yudao.module.pms.service.kb.content; + +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.folder.PmsKnowledgeFolderMoveReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.folder.PmsKnowledgeFolderSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeDocumentDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeFolderDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryMemberDO; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.content.PmsKnowledgeDocumentMapper; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.content.PmsKnowledgeFolderMapper; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.library.PmsKnowledgeLibraryMapper; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.library.PmsKnowledgeLibraryMemberMapper; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeDocumentStatusEnum; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeDocumentTypeEnum; +import cn.iocoder.yudao.module.pms.enums.kb.library.PmsKnowledgeLibraryMemberLevelEnum; +import cn.iocoder.yudao.module.pms.service.kb.content.PmsKnowledgeContentPermissionService; +import cn.iocoder.yudao.module.pms.service.kb.interaction.PmsKnowledgeDocumentCommentService; +import cn.iocoder.yudao.module.pms.service.kb.interaction.PmsKnowledgeDocumentLikeService; +import cn.iocoder.yudao.module.pms.service.kb.interaction.PmsKnowledgeDocumentShareService; +import cn.iocoder.yudao.module.pms.service.kb.interaction.PmsKnowledgeFavoriteService; +import cn.iocoder.yudao.module.pms.service.kb.interaction.PmsKnowledgeViewRecordService; +import cn.iocoder.yudao.module.pms.service.kb.library.PmsKnowledgeGroupService; +import cn.iocoder.yudao.module.pms.service.kb.library.PmsKnowledgeLibraryMemberService; +import cn.iocoder.yudao.module.pms.service.kb.library.PmsKnowledgeLibraryService; +import cn.iocoder.yudao.module.pms.service.kb.recycle.PmsKnowledgeRecycleServiceImpl; +import cn.iocoder.yudao.module.system.api.permission.PermissionApi; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_FOLDER_MOVE_INVALID; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link PmsKnowledgeFolderServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import({PmsKnowledgeFolderServiceImpl.class, PmsKnowledgeRecycleServiceImpl.class}) +public class PmsKnowledgeFolderServiceImplTest extends BaseDbUnitTest { + + @Resource + private PmsKnowledgeFolderServiceImpl folderService; + + @Resource + private PmsKnowledgeLibraryMapper libraryMapper; + @Resource + private PmsKnowledgeLibraryMemberMapper memberMapper; + @Resource + private PmsKnowledgeFolderMapper folderMapper; + @Resource + private PmsKnowledgeDocumentMapper documentMapper; + + @MockitoBean + private PermissionApi permissionApi; + @MockitoBean + private AdminUserApi adminUserApi; + @MockitoBean + private PmsKnowledgeLibraryService libraryService; + @MockitoBean + private PmsKnowledgeLibraryMemberService memberService; + @MockitoBean + private PmsKnowledgeGroupService knowledgeGroupService; + @MockitoBean + private PmsKnowledgeDocumentShareService knowledgeDocumentShareService; + @MockitoBean + private PmsKnowledgeDocumentCommentService knowledgeDocumentCommentService; + @MockitoBean + private PmsKnowledgeContentPermissionService contentPermissionService; + @MockitoBean + private PmsKnowledgeDocumentService documentService; + @MockitoBean + private PmsKnowledgeFavoriteService favoriteService; + @MockitoBean + private PmsKnowledgeDocumentLikeService documentLikeService; + @MockitoBean + private PmsKnowledgeViewRecordService viewRecordService; + + @Test + public void testCreateFolder_success() { + // mock 数据 + Long userId = randomLongId(); + PmsKnowledgeLibraryDO library = randomLibraryDO(); + libraryMapper.insert(library); + memberMapper.insert(randomMemberDO(library.getId(), userId)); + when(memberService.validateLibraryReadable(library.getId(), userId)).thenReturn(library); + when(memberService.validateLibraryWritable(library.getId(), userId)).thenReturn(library); + when(contentPermissionService.createDefaultContentPermission(library.getId(), userId)).thenReturn(randomLongId()); + // 准备参数 + PmsKnowledgeFolderSaveReqVO reqVO = new PmsKnowledgeFolderSaveReqVO() + .setLibraryId(library.getId()).setParentId(0L).setTitle(" 产品文档 "); + + // 调用 + Long folderId = folderService.createFolder(reqVO, userId); + + // 断言 + PmsKnowledgeFolderDO folder = folderMapper.selectById(folderId); + assertEquals(library.getId(), folder.getLibraryId()); + assertEquals(" 产品文档 ", folder.getTitle()); + assertEquals(1, folder.getStatus()); + } + + @Test + public void testMoveFolder_toDescendant() { + // mock 数据 + Long userId = randomLongId(); + PmsKnowledgeLibraryDO library = randomLibraryDO(); + libraryMapper.insert(library); + memberMapper.insert(randomMemberDO(library.getId(), userId)); + when(memberService.validateLibraryReadable(library.getId(), userId)).thenReturn(library); + when(memberService.validateLibraryWritable(library.getId(), userId)).thenReturn(library); + PmsKnowledgeFolderDO parent = randomFolderDO(library.getId(), 0L); + folderMapper.insert(parent); + PmsKnowledgeFolderDO child = randomFolderDO(library.getId(), parent.getId()); + folderMapper.insert(child); + // 准备参数 + PmsKnowledgeFolderMoveReqVO reqVO = new PmsKnowledgeFolderMoveReqVO().setId(parent.getId()) + .setTargetLibraryId(library.getId()).setTargetParentId(child.getId()); + + // 调用,并断言异常 + assertServiceException(() -> folderService.moveFolder(reqVO, userId), KNOWLEDGE_FOLDER_MOVE_INVALID); + } + + @Test + public void testMoveFolder_crossLibraryUpdatesInteractionLibraryId() { + // mock 数据 + Long userId = randomLongId(); + PmsKnowledgeLibraryDO sourceLibrary = randomLibraryDO(); + libraryMapper.insert(sourceLibrary); + PmsKnowledgeLibraryDO targetLibrary = randomLibraryDO(); + libraryMapper.insert(targetLibrary); + when(memberService.validateLibraryReadable(sourceLibrary.getId(), userId)).thenReturn(sourceLibrary); + when(memberService.validateLibraryReadable(targetLibrary.getId(), userId)).thenReturn(targetLibrary); + when(memberService.validateLibraryWritable(targetLibrary.getId(), userId)).thenReturn(targetLibrary); + PmsKnowledgeFolderDO parent = randomFolderDO(sourceLibrary.getId(), 0L); + folderMapper.insert(parent); + PmsKnowledgeFolderDO child = randomFolderDO(sourceLibrary.getId(), parent.getId()); + folderMapper.insert(child); + PmsKnowledgeDocumentDO document = randomDocumentDO(sourceLibrary.getId(), child.getId(), 0L) + .setId(randomLongId()); + when(documentService.getDocumentListByFolderIds(anyCollection())).thenReturn(Arrays.asList(document)); + Map clonedPermissionIdMap = new HashMap<>(); + clonedPermissionIdMap.put(parent.getPermissionId(), randomLongId()); + clonedPermissionIdMap.put(child.getPermissionId(), randomLongId()); + clonedPermissionIdMap.put(document.getPermissionId(), randomLongId()); + when(contentPermissionService.cloneContentPermissions(anyCollection(), eq(targetLibrary.getId()))) + .thenReturn(clonedPermissionIdMap); + // 准备参数 + PmsKnowledgeFolderMoveReqVO reqVO = new PmsKnowledgeFolderMoveReqVO().setId(parent.getId()) + .setTargetLibraryId(targetLibrary.getId()).setTargetParentId(0L); + + // 调用 + folderService.moveFolder(reqVO, userId); + + // 断言 + Set folderIds = new HashSet<>(Arrays.asList(parent.getId(), child.getId())); + Set documentIds = Collections.singleton(document.getId()); + assertEquals(targetLibrary.getId(), folderMapper.selectById(parent.getId()).getLibraryId()); + assertEquals(targetLibrary.getId(), folderMapper.selectById(child.getId()).getLibraryId()); + verify(memberService).validateLibraryAdmin(targetLibrary.getId(), userId); + verify(contentPermissionService).validateContentPermissionManageable( + parent.getPermissionId(), sourceLibrary.getId(), userId); + verify(favoriteService).updateFavoriteLibraryIdByEntityIds(folderIds, documentIds, targetLibrary.getId()); + verify(viewRecordService).updateViewRecordLibraryIdByEntityIds(folderIds, documentIds, + targetLibrary.getId()); + } + + @Test + public void testDeleteFolder_recursive() { + // mock 数据 + Long userId = randomLongId(); + PmsKnowledgeLibraryDO library = randomLibraryDO(); + libraryMapper.insert(library); + memberMapper.insert(randomMemberDO(library.getId(), userId)); + when(memberService.validateLibraryReadable(library.getId(), userId)).thenReturn(library); + when(memberService.validateLibraryWritable(library.getId(), userId)).thenReturn(library); + PmsKnowledgeFolderDO parentFolder = randomFolderDO(library.getId(), 0L); + folderMapper.insert(parentFolder); + PmsKnowledgeFolderDO childFolder = randomFolderDO(library.getId(), parentFolder.getId()); + folderMapper.insert(childFolder); + PmsKnowledgeDocumentDO parentDocument = randomDocumentDO(library.getId(), childFolder.getId(), 0L); + documentMapper.insert(parentDocument); + PmsKnowledgeDocumentDO childDocument = randomDocumentDO(library.getId(), 0L, parentDocument.getId()); + documentMapper.insert(childDocument); + when(documentService.getDocumentListByFolderIds(anyCollection())) + .thenReturn(Arrays.asList(parentDocument, childDocument)); + doAnswer(invocation -> { + List documents = invocation.getArgument(0); + documentMapper.updateBatch(documents); + return null; + }).when(documentService).updateDocumentList(anyCollection()); + + // 调用 + folderService.deleteFolder(parentFolder.getId(), userId); + + // 断言 + assertEquals(-1, folderMapper.selectById(parentFolder.getId()).getStatus()); + assertEquals(-1, folderMapper.selectById(childFolder.getId()).getStatus()); + assertEquals(-1, documentMapper.selectById(parentDocument.getId()).getStatus()); + assertEquals(-1, documentMapper.selectById(childDocument.getId()).getStatus()); + verify(memberService).validateLibraryReadable(library.getId(), userId); + } + + // ========== 随机对象 ========== + + private PmsKnowledgeLibraryDO randomLibraryDO() { + return randomPojo(PmsKnowledgeLibraryDO.class, library -> library.setId(null).setName("测试知识库") + .setDescription("测试").setOpenStatus(false)); + } + + private PmsKnowledgeLibraryMemberDO randomMemberDO(Long libraryId, Long userId) { + return randomPojo(PmsKnowledgeLibraryMemberDO.class, member -> member.setId(null).setLibraryId(libraryId) + .setUserId(userId).setLevel(PmsKnowledgeLibraryMemberLevelEnum.CREATOR.getLevel())); + } + + private PmsKnowledgeFolderDO randomFolderDO(Long libraryId, Long parentId) { + return randomPojo(PmsKnowledgeFolderDO.class, folder -> folder.setId(null).setLibraryId(libraryId) + .setPermissionId(randomLongId()).setParentId(parentId).setTitle("测试目录").setStatus(1)); + } + + private PmsKnowledgeDocumentDO randomDocumentDO(Long libraryId, Long folderId, Long parentId) { + return randomPojo(PmsKnowledgeDocumentDO.class, document -> document.setId(null).setLibraryId(libraryId) + .setPermissionId(randomLongId()).setFolderId(folderId).setParentId(parentId).setTitle("测试文档") + .setType(PmsKnowledgeDocumentTypeEnum.RICH_TEXT.getType()) + .setStatus(PmsKnowledgeDocumentStatusEnum.NORMAL.getStatus())); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeDocumentCommentServiceImplTest.java b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeDocumentCommentServiceImplTest.java new file mode 100644 index 000000000..0425aca6a --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeDocumentCommentServiceImplTest.java @@ -0,0 +1,145 @@ +package cn.iocoder.yudao.module.pms.service.kb.interaction; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.comment.PmsKnowledgeDocumentCommentSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeDocumentDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.interaction.PmsKnowledgeDocumentCommentDO; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.interaction.PmsKnowledgeDocumentCommentMapper; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeDocumentTypeEnum; +import cn.iocoder.yudao.module.pms.service.kb.content.PmsKnowledgeDocumentService; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.List; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_DOCUMENT_COMMENT_REPLY_INVALID; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_DOCUMENT_COMMENT_TYPE_INVALID; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +/** + * {@link PmsKnowledgeDocumentCommentServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(PmsKnowledgeDocumentCommentServiceImpl.class) +public class PmsKnowledgeDocumentCommentServiceImplTest extends BaseDbUnitTest { + + @Resource + private PmsKnowledgeDocumentCommentServiceImpl commentService; + + @Resource + private PmsKnowledgeDocumentCommentMapper commentMapper; + + @MockitoBean + private PmsKnowledgeDocumentService documentService; + + @Test + public void testCreateComment_reply() { + // mock 数据 + Long documentId = randomLongId(); + Long userId = randomLongId(); + mockRichTextDocument(documentId); + Long mainId = commentService.createDocumentComment(new PmsKnowledgeDocumentCommentSaveReqVO() + .setDocumentId(documentId).setContent("主评论"), userId); + + // 准备参数 + PmsKnowledgeDocumentCommentSaveReqVO reqVO = new PmsKnowledgeDocumentCommentSaveReqVO() + .setDocumentId(documentId).setMainId(mainId).setReplyUserId(userId).setContent("回复内容"); + + // 调用 + Long replyId = commentService.createDocumentComment(reqVO, randomLongId()); + + // 断言 + PmsKnowledgeDocumentCommentDO reply = commentMapper.selectById(replyId); + assertEquals(mainId, reply.getMainId()); + assertEquals("回复内容", reply.getContent()); + } + + @Test + public void testCreateComment_replyDifferentDocument() { + // mock 数据 + Long mainDocumentId = randomLongId(); + Long targetDocumentId = randomLongId(); + mockRichTextDocument(mainDocumentId); + mockRichTextDocument(targetDocumentId); + Long mainId = commentService.createDocumentComment(new PmsKnowledgeDocumentCommentSaveReqVO() + .setDocumentId(mainDocumentId).setContent("主评论"), randomLongId()); + + // 准备参数 + PmsKnowledgeDocumentCommentSaveReqVO reqVO = new PmsKnowledgeDocumentCommentSaveReqVO() + .setDocumentId(targetDocumentId).setMainId(mainId).setContent("非法回复"); + + // 调用并断言 + assertServiceException(() -> commentService.createDocumentComment(reqVO, randomLongId()), + KNOWLEDGE_DOCUMENT_COMMENT_REPLY_INVALID); + } + + @Test + public void testDeleteComment_deletesReplies() { + // mock 数据 + Long documentId = randomLongId(); + Long userId = randomLongId(); + mockRichTextDocument(documentId); + Long mainId = commentService.createDocumentComment(new PmsKnowledgeDocumentCommentSaveReqVO() + .setDocumentId(documentId).setContent("主评论"), userId); + commentService.createDocumentComment(new PmsKnowledgeDocumentCommentSaveReqVO().setDocumentId(documentId) + .setMainId(mainId).setReplyUserId(userId).setContent("回复"), randomLongId()); + + // 调用 + commentService.deleteDocumentComment(mainId, userId); + + // 断言 + assertTrue(CollUtil.isEmpty(commentMapper.selectListByDocumentId(documentId))); + } + + @Test + public void testGetCommentList() { + // mock 数据 + Long documentId = randomLongId(); + mockRichTextDocument(documentId); + commentService.createDocumentComment(new PmsKnowledgeDocumentCommentSaveReqVO() + .setDocumentId(documentId).setContent("第一条"), randomLongId()); + commentService.createDocumentComment(new PmsKnowledgeDocumentCommentSaveReqVO() + .setDocumentId(documentId).setContent("第二条"), randomLongId()); + + // 调用 + List comments = commentService.getDocumentCommentList(documentId, randomLongId()); + + // 断言 + assertEquals(2, comments.size()); + } + + @Test + public void testCreateComment_fileDocument() { + // mock 数据 + Long documentId = randomLongId(); + Long userId = randomLongId(); + when(documentService.getDocument(documentId, userId)).thenReturn(new PmsKnowledgeDocumentDO() + .setId(documentId).setType(PmsKnowledgeDocumentTypeEnum.FILE.getType())); + + // 准备参数 + PmsKnowledgeDocumentCommentSaveReqVO reqVO = new PmsKnowledgeDocumentCommentSaveReqVO() + .setDocumentId(documentId).setContent("文件评论"); + + // 调用并断言 + assertServiceException(() -> commentService.createDocumentComment(reqVO, userId), + KNOWLEDGE_DOCUMENT_COMMENT_TYPE_INVALID); + } + + // ========== Mock 方法 ========== + + private void mockRichTextDocument(Long documentId) { + when(documentService.getDocument(eq(documentId), anyLong())).thenReturn(new PmsKnowledgeDocumentDO() + .setId(documentId).setType(PmsKnowledgeDocumentTypeEnum.RICH_TEXT.getType())); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeDocumentLikeServiceImplTest.java b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeDocumentLikeServiceImplTest.java new file mode 100644 index 000000000..9e915a99b --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeDocumentLikeServiceImplTest.java @@ -0,0 +1,86 @@ +package cn.iocoder.yudao.module.pms.service.kb.interaction; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.interaction.PmsKnowledgeDocumentLikeMapper; +import cn.iocoder.yudao.module.pms.enums.kb.PmsKnowledgeObjectTypeEnum; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Collections; + +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +/** + * {@link PmsKnowledgeDocumentLikeServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(PmsKnowledgeDocumentLikeServiceImpl.class) +public class PmsKnowledgeDocumentLikeServiceImplTest extends BaseDbUnitTest { + + @Resource + private PmsKnowledgeDocumentLikeServiceImpl documentLikeService; + + @Resource + private PmsKnowledgeDocumentLikeMapper documentLikeMapper; + + @MockitoBean + private PmsKnowledgeInteractionTargetService interactionTargetService; + + @Test + public void testCreateDocumentLike_idempotent() { + // mock 数据 + Long userId = randomLongId(); + Long documentId = randomLongId(); + when(interactionTargetService.validateTargetReadable( + PmsKnowledgeObjectTypeEnum.DOCUMENT.getType(), documentId, userId)).thenReturn(randomLongId()); + + // 调用 + documentLikeService.createDocumentLike(documentId, userId); + documentLikeService.createDocumentLike(documentId, userId); + + // 断言 + assertNotNull(documentLikeMapper.selectByDocumentIdAndUserId(documentId, userId)); + } + + @Test + public void testDeleteDocumentLike_idempotent() { + // mock 数据 + Long userId = randomLongId(); + Long documentId = randomLongId(); + when(interactionTargetService.validateTargetReadable( + PmsKnowledgeObjectTypeEnum.DOCUMENT.getType(), documentId, userId)).thenReturn(randomLongId()); + documentLikeService.createDocumentLike(documentId, userId); + + // 调用 + documentLikeService.deleteDocumentLike(documentId, userId); + documentLikeService.deleteDocumentLike(documentId, userId); + + // 断言 + assertNull(documentLikeMapper.selectByDocumentIdAndUserId(documentId, userId)); + } + + @Test + public void testDeleteLikesByDocumentIds() { + // mock 数据 + Long userId = randomLongId(); + Long documentId = randomLongId(); + when(interactionTargetService.validateTargetReadable( + PmsKnowledgeObjectTypeEnum.DOCUMENT.getType(), documentId, userId)).thenReturn(randomLongId()); + documentLikeService.createDocumentLike(documentId, userId); + + // 调用 + documentLikeService.deleteLikesByDocumentIds(Collections.singleton(documentId)); + + // 断言 + assertTrue(CollUtil.isEmpty(documentLikeMapper.selectListByDocumentId(documentId))); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeDocumentShareServiceImplTest.java b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeDocumentShareServiceImplTest.java new file mode 100644 index 000000000..7e3d690c8 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeDocumentShareServiceImplTest.java @@ -0,0 +1,227 @@ +package cn.iocoder.yudao.module.pms.service.kb.interaction; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeDocumentDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.interaction.PmsKnowledgeDocumentShareDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryDO; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.content.PmsKnowledgeDocumentMapper; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.interaction.PmsKnowledgeDocumentShareMapper; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.library.PmsKnowledgeLibraryMapper; +import cn.iocoder.yudao.module.pms.service.kb.content.PmsKnowledgeContentPermissionService; +import cn.iocoder.yudao.module.system.api.notify.NotifyMessageSendApi; +import cn.iocoder.yudao.module.system.api.notify.dto.NotifySendSingleToUserReqDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_DOCUMENT_SHARE_ALREADY_OPEN; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_DOCUMENT_SHARE_INVALID; +import static cn.iocoder.yudao.module.pms.enums.MessageTemplateConstants.KNOWLEDGE_DOCUMENT_SHARED; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link PmsKnowledgeDocumentShareServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(PmsKnowledgeDocumentShareServiceImpl.class) +public class PmsKnowledgeDocumentShareServiceImplTest extends BaseDbUnitTest { + + @Resource + private PmsKnowledgeDocumentShareServiceImpl documentShareService; + + @Resource + private PmsKnowledgeDocumentShareMapper documentShareMapper; + @Resource + private PmsKnowledgeDocumentMapper documentMapper; + @Resource + private PmsKnowledgeLibraryMapper libraryMapper; + + @MockitoBean + private PmsKnowledgeContentPermissionService contentPermissionService; + @MockitoBean + private NotifyMessageSendApi notifyMessageSendApi; + @MockitoBean + private AdminUserApi adminUserApi; + + @BeforeEach + public void setUpCloudApiMocks() { + lenient().when(adminUserApi.validateUserList(anyCollection())).thenReturn(success(true)); + lenient().when(notifyMessageSendApi.sendSingleMessageToAdmin(any())).thenReturn(success(randomLongId())); + } + + @Test + public void testOpenShare_duplicateAndReopen() { + // mock 数据 + Long userId = randomLongId(); + PmsKnowledgeLibraryDO library = randomLibraryDO(); + libraryMapper.insert(library); + PmsKnowledgeDocumentDO document = randomDocumentDO(library.getId()); + documentMapper.insert(document); + when(contentPermissionService.validateContentPermissionWritable( + document.getPermissionId(), document.getLibraryId(), userId)).thenReturn(2); + + // 调用开启分享,并断言重复开启异常 + PmsKnowledgeDocumentShareDO firstShare = documentShareService.openShare(document.getId(), + Arrays.asList(userId, userId), userId); + assertEquals(Collections.singletonList(userId), firstShare.getShareUserIds()); + assertServiceException(() -> documentShareService.openShare(document.getId(), null, userId), + KNOWLEDGE_DOCUMENT_SHARE_ALREADY_OPEN); + + // 调用关闭和重新开启,并断言令牌已更新 + documentShareService.closeShare(document.getId(), userId); + PmsKnowledgeDocumentShareDO reopenedShare = documentShareService.openShare(document.getId(), null, userId); + assertEquals(firstShare.getId(), reopenedShare.getId()); + assertNotEquals(firstShare.getToken(), reopenedShare.getToken()); + assertNull(reopenedShare.getCloseTime()); + } + + @Test + public void testUpdateShareMembers() { + // mock 数据 + Long userId = randomLongId(); + Long memberUserId = randomLongId(); + PmsKnowledgeLibraryDO library = randomLibraryDO(); + libraryMapper.insert(library); + PmsKnowledgeDocumentDO document = randomDocumentDO(library.getId()); + documentMapper.insert(document); + when(contentPermissionService.validateContentPermissionWritable( + document.getPermissionId(), document.getLibraryId(), userId)).thenReturn(2); + documentShareService.openShare(document.getId(), null, userId); + + // 调用 + documentShareService.updateShareMemberList(document.getId(), Collections.singletonList(memberUserId), userId); + + // 断言 + assertEquals(Collections.singletonList(memberUserId), + documentShareMapper.selectByDocumentId(document.getId()).getShareUserIds()); + ArgumentCaptor captor = ArgumentCaptor + .forClass(NotifySendSingleToUserReqDTO.class); + verify(notifyMessageSendApi).sendSingleMessageToAdmin(captor.capture()); + assertEquals(memberUserId, captor.getValue().getUserId()); + assertEquals(KNOWLEDGE_DOCUMENT_SHARED, captor.getValue().getTemplateCode()); + assertEquals(document.getTitle(), captor.getValue().getTemplateParams().get("documentTitle")); + String shareToken = documentShareMapper.selectByDocumentId(document.getId()).getToken(); + assertEquals("/pms/kb/document/share/" + shareToken, + captor.getValue().getTemplateParams().get("route")); + } + + @Test + public void testUpdateShareMembers_deduplicateAndValidateUsers() { + // mock 数据 + Long userId = randomLongId(); + Long memberUserId = randomLongId(); + PmsKnowledgeLibraryDO library = randomLibraryDO(); + libraryMapper.insert(library); + PmsKnowledgeDocumentDO document = randomDocumentDO(library.getId()); + documentMapper.insert(document); + when(contentPermissionService.validateContentPermissionWritable( + document.getPermissionId(), document.getLibraryId(), userId)).thenReturn(2); + documentShareService.openShare(document.getId(), null, userId); + + // 调用 + documentShareService.updateShareMemberList(document.getId(), + Arrays.asList(memberUserId, memberUserId), userId); + + // 断言:持久化和用户校验均使用去重后的名单 + List expectedUserIds = Collections.singletonList(memberUserId); + assertEquals(expectedUserIds, + documentShareMapper.selectByDocumentId(document.getId()).getShareUserIds()); + verify(adminUserApi).validateUserList(expectedUserIds); + } + + @Test + public void testGetDocumentByShareToken_invalidAfterRecycle() { + // mock 数据 + Long userId = randomLongId(); + PmsKnowledgeLibraryDO library = randomLibraryDO(); + libraryMapper.insert(library); + PmsKnowledgeDocumentDO document = randomDocumentDO(library.getId()); + documentMapper.insert(document); + when(contentPermissionService.validateContentPermissionWritable( + document.getPermissionId(), document.getLibraryId(), userId)).thenReturn(2); + PmsKnowledgeDocumentShareDO share = documentShareService.openShare(document.getId(), null, userId); + documentMapper.updateById(new PmsKnowledgeDocumentDO().setId(document.getId()).setStatus(-1)); + + // 调用并断言 + assertServiceException(() -> documentShareService.getDocumentByShareToken(share.getToken()), + KNOWLEDGE_DOCUMENT_SHARE_INVALID); + } + + @Test + public void testGetActiveDocumentShare_requiresContentReadable() { + // mock 数据 + Long userId = randomLongId(); + PmsKnowledgeLibraryDO library = randomLibraryDO(); + libraryMapper.insert(library); + PmsKnowledgeDocumentDO document = randomDocumentDO(library.getId()); + documentMapper.insert(document); + PmsKnowledgeDocumentShareDO share = new PmsKnowledgeDocumentShareDO().setDocumentId(document.getId()) + .setToken("share-token").setStatus(CommonStatusEnum.ENABLE.getStatus()); + documentShareMapper.insert(share); + when(contentPermissionService.validateContentPermissionReadable( + document.getPermissionId(), document.getLibraryId(), userId)).thenReturn(1); + + // 调用 + PmsKnowledgeDocumentShareDO result = documentShareService.getActiveDocumentShare(document.getId(), userId); + + // 断言 + assertEquals(share.getId(), result.getId()); + verify(contentPermissionService).validateContentPermissionReadable( + document.getPermissionId(), document.getLibraryId(), userId); + } + + @Test + public void testDeleteSharesByDocumentIds() { + // mock 数据 + Long userId = randomLongId(); + PmsKnowledgeLibraryDO library = randomLibraryDO(); + libraryMapper.insert(library); + PmsKnowledgeDocumentDO document = randomDocumentDO(library.getId()); + documentMapper.insert(document); + when(contentPermissionService.validateContentPermissionWritable( + document.getPermissionId(), document.getLibraryId(), userId)).thenReturn(2); + documentShareService.openShare(document.getId(), null, userId); + + // 调用 + documentShareService.deleteSharesByDocumentIds(Collections.singleton(document.getId())); + + // 断言 + assertNull(documentShareMapper.selectByDocumentId(document.getId())); + } + + // ========== 随机对象 ========== + + private PmsKnowledgeLibraryDO randomLibraryDO() { + return randomPojo(PmsKnowledgeLibraryDO.class, library -> library.setId(null).setName("测试知识库") + .setOpenStatus(true).setStatus(1)); + } + + private PmsKnowledgeDocumentDO randomDocumentDO(Long libraryId) { + return randomPojo(PmsKnowledgeDocumentDO.class, document -> document.setId(null).setLibraryId(libraryId) + .setPermissionId(randomLongId()).setFolderId(0L).setParentId(0L).setTitle("测试文档") + .setType(3).setStatus(1)); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeFavoriteServiceImplTest.java b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeFavoriteServiceImplTest.java new file mode 100644 index 000000000..bbaa46153 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeFavoriteServiceImplTest.java @@ -0,0 +1,189 @@ +package cn.iocoder.yudao.module.pms.service.kb.interaction; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.PmsKnowledgeInteractionItemRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.favorite.PmsKnowledgeFavoritePageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.favorite.PmsKnowledgeFavoriteSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.interaction.PmsKnowledgeFavoriteDO; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.interaction.PmsKnowledgeFavoriteMapper; +import cn.iocoder.yudao.module.pms.enums.kb.PmsKnowledgeObjectTypeEnum; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_FAVORITE_DUPLICATE; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +/** + * {@link PmsKnowledgeFavoriteServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(PmsKnowledgeFavoriteServiceImpl.class) +public class PmsKnowledgeFavoriteServiceImplTest extends BaseDbUnitTest { + + @Resource + private PmsKnowledgeFavoriteServiceImpl favoriteService; + + @Resource + private PmsKnowledgeFavoriteMapper favoriteMapper; + + @MockitoBean + private PmsKnowledgeInteractionTargetService interactionTargetService; + + @Test + public void testCreateFavorite_duplicateAndDelete() { + // mock 数据 + Long userId = randomLongId(); + Long libraryId = randomLongId(); + PmsKnowledgeFavoriteSaveReqVO saveReqVO = new PmsKnowledgeFavoriteSaveReqVO() + .setType(PmsKnowledgeObjectTypeEnum.LIBRARY.getType()).setEntityId(libraryId); + when(interactionTargetService.validateTargetReadable(saveReqVO.getType(), libraryId, userId)) + .thenReturn(libraryId); + + // 调用创建,并断言重复关注异常 + favoriteService.createFavorite(saveReqVO, userId); + assertTrue(favoriteService.isFavorite(saveReqVO.getType(), libraryId, userId)); + assertServiceException(() -> favoriteService.createFavorite(saveReqVO, userId), + KNOWLEDGE_FAVORITE_DUPLICATE); + assertNotNull(favoriteMapper.selectByUserIdAndTypeAndEntityId(userId, saveReqVO.getType(), libraryId)); + + // 调用取消关注,并断言 + favoriteService.deleteFavorite(saveReqVO.getType(), libraryId, userId); + assertFalse(favoriteService.isFavorite(saveReqVO.getType(), libraryId, userId)); + assertNull(favoriteMapper.selectByUserIdAndTypeAndEntityId(userId, saveReqVO.getType(), libraryId)); + } + + @Test + public void testGetFavoritePage_filters() { + // mock 数据 + Long userId = randomLongId(); + PmsKnowledgeFavoriteDO firstFolder = randomFavoriteDO(userId, randomLongId(), + PmsKnowledgeObjectTypeEnum.FOLDER.getType(), randomLongId()); + favoriteMapper.insert(firstFolder); + PmsKnowledgeFavoriteDO secondFolder = randomFavoriteDO(userId, randomLongId(), + PmsKnowledgeObjectTypeEnum.FOLDER.getType(), randomLongId()); + favoriteMapper.insert(secondFolder); + PmsKnowledgeFavoriteDO document = randomFavoriteDO(userId, randomLongId(), + PmsKnowledgeObjectTypeEnum.DOCUMENT.getType(), randomLongId()); + favoriteMapper.insert(document); + when(interactionTargetService.getReadableItemList(anyCollection(), eq(userId))).thenReturn(Arrays.asList( + new PmsKnowledgeInteractionItemRespVO().setId(firstFolder.getId()) + .setType(PmsKnowledgeObjectTypeEnum.FOLDER.getType()) + .setName("产品资料").setLibraryName("产品知识库"), + new PmsKnowledgeInteractionItemRespVO().setId(secondFolder.getId()) + .setType(PmsKnowledgeObjectTypeEnum.FOLDER.getType()) + .setName("设计资料").setLibraryName("产品知识库"), + new PmsKnowledgeInteractionItemRespVO().setId(document.getId()) + .setType(PmsKnowledgeObjectTypeEnum.DOCUMENT.getType()) + .setName("产品说明").setLibraryName("产品知识库"))); + PmsKnowledgeFavoritePageReqVO pageReqVO = new PmsKnowledgeFavoritePageReqVO() + .setType(PmsKnowledgeObjectTypeEnum.FOLDER.getType()); + pageReqVO.setPageNo(1); + pageReqVO.setPageSize(1); + + // 调用 + PageResult favoritePage = favoriteService.getFavoritePage(pageReqVO, + userId); + + // 断言 + assertEquals(2, favoritePage.getTotal()); + assertEquals(1, favoritePage.getList().size()); + assertEquals(PmsKnowledgeObjectTypeEnum.FOLDER.getType(), + CollUtil.getFirst(favoritePage.getList()).getType()); + } + + @Test + @SuppressWarnings("unchecked") + public void testGetFavoriteListByLibraryId_filtersLibrary() { + // mock 数据 + Long userId = randomLongId(); + Long libraryId = randomLongId(); + favoriteMapper.insert(randomFavoriteDO(userId, libraryId, + PmsKnowledgeObjectTypeEnum.FOLDER.getType(), randomLongId())); + favoriteMapper.insert(randomFavoriteDO(userId, randomLongId(), + PmsKnowledgeObjectTypeEnum.DOCUMENT.getType(), randomLongId())); + when(interactionTargetService.getReadableItemList(anyCollection(), eq(userId))) + .thenAnswer(invocation -> (List) invocation.getArgument(0)); + + // 调用 + List favoriteList = favoriteService + .getFavoriteListByLibraryId(libraryId, userId); + + // 断言 + assertEquals(1, favoriteList.size()); + assertEquals(libraryId, CollUtil.getFirst(favoriteList).getLibraryId()); + } + + @Test + public void testDeleteFavoritesByEntityIds() { + // mock 数据 + Long userId = randomLongId(); + Long libraryId = randomLongId(); + Long folderId = randomLongId(); + Long documentId = randomLongId(); + favoriteMapper.insert(randomFavoriteDO(userId, libraryId, + PmsKnowledgeObjectTypeEnum.FOLDER.getType(), folderId)); + favoriteMapper.insert(randomFavoriteDO(userId, libraryId, + PmsKnowledgeObjectTypeEnum.DOCUMENT.getType(), documentId)); + + // 调用 + favoriteService.deleteFavoritesByEntityIds(Collections.singleton(folderId), + Collections.singleton(documentId)); + + // 断言 + assertTrue(CollUtil.isEmpty(favoriteMapper.selectListByUserId(userId))); + } + + @Test + public void testUpdateFavoriteLibraryIdByEntityIds() { + // mock 数据 + Long userId = randomLongId(); + Long sourceLibraryId = randomLongId(); + Long targetLibraryId = randomLongId(); + Long entityId = randomLongId(); + favoriteMapper.insert(randomFavoriteDO(userId, sourceLibraryId, + PmsKnowledgeObjectTypeEnum.FOLDER.getType(), entityId)); + favoriteMapper.insert(randomFavoriteDO(userId, sourceLibraryId, + PmsKnowledgeObjectTypeEnum.FILE.getType(), entityId)); + favoriteMapper.insert(randomFavoriteDO(userId, sourceLibraryId, + PmsKnowledgeObjectTypeEnum.LIBRARY.getType(), entityId)); + + // 调用 + favoriteService.updateFavoriteLibraryIdByEntityIds(Collections.singleton(entityId), + Collections.singleton(entityId), targetLibraryId); + + // 断言 + assertEquals(targetLibraryId, favoriteMapper.selectByUserIdAndTypeAndEntityId(userId, + PmsKnowledgeObjectTypeEnum.FOLDER.getType(), entityId).getLibraryId()); + assertEquals(targetLibraryId, favoriteMapper.selectByUserIdAndTypeAndEntityId(userId, + PmsKnowledgeObjectTypeEnum.FILE.getType(), entityId).getLibraryId()); + assertEquals(sourceLibraryId, favoriteMapper.selectByUserIdAndTypeAndEntityId(userId, + PmsKnowledgeObjectTypeEnum.LIBRARY.getType(), entityId).getLibraryId()); + } + + // ========== 随机对象 ========== + + private PmsKnowledgeFavoriteDO randomFavoriteDO(Long userId, Long libraryId, Integer type, Long entityId) { + return randomPojo(PmsKnowledgeFavoriteDO.class, favorite -> favorite.setId(null).setLibraryId(libraryId) + .setType(type).setEntityId(entityId).setUserId(userId)); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeInteractionTargetServiceImplTest.java b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeInteractionTargetServiceImplTest.java new file mode 100644 index 000000000..2fe5ea134 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeInteractionTargetServiceImplTest.java @@ -0,0 +1,101 @@ +package cn.iocoder.yudao.module.pms.service.kb.interaction; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.PmsKnowledgeInteractionItemRespVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeDocumentDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeFolderDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryDO; +import cn.iocoder.yudao.module.pms.enums.kb.PmsKnowledgeObjectTypeEnum; +import cn.iocoder.yudao.module.pms.service.kb.content.PmsKnowledgeContentPermissionService; +import cn.iocoder.yudao.module.pms.service.kb.content.PmsKnowledgeDocumentService; +import cn.iocoder.yudao.module.pms.service.kb.library.PmsKnowledgeLibraryMemberService; +import cn.iocoder.yudao.module.pms.service.kb.library.PmsKnowledgeLibraryService; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_INTERACTION_OBJECT_INVALID; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.Mockito.when; + +/** + * {@link PmsKnowledgeInteractionTargetServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +public class PmsKnowledgeInteractionTargetServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private PmsKnowledgeInteractionTargetServiceImpl interactionTargetService; + + @Mock + private PmsKnowledgeLibraryMemberService libraryMemberService; + @Mock + private PmsKnowledgeLibraryService libraryService; + @Mock + private PmsKnowledgeFolderReadService folderReadService; + @Mock + private PmsKnowledgeDocumentService documentService; + @Mock + private PmsKnowledgeContentPermissionService contentPermissionService; + + @Test + public void testGetReadableItemList_filtersContentPermission() { + // mock 数据 + Long userId = randomLongId(); + Long libraryId = randomLongId(); + Long readablePermissionId = randomLongId(); + Long unreadablePermissionId = randomLongId(); + PmsKnowledgeLibraryDO library = new PmsKnowledgeLibraryDO().setId(libraryId) + .setName("产品知识库").setStatus(1); + PmsKnowledgeFolderDO readableFolder = new PmsKnowledgeFolderDO().setId(randomLongId()) + .setLibraryId(libraryId).setPermissionId(readablePermissionId).setTitle("可读文件夹").setStatus(1); + PmsKnowledgeFolderDO unreadableFolder = new PmsKnowledgeFolderDO().setId(randomLongId()) + .setLibraryId(libraryId).setPermissionId(unreadablePermissionId).setTitle("不可读文件夹").setStatus(1); + when(libraryMemberService.getReadableLibraryIdList(userId)).thenReturn(Collections.singletonList(libraryId)); + when(libraryService.getLibraryList(Collections.singleton(libraryId))) + .thenReturn(Collections.singletonList(library)); + when(folderReadService.getFolderList(anyCollection())) + .thenReturn(Arrays.asList(readableFolder, unreadableFolder)); + when(documentService.getDocumentList(anyCollection())).thenReturn(Collections.emptyList()); + when(contentPermissionService.getReadableContentPermissionIdSet(Collections.singleton(libraryId), userId)) + .thenReturn(Collections.singleton(readablePermissionId)); + List targets = Arrays.asList( + new PmsKnowledgeInteractionItemRespVO().setId(randomLongId()) + .setType(PmsKnowledgeObjectTypeEnum.FOLDER.getType()) + .setEntityId(readableFolder.getId()).setLibraryId(libraryId), + new PmsKnowledgeInteractionItemRespVO().setId(randomLongId()) + .setType(PmsKnowledgeObjectTypeEnum.FOLDER.getType()) + .setEntityId(unreadableFolder.getId()).setLibraryId(libraryId)); + + // 调用 + List items = interactionTargetService.getReadableItemList(targets, userId); + + // 断言 + assertEquals(1, items.size()); + assertEquals(readableFolder.getId(), CollUtil.getFirst(items).getEntityId()); + } + + @Test + public void testValidateTargetReadable_documentTypeMismatch() { + // mock 数据 + Long userId = randomLongId(); + Long documentId = randomLongId(); + when(documentService.getDocument(documentId, userId)).thenReturn(new PmsKnowledgeDocumentDO() + .setId(documentId).setLibraryId(randomLongId()).setType(PmsKnowledgeObjectTypeEnum.FILE.getType())); + + // 调用,并断言 + assertServiceException(() -> interactionTargetService.validateTargetReadable( + PmsKnowledgeObjectTypeEnum.DOCUMENT.getType(), documentId, userId), + KNOWLEDGE_INTERACTION_OBJECT_INVALID); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeViewRecordServiceImplTest.java b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeViewRecordServiceImplTest.java new file mode 100644 index 000000000..91f04f185 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/interaction/PmsKnowledgeViewRecordServiceImplTest.java @@ -0,0 +1,158 @@ +package cn.iocoder.yudao.module.pms.service.kb.interaction; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.PmsKnowledgeInteractionItemRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.interaction.vo.viewrecord.PmsKnowledgeRecentListRespVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.interaction.PmsKnowledgeViewRecordDO; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.interaction.PmsKnowledgeViewRecordMapper; +import cn.iocoder.yudao.module.pms.enums.kb.PmsKnowledgeObjectTypeEnum; +import cn.iocoder.yudao.module.pms.service.kb.library.PmsKnowledgeLibraryMemberService; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; + +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +/** + * {@link PmsKnowledgeViewRecordServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(PmsKnowledgeViewRecordServiceImpl.class) +public class PmsKnowledgeViewRecordServiceImplTest extends BaseDbUnitTest { + + @Resource + private PmsKnowledgeViewRecordServiceImpl viewRecordService; + + @Resource + private PmsKnowledgeViewRecordMapper viewRecordMapper; + + @MockitoBean + private PmsKnowledgeInteractionTargetService interactionTargetService; + @MockitoBean + private PmsKnowledgeLibraryMemberService libraryMemberService; + + @Test + public void testCreateViewRecord() { + // mock 数据 + Long userId = randomLongId(); + Long sourceLibraryId = randomLongId(); + Long targetLibraryId = randomLongId(); + Long documentId = randomLongId(); + Integer type = PmsKnowledgeObjectTypeEnum.DOCUMENT.getType(); + + // 调用 + viewRecordService.createViewRecord(sourceLibraryId, type, documentId, userId); + LocalDateTime firstViewTime = CollUtil.getFirst(viewRecordMapper.selectList()).getCreateTime(); + viewRecordService.createViewRecord(targetLibraryId, type, documentId, userId); + + // 断言 + assertEquals(1, viewRecordMapper.selectList().size()); + PmsKnowledgeViewRecordDO record = CollUtil.getFirst(viewRecordMapper.selectList()); + assertEquals(targetLibraryId, record.getLibraryId()); + assertEquals(type, record.getType()); + assertEquals(documentId, record.getEntityId()); + assertEquals(userId, record.getUserId()); + assertFalse(record.getCreateTime().isBefore(firstViewTime)); + } + + @Test + public void testGetRecentList_groupsAndDeduplicates() { + // mock 数据 + Long userId = randomLongId(); + Long libraryId = randomLongId(); + Long folderId = randomLongId(); + LocalDateTime todayTime = LocalDateTime.now(); + LocalDateTime yesterdayTime = LocalDateTime.now().toLocalDate().atStartOfDay().minusHours(1); + viewRecordMapper.insert(randomViewRecordDO(userId, libraryId, + PmsKnowledgeObjectTypeEnum.FOLDER.getType(), folderId, todayTime)); + viewRecordMapper.insert(randomViewRecordDO(userId, libraryId, + PmsKnowledgeObjectTypeEnum.DOCUMENT.getType(), randomLongId(), yesterdayTime)); + when(interactionTargetService.getReadableItemList(anyCollection(), eq(userId))).thenReturn(Arrays.asList( + new PmsKnowledgeInteractionItemRespVO().setId(randomLongId()) + .setType(PmsKnowledgeObjectTypeEnum.FOLDER.getType()).setEntityId(folderId) + .setCreateTime(todayTime), + new PmsKnowledgeInteractionItemRespVO().setId(randomLongId()) + .setType(PmsKnowledgeObjectTypeEnum.DOCUMENT.getType()).setEntityId(randomLongId()) + .setCreateTime(yesterdayTime))); + + // 调用 + PmsKnowledgeRecentListRespVO recentItems = viewRecordService.getRecentViewRecordList(null, userId); + + // 断言 + assertEquals(1, recentItems.getTodayItems().size()); + assertEquals(folderId, CollUtil.getFirst(recentItems.getTodayItems()).getEntityId()); + assertEquals(1, recentItems.getYesterdayItems().size()); + assertTrue(CollUtil.isEmpty(recentItems.getRecent30DayItems())); + } + + @Test + public void testDeleteViewRecordsByEntityIds() { + // mock 数据 + Long userId = randomLongId(); + Long libraryId = randomLongId(); + Long folderId = randomLongId(); + Long documentId = randomLongId(); + viewRecordMapper.insert(randomViewRecordDO(userId, libraryId, + PmsKnowledgeObjectTypeEnum.FOLDER.getType(), folderId, LocalDateTime.now())); + viewRecordMapper.insert(randomViewRecordDO(userId, libraryId, + PmsKnowledgeObjectTypeEnum.DOCUMENT.getType(), documentId, LocalDateTime.now())); + + // 调用 + viewRecordService.deleteViewRecordsByEntityIds(Collections.singleton(folderId), + Collections.singleton(documentId)); + + // 断言 + assertTrue(CollUtil.isEmpty(viewRecordMapper.selectListByUserIdAndCreateTimeAfter( + userId, LocalDateTime.now().minusDays(1)))); + } + + @Test + public void testUpdateViewRecordLibraryIdByEntityIds() { + // mock 数据 + Long userId = randomLongId(); + Long sourceLibraryId = randomLongId(); + Long targetLibraryId = randomLongId(); + Long entityId = randomLongId(); + PmsKnowledgeViewRecordDO folderRecord = randomViewRecordDO(userId, sourceLibraryId, + PmsKnowledgeObjectTypeEnum.FOLDER.getType(), entityId, LocalDateTime.now()); + viewRecordMapper.insert(folderRecord); + PmsKnowledgeViewRecordDO fileRecord = randomViewRecordDO(userId, sourceLibraryId, + PmsKnowledgeObjectTypeEnum.FILE.getType(), entityId, LocalDateTime.now()); + viewRecordMapper.insert(fileRecord); + PmsKnowledgeViewRecordDO otherRecord = randomViewRecordDO(userId, sourceLibraryId, + PmsKnowledgeObjectTypeEnum.DOCUMENT.getType(), randomLongId(), LocalDateTime.now()); + viewRecordMapper.insert(otherRecord); + + // 调用 + viewRecordService.updateViewRecordLibraryIdByEntityIds(Collections.singleton(entityId), + Collections.singleton(entityId), targetLibraryId); + + // 断言 + assertEquals(targetLibraryId, viewRecordMapper.selectById(folderRecord.getId()).getLibraryId()); + assertEquals(targetLibraryId, viewRecordMapper.selectById(fileRecord.getId()).getLibraryId()); + assertEquals(sourceLibraryId, viewRecordMapper.selectById(otherRecord.getId()).getLibraryId()); + } + + // ========== 随机对象 ========== + + private PmsKnowledgeViewRecordDO randomViewRecordDO(Long userId, Long libraryId, Integer type, + Long entityId, LocalDateTime createTime) { + return randomPojo(PmsKnowledgeViewRecordDO.class, record -> record.setId(null).setLibraryId(libraryId) + .setType(type).setEntityId(entityId).setUserId(userId).setCreateTime(createTime)); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeGroupServiceImplTest.java b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeGroupServiceImplTest.java new file mode 100644 index 000000000..80f48a64e --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeGroupServiceImplTest.java @@ -0,0 +1,203 @@ +package cn.iocoder.yudao.module.pms.service.kb.library; + +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.group.PmsKnowledgeGroupSaveReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.group.PmsKnowledgeLibraryMoveGroupReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeGroupDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeGroupRelationDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryDO; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.library.PmsKnowledgeGroupMapper; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.library.PmsKnowledgeGroupRelationMapper; +import cn.iocoder.yudao.module.pms.enums.kb.library.PmsKnowledgeGroupTypeEnum; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_GROUP_DEFAULT_CANNOT_DELETE; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.when; + +/** + * {@link PmsKnowledgeGroupServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(PmsKnowledgeGroupServiceImpl.class) +public class PmsKnowledgeGroupServiceImplTest extends BaseDbUnitTest { + + @Resource + private PmsKnowledgeGroupServiceImpl groupService; + + @Resource + private PmsKnowledgeGroupMapper groupMapper; + @Resource + private PmsKnowledgeGroupRelationMapper relationMapper; + + @MockitoBean + private PmsKnowledgeLibraryMemberService libraryMemberService; + + @Test + public void testCreateKnowledgeGroup_success() { + // 准备参数 + Long userId = randomLongId(); + PmsKnowledgeGroupSaveReqVO reqVO = new PmsKnowledgeGroupSaveReqVO().setName(" 产品资料 "); + + // 调用 + Long groupId = groupService.createGroup(reqVO, userId); + + // 断言 + PmsKnowledgeGroupDO group = groupMapper.selectById(groupId); + assertEquals("产品资料", group.getName()); + assertEquals(PmsKnowledgeGroupTypeEnum.CUSTOM.getType(), group.getType()); + assertEquals(1, groupMapper.selectListByUserId(userId).size()); + } + + @Test + public void testGetKnowledgeGroupList_defaultGroups() { + // mock 数据 + Long userId = randomLongId(); + groupMapper.insert(randomGroupDO(userId, PmsKnowledgeGroupTypeEnum.CUSTOM.getType())); + + // 调用 + List groups = groupService.getGroupList(userId); + + // 断言 + assertEquals(1, groupMapper.selectListByUserId(userId).stream() + .filter(item -> PmsKnowledgeGroupTypeEnum.ALL.getType().equals(item.getType())).count()); + assertEquals(1, groupMapper.selectListByUserId(userId).stream() + .filter(item -> PmsKnowledgeGroupTypeEnum.UNGROUPED.getType().equals(item.getType())).count()); + assertEquals(1, groups.stream() + .filter(item -> PmsKnowledgeGroupTypeEnum.ALL.getType().equals(item.getType())).count()); + assertEquals(1, groups.stream() + .filter(item -> PmsKnowledgeGroupTypeEnum.UNGROUPED.getType().equals(item.getType())).count()); + assertEquals(3, groups.size()); + } + + @Test + public void testMoveKnowledgeGroup_success() { + // mock 数据 + Long userId = randomLongId(); + Long libraryId = randomLongId(); + PmsKnowledgeGroupDO customGroup = randomGroupDO(userId, PmsKnowledgeGroupTypeEnum.CUSTOM.getType()); + groupMapper.insert(customGroup); + when(libraryMemberService.validateLibraryReadable(libraryId, userId)) + .thenReturn(new PmsKnowledgeLibraryDO().setId(libraryId)); + // 准备参数 + PmsKnowledgeLibraryMoveGroupReqVO reqVO = new PmsKnowledgeLibraryMoveGroupReqVO() + .setLibraryId(libraryId).setGroupId(customGroup.getId()); + + // 调用 + groupService.moveLibraryToGroup(reqVO, userId); + + // 断言 + PmsKnowledgeGroupRelationDO relation = relationMapper.selectByUserIdAndLibraryId(userId, libraryId); + assertNotNull(relation); + assertEquals(customGroup.getId(), relation.getGroupId()); + } + + @Test + public void testGetGroupLibraryCountMap_success() { + // mock 数据 + Long userId = randomLongId(); + Long libraryId1 = randomLongId(); + Long libraryId2 = randomLongId(); + Long libraryId3 = randomLongId(); + PmsKnowledgeGroupDO allGroup = randomGroupDO(userId, PmsKnowledgeGroupTypeEnum.ALL.getType()); + PmsKnowledgeGroupDO ungroupedGroup = randomGroupDO(userId, PmsKnowledgeGroupTypeEnum.UNGROUPED.getType()); + PmsKnowledgeGroupDO customGroup = randomGroupDO(userId, PmsKnowledgeGroupTypeEnum.CUSTOM.getType()); + groupMapper.insert(allGroup); + groupMapper.insert(ungroupedGroup); + groupMapper.insert(customGroup); + relationMapper.insert(new PmsKnowledgeGroupRelationDO().setUserId(userId).setGroupId(customGroup.getId()) + .setLibraryId(libraryId1).setSort(0)); + relationMapper.insert(new PmsKnowledgeGroupRelationDO().setUserId(userId).setGroupId(customGroup.getId()) + .setLibraryId(libraryId1).setSort(1)); + when(libraryMemberService.getReadableLibraryIdList(userId)) + .thenReturn(Arrays.asList(libraryId1, libraryId2, libraryId3)); + + // 调用 + Map countMap = groupService.getGroupLibraryCountMap(userId, + Arrays.asList(allGroup.getId(), ungroupedGroup.getId(), customGroup.getId())); + + // 断言:重复关系按知识库去重,未分组数量不被重复关系影响 + assertEquals(3, countMap.get(allGroup.getId())); + assertEquals(2, countMap.get(ungroupedGroup.getId())); + assertEquals(1, countMap.get(customGroup.getId())); + } + + @Test + public void testMoveKnowledgeGroup_ungrouped() { + // mock 数据 + Long userId = randomLongId(); + Long libraryId = randomLongId(); + PmsKnowledgeGroupDO customGroup = randomGroupDO(userId, PmsKnowledgeGroupTypeEnum.CUSTOM.getType()); + groupMapper.insert(customGroup); + PmsKnowledgeGroupDO ungrouped = randomGroupDO(userId, PmsKnowledgeGroupTypeEnum.UNGROUPED.getType()); + groupMapper.insert(ungrouped); + relationMapper.insert(new PmsKnowledgeGroupRelationDO().setUserId(userId).setGroupId(customGroup.getId()) + .setLibraryId(libraryId).setSort(0)); + when(libraryMemberService.validateLibraryReadable(libraryId, userId)) + .thenReturn(new PmsKnowledgeLibraryDO().setId(libraryId)); + // 准备参数 + PmsKnowledgeLibraryMoveGroupReqVO reqVO = new PmsKnowledgeLibraryMoveGroupReqVO() + .setLibraryId(libraryId).setGroupId(ungrouped.getId()); + + // 调用 + groupService.moveLibraryToGroup(reqVO, userId); + + // 断言 + assertNull(relationMapper.selectByUserIdAndLibraryId(userId, libraryId)); + } + + @Test + public void testFilterLibraryIdListByGroup_ungrouped() { + // mock 数据 + Long userId = randomLongId(); + Long groupedLibraryId = randomLongId(); + Long ungroupedLibraryId = randomLongId(); + PmsKnowledgeGroupDO customGroup = randomGroupDO(userId, PmsKnowledgeGroupTypeEnum.CUSTOM.getType()); + groupMapper.insert(customGroup); + PmsKnowledgeGroupDO ungrouped = randomGroupDO(userId, PmsKnowledgeGroupTypeEnum.UNGROUPED.getType()); + groupMapper.insert(ungrouped); + relationMapper.insert(new PmsKnowledgeGroupRelationDO().setUserId(userId).setGroupId(customGroup.getId()) + .setLibraryId(groupedLibraryId).setSort(0)); + + // 调用 + List filteredLibraryIds = groupService.filterLibraryIdListByGroup(ungrouped.getId(), userId, + Arrays.asList(groupedLibraryId, ungroupedLibraryId)); + + // 断言 + assertEquals(Collections.singletonList(ungroupedLibraryId), filteredLibraryIds); + } + + @Test + public void testDeleteKnowledgeGroup_defaultGroup() { + // mock 数据 + Long userId = randomLongId(); + PmsKnowledgeGroupDO allGroup = randomGroupDO(userId, PmsKnowledgeGroupTypeEnum.ALL.getType()); + groupMapper.insert(allGroup); + + // 调用,并断言异常 + assertServiceException(() -> groupService.deleteGroup(allGroup.getId(), userId), + KNOWLEDGE_GROUP_DEFAULT_CANNOT_DELETE); + } + + // ========== 随机对象 ========== + + private PmsKnowledgeGroupDO randomGroupDO(Long userId, Integer type) { + return randomPojo(PmsKnowledgeGroupDO.class, group -> group.setId(null).setUserId(userId) + .setName(PmsKnowledgeGroupTypeEnum.valueOf(type).getName()).setSort(type).setType(type)); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeLibraryMemberServiceImplTest.java b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeLibraryMemberServiceImplTest.java new file mode 100644 index 000000000..ec074e8d6 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeLibraryMemberServiceImplTest.java @@ -0,0 +1,393 @@ +package cn.iocoder.yudao.module.pms.service.kb.library; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.member.PmsKnowledgeLibraryUpdateMemberListReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryMemberDO; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.library.PmsKnowledgeLibraryMemberMapper; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeDocumentStatusEnum; +import cn.iocoder.yudao.module.pms.enums.kb.library.PmsKnowledgeLibraryMemberLevelEnum; +import cn.iocoder.yudao.module.pms.service.kb.content.PmsKnowledgeContentPermissionService; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.permission.PermissionApi; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_LIBRARY_ACCESS_DENIED; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_LIBRARY_ADMIN_REQUIRED; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_LIBRARY_CREATOR_CANNOT_REMOVE; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_LIBRARY_MEMBERS_INVALID; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +/** + * {@link PmsKnowledgeLibraryMemberServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(PmsKnowledgeLibraryMemberServiceImpl.class) +public class PmsKnowledgeLibraryMemberServiceImplTest extends BaseDbUnitTest { + + @Resource + private PmsKnowledgeLibraryMemberServiceImpl memberService; + + @Resource + private PmsKnowledgeLibraryMemberMapper memberMapper; + + @MockitoBean + private PmsKnowledgeLibraryService libraryService; + @MockitoBean + private PmsKnowledgeContentPermissionService contentPermissionService; + @MockitoBean + private PermissionApi permissionApi; + @MockitoBean + private AdminUserApi adminUserApi; + @MockitoBean + private DeptApi deptApi; + + @BeforeEach + public void setUpCloudApiMocks() { + lenient().when(permissionApi.hasAnyRoles(anyLong(), org.mockito.ArgumentMatchers.any(String[].class))) + .thenReturn(success(false)); + lenient().when(adminUserApi.getUser(anyLong())).thenReturn(success(null)); + lenient().when(adminUserApi.validateUserList(anyCollection())).thenReturn(success(true)); + lenient().when(deptApi.validateDeptList(anyCollection())).thenReturn(success(true)); + } + + @Test + public void testValidateLibraryReadable_privateDenied() { + // mock 数据 + Long libraryId = randomLongId(); + Long userId = randomLongId(); + when(libraryService.getLibrary(libraryId)).thenReturn(randomLibraryDO(libraryId, false)); + + // 调用,并断言异常 + assertServiceException(() -> memberService.validateLibraryReadable(libraryId, userId), + KNOWLEDGE_LIBRARY_ACCESS_DENIED); + } + + @Test + public void testValidateLibraryReadable_privateMember() { + // mock 数据 + Long libraryId = randomLongId(); + Long userId = randomLongId(); + PmsKnowledgeLibraryDO library = randomLibraryDO(libraryId, false); + when(libraryService.getLibrary(libraryId)).thenReturn(library); + memberMapper.insert(randomMemberDO(libraryId, userId, null, + PmsKnowledgeLibraryMemberLevelEnum.MEMBER.getLevel())); + + // 调用 + PmsKnowledgeLibraryDO readableLibrary = memberService.validateLibraryReadable(libraryId, userId); + + // 断言 + assertEquals(library, readableLibrary); + } + + @Test + public void testValidateLibraryReadable_privateDeptMember() { + // mock 数据 + Long libraryId = randomLongId(); + Long userId = randomLongId(); + Long deptId = randomLongId(); + PmsKnowledgeLibraryDO library = randomLibraryDO(libraryId, false); + when(libraryService.getLibrary(libraryId)).thenReturn(library); + when(adminUserApi.getUser(userId)).thenReturn(success( + new AdminUserRespDTO().setId(userId).setDeptId(deptId))); + memberMapper.insert(randomMemberDO(libraryId, null, deptId, + PmsKnowledgeLibraryMemberLevelEnum.MEMBER.getLevel())); + + // 调用 + PmsKnowledgeLibraryDO readableLibrary = memberService.validateLibraryReadable(libraryId, userId); + + // 断言 + assertEquals(library, readableLibrary); + } + + @Test + public void testValidateLibraryReadable_parentDeptMemberDenied() { + // mock 数据 + Long libraryId = randomLongId(); + Long userId = randomLongId(); + Long deptId = randomLongId(); + Long parentDeptId = randomLongId(); + when(libraryService.getLibrary(libraryId)).thenReturn(randomLibraryDO(libraryId, false)); + when(adminUserApi.getUser(userId)).thenReturn(success( + new AdminUserRespDTO().setId(userId).setDeptId(deptId))); + memberMapper.insert(randomMemberDO(libraryId, null, parentDeptId, + PmsKnowledgeLibraryMemberLevelEnum.MEMBER.getLevel())); + + // 调用,并断言异常 + assertServiceException(() -> memberService.validateLibraryReadable(libraryId, userId), + KNOWLEDGE_LIBRARY_ACCESS_DENIED); + } + + @Test + public void testGetJoinedLibraryIdList_filterInvalidLibrary() { + // mock 数据 + Long userId = randomLongId(); + Long invalidLibraryId = randomLongId(); + Long validLibraryId = randomLongId(); + memberMapper.insert(randomMemberDO(invalidLibraryId, userId, null, + PmsKnowledgeLibraryMemberLevelEnum.MEMBER.getLevel())); + memberMapper.insert(randomMemberDO(validLibraryId, userId, null, + PmsKnowledgeLibraryMemberLevelEnum.MEMBER.getLevel())); + when(libraryService.getLibraryIdList(null)).thenReturn(Collections.singletonList(validLibraryId)); + + // 调用 + List joinedLibraryIds = memberService.getJoinedLibraryIdList(userId); + + // 断言 + assertEquals(Collections.singletonList(validLibraryId), joinedLibraryIds); + } + + @Test + public void testCreateMemberList_success() { + // 准备参数 + Long libraryId = randomLongId(); + Long creatorUserId = randomLongId(); + Long adminUserId = randomLongId(); + Long memberUserId = randomLongId(); + // 调用 + memberService.createLibraryMemberList(libraryId, creatorUserId, + Collections.singletonList(adminUserId), Collections.singletonList(memberUserId)); + + // 断言 + List members = memberMapper.selectListByLibraryId(libraryId); + assertEquals(3, members.size()); + assertNotNull(CollUtil.findOne(members, member -> + PmsKnowledgeLibraryMemberLevelEnum.CREATOR.getLevel().equals(member.getLevel()))); + assertNotNull(CollUtil.findOne(members, member -> + adminUserId.equals(member.getUserId()) + && PmsKnowledgeLibraryMemberLevelEnum.ADMIN.getLevel().equals(member.getLevel()))); + assertNotNull(CollUtil.findOne(members, member -> + memberUserId.equals(member.getUserId()) + && PmsKnowledgeLibraryMemberLevelEnum.MEMBER.getLevel().equals(member.getLevel()))); + verify(adminUserApi).validateUserList(new LinkedHashSet<>(Arrays.asList(adminUserId, memberUserId))); + } + + @Test + public void testCreateMemberList_duplicateMember_invalid() { + // 准备参数 + Long libraryId = randomLongId(); + Long creatorUserId = randomLongId(); + Long memberUserId = randomLongId(); + // 调用,并断言异常 + assertServiceException(() -> memberService.createLibraryMemberList(libraryId, creatorUserId, + Arrays.asList(memberUserId, memberUserId), Collections.emptyList()), + KNOWLEDGE_LIBRARY_MEMBERS_INVALID); + } + + @Test + public void testCreateMemberList_duplicateRoleMember_invalid() { + // 准备参数 + Long libraryId = randomLongId(); + Long creatorUserId = randomLongId(); + Long memberUserId = randomLongId(); + + // 调用,并断言异常 + assertServiceException(() -> memberService.createLibraryMemberList(libraryId, creatorUserId, + Collections.singletonList(memberUserId), Collections.singletonList(memberUserId)), + KNOWLEDGE_LIBRARY_MEMBERS_INVALID); + } + + @Test + public void testIsLibraryAdmin_publicAdminMemberAllowed() { + // mock 数据 + Long libraryId = randomLongId(); + Long userId = randomLongId(); + when(libraryService.getLibrary(libraryId)).thenReturn(randomLibraryDO(libraryId, true)); + memberMapper.insert(randomMemberDO(libraryId, userId, null, + PmsKnowledgeLibraryMemberLevelEnum.ADMIN.getLevel())); + + // 调用,并断言:公开库管理员可以参与库和内容协作 + assertTrue(memberService.isLibraryAdmin(libraryId, userId)); + } + + @Test + public void testValidateLibraryAdmin_publicAdminMemberAllowed() { + // mock 数据 + Long libraryId = randomLongId(); + Long userId = randomLongId(); + when(libraryService.getLibrary(libraryId)).thenReturn(randomLibraryDO(libraryId, true)); + memberMapper.insert(randomMemberDO(libraryId, userId, null, + PmsKnowledgeLibraryMemberLevelEnum.ADMIN.getLevel())); + + // 调用 + assertNotNull(memberService.validateLibraryAdmin(libraryId, userId)); + } + + @Test + public void testValidateLibraryCreator_adminMemberDenied() { + // mock 数据 + Long libraryId = randomLongId(); + Long userId = randomLongId(); + when(libraryService.getLibrary(libraryId)).thenReturn(randomLibraryDO(libraryId, true)); + memberMapper.insert(randomMemberDO(libraryId, userId, null, + PmsKnowledgeLibraryMemberLevelEnum.ADMIN.getLevel())); + + // 调用,并断言:管理员可以协作,但不能执行创建人级别的高危操作 + assertServiceException(() -> memberService.validateLibraryCreator(libraryId, userId), + KNOWLEDGE_LIBRARY_ADMIN_REQUIRED); + } + + @Test + public void testUpdateMemberList_publicAllowsMembers() { + // mock 数据 + Long libraryId = randomLongId(); + Long creatorUserId = randomLongId(); + when(libraryService.getLibrary(libraryId)).thenReturn(randomLibraryDO(libraryId, true)); + memberMapper.insert(randomMemberDO(libraryId, creatorUserId, null, + PmsKnowledgeLibraryMemberLevelEnum.CREATOR.getLevel())); + PmsKnowledgeLibraryUpdateMemberListReqVO reqVO = new PmsKnowledgeLibraryUpdateMemberListReqVO() + .setLibraryId(libraryId).setMembers(Collections.singletonList( + new PmsKnowledgeLibraryUpdateMemberListReqVO.Member().setUserId(randomLongId()) + .setLevel(PmsKnowledgeLibraryMemberLevelEnum.MEMBER.getLevel()))); + + // 调用 + memberService.updateLibraryMemberList(reqVO, creatorUserId); + + // 断言 + List members = memberMapper.selectListByLibraryId(libraryId); + assertEquals(2, members.size()); + } + + @Test + public void testUpdateMemberList_deptMember() { + // mock 数据 + Long creatorUserId = randomLongId(); + Long libraryId = randomLongId(); + Long deptId = randomLongId(); + when(libraryService.getLibrary(libraryId)).thenReturn(randomLibraryDO(libraryId, false)); + memberMapper.insert(randomMemberDO(libraryId, creatorUserId, null, + PmsKnowledgeLibraryMemberLevelEnum.CREATOR.getLevel())); + // 准备参数 + PmsKnowledgeLibraryUpdateMemberListReqVO reqVO = new PmsKnowledgeLibraryUpdateMemberListReqVO() + .setLibraryId(libraryId).setMembers(Collections.singletonList( + new PmsKnowledgeLibraryUpdateMemberListReqVO.Member().setDeptId(deptId) + .setLevel(PmsKnowledgeLibraryMemberLevelEnum.MEMBER.getLevel()))); + + // 调用 + memberService.updateLibraryMemberList(reqVO, creatorUserId); + + // 断言 + List members = memberMapper.selectListByLibraryId(libraryId); + assertEquals(2, members.size()); + assertTrue(members.stream().anyMatch(member -> deptId.equals(member.getDeptId()))); + } + + @Test + public void testUpdateMemberList_retainCreator() { + // mock 数据 + Long creatorUserId = randomLongId(); + Long libraryId = randomLongId(); + Long memberUserId = randomLongId(); + Long oldMemberUserId = randomLongId(); + when(libraryService.getLibrary(libraryId)).thenReturn(randomLibraryDO(libraryId, false)); + memberMapper.insert(randomMemberDO(libraryId, creatorUserId, null, + PmsKnowledgeLibraryMemberLevelEnum.CREATOR.getLevel())); + memberMapper.insert(randomMemberDO(libraryId, oldMemberUserId, null, + PmsKnowledgeLibraryMemberLevelEnum.MEMBER.getLevel())); + // 准备参数 + PmsKnowledgeLibraryUpdateMemberListReqVO reqVO = new PmsKnowledgeLibraryUpdateMemberListReqVO() + .setLibraryId(libraryId).setMembers(Collections.singletonList( + new PmsKnowledgeLibraryUpdateMemberListReqVO.Member().setUserId(memberUserId) + .setLevel(PmsKnowledgeLibraryMemberLevelEnum.ADMIN.getLevel()))); + + // 调用 + memberService.updateLibraryMemberList(reqVO, creatorUserId); + + // 断言 + List members = memberMapper.selectListByLibraryId(libraryId); + assertEquals(2, members.size()); + assertTrue(members.stream().anyMatch(item -> item.getUserId().equals(creatorUserId))); + assertTrue(members.stream().anyMatch(item -> item.getUserId().equals(memberUserId))); + assertFalse(members.stream().anyMatch(item -> item.getUserId().equals(oldMemberUserId))); + } + + @Test + public void testUpdateMemberList_creatorDuplicated() { + // mock 数据 + Long creatorUserId = randomLongId(); + Long libraryId = randomLongId(); + when(libraryService.getLibrary(libraryId)).thenReturn(randomLibraryDO(libraryId, false)); + memberMapper.insert(randomMemberDO(libraryId, creatorUserId, null, + PmsKnowledgeLibraryMemberLevelEnum.CREATOR.getLevel())); + // 准备参数 + PmsKnowledgeLibraryUpdateMemberListReqVO reqVO = new PmsKnowledgeLibraryUpdateMemberListReqVO() + .setLibraryId(libraryId).setMembers(Collections.singletonList( + new PmsKnowledgeLibraryUpdateMemberListReqVO.Member().setUserId(creatorUserId) + .setLevel(PmsKnowledgeLibraryMemberLevelEnum.ADMIN.getLevel()))); + + // 调用,并断言异常 + assertServiceException(() -> memberService.updateLibraryMemberList(reqVO, creatorUserId), + KNOWLEDGE_LIBRARY_MEMBERS_INVALID); + } + + @Test + public void testExitLibrary_success() { + // mock 数据 + Long userId = randomLongId(); + Long libraryId = randomLongId(); + when(libraryService.getLibrary(libraryId)).thenReturn(randomLibraryDO(libraryId, false)); + memberMapper.insert(randomMemberDO(libraryId, userId, null, + PmsKnowledgeLibraryMemberLevelEnum.MEMBER.getLevel())); + + // 调用 + memberService.exitLibrary(libraryId, userId); + + // 断言 + assertNull(memberMapper.selectByLibraryIdAndUserId(libraryId, userId)); + verify(contentPermissionService).deleteContentPermissionMembersByLibraryId(libraryId, Collections.singleton(userId), + Collections.emptySet()); + } + + @Test + public void testExitLibrary_creatorDenied() { + // mock 数据 + Long creatorUserId = randomLongId(); + Long libraryId = randomLongId(); + when(libraryService.getLibrary(libraryId)).thenReturn(randomLibraryDO(libraryId, false)); + memberMapper.insert(randomMemberDO(libraryId, creatorUserId, null, + PmsKnowledgeLibraryMemberLevelEnum.CREATOR.getLevel())); + + // 调用,并断言异常 + assertServiceException(() -> memberService.exitLibrary(libraryId, creatorUserId), + KNOWLEDGE_LIBRARY_CREATOR_CANNOT_REMOVE); + } + + // ========== 随机对象 ========== + + private PmsKnowledgeLibraryMemberDO randomMemberDO(Long libraryId, Long userId, Long deptId, Integer level) { + return randomPojo(PmsKnowledgeLibraryMemberDO.class, member -> member.setId(null).setLibraryId(libraryId) + .setUserId(userId).setDeptId(deptId).setLevel(level)); + } + + private static PmsKnowledgeLibraryDO randomLibraryDO(Long libraryId, boolean openStatus) { + return randomPojo(PmsKnowledgeLibraryDO.class, library -> library.setId(libraryId) + .setOpenStatus(openStatus).setStatus(PmsKnowledgeDocumentStatusEnum.NORMAL.getStatus())); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeLibraryServiceImplTest.java b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeLibraryServiceImplTest.java new file mode 100644 index 000000000..ca82affbf --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeLibraryServiceImplTest.java @@ -0,0 +1,215 @@ +package cn.iocoder.yudao.module.pms.service.kb.library; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.library.PmsKnowledgeLibraryPageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.library.PmsKnowledgeLibrarySaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryDO; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.library.PmsKnowledgeLibraryMapper; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeDocumentStatusEnum; +import cn.iocoder.yudao.module.pms.service.kb.recycle.PmsKnowledgeRecycleService; +import cn.iocoder.yudao.module.system.api.permission.PermissionApi; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link PmsKnowledgeLibraryServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(PmsKnowledgeLibraryServiceImpl.class) +public class PmsKnowledgeLibraryServiceImplTest extends BaseDbUnitTest { + + @Resource + private PmsKnowledgeLibraryServiceImpl libraryService; + + @Resource + private PmsKnowledgeLibraryMapper libraryMapper; + + @MockitoBean + private PmsKnowledgeLibraryMemberService memberService; + @MockitoBean + private PmsKnowledgeGroupService knowledgeGroupService; + @MockitoBean + private PmsKnowledgeRecycleService recycleService; + @MockitoBean + private PmsKnowledgeLibraryTemplateService libraryTemplateService; + @MockitoBean + private PermissionApi permissionApi; + + @BeforeEach + public void setUpCloudApiMocks() { + lenient().when(permissionApi.hasAnyRoles(anyLong(), any(String[].class))).thenReturn(success(false)); + } + + @Test + public void testCreateLibrary_success() { + // 准备参数 + Long userId = randomLongId(); + Long memberUserId = randomLongId(); + PmsKnowledgeLibrarySaveReqVO reqVO = randomSaveReqVO() + .setAdminUserIds(Collections.singletonList(memberUserId)); + + // 调用 + Long libraryId = libraryService.createLibrary(reqVO, userId); + + // 断言 + PmsKnowledgeLibraryDO library = libraryMapper.selectById(libraryId); + assertEquals(reqVO.getName(), library.getName()); + assertNull(library.getCreator()); // 单元测试无登录上下文,验证 Service 未手工写入审计字段 + verify(memberService).createLibraryMemberList(libraryId, userId, + Collections.singletonList(memberUserId), Collections.emptyList()); + } + + @Test + public void testCreateLibrary_withTemplate() { + // 准备参数 + Long userId = randomLongId(); + PmsKnowledgeLibrarySaveReqVO reqVO = randomSaveReqVO().setTemplateId(5L); + + // 调用 + Long libraryId = libraryService.createLibrary(reqVO, userId); + + // 断言 + verify(memberService).createLibraryMemberList(libraryId, userId, + Collections.emptyList(), Collections.emptyList()); + verify(libraryTemplateService).createTemplateDocumentList(5L, libraryId, userId); + } + + @Test + public void testCreateLibrary_publicKeepsInitialMembers() { + // 准备参数 + Long userId = randomLongId(); + PmsKnowledgeLibrarySaveReqVO reqVO = randomSaveReqVO().setOpenStatus(true) + .setAdminUserIds(Collections.singletonList(randomLongId())) + .setMemberUserIds(Collections.singletonList(randomLongId())); + + // 调用 + libraryService.createLibrary(reqVO, userId); + + // 断言:公开状态只影响可见范围,初始成员仍可用于内容协作 + verify(memberService).createLibraryMemberList(org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.eq(userId), + org.mockito.ArgumentMatchers.eq(reqVO.getAdminUserIds()), + org.mockito.ArgumentMatchers.eq(reqVO.getMemberUserIds())); + } + + @Test + public void testUpdateLibrary_publicKeepsMembers() { + // mock 数据 + Long libraryId = randomLongId(); + Long userId = randomLongId(); + when(memberService.validateLibraryAdmin(libraryId, userId)).thenReturn(randomLibraryDO(true).setId(libraryId)); + PmsKnowledgeLibrarySaveReqVO reqVO = randomSaveReqVO().setId(libraryId).setOpenStatus(true); + + // 调用 + libraryService.updateLibrary(reqVO, userId); + + // 断言:公开状态更新不清理已有成员 + verify(memberService, org.mockito.Mockito.never()).validateLibraryCreator(libraryId, userId); + } + + @Test + public void testUpdateLibrary_privateAdminCanMakePublic() { + // mock 数据 + Long libraryId = randomLongId(); + Long userId = randomLongId(); + when(memberService.validateLibraryAdmin(libraryId, userId)).thenReturn(randomLibraryDO(false).setId(libraryId)); + PmsKnowledgeLibrarySaveReqVO reqVO = randomSaveReqVO().setId(libraryId).setOpenStatus(true); + + // 调用 + libraryService.updateLibrary(reqVO, userId); + + // 断言:私有库管理员可以将知识库公开 + verify(memberService, org.mockito.Mockito.never()).validateLibraryCreator(libraryId, userId); + } + + @Test + public void testGetLibraryPage_accessScope() { + // mock 数据 + Long userId = randomLongId(); + PmsKnowledgeLibraryDO publicLibrary = randomLibraryDO(true); + libraryMapper.insert(publicLibrary); + PmsKnowledgeLibraryDO memberLibrary = randomLibraryDO(false); + libraryMapper.insert(memberLibrary); + libraryMapper.insert(randomLibraryDO(false)); + when(memberService.getJoinedLibraryIdList(userId)) + .thenReturn(Collections.singletonList(memberLibrary.getId())); + // 准备参数 + PmsKnowledgeLibraryPageReqVO reqVO = new PmsKnowledgeLibraryPageReqVO(); + reqVO.setPageNo(1).setPageSize(10); + + // 调用 + PageResult pageResult = libraryService.getLibraryPage(reqVO, userId); + + // 断言 + assertEquals(2, pageResult.getTotal()); + assertTrue(pageResult.getList().stream().anyMatch(item -> item.getId().equals(publicLibrary.getId()))); + assertTrue(pageResult.getList().stream().anyMatch(item -> item.getId().equals(memberLibrary.getId()))); + } + + @Test + @SuppressWarnings("unchecked") + public void testGetLibraryPage_allGroupUsesReadableCandidates() { + // mock 数据 + Long userId = randomLongId(); + Long groupId = randomLongId(); + PmsKnowledgeLibraryDO publicLibrary = randomLibraryDO(true); + libraryMapper.insert(publicLibrary); + PmsKnowledgeLibraryDO memberLibrary = randomLibraryDO(false); + libraryMapper.insert(memberLibrary); + when(memberService.getJoinedLibraryIdList(userId)).thenReturn(Collections.singletonList(memberLibrary.getId())); + List readableLibraryIds = Arrays.asList(publicLibrary.getId(), memberLibrary.getId()); + when(memberService.getReadableLibraryIdList(userId)).thenReturn(readableLibraryIds); + doAnswer(invocation -> new java.util.ArrayList<>((java.util.Collection) invocation.getArgument(2))) + .when(knowledgeGroupService).filterLibraryIdListByGroup(groupId, userId, readableLibraryIds); + + // 调用 + PmsKnowledgeLibraryPageReqVO reqVO = new PmsKnowledgeLibraryPageReqVO(); + reqVO.setPageNo(1); + reqVO.setPageSize(10); + reqVO.setGroupId(groupId); + PageResult pageResult = libraryService.getLibraryPage(reqVO, userId); + + // 断言:默认“全部”分组的候选集必须包含公开库 + assertEquals(2, pageResult.getTotal()); + assertTrue(pageResult.getList().stream().anyMatch(item -> item.getId().equals(publicLibrary.getId()))); + } + + // ========== 随机对象 ========== + + private PmsKnowledgeLibraryDO randomLibraryDO(boolean openStatus) { + return randomPojo(PmsKnowledgeLibraryDO.class, library -> library.setId(null).setName("测试知识库") + .setDescription("测试").setOpenStatus(openStatus).setCoverUrl(null) + .setStatus(PmsKnowledgeDocumentStatusEnum.NORMAL.getStatus()) + .setDeleteUserId(null).setDeleteTime(null)); + } + + private static PmsKnowledgeLibrarySaveReqVO randomSaveReqVO() { + return randomPojo(PmsKnowledgeLibrarySaveReqVO.class, reqVO -> reqVO.setId(null) + .setName("产品知识库").setDescription("产品文档").setOpenStatus(false) + .setCoverUrl(null).setAdminUserIds(Collections.emptyList()) + .setMemberUserIds(Collections.emptyList())); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeLibraryTemplateServiceImplTest.java b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeLibraryTemplateServiceImplTest.java new file mode 100644 index 000000000..ea45497d0 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/library/PmsKnowledgeLibraryTemplateServiceImplTest.java @@ -0,0 +1,181 @@ +package cn.iocoder.yudao.module.pms.service.kb.library; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.pms.controller.admin.kb.content.vo.document.PmsKnowledgeDocumentCreateReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.kb.library.vo.template.PmsKnowledgeLibraryTemplateSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryTemplateDO; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.library.PmsKnowledgeLibraryTemplateMapper; +import cn.iocoder.yudao.module.pms.service.kb.content.PmsKnowledgeDocumentService; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_LIBRARY_TEMPLATE_INVALID; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_LIBRARY_TEMPLATE_NAME_DUPLICATE; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_LIBRARY_TEMPLATE_NOT_EXISTS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link PmsKnowledgeLibraryTemplateServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(PmsKnowledgeLibraryTemplateServiceImpl.class) +public class PmsKnowledgeLibraryTemplateServiceImplTest extends BaseDbUnitTest { + + @Resource + private PmsKnowledgeLibraryTemplateServiceImpl templateService; + + @Resource + private PmsKnowledgeLibraryTemplateMapper templateMapper; + + @MockitoBean + private PmsKnowledgeDocumentService documentService; + + @org.junit.jupiter.api.BeforeEach + public void setUp() { + if (templateMapper.selectCount(null) > 0) { + return; + } + for (int i = 1; i <= 8; i++) { + List documents = new ArrayList<>(); + int documentCount = i == 1 ? 5 : 1; + for (int j = 1; j <= documentCount; j++) { + documents.add(new PmsKnowledgeLibraryTemplateDO.Document() + .setTitle(i == 1 && j == 1 ? "产品需求文档" + : i == 7 && j == 1 ? "2020.01.01 更新日志" : "模板文档" + j) + .setContent(i == 1 && j == 1 ? "

基本信息

" + : i == 7 && j == 1 ? "

2020.01.01 更新日志

" : "

模板内容

")); + } + templateMapper.insert(new PmsKnowledgeLibraryTemplateDO().setId((long) i) + .setName(i == 1 ? "产品研发" : "模板" + i).setDescription("模板简介") + .setStatus(0).setSort(i * 10).setDocuments(documents)); + } + } + + @Test + public void testGetTemplateList_success() { + // 调用 + List templates = templateService + .getLibraryTemplateList(CommonStatusEnum.ENABLE.getStatus()); + + // 断言 + PmsKnowledgeLibraryTemplateDO template = CollUtil.getFirst(templates); + assertEquals("产品研发", template.getName()); + assertEquals(5, template.getDocuments().size()); + PmsKnowledgeLibraryTemplateDO.Document document = CollUtil.getFirst(template.getDocuments()); + assertEquals("产品需求文档", document.getTitle()); + assertTrue(document.getContent().contains("基本信息")); + } + + @Test + public void testGetTemplateList_withStatus() { + // 准备数据 + templateMapper.insert(new PmsKnowledgeLibraryTemplateDO().setId(99L).setName("关闭模板") + .setStatus(CommonStatusEnum.DISABLE.getStatus()).setSort(99) + .setDocuments(Collections.singletonList(new PmsKnowledgeLibraryTemplateDO.Document() + .setTitle("关闭文档").setContent("

关闭内容

")))); + assertEquals(CommonStatusEnum.DISABLE.getStatus(), templateMapper.selectById(99L).getStatus()); + + // 调用,并断言状态过滤 + List enabledTemplates = templateService + .getLibraryTemplateList(CommonStatusEnum.ENABLE.getStatus()); + List allTemplates = templateService.getLibraryTemplateList(null); + assertTrue(enabledTemplates.stream().noneMatch(template -> template.getId().equals(99L))); + assertTrue(allTemplates.stream().anyMatch(template -> template.getId().equals(99L))); + } + + @Test + public void testCreateLibraryTemplate_success() { + // 准备参数 + PmsKnowledgeLibraryTemplateSaveReqVO saveReqVO = new PmsKnowledgeLibraryTemplateSaveReqVO() + .setName("新模板").setDescription("模板简介").setStatus(0).setSort(100) + .setDocuments(Collections.singletonList(new PmsKnowledgeLibraryTemplateSaveReqVO.Document() + .setTitle("模板文档").setContent("

模板内容

"))); + + // 调用 + Long templateId = templateService.createLibraryTemplate(saveReqVO); + + // 断言 + PmsKnowledgeLibraryTemplateDO template = templateService.getLibraryTemplate(templateId); + assertEquals("新模板", template.getName()); + assertEquals("模板文档", CollUtil.getFirst(template.getDocuments()).getTitle()); + } + + @Test + public void testCreateLibraryTemplate_duplicateName() { + // 准备参数 + PmsKnowledgeLibraryTemplateSaveReqVO saveReqVO = new PmsKnowledgeLibraryTemplateSaveReqVO() + .setName("产品研发").setStatus(0).setSort(100) + .setDocuments(Collections.singletonList(new PmsKnowledgeLibraryTemplateSaveReqVO.Document() + .setTitle("模板文档").setContent("

模板内容

"))); + + // 调用,并断言异常 + assertServiceException(() -> templateService.createLibraryTemplate(saveReqVO), + KNOWLEDGE_LIBRARY_TEMPLATE_NAME_DUPLICATE); + } + + @Test + public void testCreateLibraryTemplate_invalidDocuments() { + // 准备参数 + PmsKnowledgeLibraryTemplateSaveReqVO saveReqVO = new PmsKnowledgeLibraryTemplateSaveReqVO() + .setName("无效模板").setStatus(0).setSort(100).setDocuments(Arrays.asList( + new PmsKnowledgeLibraryTemplateSaveReqVO.Document().setTitle("重复").setContent("内容"), + new PmsKnowledgeLibraryTemplateSaveReqVO.Document().setTitle("重复").setContent("内容"))); + + // 调用,并断言异常 + assertServiceException(() -> templateService.createLibraryTemplate(saveReqVO), + KNOWLEDGE_LIBRARY_TEMPLATE_INVALID); + } + + @Test + public void testCreateTemplateDocumentList_success() { + // 准备参数 + Long libraryId = randomLongId(); + Long userId = randomLongId(); + Long documentId = randomLongId(); + when(documentService.createDocument(any(), eq(userId))).thenReturn(documentId); + + // 调用 + templateService.createTemplateDocumentList(7L, libraryId, userId); + + // 断言 + ArgumentCaptor createCaptor = + ArgumentCaptor.forClass(PmsKnowledgeDocumentCreateReqVO.class); + verify(documentService).createDocument(createCaptor.capture(), eq(userId)); + PmsKnowledgeDocumentCreateReqVO document = createCaptor.getValue(); + assertEquals(libraryId, document.getLibraryId()); + assertEquals(0L, document.getFolderId()); + assertEquals(0L, document.getParentId()); + assertEquals("2020.01.01 更新日志", document.getTitle()); + assertTrue(document.getContent().contains("2020.01.01 更新日志")); + verify(documentService, times(0)).updateDocument(any(), eq(userId)); + } + + @Test + public void testCreateTemplateDocumentList_notExists() { + // 调用,并断言异常 + assertServiceException(() -> templateService.createTemplateDocumentList(99L, randomLongId(), randomLongId()), + KNOWLEDGE_LIBRARY_TEMPLATE_NOT_EXISTS); + verify(documentService, times(0)).createDocument(any(), anyLong()); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/recycle/PmsKnowledgeRecycleServiceImplTest.java b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/recycle/PmsKnowledgeRecycleServiceImplTest.java new file mode 100644 index 000000000..30de0aecc --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/kb/recycle/PmsKnowledgeRecycleServiceImplTest.java @@ -0,0 +1,385 @@ +package cn.iocoder.yudao.module.pms.service.kb.recycle; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeDocumentDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.content.PmsKnowledgeFolderDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.library.PmsKnowledgeLibraryMemberDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.kb.recycle.PmsKnowledgeRecycleRecordDO; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.content.PmsKnowledgeDocumentMapper; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.content.PmsKnowledgeFolderMapper; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.library.PmsKnowledgeLibraryMapper; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.library.PmsKnowledgeLibraryMemberMapper; +import cn.iocoder.yudao.module.pms.dal.mysql.kb.recycle.PmsKnowledgeRecycleRecordMapper; +import cn.iocoder.yudao.module.pms.enums.kb.PmsKnowledgeObjectTypeEnum; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeContentLevelEnum; +import cn.iocoder.yudao.module.pms.enums.kb.content.PmsKnowledgeDocumentTypeEnum; +import cn.iocoder.yudao.module.pms.enums.kb.library.PmsKnowledgeLibraryMemberLevelEnum; +import cn.iocoder.yudao.module.pms.service.kb.content.PmsKnowledgeContentPermissionService; +import cn.iocoder.yudao.module.pms.service.kb.content.PmsKnowledgeDocumentLabelService; +import cn.iocoder.yudao.module.pms.service.kb.content.PmsKnowledgeDocumentServiceImpl; +import cn.iocoder.yudao.module.pms.service.kb.content.PmsKnowledgeFolderServiceImpl; +import cn.iocoder.yudao.module.pms.service.kb.interaction.PmsKnowledgeDocumentCommentService; +import cn.iocoder.yudao.module.pms.service.kb.interaction.PmsKnowledgeDocumentLikeService; +import cn.iocoder.yudao.module.pms.service.kb.interaction.PmsKnowledgeDocumentShareService; +import cn.iocoder.yudao.module.pms.service.kb.interaction.PmsKnowledgeFavoriteService; +import cn.iocoder.yudao.module.pms.service.kb.interaction.PmsKnowledgeViewRecordService; +import cn.iocoder.yudao.module.pms.service.kb.library.PmsKnowledgeGroupService; +import cn.iocoder.yudao.module.pms.service.kb.library.PmsKnowledgeLibraryMemberService; +import cn.iocoder.yudao.module.pms.service.kb.library.PmsKnowledgeLibraryServiceImpl; +import cn.iocoder.yudao.module.pms.service.kb.library.PmsKnowledgeLibraryTemplateService; +import cn.iocoder.yudao.module.system.api.permission.PermissionApi; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.KNOWLEDGE_RECYCLE_TYPE_INVALID; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +/** + * {@link PmsKnowledgeRecycleServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import({PmsKnowledgeRecycleServiceImpl.class, PmsKnowledgeLibraryServiceImpl.class, + PmsKnowledgeFolderServiceImpl.class, PmsKnowledgeDocumentServiceImpl.class}) +public class PmsKnowledgeRecycleServiceImplTest extends BaseDbUnitTest { + + @Resource + private PmsKnowledgeRecycleServiceImpl recycleService; + + @Resource + private PmsKnowledgeLibraryMapper libraryMapper; + @Resource + private PmsKnowledgeLibraryMemberMapper memberMapper; + @Resource + private PmsKnowledgeFolderMapper folderMapper; + @Resource + private PmsKnowledgeDocumentMapper documentMapper; + @Resource + private PmsKnowledgeRecycleRecordMapper recycleRecordMapper; + + @MockitoBean + private PmsKnowledgeGroupService knowledgeGroupService; + @MockitoBean + private PmsKnowledgeFavoriteService favoriteService; + @MockitoBean + private PmsKnowledgeDocumentLikeService documentLikeService; + @MockitoBean + private PmsKnowledgeViewRecordService viewRecordService; + @MockitoBean + private PmsKnowledgeDocumentShareService documentShareService; + @MockitoBean + private PmsKnowledgeDocumentCommentService documentCommentService; + @MockitoBean + private PmsKnowledgeContentPermissionService contentPermissionService; + @MockitoBean + private PmsKnowledgeDocumentLabelService documentLabelService; + @MockitoBean + private PermissionApi permissionApi; + @MockitoBean + private AdminUserApi adminUserApi; + @MockitoBean + private PmsKnowledgeLibraryTemplateService libraryTemplateService; + @MockitoBean + private PmsKnowledgeLibraryMemberService memberService; + + @BeforeEach + public void setUpCloudApiMocks() { + lenient().when(permissionApi.hasAnyRoles(anyLong(), any(String[].class))).thenReturn(success(false)); + lenient().when(adminUserApi.getUser(anyLong())).thenReturn(success(null)); + } + + @Test + public void testRestoreDocument_preservesExplicitlyDeletedChild() { + // mock 数据 + Long userId = randomLongId(); + PmsKnowledgeLibraryDO library = randomLibraryDO(); + libraryMapper.insert(library); + PmsKnowledgeLibraryMemberDO member = randomMemberDO(library.getId(), userId); + memberMapper.insert(member); + when(memberService.validateLibraryReadable(library.getId(), userId)).thenReturn(library); + when(memberService.getMemberByLibraryIdAndUserId(library.getId(), userId)).thenReturn(member); + PmsKnowledgeDocumentDO parent = randomDocumentDO(library.getId(), 0L, 0L); + documentMapper.insert(parent); + PmsKnowledgeDocumentDO child = randomDocumentDO(library.getId(), 0L, parent.getId()); + documentMapper.insert(child); + recycleService.recycleDocument(child, Collections.singletonList(child), userId); + recycleService.recycleDocument(parent, Arrays.asList(parent, child), userId); + PmsKnowledgeRecycleRecordDO parentRecord = recycleRecordMapper.selectByTypeAndEntityId( + PmsKnowledgeObjectTypeEnum.DOCUMENT.getType(), parent.getId()); + + // 调用 + recycleService.restoreContentRecycle(parentRecord.getId(), userId); + + // 断言 + assertEquals(1, documentMapper.selectById(parent.getId()).getStatus()); + assertEquals(-1, documentMapper.selectById(child.getId()).getStatus()); + assertNotNull(recycleRecordMapper.selectByTypeAndEntityId( + PmsKnowledgeObjectTypeEnum.DOCUMENT.getType(), child.getId())); + } + + @Test + public void testRestoreLibrary_preservesExplicitlyDeletedFolder() { + // mock 数据 + Long userId = randomLongId(); + PmsKnowledgeLibraryDO library = randomLibraryDO(); + libraryMapper.insert(library); + PmsKnowledgeLibraryMemberDO member = randomMemberDO(library.getId(), userId); + memberMapper.insert(member); + when(memberService.validateLibraryReadable(library.getId(), userId)).thenReturn(library); + when(memberService.getMemberByLibraryIdAndUserId(library.getId(), userId)).thenReturn(member); + PmsKnowledgeFolderDO deletedFolder = randomFolderDO(library.getId(), 0L); + folderMapper.insert(deletedFolder); + PmsKnowledgeFolderDO normalFolder = randomFolderDO(library.getId(), 0L); + folderMapper.insert(normalFolder); + recycleService.recycleFolder(deletedFolder, Collections.singletonList(deletedFolder), + Collections.emptyList(), userId); + recycleService.recycleLibrary(library, userId); + PmsKnowledgeRecycleRecordDO libraryRecord = recycleRecordMapper.selectByTypeAndEntityId( + PmsKnowledgeObjectTypeEnum.LIBRARY.getType(), library.getId()); + + // 调用 + recycleService.restoreContentRecycle(libraryRecord.getId(), userId); + + // 断言 + assertEquals(1, libraryMapper.selectById(library.getId()).getStatus()); + assertEquals(-1, folderMapper.selectById(deletedFolder.getId()).getStatus()); + assertEquals(1, folderMapper.selectById(normalFolder.getId()).getStatus()); + } + + @Test + public void testPermanentDeleteDocument_recursive() { + // mock 数据 + Long userId = randomLongId(); + PmsKnowledgeLibraryDO library = randomLibraryDO(); + libraryMapper.insert(library); + PmsKnowledgeLibraryMemberDO member = randomMemberDO(library.getId(), userId); + memberMapper.insert(member); + when(memberService.validateLibraryReadable(library.getId(), userId)).thenReturn(library); + when(memberService.getMemberByLibraryIdAndUserId(library.getId(), userId)).thenReturn(member); + PmsKnowledgeDocumentDO parent = randomDocumentDO(library.getId(), 0L, 0L); + documentMapper.insert(parent); + PmsKnowledgeDocumentDO child = randomDocumentDO(library.getId(), 0L, parent.getId()); + documentMapper.insert(child); + recycleService.recycleDocument(parent, Arrays.asList(parent, child), userId); + PmsKnowledgeRecycleRecordDO record = recycleRecordMapper.selectByTypeAndEntityId( + PmsKnowledgeObjectTypeEnum.DOCUMENT.getType(), parent.getId()); + + // 调用 + recycleService.deleteContentRecycle(record.getId(), userId); + + // 断言 + assertNull(documentMapper.selectById(parent.getId())); + assertNull(documentMapper.selectById(child.getId())); + assertNull(recycleRecordMapper.selectById(record.getId())); + } + + @Test + public void testRestoreDocument_repeatedRecycle() { + // mock 数据 + Long userId = randomLongId(); + PmsKnowledgeLibraryDO library = randomLibraryDO(); + libraryMapper.insert(library); + PmsKnowledgeLibraryMemberDO member = randomMemberDO(library.getId(), userId); + memberMapper.insert(member); + when(memberService.validateLibraryReadable(library.getId(), userId)).thenReturn(library); + when(memberService.getMemberByLibraryIdAndUserId(library.getId(), userId)).thenReturn(member); + PmsKnowledgeDocumentDO document = randomDocumentDO(library.getId(), 0L, 0L); + documentMapper.insert(document); + recycleService.recycleDocument(document, Collections.singletonList(document), userId); + PmsKnowledgeRecycleRecordDO firstRecord = recycleRecordMapper.selectByTypeAndEntityId( + PmsKnowledgeObjectTypeEnum.DOCUMENT.getType(), document.getId()); + recycleService.restoreContentRecycle(firstRecord.getId(), userId); + + // 调用 + document = documentMapper.selectById(document.getId()); + recycleService.recycleDocument(document, Collections.singletonList(document), userId); + PmsKnowledgeRecycleRecordDO secondRecord = recycleRecordMapper.selectByTypeAndEntityId( + PmsKnowledgeObjectTypeEnum.DOCUMENT.getType(), document.getId()); + recycleService.restoreContentRecycle(secondRecord.getId(), userId); + + // 断言 + assertEquals(1, documentMapper.selectById(document.getId()).getStatus()); + assertNull(recycleRecordMapper.selectByTypeAndEntityId( + PmsKnowledgeObjectTypeEnum.DOCUMENT.getType(), document.getId())); + } + + @Test + public void testGetContentRecycleList_filtersByContentPermission() { + // mock 数据 + Long userId = randomLongId(); + PmsKnowledgeLibraryDO library = randomLibraryDO(); + libraryMapper.insert(library); + PmsKnowledgeLibraryMemberDO member = randomMemberDO(library.getId(), userId); + memberMapper.insert(member); + when(memberService.validateLibraryReadable(library.getId(), userId)).thenReturn(library); + when(memberService.getMemberByLibraryIdAndUserId(library.getId(), userId)).thenReturn(member); + PmsKnowledgeDocumentDO editableDocument = randomDocumentDO(library.getId(), 0L, 0L); + documentMapper.insert(editableDocument); + PmsKnowledgeDocumentDO previewDocument = randomDocumentDO(library.getId(), 0L, 0L); + documentMapper.insert(previewDocument); + recycleService.recycleDocument(editableDocument, Collections.singletonList(editableDocument), userId); + recycleService.recycleDocument(previewDocument, Collections.singletonList(previewDocument), userId); + Map levelMap = new LinkedHashMap<>(); + levelMap.put(editableDocument.getPermissionId(), PmsKnowledgeContentLevelEnum.EDIT.getLevel()); + levelMap.put(previewDocument.getPermissionId(), PmsKnowledgeContentLevelEnum.PREVIEW.getLevel()); + + // mock 方法 + when(contentPermissionService.getCurrentUserContentPermissionLevelMap(anyCollection(), eq(library.getId()), eq(userId))) + .thenReturn(levelMap); + + // 调用 + List records = recycleService.getContentRecycleList(library.getId(), userId); + + // 断言 + assertEquals(1, records.size()); + assertEquals(editableDocument.getId(), CollUtil.getFirst(records).getEntityId()); + } + + @Test + public void testGetContentRecycleList_sameEntityIdUsesObjectTypePermission() { + // mock 数据:文件夹和文档来自不同表,刻意使用相同的业务 ID + Long userId = randomLongId(); + PmsKnowledgeLibraryDO library = randomLibraryDO(); + libraryMapper.insert(library); + PmsKnowledgeLibraryMemberDO member = randomMemberDO(library.getId(), userId); + memberMapper.insert(member); + when(memberService.validateLibraryReadable(library.getId(), userId)).thenReturn(library); + when(memberService.getMemberByLibraryIdAndUserId(library.getId(), userId)).thenReturn(member); + Long sharedEntityId = 100L; + Long editablePermissionId = randomLongId(); + Long previewPermissionId = randomLongId(); + PmsKnowledgeFolderDO folder = randomFolderDO(library.getId(), 0L) + .setId(sharedEntityId).setPermissionId(editablePermissionId); + PmsKnowledgeDocumentDO document = randomDocumentDO(library.getId(), 0L, 0L) + .setId(sharedEntityId).setPermissionId(previewPermissionId); + folderMapper.insert(folder); + documentMapper.insert(document); + recycleService.recycleFolder(folder, Collections.singletonList(folder), Collections.emptyList(), userId); + recycleService.recycleDocument(document, Collections.singletonList(document), userId); + Map levelMap = new LinkedHashMap<>(); + levelMap.put(editablePermissionId, PmsKnowledgeContentLevelEnum.EDIT.getLevel()); + levelMap.put(previewPermissionId, PmsKnowledgeContentLevelEnum.PREVIEW.getLevel()); + + // mock 方法 + when(contentPermissionService.getCurrentUserContentPermissionLevelMap(anyCollection(), eq(library.getId()), eq(userId))) + .thenReturn(levelMap); + + // 调用 + List records = recycleService.getContentRecycleList(library.getId(), userId); + + // 断言:文件夹权限为可删除,文档权限为仅预览,不能因同号 ID 串用权限 + assertEquals(1, records.size()); + assertEquals(PmsKnowledgeObjectTypeEnum.FOLDER.getType(), CollUtil.getFirst(records).getType()); + assertEquals(sharedEntityId, CollUtil.getFirst(records).getEntityId()); + } + + @Test + public void testRestoreContentRecycle_unknownType() { + // mock 数据 + PmsKnowledgeRecycleRecordDO record = randomPojo(PmsKnowledgeRecycleRecordDO.class, + item -> item.setId(null).setLibraryId(randomLongId()).setType(999) + .setEntityId(randomLongId()).setName("未知类型")); + recycleRecordMapper.insert(record); + + // 调用并断言统一业务异常 + assertServiceException(() -> recycleService.restoreContentRecycle(record.getId(), randomLongId()), + KNOWLEDGE_RECYCLE_TYPE_INVALID); + } + + @Test + public void testDeleteContentRecycle_unknownType() { + // mock 数据 + PmsKnowledgeRecycleRecordDO record = randomPojo(PmsKnowledgeRecycleRecordDO.class, + item -> item.setId(null).setLibraryId(randomLongId()).setType(999) + .setEntityId(randomLongId()).setName("未知类型")); + recycleRecordMapper.insert(record); + + // 调用并断言统一业务异常 + assertServiceException(() -> recycleService.deleteContentRecycle(record.getId(), randomLongId()), + KNOWLEDGE_RECYCLE_TYPE_INVALID); + } + + @Test + public void testCleanExpiredRecycleRecords_onlyDeletesExpired() { + // mock 数据 + Long userId = randomLongId(); + PmsKnowledgeLibraryDO library = randomLibraryDO(); + libraryMapper.insert(library); + PmsKnowledgeDocumentDO expiredDocument = randomDocumentDO(library.getId(), 0L, 0L); + documentMapper.insert(expiredDocument); + recycleService.recycleDocument(expiredDocument, Collections.singletonList(expiredDocument), userId); + PmsKnowledgeRecycleRecordDO expiredRecord = recycleRecordMapper.selectByTypeAndEntityId( + PmsKnowledgeObjectTypeEnum.DOCUMENT.getType(), expiredDocument.getId()); + recycleRecordMapper.updateById(new PmsKnowledgeRecycleRecordDO().setId(expiredRecord.getId()) + .setDeleteTime(LocalDateTime.now().minusDays(31))); + PmsKnowledgeDocumentDO recentDocument = randomDocumentDO(library.getId(), 0L, 0L); + documentMapper.insert(recentDocument); + recycleService.recycleDocument(recentDocument, Collections.singletonList(recentDocument), userId); + + // 调用 + int cleanCount = recycleService.deleteExpiredRecycleRecords(LocalDateTime.now().minusDays(30)); + + // 断言 + assertEquals(1, cleanCount); + assertNull(documentMapper.selectById(expiredDocument.getId())); + assertNotNull(documentMapper.selectById(recentDocument.getId())); + } + + @Test + public void testDeleteExpiredRecycleRecords_empty() { + // 调用 + int cleanCount = recycleService.deleteExpiredRecycleRecords(LocalDateTime.now().minusDays(30)); + + // 断言 + assertEquals(0, cleanCount); + } + + // ========== 随机对象 ========== + + private PmsKnowledgeLibraryDO randomLibraryDO() { + return randomPojo(PmsKnowledgeLibraryDO.class, library -> library.setId(null).setName("测试知识库") + .setOpenStatus(false).setStatus(1)); + } + + private PmsKnowledgeLibraryMemberDO randomMemberDO(Long libraryId, Long userId) { + return randomPojo(PmsKnowledgeLibraryMemberDO.class, member -> member.setId(null).setLibraryId(libraryId) + .setUserId(userId).setLevel(PmsKnowledgeLibraryMemberLevelEnum.CREATOR.getLevel())); + } + + private PmsKnowledgeFolderDO randomFolderDO(Long libraryId, Long parentId) { + return randomPojo(PmsKnowledgeFolderDO.class, folder -> folder.setId(null).setLibraryId(libraryId) + .setPermissionId(randomLongId()).setParentId(parentId).setTitle("测试目录").setStatus(1)); + } + + private PmsKnowledgeDocumentDO randomDocumentDO(Long libraryId, Long folderId, Long parentId) { + return randomPojo(PmsKnowledgeDocumentDO.class, document -> document.setId(null).setLibraryId(libraryId) + .setPermissionId(randomLongId()).setFolderId(folderId).setParentId(parentId).setTitle("测试文档") + .setType(PmsKnowledgeDocumentTypeEnum.RICH_TEXT.getType()).setStatus(1)); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/iteration/PmsIterationServiceImplTest.java b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/iteration/PmsIterationServiceImplTest.java new file mode 100644 index 000000000..d6fc459b9 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/iteration/PmsIterationServiceImplTest.java @@ -0,0 +1,311 @@ +package cn.iocoder.yudao.module.pms.service.pm.iteration; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.pms.controller.admin.pm.iteration.vo.PmsIterationOverviewRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.iteration.vo.PmsIterationPageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.iteration.vo.PmsIterationSaveReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.iteration.vo.PmsIterationStartReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.iteration.PmsIterationDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemActivityDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemWorkLogDO; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.iteration.PmsIterationMapper; +import cn.iocoder.yudao.module.pms.enums.pm.iteration.PmsIterationStatusEnum; +import cn.iocoder.yudao.module.pms.enums.pm.project.PmsProjectStatusEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemStatusTypeEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemTypeEnum; +import cn.iocoder.yudao.module.pms.service.pm.project.PmsProjectMemberService; +import cn.iocoder.yudao.module.pms.service.pm.workitem.PmsWorkItemActivityService; +import cn.iocoder.yudao.module.pms.service.pm.workitem.PmsWorkItemService; +import cn.iocoder.yudao.module.pms.service.pm.workitem.PmsWorkItemWorkLogService; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatchers; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.ITERATION_STATUS_INVALID; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link PmsIterationServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(PmsIterationServiceImpl.class) +public class PmsIterationServiceImplTest extends BaseDbUnitTest { + + @Resource + private PmsIterationServiceImpl iterationService; + + @Resource + private PmsIterationMapper iterationMapper; + + @MockitoBean + private PmsProjectMemberService projectMemberService; + @MockitoBean + private PmsWorkItemService workItemService; + @MockitoBean + private PmsWorkItemActivityService workItemActivityService; + @MockitoBean + private PmsWorkItemWorkLogService workItemWorkLogService; + + @Test + public void testCreateIteration_success() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + PmsProjectDO project = randomProjectDO(projectId); + when(projectMemberService.validateProjectWritable(projectId, userId)).thenReturn(project); + iterationMapper.insert(randomIterationDO(projectId, PmsIterationStatusEnum.PLANNED.getStatus()).setSort(3)); + // 准备参数 + PmsIterationSaveReqVO reqVO = randomIterationSaveReqVO(projectId); + + // 调用 + Long iterationId = iterationService.createIteration(reqVO, userId); + + // 断言 + PmsIterationDO iteration = iterationMapper.selectById(iterationId); + assertNotNull(iteration); + assertEquals(reqVO.getName(), iteration.getName()); + assertEquals(PmsIterationStatusEnum.PLANNED.getStatus(), iteration.getStatus()); + assertEquals(4, iteration.getSort()); + verify(projectMemberService).validateProjectMemberExists(projectId, reqVO.getOwnerUserId()); + } + + @Test + public void testUpdateIteration_success() { + // mock 数据 + Long userId = randomLongId(); + PmsIterationDO iteration = randomIterationDO(randomLongId(), PmsIterationStatusEnum.PLANNED.getStatus()); + iterationMapper.insert(iteration); + when(projectMemberService.validateProjectWritable(iteration.getProjectId(), userId)) + .thenReturn(randomProjectDO(iteration.getProjectId())); + // 准备参数 + PmsIterationSaveReqVO reqVO = randomPojo(PmsIterationSaveReqVO.class, + item -> item.setId(iteration.getId()).setProjectId(iteration.getProjectId()) + .setName("第二期迭代").setOwnerUserId(randomLongId()).setTarget("交付第二期") + .setStartTime(LocalDateTime.now()).setEndTime(LocalDateTime.now().plusDays(10))); + + // 调用 + iterationService.updateIteration(reqVO, userId); + + // 断言 + PmsIterationDO updatedIteration = iterationMapper.selectById(iteration.getId()); + assertEquals(reqVO.getName(), updatedIteration.getName()); + assertEquals(reqVO.getTarget(), updatedIteration.getTarget()); + assertEquals(iteration.getStatus(), updatedIteration.getStatus()); + } + + @Test + public void testStartIteration_success() { + // mock 数据 + Long userId = randomLongId(); + PmsIterationDO iteration = randomIterationDO(randomLongId(), PmsIterationStatusEnum.PLANNED.getStatus()); + iterationMapper.insert(iteration); + when(projectMemberService.validateProjectWritable(iteration.getProjectId(), userId)) + .thenReturn(randomProjectDO(iteration.getProjectId())); + // 准备参数 + LocalDateTime startTime = LocalDateTime.now(); + PmsIterationStartReqVO reqVO = new PmsIterationStartReqVO().setId(iteration.getId()) + .setStartTime(startTime).setEndTime(startTime.plusDays(14)); + + // 调用 + iterationService.startIteration(reqVO, userId); + + // 断言 + PmsIterationDO startedIteration = iterationMapper.selectById(iteration.getId()); + assertEquals(PmsIterationStatusEnum.ACTIVE.getStatus(), startedIteration.getStatus()); + assertEquals(reqVO.getStartTime(), startedIteration.getStartTime()); + assertEquals(reqVO.getEndTime(), startedIteration.getEndTime()); + } + + @Test + public void testStartIteration_statusInvalid() { + // mock 数据 + Long userId = randomLongId(); + PmsIterationDO iteration = randomIterationDO(randomLongId(), PmsIterationStatusEnum.COMPLETED.getStatus()); + iterationMapper.insert(iteration); + when(projectMemberService.validateProjectWritable(iteration.getProjectId(), userId)) + .thenReturn(randomProjectDO(iteration.getProjectId())); + // 准备参数 + LocalDateTime startTime = LocalDateTime.now(); + PmsIterationStartReqVO reqVO = new PmsIterationStartReqVO().setId(iteration.getId()) + .setStartTime(startTime).setEndTime(startTime.plusDays(14)); + + // 调用,并断言异常 + assertServiceException(() -> iterationService.startIteration(reqVO, userId), ITERATION_STATUS_INVALID); + } + + @Test + public void testCompleteIteration_success() { + // mock 数据 + Long userId = randomLongId(); + PmsIterationDO iteration = randomIterationDO(randomLongId(), PmsIterationStatusEnum.ACTIVE.getStatus()); + iterationMapper.insert(iteration); + when(projectMemberService.validateProjectWritable(iteration.getProjectId(), userId)) + .thenReturn(randomProjectDO(iteration.getProjectId())); + + // 调用 + iterationService.completeIteration(iteration.getId(), userId); + + // 断言 + PmsIterationDO completedIteration = iterationMapper.selectById(iteration.getId()); + assertEquals(PmsIterationStatusEnum.COMPLETED.getStatus(), completedIteration.getStatus()); + assertNotNull(completedIteration.getFinishTime()); + } + + @Test + public void testDeleteIteration_success() { + // mock 数据 + Long userId = randomLongId(); + PmsIterationDO iteration = randomIterationDO(randomLongId(), PmsIterationStatusEnum.PLANNED.getStatus()); + iterationMapper.insert(iteration); + when(projectMemberService.validateProjectWritable(iteration.getProjectId(), userId)) + .thenReturn(randomProjectDO(iteration.getProjectId())); + + // 调用 + iterationService.deleteIteration(iteration.getId(), userId); + + // 断言 + assertNull(iterationMapper.selectById(iteration.getId())); + verify(workItemService).clearWorkItemIterationId(iteration.getId()); + } + + @Test + public void testGetIterationPage_success() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + PmsIterationDO plannedIteration = randomIterationDO(projectId, PmsIterationStatusEnum.PLANNED.getStatus()) + .setName("第一期"); + iterationMapper.insert(plannedIteration); + iterationMapper.insert(randomIterationDO(projectId, PmsIterationStatusEnum.COMPLETED.getStatus()) + .setName("历史迭代")); + // 准备参数 + PmsIterationPageReqVO reqVO = new PmsIterationPageReqVO().setProjectId(projectId) + .setStatus(PmsIterationStatusEnum.PLANNED.getStatus()).setName("第一"); + + // 调用 + PageResult pageResult = iterationService.getIterationPage(reqVO, userId); + + // 断言 + assertEquals(1, pageResult.getTotal()); + assertEquals(plannedIteration.getId(), CollUtil.getFirst(pageResult.getList()).getId()); + verify(projectMemberService).validateProjectReadable(projectId, userId); + } + + @Test + public void testGetProjectIterationStatusCountMap_success() { + // mock 数据 + Long firstProjectId = randomLongId(); + Long secondProjectId = randomLongId(); + iterationMapper.insert(randomIterationDO(firstProjectId, PmsIterationStatusEnum.PLANNED.getStatus())); + iterationMapper.insert(randomIterationDO(firstProjectId, PmsIterationStatusEnum.PLANNED.getStatus())); + iterationMapper.insert(randomIterationDO(firstProjectId, PmsIterationStatusEnum.COMPLETED.getStatus())); + iterationMapper.insert(randomIterationDO(secondProjectId, PmsIterationStatusEnum.ACTIVE.getStatus())); + + // 调用 + Map> countMap = iterationService.getProjectIterationStatusCountMap( + Arrays.asList(firstProjectId, secondProjectId)); + + // 断言 + assertEquals(2L, countMap.get(firstProjectId).get(PmsIterationStatusEnum.PLANNED.getStatus())); + assertEquals(1L, countMap.get(firstProjectId).get(PmsIterationStatusEnum.COMPLETED.getStatus())); + assertEquals(1L, countMap.get(secondProjectId).get(PmsIterationStatusEnum.ACTIVE.getStatus())); + } + + @Test + public void testGetIterationOverview_success() { + // mock 数据 + Long userId = randomLongId(); + LocalDateTime iterationStartTime = LocalDateTime.now().minusDays(2).toLocalDate().atStartOfDay(); + PmsIterationDO iteration = randomIterationDO(randomLongId(), PmsIterationStatusEnum.ACTIVE.getStatus()) + .setStartTime(iterationStartTime).setEndTime(iterationStartTime.plusDays(4)); + iterationMapper.insert(iteration); + PmsWorkItemDO pendingWorkItem = randomWorkItemDO(PmsWorkItemTypeEnum.TASK.getType(), + PmsWorkItemStatusTypeEnum.PENDING.getType()).setEstimatedHours(8); + PmsWorkItemDO completedWorkItem = randomWorkItemDO(PmsWorkItemTypeEnum.DEFECT.getType(), + PmsWorkItemStatusTypeEnum.COMPLETED.getType()).setEstimatedHours(4); + when(workItemService.getActiveWorkItemListByIterationId(iteration.getId())) + .thenReturn(Arrays.asList(pendingWorkItem, completedWorkItem)); + PmsWorkItemWorkLogDO workLog = new PmsWorkItemWorkLogDO(); + workLog.setWorkItemId(pendingWorkItem.getId()); + workLog.setActualHours(3); + workLog.setRemainingHours(5); + workLog.setCreateTime(LocalDateTime.now().withHour(10)); + when(workItemWorkLogService.getWorkItemWorkLogListByWorkItemIds(ArgumentMatchers.anyCollection())) + .thenReturn(Collections.singletonList(workLog)); + when(workItemActivityService.getWorkItemActivityListByWorkItemIds(anyCollection(), anyInt())) + .thenReturn(Collections.singletonList(new PmsWorkItemActivityDO().setId(randomLongId()) + .setWorkItemId(pendingWorkItem.getId()).setOperatorUserId(userId).setContent("更新了工作项"))); + + // 调用 + PmsIterationOverviewRespVO overview = iterationService.getIterationOverview(iteration.getId(), userId); + + // 断言 + assertEquals(2, overview.getTotalCount()); + assertEquals(1, overview.getPendingCount()); + assertEquals(1, overview.getCompletedCount()); + assertEquals(50, overview.getProgress()); + assertEquals(1, overview.getTypeStatusCountMap().get(PmsWorkItemTypeEnum.TASK.getType()) + .get(PmsWorkItemStatusTypeEnum.PENDING.getType())); + assertEquals(1, overview.getTypeStatusCountMap().get(PmsWorkItemTypeEnum.DEFECT.getType()) + .get(PmsWorkItemStatusTypeEnum.COMPLETED.getType())); + assertEquals(14, overview.getStatusTrends().size()); + PmsIterationOverviewRespVO.TrendItem todayTrend = overview.getStatusTrends().get(13); + assertEquals(1, todayTrend.getPendingCount()); + assertEquals(1, todayTrend.getCompletedCount()); + assertEquals(5, overview.getBurnDowns().size()); + assertEquals(9, overview.getBurnDowns().get(1).getIdealRemaining()); + assertEquals(9, overview.getBurnDowns().get(2).getActualRemaining()); + assertEquals(1, overview.getRecentActivities().size()); + assertEquals(pendingWorkItem.getName(), CollUtil.getFirst(overview.getRecentActivities()).getWorkItemName()); + } + + // ========== 随机对象 ========== + + private static PmsIterationSaveReqVO randomIterationSaveReqVO(Long projectId) { + LocalDateTime startTime = LocalDateTime.now().plusDays(1); + return randomPojo(PmsIterationSaveReqVO.class, + item -> item.setId(null).setProjectId(projectId).setName("第一期迭代") + .setOwnerUserId(randomLongId()).setTarget("交付第一期").setDescription("迭代说明") + .setStartTime(startTime).setEndTime(startTime.plusDays(14))); + } + + private static PmsIterationDO randomIterationDO(Long projectId, Integer status) { + return randomPojo(PmsIterationDO.class, item -> item.setProjectId(projectId).setName("测试迭代") + .setOwnerUserId(null).setStatus(status).setSort(1).setStartTime(null).setEndTime(null) + .setFinishTime(null)); + } + + private static PmsProjectDO randomProjectDO(Long id) { + return randomPojo(PmsProjectDO.class, project -> project.setId(id).setName("测试项目") + .setStatus(PmsProjectStatusEnum.ACTIVE.getStatus()).setType(1).setLevel(3).setOpenStatus(false)); + } + + private static PmsWorkItemDO randomWorkItemDO(Integer type, Integer status) { + PmsWorkItemDO workItem = randomPojo(PmsWorkItemDO.class, item -> item.setId(randomLongId()).setType(type) + .setStatus(status).setSerialNumber(1).setName("测试工作项")); + workItem.setUpdateTime(LocalDateTime.now()); + return workItem; + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectAnnouncementServiceImplTest.java b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectAnnouncementServiceImplTest.java new file mode 100644 index 000000000..06c8c49bc --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectAnnouncementServiceImplTest.java @@ -0,0 +1,133 @@ +package cn.iocoder.yudao.module.pms.service.pm.project; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.announcement.PmsProjectAnnouncementSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectAnnouncementDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectDO; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.project.PmsProjectAnnouncementMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Collections; + +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +/** + * {@link PmsProjectAnnouncementServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(PmsProjectAnnouncementServiceImpl.class) +public class PmsProjectAnnouncementServiceImplTest extends BaseDbUnitTest { + + @Resource + private PmsProjectAnnouncementServiceImpl announcementService; + + @Resource + private PmsProjectAnnouncementMapper announcementMapper; + + @MockitoBean + private PmsProjectMemberService projectMemberService; + + @Test + public void testCreateProjectAnnouncement_success() { + // mock 数据 + Long projectId = randomLongId(); + Long userId = randomLongId(); + when(projectMemberService.validateProjectWritable(projectId, userId)) + .thenReturn(new PmsProjectDO().setId(projectId)); + // 准备参数 + PmsProjectAnnouncementSaveReqVO reqVO = new PmsProjectAnnouncementSaveReqVO() + .setProjectId(projectId).setContent("项目启动") + .setFileUrls(Collections.singletonList("https://example.com/a.pdf")); + + // 调用 + Long id = announcementService.createProjectAnnouncement(reqVO, userId); + + // 断言 + PmsProjectAnnouncementDO announcement = announcementMapper.selectById(id); + assertEquals("项目启动", announcement.getContent()); + } + + @Test + public void testUpdateProjectAnnouncement_success() { + // mock 数据 + Long projectId = randomLongId(); + Long userId = randomLongId(); + PmsProjectAnnouncementDO announcement = new PmsProjectAnnouncementDO() + .setProjectId(projectId).setContent("项目启动"); + announcementMapper.insert(announcement); + when(projectMemberService.validateProjectWritable(projectId, userId)) + .thenReturn(new PmsProjectDO().setId(projectId)); + // 准备参数 + PmsProjectAnnouncementSaveReqVO reqVO = new PmsProjectAnnouncementSaveReqVO() + .setId(announcement.getId()).setProjectId(projectId).setContent("项目已启动") + .setFileUrls(Collections.singletonList("https://example.com/a.pdf")); + + // 调用 + announcementService.updateProjectAnnouncement(reqVO, userId); + + // 断言 + announcement = announcementMapper.selectById(announcement.getId()); + assertEquals("项目已启动", announcement.getContent()); + } + + @Test + public void testDeleteProjectAnnouncement_success() { + // mock 数据 + Long projectId = randomLongId(); + Long userId = randomLongId(); + PmsProjectAnnouncementDO announcement = new PmsProjectAnnouncementDO() + .setProjectId(projectId).setContent("项目启动"); + announcementMapper.insert(announcement); + when(projectMemberService.validateProjectWritable(projectId, userId)) + .thenReturn(new PmsProjectDO().setId(projectId)); + + // 调用 + announcementService.deleteProjectAnnouncement(announcement.getId(), userId); + + // 断言 + assertNull(announcementMapper.selectById(announcement.getId())); + } + + @Test + public void testGetProjectAnnouncement_success() { + // mock 数据 + Long projectId = randomLongId(); + Long userId = randomLongId(); + PmsProjectAnnouncementDO announcement = new PmsProjectAnnouncementDO() + .setProjectId(projectId).setContent("项目启动"); + announcementMapper.insert(announcement); + when(projectMemberService.validateProjectReadable(projectId, userId)) + .thenReturn(new PmsProjectDO().setId(projectId)); + + // 调用 + PmsProjectAnnouncementDO result = announcementService + .getProjectAnnouncement(announcement.getId(), userId); + + // 断言 + assertEquals(announcement.getId(), result.getId()); + assertEquals("项目启动", result.getContent()); + } + + @Test + public void testDeleteProjectAnnouncementListByProjectId() { + // mock 数据 + Long projectId = randomLongId(); + announcementMapper.insert(new PmsProjectAnnouncementDO().setProjectId(projectId).setContent("公告")); + + // 调用 + announcementService.deleteProjectAnnouncementListByProjectId(projectId); + + // 断言 + assertTrue(CollUtil.isEmpty(announcementMapper.selectListByProjectId(projectId))); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectFavoriteServiceImplTest.java b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectFavoriteServiceImplTest.java new file mode 100644 index 000000000..d05e223b6 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectFavoriteServiceImplTest.java @@ -0,0 +1,82 @@ +package cn.iocoder.yudao.module.pms.service.pm.project; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectDO; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.project.PmsProjectFavoriteMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +/** + * {@link PmsProjectFavoriteServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(PmsProjectFavoriteServiceImpl.class) +public class PmsProjectFavoriteServiceImplTest extends BaseDbUnitTest { + + @Resource + private PmsProjectFavoriteServiceImpl favoriteService; + + @Resource + private PmsProjectFavoriteMapper favoriteMapper; + + @MockitoBean + private PmsProjectMemberService projectMemberService; + + @Test + public void testCreateProjectFavorite_success() { + // mock 数据 + Long projectId = randomLongId(); + Long userId = randomLongId(); + when(projectMemberService.validateProjectMember(projectId, userId)) + .thenReturn(new PmsProjectDO().setId(projectId)); + + // 调用 + favoriteService.createProjectFavorite(projectId, userId); + + // 断言 + assertNotNull(favoriteMapper.selectByProjectIdAndUserId(projectId, userId)); + } + + @Test + public void testDeleteProjectFavorite_success() { + // mock 数据 + Long projectId = randomLongId(); + Long userId = randomLongId(); + when(projectMemberService.validateProjectMember(projectId, userId)) + .thenReturn(new PmsProjectDO().setId(projectId)); + favoriteService.createProjectFavorite(projectId, userId); + + // 调用 + favoriteService.deleteProjectFavorite(projectId, userId); + + // 断言 + assertNull(favoriteMapper.selectByProjectIdAndUserId(projectId, userId)); + } + + @Test + public void testDeleteProjectFavoriteListByProjectId() { + // mock 数据 + Long projectId = randomLongId(); + Long userId = randomLongId(); + when(projectMemberService.validateProjectMember(projectId, userId)) + .thenReturn(new PmsProjectDO().setId(projectId)); + favoriteService.createProjectFavorite(projectId, userId); + + // 调用 + favoriteService.deleteProjectFavoriteListByProjectId(projectId); + + // 断言 + assertTrue(CollUtil.isEmpty(favoriteMapper.selectProjectIdListByUserId(userId))); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectGroupServiceImplTest.java b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectGroupServiceImplTest.java new file mode 100644 index 000000000..49a6e84f6 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectGroupServiceImplTest.java @@ -0,0 +1,345 @@ +package cn.iocoder.yudao.module.pms.service.pm.project; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.group.PmsProjectGroupMoveReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.group.PmsProjectGroupSaveReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.group.PmsProjectGroupSortReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectGroupDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectGroupRelationDO; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.project.PmsProjectGroupMapper; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.project.PmsProjectGroupRelationMapper; +import cn.iocoder.yudao.module.pms.enums.pm.project.PmsProjectGroupTypeEnum; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.PROJECT_GROUP_DEFAULT_CANNOT_DELETE; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.PROJECT_GROUP_DEFAULT_CANNOT_MODIFY; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.PROJECT_GROUP_NAME_DUPLICATE; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.PROJECT_GROUP_NOT_EXISTS; +import static java.util.Arrays.asList; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link PmsProjectGroupServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(PmsProjectGroupServiceImpl.class) +public class PmsProjectGroupServiceImplTest extends BaseDbUnitTest { + + @Resource + private PmsProjectGroupServiceImpl projectGroupService; + + @Resource + private PmsProjectGroupMapper projectGroupMapper; + @Resource + private PmsProjectGroupRelationMapper projectGroupRelationMapper; + + @MockitoBean + private PmsProjectMemberService projectMemberService; + + @Test + public void testCreateProjectGroup_success() { + // 准备参数 + Long userId = randomLongId(); + PmsProjectGroupSaveReqVO reqVO = new PmsProjectGroupSaveReqVO().setName("重点项目"); + + // 调用 + Long groupId = projectGroupService.createProjectGroup(reqVO, userId); + + // 断言 + PmsProjectGroupDO group = projectGroupMapper.selectById(groupId); + assertNotNull(group); + assertEquals(userId, group.getUserId()); + assertEquals("重点项目", group.getName()); + assertEquals(2, group.getSort()); + assertEquals(PmsProjectGroupTypeEnum.CUSTOM.getType(), group.getType()); + assertEquals(1, projectGroupMapper.selectListByUserId(userId).size()); + } + + @Test + public void testCreateProjectGroup_nameDuplicate() { + // mock 数据 + Long userId = randomLongId(); + projectGroupMapper.insert(randomProjectGroupDO(userId, "重点项目", 2, + PmsProjectGroupTypeEnum.CUSTOM.getType())); + // 准备参数 + PmsProjectGroupSaveReqVO reqVO = new PmsProjectGroupSaveReqVO().setName("重点项目"); + + // 调用,并断言异常 + assertServiceException(() -> projectGroupService.createProjectGroup(reqVO, userId), + PROJECT_GROUP_NAME_DUPLICATE); + } + + @Test + public void testUpdateProjectGroup_success() { + // mock 数据 + Long userId = randomLongId(); + PmsProjectGroupDO group = randomProjectGroupDO(userId, "旧名称", 2, + PmsProjectGroupTypeEnum.CUSTOM.getType()); + projectGroupMapper.insert(group); + // 准备参数 + PmsProjectGroupSaveReqVO reqVO = new PmsProjectGroupSaveReqVO() + .setId(group.getId()).setName("新名称"); + + // 调用 + projectGroupService.updateProjectGroup(reqVO, userId); + + // 断言 + assertEquals("新名称", projectGroupMapper.selectById(group.getId()).getName()); + } + + @Test + public void testUpdateProjectGroup_defaultCannotModify() { + // mock 数据 + Long userId = randomLongId(); + PmsProjectGroupDO group = randomProjectGroupDO(userId, "全部项目", 0, + PmsProjectGroupTypeEnum.ALL.getType()); + projectGroupMapper.insert(group); + // 准备参数 + PmsProjectGroupSaveReqVO reqVO = new PmsProjectGroupSaveReqVO() + .setId(group.getId()).setName("新名称"); + + // 调用,并断言异常 + assertServiceException(() -> projectGroupService.updateProjectGroup(reqVO, userId), + PROJECT_GROUP_DEFAULT_CANNOT_MODIFY); + } + + @Test + public void testUpdateProjectGroupSort_success() { + // mock 数据 + Long userId = randomLongId(); + PmsProjectGroupDO firstGroup = randomProjectGroupDO(userId, "分组一", 0, + PmsProjectGroupTypeEnum.CUSTOM.getType()); + projectGroupMapper.insert(firstGroup); + PmsProjectGroupDO secondGroup = randomProjectGroupDO(userId, "分组二", 1, + PmsProjectGroupTypeEnum.CUSTOM.getType()); + projectGroupMapper.insert(secondGroup); + // 准备参数 + PmsProjectGroupSortReqVO reqVO = new PmsProjectGroupSortReqVO().setItems(asList( + new PmsProjectGroupSortReqVO.Item().setId(firstGroup.getId()).setSort(2), + new PmsProjectGroupSortReqVO.Item().setId(secondGroup.getId()).setSort(1))); + + // 调用 + projectGroupService.updateProjectGroupSort(reqVO, userId); + + // 断言 + assertEquals(2, projectGroupMapper.selectById(firstGroup.getId()).getSort()); + assertEquals(1, projectGroupMapper.selectById(secondGroup.getId()).getSort()); + } + + @Test + public void testUpdateProjectGroupSort_notOwner() { + // mock 数据 + PmsProjectGroupDO group = randomProjectGroupDO(randomLongId(), "其他用户分组", 0, + PmsProjectGroupTypeEnum.CUSTOM.getType()); + projectGroupMapper.insert(group); + // 准备参数 + PmsProjectGroupSortReqVO reqVO = new PmsProjectGroupSortReqVO().setItems(asList( + new PmsProjectGroupSortReqVO.Item().setId(group.getId()).setSort(1))); + + // 调用,并断言异常 + assertServiceException(() -> projectGroupService.updateProjectGroupSort(reqVO, randomLongId()), + PROJECT_GROUP_NOT_EXISTS); + } + + @Test + public void testDeleteProjectGroup_success() { + // mock 数据 + Long userId = randomLongId(); + PmsProjectGroupDO group = randomProjectGroupDO(userId, "测试分组", 2, + PmsProjectGroupTypeEnum.CUSTOM.getType()); + projectGroupMapper.insert(group); + PmsProjectGroupRelationDO relation = randomProjectGroupRelationDO(userId, group.getId(), randomLongId()); + projectGroupRelationMapper.insert(relation); + + // 调用 + projectGroupService.deleteProjectGroup(group.getId(), userId); + + // 断言 + assertNull(projectGroupMapper.selectById(group.getId())); + assertNull(projectGroupRelationMapper.selectById(relation.getId())); + } + + @Test + public void testDeleteProjectGroup_defaultCannotDelete() { + // mock 数据 + Long userId = randomLongId(); + PmsProjectGroupDO group = randomProjectGroupDO(userId, "未分组", 1, + PmsProjectGroupTypeEnum.UNGROUPED.getType()); + projectGroupMapper.insert(group); + + // 调用,并断言异常 + assertServiceException(() -> projectGroupService.deleteProjectGroup(group.getId(), userId), + PROJECT_GROUP_DEFAULT_CANNOT_DELETE); + } + + @Test + public void testGetProjectGroupList_noRelations() { + // mock 数据 + Long userId = randomLongId(); + + // 调用 + List groups = projectGroupService.getProjectGroupList(userId); + Map groupCountMap = projectGroupService.getProjectGroupCountMap( + userId, groups, asList(101L, 102L)); + + // 断言 + PmsProjectGroupDO allGroup = CollUtil.getFirst(groups); + PmsProjectGroupDO ungroupedGroup = groups.get(1); + assertEquals(2, groupCountMap.get(allGroup.getId())); + assertEquals(2, groupCountMap.get(ungroupedGroup.getId())); + } + + @Test + public void testGetProjectGroupList_customGroupCounts() { + // mock 数据 + Long userId = randomLongId(); + PmsProjectGroupDO customGroup = randomProjectGroupDO(userId, "重点项目", 2, + PmsProjectGroupTypeEnum.CUSTOM.getType()); + projectGroupMapper.insert(customGroup); + projectGroupRelationMapper.insert(randomProjectGroupRelationDO(userId, customGroup.getId(), 101L)); + + // 调用 + List groups = projectGroupService.getProjectGroupList(userId); + Map groupCountMap = projectGroupService.getProjectGroupCountMap( + userId, groups, asList(101L, 102L, 103L)); + + // 断言 + assertEquals(3, groupCountMap.get(CollUtil.getFirst(groups).getId())); + assertEquals(2, groupCountMap.get(groups.get(1).getId())); + assertEquals(1, groupCountMap.get(customGroup.getId())); + } + + @Test + public void testGetProjectGroupList_sameProjectInMultipleGroups() { + // mock 数据 + Long userId = randomLongId(); + PmsProjectGroupDO firstGroup = randomProjectGroupDO(userId, "重点项目", 2, + PmsProjectGroupTypeEnum.CUSTOM.getType()); + projectGroupMapper.insert(firstGroup); + PmsProjectGroupDO secondGroup = randomProjectGroupDO(userId, "产品项目", 3, + PmsProjectGroupTypeEnum.CUSTOM.getType()); + projectGroupMapper.insert(secondGroup); + projectGroupRelationMapper.insert(randomProjectGroupRelationDO(userId, firstGroup.getId(), 101L)); + projectGroupRelationMapper.insert(randomProjectGroupRelationDO(userId, secondGroup.getId(), 101L)); + + // 调用 + List groups = projectGroupService.getProjectGroupList(userId); + Map groupCountMap = projectGroupService.getProjectGroupCountMap( + userId, groups, asList(101L, 102L)); + + // 断言 + assertEquals(2, groupCountMap.get(CollUtil.getFirst(groups).getId())); + assertEquals(1, groupCountMap.get(groups.get(1).getId())); + assertEquals(1, groupCountMap.get(firstGroup.getId())); + assertEquals(1, groupCountMap.get(secondGroup.getId())); + } + + @Test + public void testMoveProjectToGroup_createAndUpdate() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + PmsProjectGroupDO firstGroup = randomProjectGroupDO(userId, "分组一", 2, + PmsProjectGroupTypeEnum.CUSTOM.getType()); + projectGroupMapper.insert(firstGroup); + PmsProjectGroupDO secondGroup = randomProjectGroupDO(userId, "分组二", 3, + PmsProjectGroupTypeEnum.CUSTOM.getType()); + projectGroupMapper.insert(secondGroup); + + // 调用 + projectGroupService.moveProjectToGroup(new PmsProjectGroupMoveReqVO() + .setProjectId(projectId).setGroupId(firstGroup.getId()), userId); + projectGroupService.moveProjectToGroup(new PmsProjectGroupMoveReqVO() + .setProjectId(projectId).setGroupId(secondGroup.getId()), userId); + + // 断言 + PmsProjectGroupRelationDO relation = + projectGroupRelationMapper.selectByUserIdAndProjectId(userId, projectId); + assertNotNull(relation); + assertEquals(secondGroup.getId(), relation.getGroupId()); + verify(projectMemberService, times(2)).validateProjectMember(projectId, userId); + } + + @Test + public void testMoveProjectToGroup_allCannotMove() { + // mock 数据 + Long userId = randomLongId(); + PmsProjectGroupDO group = randomProjectGroupDO(userId, "全部项目", 0, + PmsProjectGroupTypeEnum.ALL.getType()); + projectGroupMapper.insert(group); + // 准备参数 + PmsProjectGroupMoveReqVO reqVO = new PmsProjectGroupMoveReqVO() + .setProjectId(randomLongId()).setGroupId(group.getId()); + + // 调用,并断言异常 + assertServiceException(() -> projectGroupService.moveProjectToGroup(reqVO, userId), + PROJECT_GROUP_DEFAULT_CANNOT_MODIFY); + } + + @Test + public void testFilterProjectIdListByGroup_ungrouped() { + // mock 数据 + Long userId = randomLongId(); + PmsProjectGroupDO ungrouped = randomProjectGroupDO(userId, "未分组", 1, + PmsProjectGroupTypeEnum.UNGROUPED.getType()); + projectGroupMapper.insert(ungrouped); + PmsProjectGroupDO customGroup = randomProjectGroupDO(userId, "重点项目", 2, + PmsProjectGroupTypeEnum.CUSTOM.getType()); + projectGroupMapper.insert(customGroup); + projectGroupRelationMapper.insert(randomProjectGroupRelationDO(userId, customGroup.getId(), 101L)); + + // 调用 + List projectIds = projectGroupService.filterProjectIdListByGroupId( + ungrouped.getId(), userId, asList(101L, 102L)); + + // 断言 + assertEquals(asList(102L), projectIds); + } + + @Test + public void testFilterProjectIdListByGroup_customGroup() { + // mock 数据 + Long userId = randomLongId(); + PmsProjectGroupDO customGroup = randomProjectGroupDO(userId, "重点项目", 2, + PmsProjectGroupTypeEnum.CUSTOM.getType()); + projectGroupMapper.insert(customGroup); + projectGroupRelationMapper.insert(randomProjectGroupRelationDO(userId, customGroup.getId(), 101L)); + + // 调用 + List projectIds = projectGroupService.filterProjectIdListByGroupId( + customGroup.getId(), userId, asList(101L, 102L)); + + // 断言 + assertEquals(asList(101L), projectIds); + } + + // ========== 随机对象 ========== + + private static PmsProjectGroupDO randomProjectGroupDO(Long userId, String name, Integer sort, Integer type) { + return randomPojo(PmsProjectGroupDO.class, group -> group.setUserId(userId) + .setName(name).setSort(sort).setType(type)); + } + + private static PmsProjectGroupRelationDO randomProjectGroupRelationDO( + Long userId, Long groupId, Long projectId) { + return randomPojo(PmsProjectGroupRelationDO.class, relation -> relation.setUserId(userId) + .setGroupId(groupId).setProjectId(projectId).setSort(0)); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectMemberServiceImplTest.java b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectMemberServiceImplTest.java new file mode 100644 index 000000000..3397c9e8e --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectMemberServiceImplTest.java @@ -0,0 +1,358 @@ +package cn.iocoder.yudao.module.pms.service.pm.project; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.member.PmsProjectMemberRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.member.PmsProjectMemberSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectMemberDO; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.project.PmsProjectMemberMapper; +import cn.iocoder.yudao.module.pms.enums.pm.project.PmsProjectMemberLevelEnum; +import cn.iocoder.yudao.module.pms.enums.pm.project.PmsProjectStatusEnum; +import cn.iocoder.yudao.module.system.api.permission.PermissionApi; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.enums.permission.RoleCodeEnum; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.PROJECT_ACCESS_DENIED; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.PROJECT_MEMBER_CREATOR_CANNOT_REMOVE; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.PROJECT_MEMBER_OWNER_CANNOT_EXIT; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.PROJECT_MEMBER_OWNER_LEVEL_CANNOT_ASSIGN; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.PROJECT_NOT_EXISTS; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.PROJECT_WRITE_ACCESS_DENIED; +import static java.util.Arrays.asList; +import static java.util.Collections.singletonList; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +/** + * {@link PmsProjectMemberServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(PmsProjectMemberServiceImpl.class) +public class PmsProjectMemberServiceImplTest extends BaseDbUnitTest { + + @Resource + private PmsProjectMemberServiceImpl projectMemberService; + + @Resource + private PmsProjectMemberMapper projectMemberMapper; + + @MockitoBean + private PmsProjectService projectService; + @MockitoBean + private AdminUserApi adminUserApi; + @MockitoBean + private PermissionApi permissionApi; + + @BeforeEach + public void setUpCloudApiMocks() { + lenient().when(permissionApi.hasAnyRoles(anyLong(), any(String[].class))).thenReturn(success(false)); + lenient().when(adminUserApi.validateUserList(anyCollection())).thenReturn(success(true)); + } + + @Test + public void testCreateProjectMemberList_success() { + // 准备参数 + Long projectId = randomLongId(); + Long creatorId = randomLongId(); + Long memberId = randomLongId(); + + // 调用 + projectMemberService.createProjectMemberList(projectId, creatorId, asList(creatorId, memberId)); + + // 断言 + List members = projectMemberMapper.selectListByProjectId(projectId); + assertEquals(2, members.size()); + assertTrue(members.stream().anyMatch(member -> creatorId.equals(member.getUserId()) + && PmsProjectMemberLevelEnum.OWNER.getLevel().equals(member.getLevel()))); + assertTrue(members.stream().anyMatch(member -> memberId.equals(member.getUserId()) + && PmsProjectMemberLevelEnum.WRITE.getLevel().equals(member.getLevel()))); + } + + @Test + public void testSaveProjectMembers_success() { + // mock 数据 + Long projectId = randomLongId(); + Long creatorId = randomLongId(); + Long operatorUserId = randomLongId(); + Long memberUserId = randomLongId(); + PmsProjectDO project = randomProjectDO(projectId, creatorId); + when(projectService.getProject(projectId)).thenReturn(project); + projectMemberMapper.insert(randomProjectMemberDO(projectId, operatorUserId, + PmsProjectMemberLevelEnum.ADMIN)); + projectMemberMapper.insert(randomProjectMemberDO(projectId, memberUserId, + PmsProjectMemberLevelEnum.READ)); + // 准备参数 + PmsProjectMemberSaveReqVO reqVO = new PmsProjectMemberSaveReqVO().setProjectId(projectId) + .setMembers(singletonList(new PmsProjectMemberSaveReqVO.Member().setUserId(memberUserId) + .setLevel(PmsProjectMemberLevelEnum.WRITE.getLevel()))); + + // 调用 + projectMemberService.updateProjectMemberList(reqVO, operatorUserId); + + // 断言 + PmsProjectMemberDO member = projectMemberMapper.selectByProjectIdAndUserId(projectId, memberUserId); + assertEquals(PmsProjectMemberLevelEnum.WRITE.getLevel(), member.getLevel()); + } + + @Test + public void testSaveProjectMembers_ownerLevelCannotAssign() { + // mock 数据 + Long projectId = randomLongId(); + Long creatorId = randomLongId(); + Long operatorUserId = randomLongId(); + when(projectService.getProject(projectId)).thenReturn(randomProjectDO(projectId, creatorId)); + projectMemberMapper.insert(randomProjectMemberDO(projectId, operatorUserId, + PmsProjectMemberLevelEnum.ADMIN)); + // 准备参数 + PmsProjectMemberSaveReqVO reqVO = new PmsProjectMemberSaveReqVO().setProjectId(projectId) + .setMembers(singletonList(new PmsProjectMemberSaveReqVO.Member().setUserId(randomLongId()) + .setLevel(PmsProjectMemberLevelEnum.OWNER.getLevel()))); + + // 调用,并断言异常 + assertServiceException(() -> projectMemberService.updateProjectMemberList(reqVO, operatorUserId), + PROJECT_MEMBER_OWNER_LEVEL_CANNOT_ASSIGN); + } + + @Test + public void testDeleteProjectMember_ownerCannotRemove() { + // mock 数据 + Long projectId = randomLongId(); + Long creatorId = randomLongId(); + PmsProjectDO project = randomProjectDO(projectId, creatorId); + when(projectService.getProject(projectId)).thenReturn(project); + projectMemberMapper.insert(randomProjectMemberDO(projectId, creatorId, + PmsProjectMemberLevelEnum.OWNER)); + + // 调用,并断言异常 + assertServiceException(() -> projectMemberService.deleteProjectMember(projectId, creatorId, creatorId), + PROJECT_MEMBER_CREATOR_CANNOT_REMOVE); + } + + @Test + public void testExitProject_success() { + // mock 数据 + Long projectId = randomLongId(); + Long creatorId = randomLongId(); + Long memberId = randomLongId(); + when(projectService.getProject(projectId)).thenReturn(randomProjectDO(projectId, creatorId)); + projectMemberMapper.insert(randomProjectMemberDO(projectId, memberId, + PmsProjectMemberLevelEnum.WRITE)); + + // 调用 + projectMemberService.exitProject(projectId, memberId); + + // 断言 + assertNull(projectMemberMapper.selectByProjectIdAndUserId(projectId, memberId)); + } + + @Test + public void testExitProject_ownerCannotExit() { + // mock 数据 + Long projectId = randomLongId(); + Long ownerId = randomLongId(); + when(projectService.getProject(projectId)).thenReturn(randomProjectDO(projectId, ownerId)); + projectMemberMapper.insert(randomProjectMemberDO(projectId, ownerId, + PmsProjectMemberLevelEnum.OWNER)); + + // 调用,并断言异常 + assertServiceException(() -> projectMemberService.exitProject(projectId, ownerId), + PROJECT_MEMBER_OWNER_CANNOT_EXIT); + } + + @Test + public void testGetProjectMemberList_success() { + // mock 数据 + Long projectId = randomLongId(); + Long creatorId = randomLongId(); + Long memberId = randomLongId(); + PmsProjectDO project = randomProjectDO(projectId, creatorId); + when(projectService.getProject(projectId)).thenReturn(project); + projectMemberMapper.insert(randomProjectMemberDO(projectId, creatorId, + PmsProjectMemberLevelEnum.OWNER)); + projectMemberMapper.insert(randomProjectMemberDO(projectId, memberId, + PmsProjectMemberLevelEnum.WRITE)); + + // 调用 + List members = projectMemberService.getProjectMemberList(projectId, creatorId); + + // 断言 + assertEquals(2, members.size()); + PmsProjectMemberRespVO member = CollUtil.findOne(members, item -> memberId.equals(item.getUserId())); + assertEquals(PmsProjectMemberLevelEnum.WRITE.getLevel(), member.getLevel()); + } + + @Test + public void testGetActiveProjectIdList() { + // mock 数据 + Long userId = randomLongId(); + PmsProjectDO activeProject = randomProjectDO("进行中项目", PmsProjectStatusEnum.ACTIVE.getStatus()); + PmsProjectDO archivedProject = randomProjectDO("已归档项目", PmsProjectStatusEnum.ARCHIVED.getStatus()); + projectMemberMapper.insert(randomProjectMemberDO(activeProject.getId(), userId)); + projectMemberMapper.insert(randomProjectMemberDO(activeProject.getId(), userId)); + projectMemberMapper.insert(randomProjectMemberDO(archivedProject.getId(), userId)); + when(projectService.getProjectList(anyCollection())).thenReturn(asList(activeProject, archivedProject)); + + // 调用 + List projectIds = projectMemberService.getActiveProjectIdListByUserId(userId); + + // 断言 + assertEquals(singletonList(activeProject.getId()), projectIds); + } + + @Test + public void testGetActiveProjectIdList_empty() { + // 调用 + List projectIds = projectMemberService.getActiveProjectIdListByUserId(randomLongId()); + + // 断言 + assertTrue(CollUtil.isEmpty(projectIds)); + } + + @Test + public void testValidateProjectMember_success() { + // mock 数据 + Long userId = randomLongId(); + PmsProjectDO project = randomProjectDO("测试项目", PmsProjectStatusEnum.ACTIVE.getStatus()); + when(projectService.getProject(project.getId())).thenReturn(project); + projectMemberMapper.insert(randomProjectMemberDO(project.getId(), userId)); + + // 调用 + PmsProjectDO memberProject = projectMemberService.validateProjectMember(project.getId(), userId); + + // 断言 + assertEquals(project.getId(), memberProject.getId()); + } + + @Test + public void testValidateProjectMember_notExists() { + // 调用,并断言异常 + assertServiceException(() -> projectMemberService.validateProjectMember(randomLongId(), randomLongId()), + PROJECT_NOT_EXISTS); + } + + @Test + public void testValidateProjectMember_accessDenied() { + // mock 数据 + PmsProjectDO project = randomProjectDO("测试项目", PmsProjectStatusEnum.ACTIVE.getStatus()); + when(projectService.getProject(project.getId())).thenReturn(project); + + // 调用,并断言异常 + assertServiceException(() -> projectMemberService.validateProjectMember(project.getId(), randomLongId()), + PROJECT_ACCESS_DENIED); + } + + @Test + public void testValidateProjectReadable_publicProject() { + // mock 数据 + PmsProjectDO project = randomProjectDO("公开项目", PmsProjectStatusEnum.ACTIVE.getStatus()) + .setOpenStatus(true); + when(projectService.getProject(project.getId())).thenReturn(project); + + // 调用 + PmsProjectDO readableProject = projectMemberService.validateProjectReadable(project.getId(), randomLongId()); + + // 断言 + assertEquals(project.getId(), readableProject.getId()); + } + + @Test + public void testValidateProjectReadable_privateProjectAccessDenied() { + // mock 数据 + PmsProjectDO project = randomProjectDO("私有项目", PmsProjectStatusEnum.ACTIVE.getStatus()) + .setOpenStatus(false); + when(projectService.getProject(project.getId())).thenReturn(project); + + // 调用,并断言异常 + assertServiceException(() -> projectMemberService.validateProjectReadable(project.getId(), randomLongId()), + PROJECT_ACCESS_DENIED); + } + + @Test + public void testValidateProjectReadable_superAdmin() { + // mock 数据 + Long userId = randomLongId(); + PmsProjectDO project = randomProjectDO("私有项目", PmsProjectStatusEnum.ACTIVE.getStatus()) + .setOpenStatus(false); + when(projectService.getProject(project.getId())).thenReturn(project); + when(permissionApi.hasAnyRoles(userId, RoleCodeEnum.SUPER_ADMIN.getCode())).thenReturn(success(true)); + + // 调用 + PmsProjectDO readableProject = projectMemberService.validateProjectReadable(project.getId(), userId); + + // 断言 + assertEquals(project.getId(), readableProject.getId()); + } + + @Test + public void testValidateProjectWritable_writeLevel() { + // mock 数据 + Long userId = randomLongId(); + PmsProjectDO project = randomProjectDO("可编辑项目", PmsProjectStatusEnum.ACTIVE.getStatus()); + when(projectService.getProject(project.getId())).thenReturn(project); + projectMemberMapper.insert(randomProjectMemberDO(project.getId(), userId) + .setLevel(PmsProjectMemberLevelEnum.WRITE.getLevel())); + + // 调用 + PmsProjectDO writableProject = projectMemberService.validateProjectWritable(project.getId(), userId); + + // 断言 + assertEquals(project.getId(), writableProject.getId()); + } + + @Test + public void testValidateProjectWritable_readLevel() { + // mock 数据 + Long userId = randomLongId(); + PmsProjectDO project = randomProjectDO("只读项目", PmsProjectStatusEnum.ACTIVE.getStatus()); + when(projectService.getProject(project.getId())).thenReturn(project); + projectMemberMapper.insert(randomProjectMemberDO(project.getId(), userId) + .setLevel(PmsProjectMemberLevelEnum.READ.getLevel())); + + // 调用,并断言异常 + assertServiceException(() -> projectMemberService.validateProjectWritable(project.getId(), userId), + PROJECT_WRITE_ACCESS_DENIED); + } + + // ========== 随机对象 ========== + + private static PmsProjectDO randomProjectDO(Long projectId, Long creatorId) { + return randomPojo(PmsProjectDO.class, project -> project.setId(projectId).setName("测试项目") + .setStatus(1).setType(1).setLevel(3).setOpenStatus(false).setCreator(String.valueOf(creatorId))); + } + + private static PmsProjectMemberDO randomProjectMemberDO(Long projectId, Long userId, + PmsProjectMemberLevelEnum level) { + return randomPojo(PmsProjectMemberDO.class, member -> member.setProjectId(projectId) + .setUserId(userId).setLevel(level.getLevel())); + } + + private static PmsProjectDO randomProjectDO(String name, Integer status) { + return randomPojo(PmsProjectDO.class, project -> project.setName(name).setStatus(status) + .setType(1).setLevel(3).setOpenStatus(true)); + } + + private static PmsProjectMemberDO randomProjectMemberDO(Long projectId, Long userId) { + return randomPojo(PmsProjectMemberDO.class, member -> member.setProjectId(projectId) + .setUserId(userId).setLevel(PmsProjectMemberLevelEnum.WRITE.getLevel())); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectServiceImplTest.java b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectServiceImplTest.java new file mode 100644 index 000000000..37274e57b --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectServiceImplTest.java @@ -0,0 +1,333 @@ +package cn.iocoder.yudao.module.pms.service.pm.project; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.project.PmsProjectOverviewRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.project.PmsProjectPageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.project.PmsProjectSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemDO; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.project.PmsProjectMapper; +import cn.iocoder.yudao.module.pms.enums.pm.project.PmsProjectSceneTypeEnum; +import cn.iocoder.yudao.module.pms.enums.pm.project.PmsProjectSortTypeEnum; +import cn.iocoder.yudao.module.pms.enums.pm.project.PmsProjectStatusEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemStatusTypeEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemTypeEnum; +import cn.iocoder.yudao.module.pms.service.pm.iteration.PmsIterationService; +import cn.iocoder.yudao.module.pms.service.pm.workitem.PmsWorkItemService; +import cn.iocoder.yudao.module.pms.service.pm.workitem.PmsWorkItemStatusService; +import cn.iocoder.yudao.module.pms.service.pm.workitem.PmsWorkItemWorkLogService; +import cn.iocoder.yudao.module.system.api.permission.PermissionApi; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.PROJECT_ADMIN_REQUIRED; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.PROJECT_OWNER_REQUIRED; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.PROJECT_STATUS_INVALID; +import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link PmsProjectServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(PmsProjectServiceImpl.class) +public class PmsProjectServiceImplTest extends BaseDbUnitTest { + + @Resource + private PmsProjectServiceImpl projectService; + + @Resource + private PmsProjectMapper projectMapper; + + @MockitoBean + private PmsProjectMemberService projectMemberService; + @MockitoBean + private PmsProjectGroupService projectGroupService; + @MockitoBean + private PmsProjectFavoriteService projectFavoriteService; + @MockitoBean + private PmsProjectAnnouncementService projectAnnouncementService; + @MockitoBean + private PmsIterationService iterationService; + @MockitoBean + private PmsWorkItemService workItemService; + @MockitoBean + private PmsWorkItemStatusService workItemStatusService; + @MockitoBean + private PmsWorkItemWorkLogService workItemWorkLogService; + @MockitoBean + private PermissionApi permissionApi; + + @BeforeEach + public void setUpCloudApiMocks() { + lenient().when(permissionApi.hasAnyRoles(anyLong(), any(String[].class))).thenReturn(success(false)); + } + + @Test + public void testCreateProject_success() { + // 准备参数 + Long userId = randomLongId(); + PmsProjectSaveReqVO reqVO = randomProjectSaveReqVO(); + reqVO.setMemberUserIds(asList(randomLongId(), randomLongId())); + + // 调用 + Long projectId = projectService.createProject(reqVO, userId); + + // 断言 + PmsProjectDO project = projectMapper.selectById(projectId); + assertNotNull(project); + assertEquals(reqVO.getName(), project.getName()); + assertEquals(PmsProjectStatusEnum.ACTIVE.getStatus(), project.getStatus()); + verify(projectMemberService).createProjectMemberList(projectId, userId, reqVO.getMemberUserIds()); + verify(workItemStatusService).initProjectWorkItemStatuses(projectId, reqVO.getType()); + } + + @Test + public void testUpdateProject_notAdmin() { + // mock 数据 + Long userId = randomLongId(); + PmsProjectDO project = randomProjectDO(PmsProjectStatusEnum.ACTIVE.getStatus()); + projectMapper.insert(project); + when(projectMemberService.hasProjectManagerPermission(project.getId(), userId)).thenReturn(false); + // 准备参数 + PmsProjectSaveReqVO reqVO = randomProjectSaveReqVO(project.getId()); + + // 调用,并断言异常 + assertServiceException(() -> projectService.updateProject(reqVO, userId), PROJECT_ADMIN_REQUIRED); + } + + @Test + public void testGetProjectPage_allIncludesOpenProject() { + // mock 数据 + Long userId = randomLongId(); + PmsProjectDO openProject = randomProjectDO(PmsProjectStatusEnum.ACTIVE.getStatus()).setOpenStatus(true); + projectMapper.insert(openProject); + when(projectMemberService.getProjectIdListByUserId(userId)).thenReturn(emptyList()); + // 准备参数 + PmsProjectPageReqVO reqVO = new PmsProjectPageReqVO() + .setSceneType(PmsProjectSceneTypeEnum.ALL.getType()) + .setStatus(PmsProjectStatusEnum.ACTIVE.getStatus()); + + // 调用 + PageResult pageResult = projectService.getProjectPage(reqVO, userId); + + // 断言 + assertEquals(1, pageResult.getTotal()); + assertEquals(openProject.getId(), CollUtil.getFirst(pageResult.getList()).getId()); + } + + @Test + public void testGetProjectPage_createTimeAscending() { + // mock 数据 + Long userId = randomLongId(); + PmsProjectDO oldProject = randomProjectDO(PmsProjectStatusEnum.ACTIVE.getStatus()).setOpenStatus(true); + oldProject.setCreateTime(LocalDateTime.now().minusDays(2)); + PmsProjectDO newProject = randomProjectDO(PmsProjectStatusEnum.ACTIVE.getStatus()).setOpenStatus(true); + newProject.setCreateTime(LocalDateTime.now().minusDays(1)); + projectMapper.insert(oldProject); + projectMapper.insert(newProject); + when(projectMemberService.getProjectIdListByUserId(userId)).thenReturn(emptyList()); + // 准备参数 + PmsProjectPageReqVO reqVO = new PmsProjectPageReqVO() + .setSceneType(PmsProjectSceneTypeEnum.ALL.getType()) + .setStatus(PmsProjectStatusEnum.ACTIVE.getStatus()) + .setSortType(PmsProjectSortTypeEnum.CREATE_TIME.getType()); + + // 调用 + PageResult pageResult = projectService.getProjectPage(reqVO, userId); + + // 断言:创建时间按升序,旧项目在前 + assertEquals(Arrays.asList(oldProject.getId(), newProject.getId()), + convertList(pageResult.getList(), PmsProjectDO::getId)); + } + + @Test + public void testGetFavoriteProjectList_success() { + // mock 数据 + Long userId = randomLongId(); + PmsProjectDO favoriteProject = randomProjectDO(PmsProjectStatusEnum.ACTIVE.getStatus()); + projectMapper.insert(favoriteProject); + PmsProjectDO nonMemberProject = randomProjectDO(PmsProjectStatusEnum.ACTIVE.getStatus()); + projectMapper.insert(nonMemberProject); + PmsProjectDO archivedProject = randomProjectDO(PmsProjectStatusEnum.ARCHIVED.getStatus()); + projectMapper.insert(archivedProject); + when(projectFavoriteService.getFavoriteProjectIdListByUserId(userId)) + .thenReturn(asList(favoriteProject.getId(), nonMemberProject.getId(), archivedProject.getId())); + when(projectMemberService.getProjectIdListByUserId(userId)) + .thenReturn(asList(favoriteProject.getId(), archivedProject.getId())); + + // 调用 + List projects = projectService.getFavoriteProjectList(userId); + + // 断言 + assertEquals(1, projects.size()); + assertEquals(favoriteProject.getId(), CollUtil.getFirst(projects).getId()); + } + + @Test + public void testArchiveProject_success() { + // mock 数据 + Long userId = randomLongId(); + PmsProjectDO project = randomProjectDO(PmsProjectStatusEnum.ACTIVE.getStatus()); + projectMapper.insert(project); + when(projectMemberService.validateProjectReadable(project.getId(), userId)).thenReturn(project); + when(projectMemberService.hasProjectManagerPermission(project.getId(), userId)).thenReturn(true); + + // 调用 + projectService.archiveProject(project.getId(), userId); + + // 断言 + PmsProjectDO archivedProject = projectMapper.selectById(project.getId()); + assertEquals(PmsProjectStatusEnum.ARCHIVED.getStatus(), archivedProject.getStatus()); + assertNotNull(archivedProject.getArchiveTime()); + verify(projectGroupService).deleteProjectGroupRelationListByProjectId(project.getId()); + verify(projectFavoriteService).deleteProjectFavoriteListByProjectId(project.getId()); + } + + @Test + public void testRecycleProject_success() { + // mock 数据 + Long userId = randomLongId(); + PmsProjectDO project = randomProjectDO(PmsProjectStatusEnum.ACTIVE.getStatus()); + projectMapper.insert(project); + when(projectMemberService.hasProjectManagerPermission(project.getId(), userId)).thenReturn(true); + + // 调用 + projectService.recycleProject(project.getId(), userId); + + // 断言 + PmsProjectDO recycledProject = projectMapper.selectById(project.getId()); + assertEquals(PmsProjectStatusEnum.RECYCLED.getStatus(), recycledProject.getStatus()); + assertNotNull(recycledProject.getRecycleTime()); + verify(projectGroupService).deleteProjectGroupRelationListByProjectId(project.getId()); + verify(projectFavoriteService).deleteProjectFavoriteListByProjectId(project.getId()); + } + + @Test + public void testRestoreProject_success() { + // mock 数据 + Long userId = randomLongId(); + PmsProjectDO project = randomProjectDO(PmsProjectStatusEnum.ARCHIVED.getStatus()) + .setArchiveTime(LocalDateTime.now()); + projectMapper.insert(project); + when(projectMemberService.validateProjectReadable(project.getId(), userId)).thenReturn(project); + when(projectMemberService.hasProjectManagerPermission(project.getId(), userId)).thenReturn(true); + + // 调用 + projectService.restoreProject(project.getId(), userId); + + // 断言 + PmsProjectDO restoredProject = projectMapper.selectById(project.getId()); + assertEquals(PmsProjectStatusEnum.ACTIVE.getStatus(), restoredProject.getStatus()); + assertNull(restoredProject.getArchiveTime()); + assertNull(restoredProject.getRecycleTime()); + } + + @Test + public void testDeleteProject_statusInvalid() { + // mock 数据 + Long userId = randomLongId(); + PmsProjectDO project = randomProjectDO(PmsProjectStatusEnum.ACTIVE.getStatus()); + projectMapper.insert(project); + when(projectMemberService.hasProjectOwnerPermission(project.getId(), userId)).thenReturn(true); + + // 调用,并断言异常 + assertServiceException(() -> projectService.deleteProject(project.getId(), userId), PROJECT_STATUS_INVALID); + } + + @Test + public void testDeleteProject_ownerRequired() { + // mock 数据 + Long userId = randomLongId(); + PmsProjectDO project = randomProjectDO(PmsProjectStatusEnum.RECYCLED.getStatus()); + projectMapper.insert(project); + when(projectMemberService.hasProjectOwnerPermission(project.getId(), userId)).thenReturn(false); + + // 调用,并断言异常 + assertServiceException(() -> projectService.deleteProject(project.getId(), userId), PROJECT_OWNER_REQUIRED); + } + + @Test + public void testGetProjectOverview_success() { + // mock 数据 + Long projectId = randomLongId(); + Long userId = randomLongId(); + PmsWorkItemDO pendingWorkItem = randomWorkItemDO(userId, PmsWorkItemTypeEnum.TASK.getType(), + PmsWorkItemStatusTypeEnum.PENDING.getType(), 1); + PmsWorkItemDO processingWorkItem = randomWorkItemDO(userId, PmsWorkItemTypeEnum.DEFECT.getType(), + PmsWorkItemStatusTypeEnum.PROCESSING.getType(), 2); + PmsWorkItemDO completedWorkItem = randomWorkItemDO(randomLongId(), + PmsWorkItemTypeEnum.REQUIREMENT.getType(), PmsWorkItemStatusTypeEnum.COMPLETED.getType(), 3); + when(workItemService.getActiveWorkItemListByProjectId(projectId)) + .thenReturn(Arrays.asList(pendingWorkItem, processingWorkItem, completedWorkItem)); + + // 调用 + PmsProjectOverviewRespVO overview = projectService.getProjectOverview(projectId, userId); + + // 断言 + assertEquals(3L, overview.getTotalCount()); + assertEquals(1L, overview.getPendingCount()); + assertEquals(1L, overview.getProcessingCount()); + assertEquals(1L, overview.getCompletedCount()); + assertEquals(1L, overview.getTypeCountMap().get(PmsWorkItemTypeEnum.TASK.getType())); + assertEquals(14, overview.getCompletedTrends().size()); + assertEquals(2, overview.getAssignedWorkItems().size()); + verify(projectMemberService).validateProjectReadable(projectId, userId); + } + + // ========== 随机对象 ========== + + private static PmsProjectSaveReqVO randomProjectSaveReqVO() { + LocalDateTime startTime = LocalDateTime.now().plusDays(1); + return randomPojo(PmsProjectSaveReqVO.class, + reqVO -> reqVO.setId(null).setType(1).setMemberUserIds(emptyList()) + .setName("官网重构").setLevel(3).setDescription("测试项目").setOpenStatus(false) + .setIcon("ep:folder").setStartTime(startTime).setEndTime(startTime.plusMonths(1))); + } + + private static PmsProjectSaveReqVO randomProjectSaveReqVO(Long id) { + LocalDateTime startTime = LocalDateTime.now().plusDays(1); + return randomPojo(PmsProjectSaveReqVO.class, reqVO -> reqVO.setId(id).setType(1) + .setMemberUserIds(emptyList()).setName("新项目名称").setLevel(3) + .setDescription("更新描述").setOpenStatus(true).setIcon("ep:folder") + .setStartTime(startTime).setEndTime(startTime.plusMonths(1))); + } + + private static PmsProjectDO randomProjectDO(Integer status) { + return randomPojo(PmsProjectDO.class, project -> project.setName("测试项目").setStatus(status) + .setType(1).setLevel(3).setOpenStatus(false).setCreator(String.valueOf(randomLongId()))); + } + + private static PmsWorkItemDO randomWorkItemDO(Long assigneeUserId, Integer type, Integer status, Integer serial) { + PmsWorkItemDO workItem = randomPojo(PmsWorkItemDO.class, + item -> item.setId(randomLongId()).setAssigneeUserId(assigneeUserId) + .setType(type).setStatus(status).setStatusId(randomLongId()).setSerialNumber(serial) + .setName("测试工作项").setProgress(0).setSort(serial)); + workItem.setUpdateTime(LocalDateTime.now()); + return workItem; + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectTemplateServiceImplTest.java b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectTemplateServiceImplTest.java new file mode 100644 index 000000000..2bec6b0db --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/project/PmsProjectTemplateServiceImplTest.java @@ -0,0 +1,138 @@ +package cn.iocoder.yudao.module.pms.service.pm.project; + +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.pms.controller.admin.pm.project.vo.template.PmsProjectTemplateSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectTemplateDO; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.project.PmsProjectTemplateMapper; +import cn.iocoder.yudao.module.pms.enums.pm.project.PmsProjectTypeEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemStatusTypeEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemTypeEnum; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; + +import static cn.iocoder.yudao.framework.common.enums.CommonStatusEnum.ENABLE; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.PROJECT_TEMPLATE_CONFIG_INVALID; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.PROJECT_TEMPLATE_NAME_DUPLICATE; +import static java.util.Arrays.asList; +import static java.util.Collections.singletonList; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * {@link PmsProjectTemplateServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(PmsProjectTemplateServiceImpl.class) +public class PmsProjectTemplateServiceImplTest extends BaseDbUnitTest { + + @Resource + private PmsProjectTemplateServiceImpl projectTemplateService; + + @Resource + private PmsProjectTemplateMapper projectTemplateMapper; + + @Test + public void testCreateProjectTemplate_success() { + // 准备参数 + PmsProjectTemplateSaveReqVO reqVO = buildProjectTemplateSaveReqVO("通用项目模板-创建"); + + // 调用 + Long templateId = projectTemplateService.createProjectTemplate(reqVO); + + // 断言 + PmsProjectTemplateDO template = projectTemplateMapper.selectById(templateId); + assertNotNull(template); + assertEquals("通用项目模板-创建", template.getName()); + assertEquals(singletonList(PmsWorkItemTypeEnum.TASK.getType()), template.getItemTypes()); + assertEquals("task_todo", template.getStatuses().get(0).getCode()); + assertEquals("todo", template.getBoards().get(0).getCode()); + } + + @Test + public void testCreateProjectTemplate_defaultStatusMissing() { + // 准备参数 + PmsProjectTemplateSaveReqVO reqVO = buildProjectTemplateSaveReqVO("通用项目模板-缺少初始状态"); + reqVO.getStatuses().get(0).setDefaultStatus(false); + + // 调用,并断言异常 + assertServiceException(() -> projectTemplateService.createProjectTemplate(reqVO), + PROJECT_TEMPLATE_CONFIG_INVALID, "每种工作项必须且只能配置一个初始状态"); + } + + @Test + public void testCreateProjectTemplate_nameDuplicate() { + // mock 数据 + projectTemplateService.createProjectTemplate(buildProjectTemplateSaveReqVO("通用项目模板-重名")); + + // 准备参数 + PmsProjectTemplateSaveReqVO reqVO = buildProjectTemplateSaveReqVO("通用项目模板-重名"); + + // 调用,并断言异常 + assertServiceException(() -> projectTemplateService.createProjectTemplate(reqVO), + PROJECT_TEMPLATE_NAME_DUPLICATE); + } + + @Test + public void testUpdateProjectTemplate_success() { + // mock 数据 + Long templateId = projectTemplateService.createProjectTemplate( + buildProjectTemplateSaveReqVO("通用项目模板-修改前")); + + // 准备参数 + PmsProjectTemplateSaveReqVO reqVO = buildProjectTemplateSaveReqVO("通用项目模板-修改后") + .setId(templateId).setDescription("更新后的通用协作模板"); + + // 调用 + projectTemplateService.updateProjectTemplate(reqVO); + + // 断言 + PmsProjectTemplateDO template = projectTemplateMapper.selectById(templateId); + assertNotNull(template); + assertEquals("通用项目模板-修改后", template.getName()); + assertEquals("更新后的通用协作模板", template.getDescription()); + } + + @Test + public void testDeleteProjectTemplate_success() { + // mock 数据 + Long templateId = projectTemplateService.createProjectTemplate( + buildProjectTemplateSaveReqVO("通用项目模板-删除")); + + // 调用 + projectTemplateService.deleteProjectTemplate(templateId); + + // 断言 + assertNull(projectTemplateMapper.selectById(templateId)); + } + + /** + * 构建有效的通用项目模板保存参数 + * + * @return 项目模板保存参数 + */ + private PmsProjectTemplateSaveReqVO buildProjectTemplateSaveReqVO(String name) { + PmsProjectTemplateSaveReqVO.StatusTemplate todoStatus = new PmsProjectTemplateSaveReqVO.StatusTemplate() + .setCode("task_todo").setName("待处理").setWorkItemType(PmsWorkItemTypeEnum.TASK.getType()) + .setStatusType(PmsWorkItemStatusTypeEnum.PENDING.getType()).setDefaultStatus(true) + .setSort(10).setBoardCode("todo"); + PmsProjectTemplateSaveReqVO.StatusTemplate doneStatus = new PmsProjectTemplateSaveReqVO.StatusTemplate() + .setCode("task_done").setName("已完成").setWorkItemType(PmsWorkItemTypeEnum.TASK.getType()) + .setStatusType(PmsWorkItemStatusTypeEnum.COMPLETED.getType()).setDefaultStatus(false) + .setSort(20).setBoardCode("done"); + PmsProjectTemplateSaveReqVO.BoardTemplate todoBoard = new PmsProjectTemplateSaveReqVO.BoardTemplate() + .setCode("todo").setName("待处理").setWorkItemType(PmsWorkItemTypeEnum.TASK.getType()) + .setSort(10).setStatusCodes(singletonList("task_todo")); + PmsProjectTemplateSaveReqVO.BoardTemplate doneBoard = new PmsProjectTemplateSaveReqVO.BoardTemplate() + .setCode("done").setName("已完成").setWorkItemType(PmsWorkItemTypeEnum.TASK.getType()) + .setSort(20).setStatusCodes(singletonList("task_done")); + return new PmsProjectTemplateSaveReqVO().setName(name).setDescription("通用协作") + .setProjectType(PmsProjectTypeEnum.GENERAL.getType()).setStatus(ENABLE.getStatus()).setSort(10) + .setItemTypes(singletonList(PmsWorkItemTypeEnum.TASK.getType())) + .setStatuses(asList(todoStatus, doneStatus)).setBoards(asList(todoBoard, doneBoard)); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/workbench/PmsWorkbenchServiceImplTest.java b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/workbench/PmsWorkbenchServiceImplTest.java new file mode 100644 index 000000000..af9f16c62 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/workbench/PmsWorkbenchServiceImplTest.java @@ -0,0 +1,106 @@ +package cn.iocoder.yudao.module.pms.service.pm.workbench; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workbench.vo.PmsWorkbenchCountRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workbench.vo.PmsWorkbenchPageReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.iteration.PmsIterationDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemDO; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemStatusTypeEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemTypeEnum; +import cn.iocoder.yudao.module.pms.service.pm.iteration.PmsIterationService; +import cn.iocoder.yudao.module.pms.service.pm.project.PmsProjectMemberService; +import cn.iocoder.yudao.module.pms.service.pm.workitem.PmsWorkItemService; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +/** + * {@link PmsWorkbenchServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(PmsWorkbenchServiceImpl.class) +public class PmsWorkbenchServiceImplTest extends BaseDbUnitTest { + + @Resource + private PmsWorkbenchServiceImpl workbenchService; + @MockitoBean + private PmsProjectMemberService projectMemberService; + @MockitoBean + private PmsWorkItemService workItemService; + @MockitoBean + private PmsIterationService iterationService; + + @Test + public void testGetWorkItemPage_filtersOwnerAndCompleted() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = mockActiveProject(userId); + Long iterationId = randomLongId(); + LocalDateTime[] endTime = new LocalDateTime[]{LocalDateTime.now().minusDays(1), LocalDateTime.now()}; + PmsWorkbenchPageReqVO pageReqVO = new PmsWorkbenchPageReqVO().setProjectId(projectId) + .setStatus(PmsWorkItemStatusTypeEnum.PROCESSING.getType()).setPriority(2) + .setIterationId(iterationId).setEndTime(endTime); + when(workItemService.getAssignedWorkItemPage( + pageReqVO, Collections.singletonList(projectId), userId)) + .thenReturn(new PageResult<>(Collections.singletonList( + randomWorkItemDO(PmsWorkItemTypeEnum.TASK.getType(), PmsWorkItemStatusTypeEnum.PROCESSING.getType())), + 1L)); + + // 调用 + PageResult pageResult = workbenchService.getWorkbenchWorkItemPage( + pageReqVO, userId); + + // 断言 + assertEquals(1, pageResult.getTotal()); + } + + @Test + public void testGetCount_success() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = mockActiveProject(userId); + PmsWorkbenchPageReqVO pageReqVO = new PmsWorkbenchPageReqVO().setProjectId(projectId) + .setName("登录").setPriority(2); + Map typeCountMap = new HashMap<>(); + typeCountMap.put(PmsWorkItemTypeEnum.REQUIREMENT.getType(), 1L); + typeCountMap.put(PmsWorkItemTypeEnum.DEFECT.getType(), 1L); + when(workItemService.getAssignedWorkItemTypeCountMap(pageReqVO, + Collections.singletonList(projectId), userId)) + .thenReturn(typeCountMap); + when(iterationService.getUncompletedIterationCount(pageReqVO, + Collections.singletonList(projectId), userId)).thenReturn(1L); + + // 调用 + PmsWorkbenchCountRespVO count = workbenchService.getWorkbenchCount(pageReqVO, userId); + + // 断言 + assertEquals(1, count.getRequirementCount()); + assertEquals(1, count.getDefectCount()); + assertEquals(1, count.getIterationCount()); + } + + // ========== 随机对象 ========== + + private Long mockActiveProject(Long userId) { + Long projectId = randomLongId(); + when(projectMemberService.getActiveProjectIdListByUserId(userId)).thenReturn(Collections.singletonList(projectId)); + return projectId; + } + + private PmsWorkItemDO randomWorkItemDO(Integer type, Integer status) { + return new PmsWorkItemDO().setId(randomLongId()).setType(type).setStatus(status); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemActivityServiceImplTest.java b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemActivityServiceImplTest.java new file mode 100644 index 000000000..208d34865 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemActivityServiceImplTest.java @@ -0,0 +1,77 @@ +package cn.iocoder.yudao.module.pms.service.pm.workitem; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemActivityDO; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.workitem.PmsWorkItemActivityMapper; +import cn.iocoder.yudao.module.pms.service.pm.iteration.PmsIterationService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.List; + +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemActivityContentEnum.WORK_ITEM_CREATED; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * {@link PmsWorkItemActivityServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(PmsWorkItemActivityServiceImpl.class) +public class PmsWorkItemActivityServiceImplTest extends BaseDbUnitTest { + + @Resource + private PmsWorkItemActivityServiceImpl activityService; + + @Resource + private PmsWorkItemActivityMapper activityMapper; + + @MockitoBean + private PmsWorkItemLabelService workItemLabelService; + @MockitoBean + private PmsWorkItemService workItemService; + @MockitoBean + private PmsIterationService iterationService; + @MockitoBean + private AdminUserApi adminUserApi; + + @Test + public void testRecordAndGetActivity_success() { + // 准备参数 + Long projectId = randomLongId(); + Long workItemId = randomLongId(); + Long userId = randomLongId(); + + // 调用 + activityService.createWorkItemActivity(projectId, workItemId, userId, WORK_ITEM_CREATED); + List activities = activityService.getWorkItemActivityList(workItemId); + + // 断言 + assertEquals(1, activities.size()); + PmsWorkItemActivityDO activity = CollUtil.getFirst(activities); + assertEquals(projectId, activity.getProjectId()); + assertEquals(userId, activity.getOperatorUserId()); + assertEquals("创建了工作项", activity.getContent()); + } + + @Test + public void testDeleteWorkItemActivityListByProjectId_success() { + // mock 数据 + Long projectId = randomLongId(); + PmsWorkItemActivityDO activity = new PmsWorkItemActivityDO().setProjectId(projectId) + .setWorkItemId(randomLongId()).setOperatorUserId(randomLongId()).setContent("更新了工作项"); + activityMapper.insert(activity); + + // 调用 + activityService.deleteWorkItemActivityListByProjectId(projectId); + + // 断言 + assertEquals(0, activityService.getWorkItemActivityList(activity.getWorkItemId()).size()); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemCommentServiceImplTest.java b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemCommentServiceImplTest.java new file mode 100644 index 000000000..23345e4fb --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemCommentServiceImplTest.java @@ -0,0 +1,118 @@ +package cn.iocoder.yudao.module.pms.service.pm.workitem; + +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.comment.PmsWorkItemCommentSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemCommentDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemDO; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.workitem.PmsWorkItemCommentMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.WORK_ITEM_COMMENT_ACCESS_DENIED; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.WORK_ITEM_COMMENT_REPLY_INVALID; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +/** + * {@link PmsWorkItemCommentServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(PmsWorkItemCommentServiceImpl.class) +public class PmsWorkItemCommentServiceImplTest extends BaseDbUnitTest { + + @Resource + private PmsWorkItemCommentServiceImpl commentService; + + @Resource + private PmsWorkItemCommentMapper commentMapper; + + @MockitoBean + private PmsWorkItemService workItemService; + @MockitoBean + private PmsWorkItemActivityService workItemActivityService; + + @Test + public void testCreateCommentAndReply_success() { + // 准备参数 + Long workItemId = randomLongId(); + Long userId = randomLongId(); + when(workItemService.getWritableWorkItem(eq(workItemId), anyLong())) + .thenReturn(new PmsWorkItemDO().setId(workItemId).setProjectId(randomLongId())); + PmsWorkItemCommentSaveReqVO rootReqVO = new PmsWorkItemCommentSaveReqVO() + .setWorkItemId(workItemId).setContent(" 主评论 "); + + // 调用 + Long rootId = commentService.createWorkItemComment(rootReqVO, userId); + Long replyId = commentService.createWorkItemComment(new PmsWorkItemCommentSaveReqVO() + .setWorkItemId(workItemId).setMainId(rootId).setReplyUserId(userId).setContent("回复"), + randomLongId()); + + // 断言 + assertEquals(" 主评论 ", commentMapper.selectById(rootId).getContent()); + assertEquals(rootId, commentMapper.selectById(replyId).getMainId()); + assertEquals(2, commentService.getWorkItemCommentList(workItemId, userId).size()); + } + + @Test + public void testCreateComment_replyOtherWorkItem() { + // mock 数据 + PmsWorkItemCommentDO mainComment = randomCommentDO(randomLongId(), randomLongId(), 0L); + commentMapper.insert(mainComment); + Long workItemId = randomLongId(); + when(workItemService.getWritableWorkItem(eq(workItemId), anyLong())) + .thenReturn(new PmsWorkItemDO().setId(workItemId).setProjectId(randomLongId())); + + // 调用,并断言异常 + assertServiceException(() -> commentService.createWorkItemComment(new PmsWorkItemCommentSaveReqVO() + .setWorkItemId(workItemId).setMainId(mainComment.getId()).setContent("回复"), + randomLongId()), WORK_ITEM_COMMENT_REPLY_INVALID); + } + + @Test + public void testUpdateComment_notOwner() { + // mock 数据 + PmsWorkItemCommentDO comment = randomCommentDO(randomLongId(), randomLongId(), 0L); + commentMapper.insert(comment); + + // 调用,并断言异常 + assertServiceException(() -> commentService.updateWorkItemComment(new PmsWorkItemCommentSaveReqVO() + .setId(comment.getId()).setWorkItemId(comment.getWorkItemId()).setContent("修改"), + randomLongId()), WORK_ITEM_COMMENT_ACCESS_DENIED); + } + + @Test + public void testDeleteComment_deleteReplies() { + // mock 数据 + Long userId = randomLongId(); + PmsWorkItemCommentDO root = randomCommentDO(randomLongId(), userId, 0L); + commentMapper.insert(root); + PmsWorkItemCommentDO reply = randomCommentDO(root.getWorkItemId(), randomLongId(), root.getId()); + commentMapper.insert(reply); + when(workItemService.getWritableWorkItem(root.getWorkItemId(), userId)) + .thenReturn(new PmsWorkItemDO().setId(root.getWorkItemId()).setProjectId(randomLongId())); + + // 调用 + commentService.deleteWorkItemComment(root.getId(), userId); + + // 断言 + assertNull(commentMapper.selectById(root.getId())); + assertNull(commentMapper.selectById(reply.getId())); + } + + // ========== 随机对象 ========== + + private PmsWorkItemCommentDO randomCommentDO(Long workItemId, Long userId, Long mainId) { + return randomPojo(PmsWorkItemCommentDO.class, comment -> comment.setId(null).setWorkItemId(workItemId) + .setUserId(userId).setMainId(mainId).setContent("评论")); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemLabelServiceImplTest.java b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemLabelServiceImplTest.java new file mode 100644 index 000000000..3e61fbb24 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemLabelServiceImplTest.java @@ -0,0 +1,70 @@ +package cn.iocoder.yudao.module.pms.service.pm.workitem; + +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.label.PmsWorkItemLabelSaveReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemLabelDO; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.workitem.PmsWorkItemLabelMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; + +import java.util.Arrays; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.WORK_ITEM_LABELS_INVALID; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * {@link PmsWorkItemLabelServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(PmsWorkItemLabelServiceImpl.class) +public class PmsWorkItemLabelServiceImplTest extends BaseDbUnitTest { + + @Resource + private PmsWorkItemLabelServiceImpl labelService; + + @Resource + private PmsWorkItemLabelMapper labelMapper; + + @Test + public void testCreateWorkItemLabel_success() { + // 准备参数 + PmsWorkItemLabelSaveReqVO createReqVO = new PmsWorkItemLabelSaveReqVO() + .setName(" 紧急 ").setColor(" #F56C6C "); + + // 调用 + Long labelId = labelService.createWorkItemLabel(createReqVO); + + // 断言 + PmsWorkItemLabelDO label = labelMapper.selectById(labelId); + assertEquals("紧急", label.getName()); + assertEquals("#F56C6C", label.getColor()); + } + + @Test + public void testUpdateWorkItemLabel_success() { + // mock 数据 + PmsWorkItemLabelDO label = new PmsWorkItemLabelDO().setName("紧急").setColor("#F56C6C"); + labelMapper.insert(label); + // 准备参数 + PmsWorkItemLabelSaveReqVO reqVO = new PmsWorkItemLabelSaveReqVO().setId(label.getId()) + .setName("普通").setColor("#409EFF"); + + // 调用 + labelService.updateWorkItemLabel(reqVO); + + // 断言 + assertEquals("普通", labelMapper.selectById(label.getId()).getName()); + } + + @Test + public void testValidateLabelIds_notExists() { + // 调用,并断言异常 + assertServiceException(() -> labelService.validateWorkItemLabelIds( + Arrays.asList(randomLongId(), randomLongId())), WORK_ITEM_LABELS_INVALID); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemServiceImplTest.java b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemServiceImplTest.java new file mode 100644 index 000000000..0dfd682a8 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemServiceImplTest.java @@ -0,0 +1,1119 @@ +package cn.iocoder.yudao.module.pms.service.pm.workitem; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status.PmsWorkItemStatusUpdateReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemBoardReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemBoardRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemImportExcelVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemImportRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemIterationUpdateReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemNameUpdateReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemPageReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemPlanningSortReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemSaveReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemSortReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.iteration.PmsIterationDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemBoardDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemCommentDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemLabelDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemMemberDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemStatusDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemUserSortDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemWorkLogDO; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.workitem.PmsWorkItemBoardMapper; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.workitem.PmsWorkItemCommentMapper; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.workitem.PmsWorkItemMapper; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.workitem.PmsWorkItemMemberMapper; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.workitem.PmsWorkItemStatusMapper; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.workitem.PmsWorkItemUserSortMapper; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.workitem.PmsWorkItemWorkLogMapper; +import cn.iocoder.yudao.module.pms.enums.pm.project.PmsProjectStatusEnum; +import cn.iocoder.yudao.module.pms.enums.pm.project.PmsProjectTypeEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemDefectTypeEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemLifecycleStatusEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemPriorityEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemStatusTypeEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemTypeEnum; +import cn.iocoder.yudao.module.pms.service.pm.iteration.PmsIterationService; +import cn.iocoder.yudao.module.pms.service.pm.project.PmsProjectMemberService; +import cn.iocoder.yudao.module.system.api.notify.NotifyMessageSendApi; +import cn.iocoder.yudao.module.system.api.notify.dto.NotifySendSingleToUserReqDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.WORK_ITEM_DEFECT_TYPE_INVALID; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.WORK_ITEM_DELETE_STATUS_INVALID; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.WORK_ITEM_PARENT_INVALID; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.WORK_ITEM_SORT_INVALID; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.WORK_ITEM_TYPE_INVALID; +import static cn.iocoder.yudao.module.pms.enums.MessageTemplateConstants.WORK_ITEM_ASSIGNED; +import static cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemActivityContentEnum.WORK_ITEM_CREATED; +import static cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemActivityContentEnum.WORK_ITEM_NAME_UPDATED; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link PmsWorkItemServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import({PmsWorkItemServiceImpl.class, PmsWorkItemUserSortServiceImpl.class, PmsWorkItemStatusServiceImpl.class, + PmsWorkItemBoardServiceImpl.class, + PmsWorkItemCommentServiceImpl.class, PmsWorkItemWorkLogServiceImpl.class}) +public class PmsWorkItemServiceImplTest extends BaseDbUnitTest { + + @Resource + private PmsWorkItemServiceImpl workItemService; + + @Resource + private PmsWorkItemMapper workItemMapper; + @Resource + private PmsWorkItemStatusMapper workItemStatusMapper; + @Resource + private PmsWorkItemBoardMapper workItemBoardMapper; + @Resource + private PmsWorkItemMemberMapper workItemMemberMapper; + @Resource + private PmsWorkItemCommentMapper workItemCommentMapper; + @Resource + private PmsWorkItemWorkLogMapper workItemWorkLogMapper; + @Resource + private PmsWorkItemUserSortMapper workItemUserSortMapper; + + @MockitoBean + private PmsProjectMemberService projectMemberService; + @MockitoBean + private PmsIterationService iterationService; + @MockitoBean + private PmsWorkItemLabelService workItemLabelService; + @MockitoBean + private PmsWorkItemActivityService workItemActivityService; + @Resource + private PmsWorkItemStatusServiceImpl workItemStatusService; + + @MockitoBean + private NotifyMessageSendApi notifyMessageSendApi; + @MockitoBean + private AdminUserApi adminUserApi; + + @Test + public void testCreateWorkItem_success() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + PmsProjectDO project = randomProjectDO(projectId, PmsProjectTypeEnum.AGILE.getType()); + when(projectMemberService.validateProjectWritable(projectId, userId)).thenReturn(project); + workItemStatusService.initProjectWorkItemStatuses(projectId, project.getType()); + PmsWorkItemSaveReqVO reqVO = randomSaveReqVO(projectId, PmsWorkItemTypeEnum.TASK.getType()); + reqVO.setAssigneeUserId(randomLongId()).setMemberUserIds(Arrays.asList(randomLongId(), randomLongId())) + .setFileUrls(Arrays.asList("https://example.com/a.png", "https://example.com/b.pdf")); + + // 调用 + Long workItemId = workItemService.createWorkItem(reqVO, userId); + + // 断言 + PmsWorkItemDO workItem = workItemMapper.selectById(workItemId); + assertNotNull(workItem); + assertEquals(reqVO.getName(), workItem.getName()); + assertEquals(1, workItem.getSerialNumber()); + assertEquals(PmsWorkItemStatusTypeEnum.PENDING.getType(), workItem.getStatus()); + assertEquals(reqVO.getFileUrls(), workItem.getFileUrls()); + assertEquals(2, workItemMemberMapper.selectListByWorkItemIds(Collections.singleton(workItemId)).size()); + assertEquals(3, workItemStatusMapper.selectListByProjectIdAndWorkItemType(projectId, + PmsWorkItemTypeEnum.TASK.getType()).size()); + verify(projectMemberService).validateProjectMemberList(eq(projectId), anySet()); + ArgumentCaptor captor = ArgumentCaptor + .forClass(NotifySendSingleToUserReqDTO.class); + verify(notifyMessageSendApi).sendSingleMessageToAdmin(captor.capture()); + assertEquals(reqVO.getAssigneeUserId(), captor.getValue().getUserId()); + assertEquals(WORK_ITEM_ASSIGNED, captor.getValue().getTemplateCode()); + assertEquals(project.getName(), captor.getValue().getTemplateParams().get("projectName")); + assertEquals("/pms/pm/project/detail/" + projectId + "?tabs=task", + captor.getValue().getTemplateParams().get("route")); + } + + @Test + public void testCreateWorkItem_withChildrenAndWorkLog() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + when(projectMemberService.validateProjectWritable(projectId, userId)) + .thenReturn(randomProjectDO(projectId, PmsProjectTypeEnum.AGILE.getType())); + workItemStatusService.initProjectWorkItemStatuses(projectId, PmsProjectTypeEnum.AGILE.getType()); + // 准备参数 + PmsWorkItemSaveReqVO reqVO = randomSaveReqVO(projectId, PmsWorkItemTypeEnum.TASK.getType()) + .setChildWorkItemNames(Arrays.asList("接口联调", "页面联调")) + .setActualHours(2).setRemainingHours(6); + + // 调用 + Long workItemId = workItemService.createWorkItem(reqVO, userId); + + // 断言 + PmsWorkItemDO parent = workItemMapper.selectById(workItemId); + List children = workItemMapper.selectList(new PmsWorkItemPageReqVO() + .setProjectId(projectId).setParentId(workItemId)); + assertEquals(2, children.size()); + assertTrue(children.stream().allMatch(child -> child.getStatusId().equals(parent.getStatusId()))); + List workLogs = workItemWorkLogMapper.selectListByWorkItemId(workItemId); + assertEquals(1, workLogs.size()); + assertEquals(2, workLogs.get(0).getActualHours()); + assertEquals(6, workLogs.get(0).getRemainingHours()); + } + + @Test + public void testCreateWorkItem_generalProjectTypeInvalid() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + when(projectMemberService.validateProjectWritable(projectId, userId)) + .thenReturn(randomProjectDO(projectId, PmsProjectTypeEnum.GENERAL.getType())); + // 准备参数 + PmsWorkItemSaveReqVO reqVO = randomSaveReqVO(projectId, PmsWorkItemTypeEnum.REQUIREMENT.getType()); + + // 调用,并断言异常 + assertServiceException(() -> workItemService.createWorkItem(reqVO, userId), WORK_ITEM_TYPE_INVALID); + } + + @Test + public void testCreateWorkItem_defectTypeRequired() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + when(projectMemberService.validateProjectWritable(projectId, userId)) + .thenReturn(randomProjectDO(projectId, PmsProjectTypeEnum.AGILE.getType())); + // 准备参数 + PmsWorkItemSaveReqVO reqVO = randomSaveReqVO(projectId, PmsWorkItemTypeEnum.DEFECT.getType()); + + // 调用,并断言异常 + assertServiceException(() -> workItemService.createWorkItem(reqVO, userId), WORK_ITEM_DEFECT_TYPE_INVALID); + } + + @Test + public void testUpdateWorkItem_parentCycle() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + PmsWorkItemStatusDO status = randomStatusDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), + PmsWorkItemStatusTypeEnum.PENDING.getType(), true, 1); + workItemStatusMapper.insert(status); + PmsWorkItemDO parent = randomWorkItemDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), status, 1); + workItemMapper.insert(parent); + PmsWorkItemDO child = randomWorkItemDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), status, 2) + .setParentId(parent.getId()); + workItemMapper.insert(child); + when(projectMemberService.validateProjectWritable(projectId, userId)) + .thenReturn(randomProjectDO(projectId, PmsProjectTypeEnum.AGILE.getType())); + // 准备参数 + PmsWorkItemSaveReqVO reqVO = randomSaveReqVO(parent); + reqVO.setParentId(child.getId()); + + // 调用,并断言异常 + assertServiceException(() -> workItemService.updateWorkItem(reqVO, userId), WORK_ITEM_PARENT_INVALID); + } + + @Test + public void testUpdateWorkItem_clearOptionalFields() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + PmsWorkItemStatusDO status = randomStatusDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), + PmsWorkItemStatusTypeEnum.PENDING.getType(), true, 1); + workItemStatusMapper.insert(status); + PmsWorkItemDO parent = randomWorkItemDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), status, 1); + workItemMapper.insert(parent); + PmsWorkItemDO workItem = randomWorkItemDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), status, 2) + .setDescription("旧描述").setAssigneeUserId(randomLongId()).setParentId(parent.getId()) + .setStartTime(LocalDateTime.now()).setEndTime(LocalDateTime.now().plusDays(1)) + .setEstimatedHours(8).setFileUrls(Collections.singletonList("https://example.com/a.png")); + workItemMapper.insert(workItem); + when(projectMemberService.validateProjectWritable(projectId, userId)) + .thenReturn(randomProjectDO(projectId, PmsProjectTypeEnum.AGILE.getType())); + // 准备参数 + PmsWorkItemSaveReqVO reqVO = randomSaveReqVO(workItem); + reqVO.setDescription(null).setAssigneeUserId(null).setParentId(null) + .setStartTime(null).setEndTime(null).setEstimatedHours(null).setFileUrls(null); + + // 调用 + workItemService.updateWorkItem(reqVO, userId); + + // 断言 + PmsWorkItemDO updatedWorkItem = workItemMapper.selectById(workItem.getId()); + assertNull(updatedWorkItem.getDescription()); + assertNull(updatedWorkItem.getAssigneeUserId()); + assertNull(updatedWorkItem.getParentId()); + assertNull(updatedWorkItem.getStartTime()); + assertNull(updatedWorkItem.getEndTime()); + assertNull(updatedWorkItem.getEstimatedHours()); + assertNull(updatedWorkItem.getFileUrls()); + } + + @Test + public void testUpdateWorkItem_assigneeChangedNotify() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + Long oldAssigneeUserId = randomLongId(); + Long newAssigneeUserId = randomLongId(); + PmsProjectDO project = randomProjectDO(projectId, PmsProjectTypeEnum.AGILE.getType()); + PmsWorkItemStatusDO status = randomStatusDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), + PmsWorkItemStatusTypeEnum.PENDING.getType(), true, 1); + workItemStatusMapper.insert(status); + PmsWorkItemDO workItem = randomWorkItemDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), status, 1) + .setAssigneeUserId(oldAssigneeUserId); + workItemMapper.insert(workItem); + when(projectMemberService.validateProjectWritable(projectId, userId)).thenReturn(project); + // 准备参数 + PmsWorkItemSaveReqVO reqVO = randomSaveReqVO(workItem).setAssigneeUserId(newAssigneeUserId); + + // 调用 + workItemService.updateWorkItem(reqVO, userId); + + // 断言 + ArgumentCaptor captor = ArgumentCaptor + .forClass(NotifySendSingleToUserReqDTO.class); + verify(notifyMessageSendApi).sendSingleMessageToAdmin(captor.capture()); + assertEquals(newAssigneeUserId, captor.getValue().getUserId()); + assertEquals(reqVO.getName(), captor.getValue().getTemplateParams().get("workItemName")); + } + + @Test + public void testUpdateWorkItemName_success() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + PmsWorkItemStatusDO status = randomStatusDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), + PmsWorkItemStatusTypeEnum.PENDING.getType(), true, 1); + workItemStatusMapper.insert(status); + PmsWorkItemDO workItem = randomWorkItemDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), status, 1); + workItemMapper.insert(workItem); + when(projectMemberService.validateProjectWritable(projectId, userId)) + .thenReturn(randomProjectDO(projectId, PmsProjectTypeEnum.AGILE.getType())); + PmsWorkItemNameUpdateReqVO reqVO = new PmsWorkItemNameUpdateReqVO().setId(workItem.getId()) + .setName(" 子任务新名称 "); + + // 调用 + workItemService.updateWorkItemName(reqVO, userId); + + // 断言 + assertEquals("子任务新名称", workItemMapper.selectById(workItem.getId()).getName()); + verify(workItemActivityService).createWorkItemActivity( + projectId, workItem.getId(), userId, WORK_ITEM_NAME_UPDATED, + workItem.getName(), "子任务新名称"); + } + + @Test + public void testUpdateWorkItem_createFieldActivitiesDelegates() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + Long oldAssigneeUserId = randomLongId(); + Long newAssigneeUserId = randomLongId(); + Long oldMemberUserId = randomLongId(); + Long newMemberUserId = randomLongId(); + Long oldLabelId = randomLongId(); + Long newLabelId = randomLongId(); + PmsWorkItemStatusDO status = randomStatusDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), + PmsWorkItemStatusTypeEnum.PENDING.getType(), true, 1); + workItemStatusMapper.insert(status); + PmsWorkItemDO workItem = randomWorkItemDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), status, 1) + .setDescription("旧描述").setAssigneeUserId(oldAssigneeUserId) + .setFileUrls(Collections.singletonList("https://example.com/old.png")) + .setLabelIds(Collections.singletonList(oldLabelId)); + workItemMapper.insert(workItem); + workItemMemberMapper.insert(new PmsWorkItemMemberDO().setProjectId(projectId) + .setWorkItemId(workItem.getId()).setUserId(oldMemberUserId)); + when(projectMemberService.validateProjectWritable(projectId, userId)) + .thenReturn(randomProjectDO(projectId, PmsProjectTypeEnum.AGILE.getType())); + Map userMap = new HashMap<>(); + userMap.put(oldAssigneeUserId, new AdminUserRespDTO().setId(oldAssigneeUserId).setNickname("旧负责人")); + userMap.put(newAssigneeUserId, new AdminUserRespDTO().setId(newAssigneeUserId).setNickname("新负责人")); + userMap.put(oldMemberUserId, new AdminUserRespDTO().setId(oldMemberUserId).setNickname("旧参与人")); + userMap.put(newMemberUserId, new AdminUserRespDTO().setId(newMemberUserId).setNickname("新参与人")); + when(adminUserApi.getUserMap(anySet())).thenReturn(userMap); + Map labelMap = new HashMap<>(); + labelMap.put(oldLabelId, new PmsWorkItemLabelDO().setId(oldLabelId).setName("旧标签")); + labelMap.put(newLabelId, new PmsWorkItemLabelDO().setId(newLabelId).setName("新标签")); + when(workItemLabelService.getWorkItemLabelMap(anySet())).thenReturn(labelMap); + // 准备参数 + PmsWorkItemSaveReqVO reqVO = randomSaveReqVO(workItem).setDescription("新描述") + .setPriority(PmsWorkItemPriorityEnum.HIGH.getPriority()).setAssigneeUserId(newAssigneeUserId) + .setMemberUserIds(Collections.singletonList(newMemberUserId)) + .setFileUrls(Collections.singletonList("https://example.com/new.pdf")) + .setLabelIds(Collections.singletonList(newLabelId)); + + // 调用 + workItemService.updateWorkItem(reqVO, userId); + + // 断言 + verify(workItemActivityService).createWorkItemUpdateActivities(eq(workItem), any(PmsWorkItemDO.class), + anyCollection(), anyCollection(), eq(userId)); + } + + @Test + public void testUpdateWorkItemStatus_success() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + PmsWorkItemStatusDO pendingStatus = randomStatusDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), + PmsWorkItemStatusTypeEnum.PENDING.getType(), true, 1); + workItemStatusMapper.insert(pendingStatus); + PmsWorkItemStatusDO completedStatus = randomStatusDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), + PmsWorkItemStatusTypeEnum.COMPLETED.getType(), false, 2); + workItemStatusMapper.insert(completedStatus); + PmsWorkItemDO workItem = randomWorkItemDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), pendingStatus, 1); + workItemMapper.insert(workItem); + when(projectMemberService.validateProjectWritable(projectId, userId)) + .thenReturn(randomProjectDO(projectId, PmsProjectTypeEnum.AGILE.getType())); + // 准备参数 + PmsWorkItemStatusUpdateReqVO reqVO = new PmsWorkItemStatusUpdateReqVO().setId(workItem.getId()) + .setStatusId(completedStatus.getId()); + + // 调用 + workItemService.updateWorkItemStatus(reqVO, userId); + + // 断言 + PmsWorkItemDO updatedWorkItem = workItemMapper.selectById(workItem.getId()); + assertEquals(completedStatus.getId(), updatedWorkItem.getStatusId()); + assertEquals(PmsWorkItemStatusTypeEnum.COMPLETED.getType(), updatedWorkItem.getStatus()); + assertEquals(0, updatedWorkItem.getProgress()); + } + + @Test + public void testUpdateWorkItemIteration_planAndUnplan() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + Long iterationId = randomLongId(); + PmsWorkItemStatusDO status = randomStatusDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), + PmsWorkItemStatusTypeEnum.PENDING.getType(), true, 1); + workItemStatusMapper.insert(status); + PmsWorkItemDO workItem = randomWorkItemDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), status, 1); + workItemMapper.insert(workItem); + when(projectMemberService.validateProjectWritable(projectId, userId)) + .thenReturn(randomProjectDO(projectId, PmsProjectTypeEnum.AGILE.getType())); + when(iterationService.getIteration(iterationId, userId)) + .thenReturn(new PmsIterationDO().setId(iterationId).setProjectId(projectId)); + + // 调用:规划到迭代 + workItemService.updateWorkItemIteration(new PmsWorkItemIterationUpdateReqVO() + .setId(workItem.getId()).setIterationId(iterationId), userId); + + // 断言 + assertEquals(iterationId, workItemMapper.selectById(workItem.getId()).getIterationId()); + + // 调用:移回待规划,并断言 + workItemService.updateWorkItemIteration(new PmsWorkItemIterationUpdateReqVO() + .setId(workItem.getId()), userId); + assertNull(workItemMapper.selectById(workItem.getId()).getIterationId()); + } + + @Test + public void testCreateWorkItem_emptyMembersDefaultsToCreator() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + when(projectMemberService.validateProjectWritable(projectId, userId)) + .thenReturn(randomProjectDO(projectId, PmsProjectTypeEnum.AGILE.getType())); + workItemStatusService.initProjectWorkItemStatuses(projectId, PmsProjectTypeEnum.AGILE.getType()); + // 准备参数 + PmsWorkItemSaveReqVO reqVO = randomSaveReqVO(projectId, PmsWorkItemTypeEnum.TASK.getType()); + + // 调用 + Long workItemId = workItemService.createWorkItem(reqVO, userId); + + // 断言 + List members = workItemMemberMapper + .selectListByWorkItemIds(Collections.singleton(workItemId)); + assertEquals(1, members.size()); + assertEquals(userId, CollUtil.getFirst(members).getUserId()); + } + + @Test + public void testUpdateWorkItemSort_success() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + PmsWorkItemStatusDO status = randomStatusDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), + PmsWorkItemStatusTypeEnum.PENDING.getType(), true, 1); + workItemStatusMapper.insert(status); + PmsWorkItemDO first = randomWorkItemDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), status, 1); + workItemMapper.insert(first); + PmsWorkItemDO second = randomWorkItemDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), status, 2); + workItemMapper.insert(second); + when(projectMemberService.validateProjectWritable(projectId, userId)) + .thenReturn(randomProjectDO(projectId, PmsProjectTypeEnum.AGILE.getType())); + // 准备参数 + PmsWorkItemSortReqVO reqVO = new PmsWorkItemSortReqVO().setStatusId(status.getId()) + .setWorkItemIds(Arrays.asList(second.getId(), first.getId())); + + // 调用 + workItemService.updateWorkItemSort(reqVO, userId); + + // 断言 + assertEquals(1, workItemMapper.selectById(second.getId()).getSort()); + assertEquals(2, workItemMapper.selectById(first.getId()).getSort()); + } + + @Test + public void testUpdateWorkItemSort_duplicateId() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + PmsWorkItemStatusDO status = randomStatusDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), + PmsWorkItemStatusTypeEnum.PENDING.getType(), true, 1); + workItemStatusMapper.insert(status); + PmsWorkItemDO workItem = randomWorkItemDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), status, 1); + workItemMapper.insert(workItem); + when(projectMemberService.validateProjectWritable(projectId, userId)) + .thenReturn(randomProjectDO(projectId, PmsProjectTypeEnum.AGILE.getType())); + // 准备参数 + PmsWorkItemSortReqVO reqVO = new PmsWorkItemSortReqVO().setStatusId(status.getId()) + .setWorkItemIds(Arrays.asList(workItem.getId(), workItem.getId())); + + // 调用,并断言异常 + assertServiceException(() -> workItemService.updateWorkItemSort(reqVO, userId), WORK_ITEM_SORT_INVALID); + } + + @Test + public void testUpdateWorkItemPlanningSort_success() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + PmsWorkItemStatusDO taskStatus = randomStatusDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), + PmsWorkItemStatusTypeEnum.PENDING.getType(), true, 1); + workItemStatusMapper.insert(taskStatus); + PmsWorkItemStatusDO defectStatus = randomStatusDO(projectId, PmsWorkItemTypeEnum.DEFECT.getType(), + PmsWorkItemStatusTypeEnum.PENDING.getType(), true, 1); + workItemStatusMapper.insert(defectStatus); + PmsWorkItemDO first = randomWorkItemDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), taskStatus, 1); + workItemMapper.insert(first); + PmsWorkItemDO second = randomWorkItemDO(projectId, PmsWorkItemTypeEnum.DEFECT.getType(), defectStatus, 2); + workItemMapper.insert(second); + when(projectMemberService.validateProjectWritable(projectId, userId)) + .thenReturn(randomProjectDO(projectId, PmsProjectTypeEnum.AGILE.getType())); + // 准备参数 + PmsWorkItemPlanningSortReqVO reqVO = new PmsWorkItemPlanningSortReqVO().setProjectId(projectId) + .setWorkItemIds(Arrays.asList(second.getId(), first.getId())); + + // 调用 + workItemService.updateWorkItemPlanningSort(reqVO, userId); + workItemService.updateWorkItemPlanningSort(reqVO, userId); + + // 断言:公共顺序不变,个人顺序按当前用户单独保存 + assertEquals(1, workItemMapper.selectById(first.getId()).getSort()); + assertEquals(2, workItemMapper.selectById(second.getId()).getSort()); + List userSorts = workItemUserSortMapper + .selectListByProjectIdAndUserId(projectId, userId); + assertEquals(Arrays.asList(second.getId(), first.getId()), + convertList(userSorts, PmsWorkItemUserSortDO::getWorkItemId)); + + // 断言:当前用户和其他用户读取到的 Backlog 顺序互不影响 + Long otherUserId = randomLongId(); + when(projectMemberService.validateProjectReadable(projectId, userId)) + .thenReturn(randomProjectDO(projectId, PmsProjectTypeEnum.AGILE.getType())); + when(projectMemberService.validateProjectReadable(projectId, otherUserId)) + .thenReturn(randomProjectDO(projectId, PmsProjectTypeEnum.AGILE.getType())); + PmsWorkItemPageReqVO pageReqVO = new PmsWorkItemPageReqVO().setProjectId(projectId) + .setPlanningOnly(true).setUnplannedOnly(true); + assertEquals(Arrays.asList(second.getId(), first.getId()), + convertList(workItemService.getWorkItemPage(pageReqVO, userId).getList(), PmsWorkItemDO::getId)); + assertEquals(Arrays.asList(first.getId(), second.getId()), + convertList(workItemService.getWorkItemPage(pageReqVO, otherUserId).getList(), PmsWorkItemDO::getId)); + } + + @Test + public void testDeleteWorkItem_requirementCleanup() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + PmsWorkItemStatusDO requirementStatus = randomStatusDO(projectId, PmsWorkItemTypeEnum.REQUIREMENT.getType(), + PmsWorkItemStatusTypeEnum.PENDING.getType(), true, 1); + workItemStatusMapper.insert(requirementStatus); + PmsWorkItemStatusDO defectStatus = randomStatusDO(projectId, PmsWorkItemTypeEnum.DEFECT.getType(), + PmsWorkItemStatusTypeEnum.PENDING.getType(), true, 1); + workItemStatusMapper.insert(defectStatus); + PmsWorkItemDO requirement = randomWorkItemDO(projectId, PmsWorkItemTypeEnum.REQUIREMENT.getType(), + requirementStatus, 1).setLifecycleStatus(PmsWorkItemLifecycleStatusEnum.RECYCLED.getStatus()); + workItemMapper.insert(requirement); + PmsWorkItemDO defect = randomWorkItemDO(projectId, PmsWorkItemTypeEnum.DEFECT.getType(), defectStatus, 2) + .setRelatedRequirementId(requirement.getId()).setDefectType(PmsWorkItemDefectTypeEnum.CODE.getType()); + workItemMapper.insert(defect); + workItemMemberMapper.insert(new PmsWorkItemMemberDO().setProjectId(projectId) + .setWorkItemId(requirement.getId()).setUserId(randomLongId())); + PmsWorkItemWorkLogDO workLog = new PmsWorkItemWorkLogDO().setProjectId(projectId) + .setWorkItemId(requirement.getId()).setActualHours(2); + workItemWorkLogMapper.insert(workLog); + when(projectMemberService.validateProjectWritable(projectId, userId)) + .thenReturn(randomProjectDO(projectId, PmsProjectTypeEnum.AGILE.getType())); + + // 调用 + workItemService.deleteWorkItem(requirement.getId(), userId); + + // 断言 + assertNull(workItemMapper.selectById(requirement.getId())); + assertNull(workItemMapper.selectById(defect.getId()).getRelatedRequirementId()); + assertEquals(0, workItemMemberMapper.selectListByWorkItemIds( + Collections.singleton(requirement.getId())).size()); + assertNull(workItemWorkLogMapper.selectById(workLog.getId())); + } + + @Test + public void testArchiveAndRestoreWorkItem_success() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + PmsWorkItemStatusDO status = randomStatusDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), + PmsWorkItemStatusTypeEnum.PENDING.getType(), true, 1); + workItemStatusMapper.insert(status); + PmsWorkItemDO workItem = randomWorkItemDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), status, 1); + workItemMapper.insert(workItem); + when(projectMemberService.validateProjectWritable(projectId, userId)) + .thenReturn(randomProjectDO(projectId, PmsProjectTypeEnum.AGILE.getType())); + + // 调用:归档 + workItemService.archiveWorkItem(workItem.getId(), userId); + + // 断言 + PmsWorkItemDO archivedWorkItem = workItemMapper.selectById(workItem.getId()); + assertEquals(PmsWorkItemLifecycleStatusEnum.ARCHIVED.getStatus(), archivedWorkItem.getLifecycleStatus()); + assertNotNull(archivedWorkItem.getArchiveTime()); + + // 调用:恢复,并断言 + workItemService.restoreWorkItem(workItem.getId(), userId); + PmsWorkItemDO restoredWorkItem = workItemMapper.selectById(workItem.getId()); + assertEquals(PmsWorkItemLifecycleStatusEnum.ACTIVE.getStatus(), restoredWorkItem.getLifecycleStatus()); + assertNull(restoredWorkItem.getArchiveTime()); + assertNull(restoredWorkItem.getRecycleTime()); + } + + @Test + public void testRecycleAndDeleteWorkItem_success() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + PmsWorkItemStatusDO status = randomStatusDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), + PmsWorkItemStatusTypeEnum.PENDING.getType(), true, 1); + workItemStatusMapper.insert(status); + PmsWorkItemDO workItem = randomWorkItemDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), status, 1); + workItemMapper.insert(workItem); + when(projectMemberService.validateProjectWritable(projectId, userId)) + .thenReturn(randomProjectDO(projectId, PmsProjectTypeEnum.AGILE.getType())); + + // 调用:移入回收站 + workItemService.recycleWorkItem(workItem.getId(), userId); + + // 断言 + PmsWorkItemDO recycledWorkItem = workItemMapper.selectById(workItem.getId()); + assertEquals(PmsWorkItemLifecycleStatusEnum.RECYCLED.getStatus(), recycledWorkItem.getLifecycleStatus()); + assertNotNull(recycledWorkItem.getRecycleTime()); + + // 调用:彻底删除,并断言 + workItemService.deleteWorkItem(workItem.getId(), userId); + assertNull(workItemMapper.selectById(workItem.getId())); + } + + @Test + public void testDeleteWorkItem_activeStatusInvalid() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + PmsWorkItemStatusDO status = randomStatusDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), + PmsWorkItemStatusTypeEnum.PENDING.getType(), true, 1); + workItemStatusMapper.insert(status); + PmsWorkItemDO workItem = randomWorkItemDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), status, 1); + workItemMapper.insert(workItem); + + // 调用,并断言异常 + assertServiceException(() -> workItemService.deleteWorkItem(workItem.getId(), userId), + WORK_ITEM_DELETE_STATUS_INVALID); + } + + @Test + public void testDeleteWorkItemListByProjectId_cleanupComments() { + // mock 数据 + Long projectId = randomLongId(); + PmsWorkItemStatusDO status = randomStatusDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), + PmsWorkItemStatusTypeEnum.PENDING.getType(), true, 1); + workItemStatusMapper.insert(status); + PmsWorkItemDO workItem = randomWorkItemDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), status, 1); + workItemMapper.insert(workItem); + PmsWorkItemCommentDO comment = new PmsWorkItemCommentDO().setWorkItemId(workItem.getId()) + .setUserId(randomLongId()).setContent("项目删除时清理评论"); + workItemCommentMapper.insert(comment); + + // 调用 + workItemService.deleteWorkItemListByProjectId(projectId); + + // 断言 + assertNull(workItemMapper.selectById(workItem.getId())); + assertNull(workItemCommentMapper.selectById(comment.getId())); + } + + @Test + public void testGetWorkItemBoard_keepsEmptyStatus() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + PmsWorkItemStatusDO pendingStatus = randomStatusDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), + PmsWorkItemStatusTypeEnum.PENDING.getType(), true, 1).setBoardName("状态1"); + workItemStatusMapper.insert(pendingStatus); + workItemStatusMapper.insert(randomStatusDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), + PmsWorkItemStatusTypeEnum.PROCESSING.getType(), false, 2).setBoardName("状态2")); + workItemBoardMapper.insert(new PmsWorkItemBoardDO().setProjectId(projectId) + .setWorkItemType(PmsWorkItemTypeEnum.TASK.getType()).setName("状态1").setSort(1)); + workItemBoardMapper.insert(new PmsWorkItemBoardDO().setProjectId(projectId) + .setWorkItemType(PmsWorkItemTypeEnum.TASK.getType()).setName("状态2").setSort(2)); + PmsWorkItemDO parent = randomWorkItemDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), pendingStatus, 1); + workItemMapper.insert(parent); + PmsWorkItemDO child = randomWorkItemDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), pendingStatus, 2) + .setParentId(parent.getId()); + workItemMapper.insert(child); + when(projectMemberService.validateProjectReadable(projectId, userId)) + .thenReturn(randomProjectDO(projectId, PmsProjectTypeEnum.AGILE.getType())); + // 准备参数 + PmsWorkItemBoardReqVO reqVO = new PmsWorkItemBoardReqVO(); + reqVO.setProjectId(projectId); + reqVO.setType(PmsWorkItemTypeEnum.TASK.getType()); + + // 调用 + List board = workItemService.getWorkItemBoard(reqVO, userId); + + // 断言 + assertEquals(2, board.size()); + assertEquals(1, CollUtil.getFirst(board).getItems().size()); + assertEquals(parent.getId(), CollUtil.getFirst(CollUtil.getFirst(board).getItems()).getId()); + PmsWorkItemBoardRespVO emptyBoard = CollUtil.findOne(board, column -> "状态2".equals(column.getName())); + assertNotNull(emptyBoard); + assertEquals(0, emptyBoard.getItems().size()); + } + + @Test + public void testGetWorkItemBoard_groupsStatusesByBoardName() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + PmsWorkItemStatusDO developingStatus = randomStatusDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), + PmsWorkItemStatusTypeEnum.PROCESSING.getType(), false, 1).setBoardName("处理中"); + workItemStatusMapper.insert(developingStatus); + PmsWorkItemStatusDO testingStatus = randomStatusDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), + PmsWorkItemStatusTypeEnum.PROCESSING.getType(), false, 2).setBoardName("处理中"); + workItemStatusMapper.insert(testingStatus); + workItemMapper.insert(randomWorkItemDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), developingStatus, 1)); + workItemMapper.insert(randomWorkItemDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), testingStatus, 2)); + when(projectMemberService.validateProjectReadable(projectId, userId)) + .thenReturn(randomProjectDO(projectId, PmsProjectTypeEnum.AGILE.getType())); + workItemBoardMapper.insert(new PmsWorkItemBoardDO().setProjectId(projectId) + .setWorkItemType(PmsWorkItemTypeEnum.TASK.getType()).setName("处理中").setSort(1)); + + // 调用 + PmsWorkItemBoardReqVO reqVO = new PmsWorkItemBoardReqVO(); + reqVO.setProjectId(projectId); + reqVO.setType(PmsWorkItemTypeEnum.TASK.getType()); + List board = workItemService.getWorkItemBoard(reqVO, userId); + + // 断言 + assertEquals(1, board.size()); + PmsWorkItemBoardRespVO processingBoard = CollUtil.findOne(board, + column -> "处理中".equals(column.getName())); + assertNotNull(processingBoard); + assertEquals(2, processingBoard.getStatuses().size()); + assertEquals(2, processingBoard.getItems().size()); + } + + @Test + public void testGetWorkItemPage_success() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + PmsWorkItemStatusDO status = randomStatusDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), + PmsWorkItemStatusTypeEnum.PENDING.getType(), true, 1); + workItemStatusMapper.insert(status); + PmsWorkItemDO matched = randomWorkItemDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), status, 1) + .setName("登录任务"); + workItemMapper.insert(matched); + workItemMapper.insert(randomWorkItemDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), status, 2) + .setName("其他任务")); + when(projectMemberService.validateProjectReadable(projectId, userId)) + .thenReturn(randomProjectDO(projectId, PmsProjectTypeEnum.AGILE.getType())); + // 准备参数 + PmsWorkItemPageReqVO reqVO = new PmsWorkItemPageReqVO().setProjectId(projectId) + .setType(PmsWorkItemTypeEnum.TASK.getType()).setName("登录"); + + // 调用 + PageResult pageResult = workItemService.getWorkItemPage(reqVO, userId); + + // 断言 + assertEquals(1, pageResult.getTotal()); + assertEquals(matched.getId(), CollUtil.getFirst(pageResult.getList()).getId()); + } + + @Test + public void testGetWorkItemPage_orderByPriorityAndSerialNumber() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + PmsWorkItemStatusDO status = randomStatusDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), + PmsWorkItemStatusTypeEnum.PENDING.getType(), true, 1); + workItemStatusMapper.insert(status); + PmsWorkItemDO mediumWorkItem = randomWorkItemDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), status, 1) + .setPriority(PmsWorkItemPriorityEnum.MEDIUM.getPriority()); + workItemMapper.insert(mediumWorkItem); + PmsWorkItemDO highSecondWorkItem = randomWorkItemDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), status, 3) + .setPriority(PmsWorkItemPriorityEnum.HIGH.getPriority()); + workItemMapper.insert(highSecondWorkItem); + PmsWorkItemDO highFirstWorkItem = randomWorkItemDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), status, 2) + .setPriority(PmsWorkItemPriorityEnum.HIGH.getPriority()); + workItemMapper.insert(highFirstWorkItem); + when(projectMemberService.validateProjectReadable(projectId, userId)) + .thenReturn(randomProjectDO(projectId, PmsProjectTypeEnum.AGILE.getType())); + + // 调用 + PageResult pageResult = workItemService.getWorkItemPage( + new PmsWorkItemPageReqVO().setProjectId(projectId).setRootOnly(true), userId); + + // 断言 + assertEquals(Arrays.asList(highFirstWorkItem.getId(), highSecondWorkItem.getId(), mediumWorkItem.getId()), + convertList(pageResult.getList(), PmsWorkItemDO::getId)); + } + + @Test + public void testGetWorkItemPage_searchSerialNumber() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + PmsWorkItemStatusDO status = randomStatusDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), + PmsWorkItemStatusTypeEnum.PENDING.getType(), true, 1); + workItemStatusMapper.insert(status); + workItemMapper.insert(randomWorkItemDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), status, 1) + .setName("其他事项")); + PmsWorkItemDO matched = randomWorkItemDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), status, 2); + workItemMapper.insert(matched); + when(projectMemberService.validateProjectReadable(projectId, userId)) + .thenReturn(randomProjectDO(projectId, PmsProjectTypeEnum.AGILE.getType())); + // 准备参数 + PmsWorkItemPageReqVO reqVO = new PmsWorkItemPageReqVO().setProjectId(projectId).setName("#2"); + + // 调用 + PageResult pageResult = workItemService.getWorkItemPage(reqVO, userId); + + // 断言 + assertEquals(1, pageResult.getTotal()); + assertEquals(matched.getId(), CollUtil.getFirst(pageResult.getList()).getId()); + } + + @Test + public void testGetWorkItemPage_rootOnly() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + PmsWorkItemStatusDO status = randomStatusDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), + PmsWorkItemStatusTypeEnum.PENDING.getType(), true, 1); + workItemStatusMapper.insert(status); + PmsWorkItemDO parent = randomWorkItemDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), status, 1); + workItemMapper.insert(parent); + PmsWorkItemDO child = randomWorkItemDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), status, 2) + .setParentId(parent.getId()); + workItemMapper.insert(child); + when(projectMemberService.validateProjectReadable(projectId, userId)) + .thenReturn(randomProjectDO(projectId, PmsProjectTypeEnum.AGILE.getType())); + // 准备参数 + PmsWorkItemPageReqVO reqVO = new PmsWorkItemPageReqVO().setProjectId(projectId).setRootOnly(true); + + // 调用 + PageResult pageResult = workItemService.getWorkItemPage(reqVO, userId); + + // 断言 + assertEquals(1, pageResult.getTotal()); + assertEquals(parent.getId(), CollUtil.getFirst(pageResult.getList()).getId()); + } + + @Test + public void testGetProjectWorkItemStatusCountMap_success() { + // mock 数据 + Long firstProjectId = randomLongId(); + Long secondProjectId = randomLongId(); + PmsWorkItemStatusDO pendingStatus = randomStatusDO(firstProjectId, PmsWorkItemTypeEnum.TASK.getType(), + PmsWorkItemStatusTypeEnum.PENDING.getType(), true, 1); + workItemStatusMapper.insert(pendingStatus); + PmsWorkItemStatusDO completedStatus = randomStatusDO(firstProjectId, PmsWorkItemTypeEnum.TASK.getType(), + PmsWorkItemStatusTypeEnum.COMPLETED.getType(), false, 2); + workItemStatusMapper.insert(completedStatus); + workItemMapper.insert(randomWorkItemDO(firstProjectId, PmsWorkItemTypeEnum.TASK.getType(), pendingStatus, 1)); + workItemMapper.insert(randomWorkItemDO(firstProjectId, PmsWorkItemTypeEnum.TASK.getType(), pendingStatus, 2)); + workItemMapper.insert(randomWorkItemDO(firstProjectId, PmsWorkItemTypeEnum.TASK.getType(), completedStatus, 3)); + PmsWorkItemDO archived = randomWorkItemDO(firstProjectId, PmsWorkItemTypeEnum.TASK.getType(), + completedStatus, 4).setLifecycleStatus(PmsWorkItemLifecycleStatusEnum.ARCHIVED.getStatus()); + workItemMapper.insert(archived); + PmsWorkItemStatusDO processingStatus = randomStatusDO(secondProjectId, PmsWorkItemTypeEnum.TASK.getType(), + PmsWorkItemStatusTypeEnum.PROCESSING.getType(), true, 1); + workItemStatusMapper.insert(processingStatus); + workItemMapper.insert(randomWorkItemDO(secondProjectId, PmsWorkItemTypeEnum.TASK.getType(), + processingStatus, 1)); + + // 调用 + Map> countMap = workItemService.getProjectWorkItemStatusCountMap( + Arrays.asList(firstProjectId, secondProjectId)); + + // 断言 + assertEquals(2L, countMap.get(firstProjectId).get(PmsWorkItemStatusTypeEnum.PENDING.getType())); + assertEquals(1L, countMap.get(firstProjectId).get(PmsWorkItemStatusTypeEnum.COMPLETED.getType())); + assertEquals(1L, countMap.get(secondProjectId).get(PmsWorkItemStatusTypeEnum.PROCESSING.getType())); + } + + @Test + public void testGetWorkItemPage_filterPriorityAndParentId() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + PmsWorkItemStatusDO status = randomStatusDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), + PmsWorkItemStatusTypeEnum.PENDING.getType(), true, 1); + workItemStatusMapper.insert(status); + PmsWorkItemDO parent = randomWorkItemDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), status, 1); + workItemMapper.insert(parent); + PmsWorkItemDO matched = randomWorkItemDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), status, 2) + .setParentId(parent.getId()).setPriority(PmsWorkItemPriorityEnum.HIGH.getPriority()); + workItemMapper.insert(matched); + PmsWorkItemDO other = randomWorkItemDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), status, 3) + .setPriority(PmsWorkItemPriorityEnum.HIGH.getPriority()); + workItemMapper.insert(other); + when(projectMemberService.validateProjectReadable(projectId, userId)) + .thenReturn(randomProjectDO(projectId, PmsProjectTypeEnum.AGILE.getType())); + // 准备参数 + PmsWorkItemPageReqVO reqVO = new PmsWorkItemPageReqVO().setProjectId(projectId) + .setPriority(PmsWorkItemPriorityEnum.HIGH.getPriority()).setParentId(parent.getId()); + + // 调用 + PageResult pageResult = workItemService.getWorkItemPage(reqVO, userId); + + // 断言 + assertEquals(1, pageResult.getTotal()); + assertEquals(matched.getId(), CollUtil.getFirst(pageResult.getList()).getId()); + } + + @Test + public void testBuildQueryWrapper_filterAnyLabel() { + // 准备参数 + PmsWorkItemPageReqVO reqVO = new PmsWorkItemPageReqVO().setProjectId(randomLongId()) + .setLabelIds(Arrays.asList(randomLongId(), randomLongId())); + + // 调用 + String sqlSegment = PmsWorkItemMapper.buildQueryWrapper(reqVO).getSqlSegment(); + + // 断言 + assertTrue(sqlSegment.contains("JSON_CONTAINS(label_ids, JSON_ARRAY(")); + assertTrue(sqlSegment.contains(" OR ")); + } + + @Test + public void testBuildQueryWrapper_combinationFilters() { + // 准备参数 + PmsWorkItemPageReqVO reqVO = new PmsWorkItemPageReqVO().setProjectId(randomLongId()) + .setTypes(Arrays.asList(PmsWorkItemTypeEnum.REQUIREMENT.getType(), + PmsWorkItemTypeEnum.TASK.getType())) + .setStatuses(Arrays.asList(PmsWorkItemStatusTypeEnum.PENDING.getType(), + PmsWorkItemStatusTypeEnum.PROCESSING.getType())) + .setPriorities(Arrays.asList(PmsWorkItemPriorityEnum.MEDIUM.getPriority(), + PmsWorkItemPriorityEnum.HIGH.getPriority())) + .setIterationIds(Arrays.asList(randomLongId(), randomLongId())) + .setExcludedIterationIds(Collections.singletonList(randomLongId())) + .setAssigneeUserIds(Arrays.asList(randomLongId(), randomLongId())); + + // 调用 + String sqlSegment = PmsWorkItemMapper.buildQueryWrapper(reqVO).getSqlSegment(); + + // 断言 + assertTrue(sqlSegment.contains("type IN")); + assertTrue(sqlSegment.contains("status IN")); + assertTrue(sqlSegment.contains("priority IN")); + assertTrue(sqlSegment.contains("iteration_id IN")); + assertTrue(sqlSegment.contains("iteration_id NOT IN")); + assertTrue(sqlSegment.contains("iteration_id IS NULL")); + assertTrue(sqlSegment.contains("assignee_user_id IN")); + } + + @Test + public void testGetWorkItemPage_filterLifecycleStatus() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + PmsWorkItemStatusDO status = randomStatusDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), + PmsWorkItemStatusTypeEnum.PENDING.getType(), true, 1); + workItemStatusMapper.insert(status); + workItemMapper.insert(randomWorkItemDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), status, 1)); + PmsWorkItemDO archived = randomWorkItemDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), status, 2) + .setLifecycleStatus(PmsWorkItemLifecycleStatusEnum.ARCHIVED.getStatus()); + workItemMapper.insert(archived); + when(projectMemberService.validateProjectReadable(projectId, userId)) + .thenReturn(randomProjectDO(projectId, PmsProjectTypeEnum.AGILE.getType())); + // 准备参数 + PmsWorkItemPageReqVO reqVO = new PmsWorkItemPageReqVO().setProjectId(projectId) + .setLifecycleStatus(PmsWorkItemLifecycleStatusEnum.ARCHIVED.getStatus()); + + // 调用 + PageResult pageResult = workItemService.getWorkItemPage(reqVO, userId); + + // 断言 + assertEquals(1, pageResult.getTotal()); + assertEquals(archived.getId(), CollUtil.getFirst(pageResult.getList()).getId()); + } + + @Test + public void testGetWorkItemPage_unplannedOnly() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + PmsWorkItemStatusDO status = randomStatusDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), + PmsWorkItemStatusTypeEnum.PENDING.getType(), true, 1); + workItemStatusMapper.insert(status); + PmsWorkItemDO unplanned = randomWorkItemDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), status, 1); + workItemMapper.insert(unplanned); + PmsWorkItemDO planned = randomWorkItemDO(projectId, PmsWorkItemTypeEnum.TASK.getType(), status, 2) + .setIterationId(randomLongId()); + workItemMapper.insert(planned); + when(projectMemberService.validateProjectReadable(projectId, userId)) + .thenReturn(randomProjectDO(projectId, PmsProjectTypeEnum.AGILE.getType())); + // 准备参数 + PmsWorkItemPageReqVO reqVO = new PmsWorkItemPageReqVO().setProjectId(projectId) + .setUnplannedOnly(true); + + // 调用 + PageResult pageResult = workItemService.getWorkItemPage(reqVO, userId); + + // 断言 + assertEquals(1, pageResult.getTotal()); + assertEquals(unplanned.getId(), CollUtil.getFirst(pageResult.getList()).getId()); + } + + @Test + public void testImportWorkItemList_partialSuccess() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + when(projectMemberService.validateProjectWritable(projectId, userId)) + .thenReturn(randomProjectDO(projectId, PmsProjectTypeEnum.AGILE.getType())); + workItemStatusService.initProjectWorkItemStatuses(projectId, PmsProjectTypeEnum.AGILE.getType()); + // 准备参数 + PmsWorkItemImportExcelVO valid = PmsWorkItemImportExcelVO.builder().name("导入任务") + .priority(PmsWorkItemPriorityEnum.MEDIUM.getPriority()).progress(0).build(); + PmsWorkItemImportExcelVO invalid = PmsWorkItemImportExcelVO.builder() + .priority(PmsWorkItemPriorityEnum.MEDIUM.getPriority()).progress(0).build(); + + // 调用 + PmsWorkItemImportRespVO importResult = workItemService.importWorkItemList(projectId, + PmsWorkItemTypeEnum.TASK.getType(), Arrays.asList(valid, invalid), userId); + + // 断言 + assertEquals(1, importResult.getSuccessCount()); + assertEquals(1, importResult.getFailureReasons().size()); + assertNotNull(importResult.getFailureReasons().get(3)); + assertEquals(1, workItemMapper.selectListByProjectId(projectId).size()); + } + + @Test + public void testImportWorkItemList_failureReason() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + when(projectMemberService.validateProjectWritable(projectId, userId)) + .thenReturn(randomProjectDO(projectId, PmsProjectTypeEnum.AGILE.getType())); + workItemStatusService.initProjectWorkItemStatuses(projectId, PmsProjectTypeEnum.AGILE.getType()); + doThrow(new IllegalStateException("动态记录失败")).when(workItemActivityService) + .createWorkItemActivity(anyLong(), anyLong(), anyLong(), + eq(WORK_ITEM_CREATED)); + // 准备参数 + PmsWorkItemImportExcelVO importItem = PmsWorkItemImportExcelVO.builder().name("导入任务") + .priority(PmsWorkItemPriorityEnum.MEDIUM.getPriority()).progress(0).build(); + + // 调用 + PmsWorkItemImportRespVO importResult = workItemService.importWorkItemList(projectId, + PmsWorkItemTypeEnum.TASK.getType(), Collections.singletonList(importItem), userId); + + // 断言 + assertEquals(0, importResult.getSuccessCount()); + assertEquals("动态记录失败", importResult.getFailureReasons().get(2)); + assertEquals(1, workItemMapper.selectListByProjectId(projectId).size()); + assertEquals(3, workItemStatusMapper.selectListByProjectIdAndWorkItemType(projectId, + PmsWorkItemTypeEnum.TASK.getType()).size()); + } + + // ========== 随机对象 ========== + + private static PmsWorkItemSaveReqVO randomSaveReqVO(Long projectId, Integer type) { + LocalDateTime startTime = LocalDateTime.now().plusDays(1); + return randomPojo(PmsWorkItemSaveReqVO.class, reqVO -> reqVO.setId(null).setProjectId(projectId) + .setType(type).setName("登录任务").setPriority(PmsWorkItemPriorityEnum.MEDIUM.getPriority()) + .setProgress(0).setDescription(null).setAssigneeUserId(null).setMemberUserIds(null) + .setIterationId(null).setParentId(null).setRelatedRequirementId(null).setDefectType(null) + .setStartTime(startTime).setEndTime(startTime.plusDays(2)).setEstimatedHours(null) + .setFileUrls(null).setLabelIds(null).setChildWorkItemNames(null) + .setActualHours(null).setRemainingHours(null)); + } + + private static PmsWorkItemSaveReqVO randomSaveReqVO(PmsWorkItemDO workItem) { + LocalDateTime startTime = LocalDateTime.now().plusDays(1); + return randomPojo(PmsWorkItemSaveReqVO.class, reqVO -> reqVO.setId(workItem.getId()) + .setProjectId(workItem.getProjectId()).setType(workItem.getType()).setName(workItem.getName()) + .setPriority(workItem.getPriority()).setProgress(workItem.getProgress()) + .setDescription(null).setAssigneeUserId(null).setMemberUserIds(null).setIterationId(null) + .setParentId(null).setRelatedRequirementId(null).setDefectType(null) + .setStartTime(startTime).setEndTime(startTime.plusDays(2)).setEstimatedHours(null) + .setFileUrls(null).setLabelIds(null).setChildWorkItemNames(null) + .setActualHours(null).setRemainingHours(null)); + } + + private static PmsProjectDO randomProjectDO(Long id, Integer type) { + return randomPojo(PmsProjectDO.class, project -> project.setId(id).setName("测试项目") + .setStatus(PmsProjectStatusEnum.ACTIVE.getStatus()).setType(type).setLevel(3).setOpenStatus(false)); + } + + private static PmsWorkItemStatusDO randomStatusDO(Long projectId, Integer workItemType, Integer statusType, + boolean defaultStatus, int sort) { + return randomPojo(PmsWorkItemStatusDO.class, status -> status.setId(null).setProjectId(projectId) + .setWorkItemType(workItemType).setName("状态" + sort).setStatusType(statusType) + .setBoardName(null).setSystemCode(null).setDefaultStatus(defaultStatus).setSort(sort)); + } + + private static PmsWorkItemDO randomWorkItemDO(Long projectId, Integer type, PmsWorkItemStatusDO status, + int serialNumber) { + return randomPojo(PmsWorkItemDO.class, workItem -> workItem.setId(null).setProjectId(projectId).setType(type) + .setSerialNumber(serialNumber).setName("工作项" + serialNumber) + .setPriority(PmsWorkItemPriorityEnum.MEDIUM.getPriority()).setStatusId(status.getId()) + .setStatus(status.getStatusType()).setProgress(0).setSort(serialNumber) + .setLifecycleStatus(PmsWorkItemLifecycleStatusEnum.ACTIVE.getStatus()) + .setDescription(null).setAssigneeUserId(null).setArchiveTime(null).setRecycleTime(null) + .setIterationId(null).setParentId(null).setRelatedRequirementId(null).setDefectType(null) + .setStartTime(null).setEndTime(null).setEstimatedHours(null).setFileUrls(null).setLabelIds(null)); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemStatusServiceImplTest.java b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemStatusServiceImplTest.java new file mode 100644 index 000000000..418da46fa --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemStatusServiceImplTest.java @@ -0,0 +1,426 @@ +package cn.iocoder.yudao.module.pms.service.pm.workitem; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status.PmsWorkItemBoardConfigSaveReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status.PmsWorkItemStatusConfigUpdateReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status.PmsWorkItemStatusCreateReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status.PmsWorkItemStatusDeleteReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status.PmsWorkItemStatusSortReqVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemBoardDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemStatusDO; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.workitem.PmsWorkItemBoardMapper; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.workitem.PmsWorkItemMapper; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.workitem.PmsWorkItemStatusMapper; +import cn.iocoder.yudao.module.pms.enums.pm.project.PmsProjectStatusEnum; +import cn.iocoder.yudao.module.pms.enums.pm.project.PmsProjectTypeEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemPriorityEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemStatusTypeEnum; +import cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemTypeEnum; +import cn.iocoder.yudao.module.pms.service.pm.iteration.PmsIterationService; +import cn.iocoder.yudao.module.pms.service.pm.project.PmsProjectMemberService; +import cn.iocoder.yudao.module.system.api.notify.NotifyMessageSendApi; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.WORK_ITEM_STATUS_DEFAULT_CANNOT_DELETE; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.WORK_ITEM_STATUS_INVALID; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.WORK_ITEM_STATUS_NAME_DUPLICATE; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.WORK_ITEM_STATUS_TRANSFER_INVALID; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +/** + * {@link PmsWorkItemStatusServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import({PmsWorkItemStatusServiceImpl.class, PmsWorkItemBoardServiceImpl.class, PmsWorkItemServiceImpl.class}) +public class PmsWorkItemStatusServiceImplTest extends BaseDbUnitTest { + + @Resource + private PmsWorkItemStatusServiceImpl workItemStatusService; + + @Resource + private PmsWorkItemStatusMapper workItemStatusMapper; + @Resource + private PmsWorkItemBoardMapper workItemBoardMapper; + @Resource + private PmsWorkItemMapper workItemMapper; + + @MockitoBean + private PmsProjectMemberService projectMemberService; + @MockitoBean + private PmsIterationService iterationService; + @MockitoBean + private PmsWorkItemCommentService workItemCommentService; + @MockitoBean + private PmsWorkItemWorkLogService workItemWorkLogService; + @MockitoBean + private PmsWorkItemUserSortService workItemUserSortService; + @MockitoBean + private PmsWorkItemLabelService workItemLabelService; + @MockitoBean + private PmsWorkItemActivityService workItemActivityService; + @MockitoBean + private NotifyMessageSendApi notifyMessageSendApi; + @MockitoBean + private AdminUserApi adminUserApi; + + @Test + public void testInitProjectWorkItemStatuses_success() { + // 准备参数 + Long projectId = randomLongId(); + + // 调用 + workItemStatusService.initProjectWorkItemStatuses(projectId, PmsProjectTypeEnum.GENERAL.getType()); + List statuses = workItemStatusService.getWorkItemStatusList(projectId, + PmsWorkItemTypeEnum.TASK.getType()); + + // 断言 + assertEquals(3, statuses.size()); + assertEquals(PmsWorkItemStatusTypeEnum.PENDING.getType(), CollUtil.getFirst(statuses).getStatusType()); + assertEquals(PmsWorkItemStatusTypeEnum.PENDING.name(), CollUtil.getFirst(statuses).getSystemCode()); + assertEquals(PmsWorkItemStatusTypeEnum.PENDING.getName(), CollUtil.getFirst(statuses).getBoardName()); + assertTrue(CollUtil.getFirst(statuses).getDefaultStatus()); + assertFalse(statuses.get(1).getDefaultStatus()); + assertEquals(3, workItemStatusMapper.selectListByProjectIdAndWorkItemType(projectId, + PmsWorkItemTypeEnum.TASK.getType()).size()); + } + + @Test + public void testGetWorkItemStatusList_success() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + when(projectMemberService.validateProjectReadable(projectId, userId)) + .thenReturn(randomProjectDO(projectId)); + workItemStatusService.initProjectWorkItemStatuses(projectId, PmsProjectTypeEnum.GENERAL.getType()); + + // 调用 + List statuses = workItemStatusService.getWorkItemStatusList(projectId, + PmsWorkItemTypeEnum.TASK.getType(), userId); + + // 断言 + assertEquals(3, statuses.size()); + assertEquals(PmsWorkItemStatusTypeEnum.PENDING.getType(), CollUtil.getFirst(statuses).getStatusType()); + } + + @Test + public void testGetWorkItemBoardList_readOnly() { + // 准备参数 + Long projectId = randomLongId(); + + // 调用 + List boards = workItemStatusService.getWorkItemBoardList(projectId, + PmsWorkItemTypeEnum.TASK.getType()); + + // 断言:读取看板配置不会补建任何数据 + assertTrue(CollUtil.isEmpty(boards)); + assertTrue(CollUtil.isEmpty(workItemBoardMapper.selectListByProjectIdAndWorkItemType(projectId, + PmsWorkItemTypeEnum.TASK.getType()))); + } + + @Test + public void testGetWorkItemStatus_success() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + PmsWorkItemStatusDO status = new PmsWorkItemStatusDO().setProjectId(projectId) + .setWorkItemType(PmsWorkItemTypeEnum.TASK.getType()).setName("开发中") + .setStatusType(PmsWorkItemStatusTypeEnum.PROCESSING.getType()) + .setBoardName("进行中").setDefaultStatus(false).setSort(2); + workItemStatusMapper.insert(status); + when(projectMemberService.validateProjectReadable(projectId, userId)) + .thenReturn(randomProjectDO(projectId)); + + // 调用 + PmsWorkItemStatusDO result = workItemStatusService.getWorkItemStatus(status.getId(), userId); + + // 断言 + assertEquals(status.getId(), result.getId()); + } + + @Test + public void testInitProjectWorkItemStatuses_generalOnlyTask() { + // 准备参数 + Long projectId = randomLongId(); + + // 调用 + workItemStatusService.initProjectWorkItemStatuses(projectId, PmsProjectTypeEnum.GENERAL.getType()); + + // 断言 + assertEquals(3, workItemStatusMapper.selectListByProjectIdAndWorkItemType(projectId, + PmsWorkItemTypeEnum.TASK.getType()).size()); + assertEquals(3, workItemBoardMapper.selectListByProjectIdAndWorkItemType(projectId, + PmsWorkItemTypeEnum.TASK.getType()).size()); + assertEquals(0, workItemStatusMapper.selectListByProjectIdAndWorkItemType(projectId, + PmsWorkItemTypeEnum.REQUIREMENT.getType()).size()); + assertEquals(0, workItemBoardMapper.selectListByProjectIdAndWorkItemType(projectId, + PmsWorkItemTypeEnum.REQUIREMENT.getType()).size()); + } + + @Test + public void testInitProjectWorkItemStatuses_agileAllTypes() { + // 准备参数 + Long projectId = randomLongId(); + + // 调用 + workItemStatusService.initProjectWorkItemStatuses(projectId, PmsProjectTypeEnum.AGILE.getType()); + + // 断言 + assertEquals(3, workItemStatusMapper.selectListByProjectIdAndWorkItemType(projectId, + PmsWorkItemTypeEnum.TASK.getType()).size()); + assertEquals(3, workItemBoardMapper.selectListByProjectIdAndWorkItemType(projectId, + PmsWorkItemTypeEnum.TASK.getType()).size()); + assertEquals(3, workItemStatusMapper.selectListByProjectIdAndWorkItemType(projectId, + PmsWorkItemTypeEnum.REQUIREMENT.getType()).size()); + assertEquals(3, workItemBoardMapper.selectListByProjectIdAndWorkItemType(projectId, + PmsWorkItemTypeEnum.REQUIREMENT.getType()).size()); + assertEquals(3, workItemStatusMapper.selectListByProjectIdAndWorkItemType(projectId, + PmsWorkItemTypeEnum.DEFECT.getType()).size()); + assertEquals(3, workItemBoardMapper.selectListByProjectIdAndWorkItemType(projectId, + PmsWorkItemTypeEnum.DEFECT.getType()).size()); + } + + @Test + public void testInitProjectWorkItemStatuses_idempotent() { + // 准备参数 + Long projectId = randomLongId(); + + // 调用 + workItemStatusService.initProjectWorkItemStatuses(projectId, PmsProjectTypeEnum.AGILE.getType()); + workItemStatusService.initProjectWorkItemStatuses(projectId, PmsProjectTypeEnum.AGILE.getType()); + + // 断言 + assertEquals(3, workItemStatusMapper.selectListByProjectIdAndWorkItemType(projectId, + PmsWorkItemTypeEnum.REQUIREMENT.getType()).size()); + } + + @Test + public void testGetWorkItemStatusList_doesNotRecreateDeletedSystemStatus() { + // 准备参数 + Long userId = randomLongId(); + Long projectId = randomLongId(); + when(projectMemberService.validateProjectWritable(projectId, userId)) + .thenReturn(randomProjectDO(projectId)); + workItemStatusService.initProjectWorkItemStatuses(projectId, PmsProjectTypeEnum.GENERAL.getType()); + List statuses = workItemStatusService.getWorkItemStatusList(projectId, + PmsWorkItemTypeEnum.TASK.getType()); + PmsWorkItemStatusDO processingStatus = statuses.get(1); + + // 调用:删除进行中系统状态后重新加载状态列表 + workItemStatusService.deleteWorkItemStatus(new PmsWorkItemStatusDeleteReqVO() + .setId(processingStatus.getId()), userId); + List recreatedStatuses = workItemStatusService + .getWorkItemStatusList(projectId, PmsWorkItemTypeEnum.TASK.getType()); + + // 断言:读取接口只查询已保存数据,不会重新创建被删除的系统状态 + assertEquals(2, recreatedStatuses.size()); + assertFalse(recreatedStatuses.stream().anyMatch(status -> + PmsWorkItemStatusTypeEnum.PROCESSING.name().equals(status.getSystemCode()))); + } + + @Test + public void testValidateWorkItemStatus_projectMismatch() { + // mock 数据 + PmsWorkItemStatusDO status = new PmsWorkItemStatusDO().setProjectId(randomLongId()) + .setWorkItemType(PmsWorkItemTypeEnum.TASK.getType()).setName("未开始") + .setStatusType(PmsWorkItemStatusTypeEnum.PENDING.getType()).setDefaultStatus(true).setSort(1); + workItemStatusMapper.insert(status); + + // 调用,并断言异常 + assertServiceException(() -> workItemStatusService.validateWorkItemStatus(status.getId(), randomLongId(), + PmsWorkItemTypeEnum.TASK.getType()), WORK_ITEM_STATUS_INVALID); + } + + @Test + public void testCreateAndUpdateWorkItemStatus_success() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + when(projectMemberService.validateProjectWritable(projectId, userId)) + .thenReturn(randomProjectDO(projectId)); + workItemStatusService.initProjectWorkItemStatuses(projectId, PmsProjectTypeEnum.AGILE.getType()); + // 准备参数并创建 + Long statusId = workItemStatusService.createWorkItemStatus(new PmsWorkItemStatusCreateReqVO() + .setProjectId(projectId).setWorkItemType(PmsWorkItemTypeEnum.TASK.getType()) + .setName("待验收").setStatusType(PmsWorkItemStatusTypeEnum.PROCESSING.getType()) + .setDescription("等待产品验收"), userId); + PmsWorkItemDO workItem = new PmsWorkItemDO().setProjectId(projectId) + .setType(PmsWorkItemTypeEnum.TASK.getType()).setSerialNumber(1).setName("任务") + .setPriority(PmsWorkItemPriorityEnum.MEDIUM.getPriority()).setStatusId(statusId) + .setStatus(PmsWorkItemStatusTypeEnum.PROCESSING.getType()).setProgress(0).setSort(1); + workItemMapper.insert(workItem); + + // 调用更新 + workItemStatusService.updateWorkItemStatusConfig(new PmsWorkItemStatusConfigUpdateReqVO().setId(statusId) + .setName("已验收").setStatusType(PmsWorkItemStatusTypeEnum.COMPLETED.getType()) + .setDescription("产品验收完成"), userId); + + // 断言 + assertEquals("已验收", workItemStatusMapper.selectById(statusId).getName()); + assertEquals("产品验收完成", workItemStatusMapper.selectById(statusId).getDescription()); + assertNull(workItemStatusMapper.selectById(statusId).getBoardName()); + assertEquals(PmsWorkItemStatusTypeEnum.COMPLETED.getType(), + workItemMapper.selectById(workItem.getId()).getStatus()); + } + + @Test + public void testUpdateWorkItemBoardConfig_success() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + when(projectMemberService.validateProjectWritable(projectId, userId)) + .thenReturn(randomProjectDO(projectId)); + workItemStatusService.initProjectWorkItemStatuses(projectId, PmsProjectTypeEnum.GENERAL.getType()); + List statuses = workItemStatusService.getWorkItemStatusList(projectId, + PmsWorkItemTypeEnum.TASK.getType()); + // 准备参数 + PmsWorkItemBoardConfigSaveReqVO reqVO = new PmsWorkItemBoardConfigSaveReqVO() + .setProjectId(projectId).setWorkItemType(PmsWorkItemTypeEnum.TASK.getType()) + .setBoards(Arrays.asList( + new PmsWorkItemBoardConfigSaveReqVO.Board().setName("待处理") + .setStatusIds(Collections.singletonList(statuses.get(0).getId())), + new PmsWorkItemBoardConfigSaveReqVO.Board().setName("处理中") + .setStatusIds(Arrays.asList(statuses.get(1).getId(), statuses.get(2).getId())), + new PmsWorkItemBoardConfigSaveReqVO.Board().setName("待发布") + .setStatusIds(Collections.emptyList()))); + + // 调用 + workItemStatusService.updateWorkItemBoardConfig(reqVO, userId); + + // 断言 + List boards = workItemBoardMapper + .selectListByProjectIdAndWorkItemType(projectId, PmsWorkItemTypeEnum.TASK.getType()); + assertEquals(3, boards.size()); + assertEquals("待发布", boards.get(2).getName()); + assertEquals("待处理", workItemStatusMapper.selectById(statuses.get(0).getId()).getBoardName()); + assertEquals("处理中", workItemStatusMapper.selectById(statuses.get(2).getId()).getBoardName()); + } + + @Test + public void testCreateWorkItemStatus_nameDuplicate() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + when(projectMemberService.validateProjectWritable(projectId, userId)) + .thenReturn(randomProjectDO(projectId)); + + workItemStatusService.initProjectWorkItemStatuses(projectId, PmsProjectTypeEnum.GENERAL.getType()); + // 调用,并断言异常:已初始化的系统状态不能重复创建 + assertServiceException(() -> workItemStatusService.createWorkItemStatus(new PmsWorkItemStatusCreateReqVO() + .setProjectId(projectId).setWorkItemType(PmsWorkItemTypeEnum.TASK.getType()) + .setName("未开始").setStatusType(PmsWorkItemStatusTypeEnum.PENDING.getType()), userId), + WORK_ITEM_STATUS_NAME_DUPLICATE); + } + + @Test + public void testUpdateDefaultAndSortWorkItemStatuses_success() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + when(projectMemberService.validateProjectWritable(projectId, userId)) + .thenReturn(randomProjectDO(projectId)); + workItemStatusService.initProjectWorkItemStatuses(projectId, PmsProjectTypeEnum.GENERAL.getType()); + List statuses = workItemStatusService.getWorkItemStatusList(projectId, + PmsWorkItemTypeEnum.TASK.getType()); + + // 调用 + workItemStatusService.updateDefaultWorkItemStatus(statuses.get(1).getId(), userId); + workItemStatusService.updateWorkItemStatusSort(new PmsWorkItemStatusSortReqVO().setStatusIds(Arrays.asList( + statuses.get(2).getId(), statuses.get(1).getId(), CollUtil.getFirst(statuses).getId())), userId); + + // 断言 + List results = workItemStatusMapper + .selectListByProjectIdAndWorkItemType(projectId, PmsWorkItemTypeEnum.TASK.getType()); + assertEquals(statuses.get(2).getId(), CollUtil.getFirst(results).getId()); + assertTrue(workItemStatusMapper.selectById(statuses.get(1).getId()).getDefaultStatus()); + } + + @Test + public void testDeleteWorkItemStatus_transferWorkItems() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + when(projectMemberService.validateProjectWritable(projectId, userId)) + .thenReturn(randomProjectDO(projectId)); + workItemStatusService.initProjectWorkItemStatuses(projectId, PmsProjectTypeEnum.GENERAL.getType()); + List statuses = workItemStatusService.getWorkItemStatusList(projectId, + PmsWorkItemTypeEnum.TASK.getType()); + PmsWorkItemDO workItem = new PmsWorkItemDO().setProjectId(projectId) + .setType(PmsWorkItemTypeEnum.TASK.getType()).setSerialNumber(1).setName("任务") + .setPriority(PmsWorkItemPriorityEnum.MEDIUM.getPriority()).setStatusId(statuses.get(1).getId()) + .setStatus(statuses.get(1).getStatusType()).setProgress(0).setSort(1); + workItemMapper.insert(workItem); + + // 调用 + workItemStatusService.deleteWorkItemStatus(new PmsWorkItemStatusDeleteReqVO().setId(statuses.get(1).getId()) + .setTransferStatusId(statuses.get(2).getId()), userId); + + // 断言 + assertNull(workItemStatusMapper.selectById(statuses.get(1).getId())); + assertEquals(statuses.get(2).getId(), workItemMapper.selectById(workItem.getId()).getStatusId()); + assertEquals(statuses.get(2).getStatusType(), workItemMapper.selectById(workItem.getId()).getStatus()); + } + + @Test + public void testDeleteWorkItemStatus_defaultStatus() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + when(projectMemberService.validateProjectWritable(projectId, userId)) + .thenReturn(randomProjectDO(projectId)); + workItemStatusService.initProjectWorkItemStatuses(projectId, PmsProjectTypeEnum.GENERAL.getType()); + PmsWorkItemStatusDO defaultStatus = CollUtil.getFirst(workItemStatusService.getWorkItemStatusList(projectId, + PmsWorkItemTypeEnum.TASK.getType())); + + // 调用,并断言异常 + assertServiceException(() -> workItemStatusService.deleteWorkItemStatus(new PmsWorkItemStatusDeleteReqVO() + .setId(defaultStatus.getId()), userId), WORK_ITEM_STATUS_DEFAULT_CANNOT_DELETE); + } + + @Test + public void testDeleteWorkItemStatus_missingTransferStatus() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + when(projectMemberService.validateProjectWritable(projectId, userId)) + .thenReturn(randomProjectDO(projectId)); + workItemStatusService.initProjectWorkItemStatuses(projectId, PmsProjectTypeEnum.GENERAL.getType()); + List statuses = workItemStatusService.getWorkItemStatusList(projectId, + PmsWorkItemTypeEnum.TASK.getType()); + PmsWorkItemDO workItem = new PmsWorkItemDO().setProjectId(projectId) + .setType(PmsWorkItemTypeEnum.TASK.getType()).setSerialNumber(1).setName("任务") + .setPriority(PmsWorkItemPriorityEnum.MEDIUM.getPriority()).setStatusId(statuses.get(1).getId()) + .setStatus(statuses.get(1).getStatusType()).setProgress(0).setSort(1); + workItemMapper.insert(workItem); + + // 调用,并断言异常 + assertServiceException(() -> workItemStatusService.deleteWorkItemStatus(new PmsWorkItemStatusDeleteReqVO() + .setId(statuses.get(1).getId()), userId), WORK_ITEM_STATUS_TRANSFER_INVALID); + } + + // ========== 随机对象 ========== + + private static PmsProjectDO randomProjectDO(Long id) { + return new PmsProjectDO().setId(id).setName("测试项目") + .setStatus(PmsProjectStatusEnum.ACTIVE.getStatus()).setType(PmsProjectTypeEnum.AGILE.getType()) + .setLevel(3).setOpenStatus(false); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemWorkLogServiceImplTest.java b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemWorkLogServiceImplTest.java new file mode 100644 index 000000000..3f78aff1a --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/test/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemWorkLogServiceImplTest.java @@ -0,0 +1,186 @@ +package cn.iocoder.yudao.module.pms.service.pm.workitem; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.worklog.PmsProjectWorkLogReportReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.worklog.PmsProjectWorkLogReportRespVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.worklog.PmsWorkItemWorkLogSaveReqVO; +import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.worklog.PmsWorkItemWorkLogSummaryRespVO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.iteration.PmsIterationDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.project.PmsProjectDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemDO; +import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemWorkLogDO; +import cn.iocoder.yudao.module.pms.dal.mysql.pm.workitem.PmsWorkItemWorkLogMapper; +import cn.iocoder.yudao.module.pms.service.pm.iteration.PmsIterationService; +import cn.iocoder.yudao.module.pms.service.pm.project.PmsProjectMemberService; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.Collections; +import java.util.List; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.module.pms.enums.ErrorCodeConstants.WORK_ITEM_WORK_LOG_INVALID; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link PmsWorkItemWorkLogServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(PmsWorkItemWorkLogServiceImpl.class) +public class PmsWorkItemWorkLogServiceImplTest extends BaseDbUnitTest { + + @Resource + private PmsWorkItemWorkLogServiceImpl workLogService; + + @Resource + private PmsWorkItemWorkLogMapper workLogMapper; + + @MockitoBean + private PmsWorkItemService workItemService; + @MockitoBean + private PmsProjectMemberService projectMemberService; + @MockitoBean + private PmsWorkItemActivityService workItemActivityService; + @MockitoBean + private PmsIterationService iterationService; + + @Test + public void testCreateWorkItemWorkLog_success() { + // mock 数据 + Long userId = randomLongId(); + PmsWorkItemDO workItem = randomWorkItemDO(); + when(workItemService.getWritableWorkItem(workItem.getId(), userId)).thenReturn(workItem); + when(projectMemberService.validateProjectWritable(workItem.getProjectId(), userId)) + .thenReturn(new PmsProjectDO().setId(workItem.getProjectId())); + // 准备参数 + PmsWorkItemWorkLogSaveReqVO reqVO = new PmsWorkItemWorkLogSaveReqVO() + .setWorkItemId(workItem.getId()).setActualHours(4).setRemainingHours(6) + .setDescription(" 完成接口联调 "); + + // 调用 + Long id = workLogService.createWorkItemWorkLog(reqVO, userId); + + // 断言 + PmsWorkItemWorkLogDO workLog = workLogMapper.selectById(id); + assertEquals(workItem.getProjectId(), workLog.getProjectId()); + assertEquals(4, workLog.getActualHours()); + assertEquals(6, workLog.getRemainingHours()); + assertEquals("完成接口联调", workLog.getDescription()); + } + + @Test + public void testUpdateWorkItemWorkLog_success() { + // mock 数据 + Long userId = randomLongId(); + PmsWorkItemDO workItem = randomWorkItemDO(); + PmsWorkItemWorkLogDO workLog = new PmsWorkItemWorkLogDO().setProjectId(workItem.getProjectId()) + .setWorkItemId(workItem.getId()).setActualHours(2).setRemainingHours(8).setDescription("旧说明"); + workLogMapper.insert(workLog); + when(workItemService.getWritableWorkItem(workItem.getId(), userId)).thenReturn(workItem); + when(projectMemberService.validateProjectWritable(workItem.getProjectId(), userId)) + .thenReturn(new PmsProjectDO().setId(workItem.getProjectId())); + // 准备参数 + PmsWorkItemWorkLogSaveReqVO reqVO = new PmsWorkItemWorkLogSaveReqVO().setId(workLog.getId()) + .setWorkItemId(workItem.getId()).setActualHours(5).setRemainingHours(3).setDescription(null); + + // 调用 + workLogService.updateWorkItemWorkLog(reqVO, userId); + + // 断言 + PmsWorkItemWorkLogDO updatedWorkLog = workLogMapper.selectById(workLog.getId()); + assertEquals(5, updatedWorkLog.getActualHours()); + assertEquals(3, updatedWorkLog.getRemainingHours()); + assertNull(updatedWorkLog.getDescription()); + } + + @Test + public void testUpdateWorkItemWorkLog_workItemMismatch() { + // mock 数据 + PmsWorkItemDO workItem = randomWorkItemDO(); + PmsWorkItemWorkLogDO workLog = new PmsWorkItemWorkLogDO().setProjectId(workItem.getProjectId()) + .setWorkItemId(workItem.getId()).setActualHours(2).setRemainingHours(8); + workLogMapper.insert(workLog); + // 准备参数 + PmsWorkItemWorkLogSaveReqVO reqVO = new PmsWorkItemWorkLogSaveReqVO().setId(workLog.getId()) + .setWorkItemId(randomLongId()).setActualHours(5).setRemainingHours(3); + + // 调用,并断言异常 + assertServiceException(() -> workLogService.updateWorkItemWorkLog(reqVO, randomLongId()), + WORK_ITEM_WORK_LOG_INVALID); + } + + @Test + public void testGetWorkItemWorkLogSummary_success() { + // mock 数据 + Long userId = randomLongId(); + PmsWorkItemDO workItem = randomWorkItemDO().setEstimatedHours(10); + when(workItemService.getWorkItem(workItem.getId(), userId)).thenReturn(workItem); + workLogMapper.insert(new PmsWorkItemWorkLogDO().setProjectId(workItem.getProjectId()) + .setWorkItemId(workItem.getId()).setActualHours(3).setRemainingHours(7)); + workLogMapper.insert(new PmsWorkItemWorkLogDO().setProjectId(workItem.getProjectId()) + .setWorkItemId(workItem.getId()).setActualHours(4).setRemainingHours(2)); + + // 调用 + PmsWorkItemWorkLogSummaryRespVO summary = workLogService.getWorkItemWorkLogSummary(workItem.getId(), userId); + + // 断言 + assertEquals(10, summary.getEstimatedHours()); + assertEquals(7, summary.getActualHours()); + assertEquals(2, summary.getRemainingHours()); + assertEquals(2, summary.getRecords().size()); + } + + @Test + public void testGetProjectWorkItemWorkLogReport_success() { + // mock 数据 + Long userId = randomLongId(); + Long projectId = randomLongId(); + Long iterationId = randomLongId(); + PmsWorkItemDO workItem = randomWorkItemDO().setProjectId(projectId).setIterationId(iterationId) + .setSerialNumber(1); + workLogMapper.insert(new PmsWorkItemWorkLogDO().setProjectId(projectId) + .setWorkItemId(workItem.getId()).setActualHours(3)); + workLogMapper.insert(new PmsWorkItemWorkLogDO().setProjectId(projectId) + .setWorkItemId(workItem.getId()).setActualHours(4)); + when(workItemService.getWorkItemList(Collections.singleton(workItem.getId()))) + .thenReturn(Collections.singletonList(workItem)); + when(iterationService.getIterationList(Collections.singleton(iterationId))) + .thenReturn(Collections.singletonList(new PmsIterationDO().setId(iterationId).setName("第一迭代"))); + + // 调用 + LocalDate today = LocalDate.now(); + PmsProjectWorkLogReportReqVO reqVO = new PmsProjectWorkLogReportReqVO().setProjectId(projectId) + .setCreateTime(new LocalDateTime[]{today.minusDays(1).atStartOfDay(), today.atTime(LocalTime.MAX)}) + .setIterationName("第一"); + PmsProjectWorkLogReportRespVO report = workLogService.getProjectWorkItemWorkLogReport( + reqVO, userId); + + // 断言 + assertEquals(2, report.getDates().size()); + assertEquals(today.minusDays(1).toString(), CollUtil.getFirst(report.getDates())); + assertEquals(today.toString(), report.getDates().get(1)); + assertEquals(7, report.getTotalHours()); + assertEquals(1, report.getGroups().size()); + assertEquals("第一迭代", CollUtil.getFirst(report.getGroups()).getIterationName()); + assertEquals(7, CollUtil.getFirst(CollUtil.getFirst(report.getGroups()).getItems()).getTotalHours()); + verify(projectMemberService).validateProjectReadable(projectId, userId); + } + + // ========== 随机对象 ========== + + private static PmsWorkItemDO randomWorkItemDO() { + return new PmsWorkItemDO().setId(randomLongId()).setProjectId(randomLongId()).setName("测试任务"); + } + +} diff --git a/yudao-module-pms/yudao-module-pms-server/src/test/resources/application-unit-test.yaml b/yudao-module-pms/yudao-module-pms-server/src/test/resources/application-unit-test.yaml new file mode 100644 index 000000000..f0fcc544d --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/test/resources/application-unit-test.yaml @@ -0,0 +1,36 @@ +spring: + main: + lazy-initialization: true + banner-mode: off + allow-bean-definition-overriding: true + +--- #################### 数据库相关配置 #################### + +spring: + datasource: + name: ruoyi-vue-pro + url: jdbc:h2:mem:testdb;MODE=MYSQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=value,day,year,month; + driver-class-name: org.h2.Driver + username: sa + password: + druid: + async-init: true + initial-size: 1 + sql: + init: + schema-locations: classpath:/sql/create_tables.sql + + data: + redis: + host: 127.0.0.1 + port: 16379 + database: 0 + +mybatis: + lazy-initialization: true + +--- #################### 芋道相关配置 #################### + +yudao: + info: + base-package: cn.iocoder.yudao.module diff --git a/yudao-module-pms/yudao-module-pms-server/src/test/resources/sql/clean.sql b/yudao-module-pms/yudao-module-pms-server/src/test/resources/sql/clean.sql new file mode 100644 index 000000000..c8a8df116 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/test/resources/sql/clean.sql @@ -0,0 +1,14 @@ +DELETE FROM "pms_knowledge_library_member"; +DELETE FROM "pms_knowledge_library"; +DELETE FROM "pms_work_item_activity"; +DELETE FROM "pms_work_item_comment"; +DELETE FROM "pms_work_item_label"; +DELETE FROM "pms_work_item_work_log"; +DELETE FROM "pms_work_item_member"; +DELETE FROM "pms_work_item"; +DELETE FROM "pms_work_item_status"; +DELETE FROM "pms_iteration"; +DELETE FROM "pms_project_group_relation"; +DELETE FROM "pms_project_group"; +DELETE FROM "pms_project_member"; +DELETE FROM "pms_project"; diff --git a/yudao-module-pms/yudao-module-pms-server/src/test/resources/sql/create_tables.sql b/yudao-module-pms/yudao-module-pms-server/src/test/resources/sql/create_tables.sql new file mode 100644 index 000000000..ba6f9f111 --- /dev/null +++ b/yudao-module-pms/yudao-module-pms-server/src/test/resources/sql/create_tables.sql @@ -0,0 +1,592 @@ +CREATE TABLE IF NOT EXISTS "pms_project" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "name" varchar(50) NOT NULL, + "status" int NOT NULL DEFAULT 1, + "type" int NOT NULL, + "level" int NOT NULL, + "description" text DEFAULT NULL, + "open_status" bit NOT NULL DEFAULT TRUE, + "icon" varchar(255) DEFAULT NULL, + "sort" int DEFAULT NULL, + "start_time" timestamp DEFAULT NULL, + "end_time" timestamp DEFAULT NULL, + "archive_time" timestamp DEFAULT NULL, + "recycle_time" timestamp DEFAULT NULL, + "access_time" timestamp DEFAULT NULL, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "pms_project_template" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "name" varchar(100) NOT NULL, + "description" varchar(500) DEFAULT NULL, + "project_type" int NOT NULL, + "status" tinyint NOT NULL DEFAULT 0, + "sort" int NOT NULL DEFAULT 0, + "item_types" varchar(5000) NOT NULL, + "statuses" varchar(10000) NOT NULL, + "boards" varchar(10000) NOT NULL, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "pms_knowledge_library_template" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "name" varchar(100) NOT NULL, + "description" varchar(500) DEFAULT NULL, + "cover_url" varchar(255) DEFAULT NULL, + "status" tinyint NOT NULL DEFAULT 0, + "sort" int NOT NULL DEFAULT 0, + "documents" varchar(1000000) NOT NULL, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "pms_project_member" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "project_id" bigint NOT NULL, + "user_id" bigint NOT NULL, + "level" tinyint NOT NULL, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "pms_project_group" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "user_id" bigint NOT NULL, + "name" varchar(100) NOT NULL, + "sort" int NOT NULL DEFAULT 0, + "type" tinyint NOT NULL DEFAULT 3, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "pms_project_group_relation" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "user_id" bigint NOT NULL, + "group_id" bigint NOT NULL, + "project_id" bigint NOT NULL, + "sort" int NOT NULL DEFAULT 0, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "pms_project_favorite" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "project_id" bigint NOT NULL, + "user_id" bigint NOT NULL, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "pms_project_announcement" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "project_id" bigint NOT NULL, + "content" text NOT NULL, + "file_urls" varchar(5000) DEFAULT NULL, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "pms_iteration" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "project_id" bigint NOT NULL, + "name" varchar(100) NOT NULL, + "owner_user_id" bigint DEFAULT NULL, + "status" int NOT NULL DEFAULT 1, + "start_time" timestamp DEFAULT NULL, + "end_time" timestamp DEFAULT NULL, + "finish_time" timestamp DEFAULT NULL, + "target" varchar(255) DEFAULT NULL, + "description" text DEFAULT NULL, + "sort" int NOT NULL DEFAULT 0, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "pms_work_item_status" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "project_id" bigint NOT NULL, + "work_item_type" int NOT NULL, + "name" varchar(50) NOT NULL, + "status_type" int NOT NULL, + "description" varchar(255) DEFAULT NULL, + "board_name" varchar(50) DEFAULT NULL, + "system_code" varchar(20) DEFAULT NULL, + "default_status" bit NOT NULL DEFAULT FALSE, + "sort" int NOT NULL DEFAULT 0, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "pms_work_item_board" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "project_id" bigint NOT NULL, + "work_item_type" int NOT NULL, + "name" varchar(50) NOT NULL, + "sort" int NOT NULL DEFAULT 0, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "pms_work_item" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "project_id" bigint NOT NULL, + "type" int NOT NULL, + "serial_number" int NOT NULL, + "name" varchar(100) NOT NULL, + "description" text DEFAULT NULL, + "priority" int NOT NULL, + "assignee_user_id" bigint DEFAULT NULL, + "status_id" bigint NOT NULL, + "status" int NOT NULL, + "lifecycle_status" int NOT NULL DEFAULT 1, + "archive_time" timestamp DEFAULT NULL, + "recycle_time" timestamp DEFAULT NULL, + "iteration_id" bigint DEFAULT NULL, + "parent_id" bigint DEFAULT NULL, + "related_requirement_id" bigint DEFAULT NULL, + "defect_type" int DEFAULT NULL, + "start_time" timestamp DEFAULT NULL, + "end_time" timestamp DEFAULT NULL, + "estimated_hours" int DEFAULT NULL, + "progress" int NOT NULL DEFAULT 0, + "file_urls" varchar(5000) DEFAULT NULL, + "label_ids" varchar(5000) DEFAULT NULL, + "sort" int NOT NULL DEFAULT 0, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "pms_work_item_member" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "project_id" bigint NOT NULL, + "work_item_id" bigint NOT NULL, + "user_id" bigint NOT NULL, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "pms_work_item_user_sort" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "project_id" bigint NOT NULL, + "work_item_id" bigint NOT NULL, + "user_id" bigint NOT NULL, + "sort" int NOT NULL DEFAULT 0, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "pms_work_item_work_log" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "project_id" bigint NOT NULL, + "work_item_id" bigint NOT NULL, + "actual_hours" int NOT NULL, + "remaining_hours" int NOT NULL DEFAULT 0, + "description" varchar(500) DEFAULT NULL, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "pms_work_item_label" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "name" varchar(50) NOT NULL, + "color" varchar(20) NOT NULL, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "pms_work_item_comment" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "work_item_id" bigint NOT NULL, + "user_id" bigint NOT NULL, + "main_id" bigint NOT NULL DEFAULT 0, + "reply_user_id" bigint DEFAULT NULL, + "content" varchar(2000) NOT NULL, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "pms_work_item_activity" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "project_id" bigint NOT NULL, + "work_item_id" bigint NOT NULL, + "operator_user_id" bigint NOT NULL, + "content" varchar(500) NOT NULL, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "pms_knowledge_library" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "name" varchar(255) NOT NULL, + "description" varchar(300) DEFAULT NULL, + "open_status" bit NOT NULL DEFAULT FALSE, + "cover_url" varchar(255) DEFAULT NULL, + "status" int NOT NULL DEFAULT 1, + "delete_user_id" bigint DEFAULT NULL, + "delete_time" timestamp DEFAULT NULL, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "pms_knowledge_library_member" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "library_id" bigint NOT NULL, + "user_id" bigint DEFAULT NULL, + "dept_id" bigint DEFAULT NULL, + "level" int NOT NULL, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "pms_knowledge_folder" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "library_id" bigint NOT NULL, + "permission_id" bigint NOT NULL, + "parent_id" bigint NOT NULL DEFAULT 0, + "title" varchar(255) NOT NULL, + "status" int NOT NULL DEFAULT 1, + "delete_user_id" bigint DEFAULT NULL, + "delete_time" timestamp DEFAULT NULL, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "pms_knowledge_document" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "library_id" bigint NOT NULL, + "permission_id" bigint NOT NULL, + "folder_id" bigint NOT NULL DEFAULT 0, + "parent_id" bigint NOT NULL DEFAULT 0, + "title" varchar(255) NOT NULL, + "content" text DEFAULT NULL, + "type" int NOT NULL, + "file_type" varchar(50) DEFAULT NULL, + "file_size" bigint DEFAULT NULL, + "status" int NOT NULL DEFAULT 1, + "label_ids" varchar(5000) DEFAULT NULL, + "delete_user_id" bigint DEFAULT NULL, + "delete_time" timestamp DEFAULT NULL, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "pms_knowledge_content_permission" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "library_id" bigint NOT NULL, + "open_status" bit NOT NULL DEFAULT TRUE, + "open_level" tinyint NOT NULL DEFAULT 3, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "pms_knowledge_content_permission_member" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "permission_id" bigint NOT NULL, + "user_id" bigint DEFAULT NULL, + "dept_id" bigint DEFAULT NULL, + "level" tinyint NOT NULL, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "pms_knowledge_document_comment" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "document_id" bigint NOT NULL, + "user_id" bigint NOT NULL, + "main_id" bigint NOT NULL DEFAULT 0, + "reply_user_id" bigint DEFAULT NULL, + "content" varchar(2000) NOT NULL, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "pms_knowledge_recycle_record" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "library_id" bigint NOT NULL, + "type" int NOT NULL, + "entity_id" bigint NOT NULL, + "name" varchar(255) NOT NULL, + "delete_user_id" bigint NOT NULL, + "delete_time" timestamp NOT NULL, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); +CREATE INDEX IF NOT EXISTS "idx_tenant_type_entity" ON "pms_knowledge_recycle_record" + ("tenant_id", "type", "entity_id"); +CREATE INDEX IF NOT EXISTS "idx_tenant_delete_time_id" ON "pms_knowledge_recycle_record" + ("tenant_id", "delete_time", "id"); +CREATE INDEX IF NOT EXISTS "idx_tenant_library_delete_time" ON "pms_knowledge_recycle_record" + ("tenant_id", "library_id", "delete_time"); +CREATE INDEX IF NOT EXISTS "idx_tenant_delete_user_type" ON "pms_knowledge_recycle_record" + ("tenant_id", "delete_user_id", "type"); + +CREATE TABLE IF NOT EXISTS "pms_knowledge_favorite" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "library_id" bigint NOT NULL, + "type" int NOT NULL, + "entity_id" bigint NOT NULL, + "user_id" bigint NOT NULL, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); +CREATE INDEX IF NOT EXISTS "idx_favorite_tenant_user_create_time_id" ON "pms_knowledge_favorite" + ("tenant_id", "user_id", "create_time", "id"); +CREATE INDEX IF NOT EXISTS "idx_favorite_tenant_user_library_create_time_id" ON "pms_knowledge_favorite" + ("tenant_id", "user_id", "library_id", "create_time", "id"); +CREATE INDEX IF NOT EXISTS "idx_favorite_tenant_user_type_entity" ON "pms_knowledge_favorite" + ("tenant_id", "user_id", "type", "entity_id"); +CREATE INDEX IF NOT EXISTS "idx_favorite_tenant_library" ON "pms_knowledge_favorite" + ("tenant_id", "library_id"); +CREATE INDEX IF NOT EXISTS "idx_favorite_tenant_type_entity" ON "pms_knowledge_favorite" + ("tenant_id", "type", "entity_id"); + +CREATE TABLE IF NOT EXISTS "pms_knowledge_document_like" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "document_id" bigint NOT NULL, + "user_id" bigint NOT NULL, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); +CREATE INDEX IF NOT EXISTS "idx_like_tenant_document_create_time" ON "pms_knowledge_document_like" + ("tenant_id", "document_id", "create_time"); +CREATE INDEX IF NOT EXISTS "idx_like_tenant_user_document" ON "pms_knowledge_document_like" + ("tenant_id", "user_id", "document_id"); + +CREATE TABLE IF NOT EXISTS "pms_knowledge_view_record" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "library_id" bigint NOT NULL, + "type" int NOT NULL, + "entity_id" bigint NOT NULL, + "user_id" bigint NOT NULL, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); +CREATE INDEX IF NOT EXISTS "idx_view_tenant_user_create_time_id" ON "pms_knowledge_view_record" + ("tenant_id", "user_id", "create_time", "id"); +CREATE INDEX IF NOT EXISTS "idx_view_tenant_user_type_entity" ON "pms_knowledge_view_record" + ("tenant_id", "user_id", "type", "entity_id"); +CREATE INDEX IF NOT EXISTS "idx_view_tenant_library_create_time_id" ON "pms_knowledge_view_record" + ("tenant_id", "library_id", "create_time", "id"); +CREATE INDEX IF NOT EXISTS "idx_view_tenant_type_entity" ON "pms_knowledge_view_record" + ("tenant_id", "type", "entity_id"); + +CREATE TABLE IF NOT EXISTS "pms_knowledge_document_share" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "document_id" bigint NOT NULL, + "share_user_ids" varchar(5000) DEFAULT NULL, + "token" varchar(64) NOT NULL, + "status" tinyint NOT NULL DEFAULT 0, + "close_user_id" bigint DEFAULT NULL, + "close_time" timestamp DEFAULT NULL, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id"), + UNIQUE ("token") +); +CREATE INDEX IF NOT EXISTS "idx_share_tenant_document" ON "pms_knowledge_document_share" + ("tenant_id", "document_id"); + +CREATE TABLE IF NOT EXISTS "pms_knowledge_document_label" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "name" varchar(255) NOT NULL, + "color" varchar(20) NOT NULL, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "pms_knowledge_group" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "user_id" bigint NOT NULL, + "name" varchar(100) NOT NULL, + "sort" int NOT NULL DEFAULT 0, + "type" tinyint NOT NULL DEFAULT 3, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); +CREATE INDEX IF NOT EXISTS "idx_group_tenant_user_type" ON "pms_knowledge_group" + ("tenant_id", "user_id", "type"); +CREATE INDEX IF NOT EXISTS "idx_group_tenant_user_sort" ON "pms_knowledge_group" + ("tenant_id", "user_id", "sort"); + +CREATE TABLE IF NOT EXISTS "pms_knowledge_group_relation" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "user_id" bigint NOT NULL, + "group_id" bigint NOT NULL, + "library_id" bigint NOT NULL, + "sort" int NOT NULL DEFAULT 0, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); +CREATE INDEX IF NOT EXISTS "idx_group_relation_tenant_user_group_library" ON "pms_knowledge_group_relation" + ("tenant_id", "user_id", "group_id", "library_id"); +CREATE INDEX IF NOT EXISTS "idx_group_relation_tenant_user_library" ON "pms_knowledge_group_relation" + ("tenant_id", "user_id", "library_id"); +CREATE INDEX IF NOT EXISTS "idx_group_relation_tenant_library" ON "pms_knowledge_group_relation" + ("tenant_id", "library_id"); diff --git a/yudao-server/pom.xml b/yudao-server/pom.xml index d952fbd2e..1c548ea90 100644 --- a/yudao-server/pom.xml +++ b/yudao-server/pom.xml @@ -148,6 +148,13 @@ + + + + + + + @@ -188,6 +195,13 @@ + + + org.springframework.boot + spring-boot-starter-test + test + + diff --git a/yudao-server/src/main/java/cn/iocoder/yudao/server/controller/DefaultController.java b/yudao-server/src/main/java/cn/iocoder/yudao/server/controller/DefaultController.java index f8ba5ece2..4488e948b 100644 --- a/yudao-server/src/main/java/cn/iocoder/yudao/server/controller/DefaultController.java +++ b/yudao-server/src/main/java/cn/iocoder/yudao/server/controller/DefaultController.java @@ -52,6 +52,24 @@ public class DefaultController { "[WMS 仓库管理系统 yudao-module-wms - 已禁用][参考 https://doc.iocoder.cn/wms/build/ 开启]"); } + @RequestMapping("/admin-api/hrm/**") + public CommonResult hrm404() { + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[HRM 人力资源管理系统 yudao-module-hrm - 已禁用][参考 https://cloud.iocoder.cn/hrm/build/ 开启]"); + } + + @RequestMapping("/admin-api/fms/**") + public CommonResult fms404() { + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[FMS 财务管理系统 yudao-module-fms - 已禁用][参考 https://cloud.iocoder.cn/fms/build/ 开启]"); + } + + @RequestMapping("/admin-api/pms/**") + public CommonResult pms404() { + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[PMS 项目管理系统 yudao-module-pms - 已禁用][参考 https://cloud.iocoder.cn/pms/build/ 开启]"); + } + @RequestMapping("/admin-api/crm/**") public CommonResult crm404() { return CommonResult.error(NOT_IMPLEMENTED.getCode(), From 589b75a1f2cff689963cf8b506521a6df0046807 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Fri, 4 Sep 2026 08:53:06 +0800 Subject: [PATCH 2/2] =?UTF-8?q?README=EF=BC=9A=E5=A2=9E=E5=8A=A0=20PMS=20?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=EF=BC=88=E6=9C=9F=E5=BE=85=20AGI=20ing?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .image/common/pms-feature.png | Bin 0 -> 399245 bytes .image/common/pms-preview.png | Bin 0 -> 138929 bytes README.md | 17 +++++++++++++---- 3 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 .image/common/pms-feature.png create mode 100644 .image/common/pms-preview.png diff --git a/.image/common/pms-feature.png b/.image/common/pms-feature.png new file mode 100644 index 0000000000000000000000000000000000000000..25e12776bf1d85819f90627ab4f07eb61b9f92fe GIT binary patch literal 399245 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 HqrP_DnO7sbteJl$`s|L8Mm%5Q#Bs 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≈vCaugF#u%#LK2@mynNAqBTgh*GXHkfbeyqO5!x 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%0D(BZEA7`PWs;PL7QUH<$@#_lC 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�&ya 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

CN_09DQSW5T`T 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

F`8?qpzGb_ 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 P00000NkvXXu0mjf{~>ob literal 0 HcmV?d00001 diff --git a/.image/common/pms-preview.png b/.image/common/pms-preview.png new file mode 100644 index 0000000000000000000000000000000000000000..c7a85aa765ad884e8b70fc3483d582d72602fa1b GIT binary patch literal 138929 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 友情提示:本项目基于 RuoYi-Vue 修改,**重构优化**后端的代码,**美化**前端的界面。 > @@ -311,6 +311,14 @@ ![预览图](/.image/common/fms-preview.png) +### PMS 项目管理系统 + +演示地址: + +![功能图](/.image/common/pms-feature.png) + +![预览图](/.image/common/pms-preview.png) + ### AI 大模型 演示地址: @@ -361,6 +369,7 @@ | `yudao-module-wms` | WMS 系统的 Module 模块 | | `yudao-module-hrm` | HRM 人力资源管理的 Module 模块 | | `yudao-module-fms` | FMS 财务管理的 Module 模块 | +| `yudao-module-pms` | PMS 项目管理的 Module 模块 | | `yudao-module-im` | IM 即时通讯的 Module 模块 | | `yudao-module-ai` | AI 大模型的 Module 模块 | | `yudao-module-iot` | IoT 物联网的 Module 模块 |