diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/constants/CcdiProjectStatusConstants.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/constants/CcdiProjectStatusConstants.java index 365aab4f..905a3240 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/constants/CcdiProjectStatusConstants.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/constants/CcdiProjectStatusConstants.java @@ -10,6 +10,7 @@ public final class CcdiProjectStatusConstants { public static final String ARCHIVED = "2"; public static final String TAGGING = "3"; public static final String TAG_FAILED = "4"; + public static final String DELETED = "5"; private CcdiProjectStatusConstants() { } diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectController.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectController.java index 7a9da790..9c317699 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectController.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectController.java @@ -74,12 +74,23 @@ public class CcdiProjectController extends BaseController { */ @DeleteMapping("/{projectId}") @Operation(summary = "删除项目") - @PreAuthorize("@ss.hasPermi('ccdi:project:remove')") + @PreAuthorize("@ss.hasPermi('ccdi:project:list')") public AjaxResult deleteProject(@PathVariable Long projectId) { - boolean success = projectService.deleteProject(projectId); + boolean success = projectService.deleteProject(projectId, SecurityUtils.getUsername()); return success ? AjaxResult.success("项目删除成功") : AjaxResult.error("项目删除失败"); } + /** + * 恢复项目 + */ + @PostMapping("/{projectId}/restore") + @Operation(summary = "恢复项目") + @PreAuthorize("@ss.hasPermi('ccdi:project:edit')") + public AjaxResult restoreProject(@PathVariable Long projectId) { + projectService.restoreProject(projectId, SecurityUtils.getUsername()); + return AjaxResult.success("项目恢复成功"); + } + /** * 查询项目详情 */ diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/CcdiProject.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/CcdiProject.java index 10ffb22f..b286e9d0 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/CcdiProject.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/CcdiProject.java @@ -32,7 +32,7 @@ public class CcdiProject implements Serializable { /** 配置方式:default-全局默认,custom-自定义 */ private String configType; - /** 项目状态:0-进行中,1-已完成,2-已归档,3-打标中,4-打标失败 */ + /** 项目状态:0-进行中,1-已完成,2-已归档,3-打标中,4-打标失败,5-已删除 */ private String status; /** 是否归档:0-未归档,1-已归档 */ @@ -54,7 +54,7 @@ public class CcdiProject implements Serializable { private Integer lsfxProjectId; /** 删除标志:0-存在,2-删除 */ - @TableLogic + @TableLogic(value = "0", delval = "2") private String delFlag; /** 创建者 */ diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiProjectQueryDTO.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiProjectQueryDTO.java index 6644b4c5..940eb4df 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiProjectQueryDTO.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiProjectQueryDTO.java @@ -14,4 +14,7 @@ public class CcdiProjectQueryDTO { /** 项目状态 */ private String status; + + /** 是否查询已删除项目列表 */ + private Boolean includeDeleted; } diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectStatusCountsVO.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectStatusCountsVO.java index 8c291c5d..724fe7c7 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectStatusCountsVO.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectStatusCountsVO.java @@ -26,4 +26,7 @@ public class CcdiProjectStatusCountsVO { /** 打标失败项目数(状态4) */ private Long status4; + + /** 已删除项目数(状态5) */ + private Long status5; } diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectVO.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectVO.java index 5ce11b35..6f716cdb 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectVO.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectVO.java @@ -67,4 +67,7 @@ public class CcdiProjectVO { /** 当前用户是否可操作 */ private Boolean canOperate; + + /** 当前用户是否可删除 */ + private Boolean canDelete; } diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectMapper.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectMapper.java index 0b5e7857..509a69df 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectMapper.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectMapper.java @@ -39,6 +39,32 @@ public interface CcdiProjectMapper extends BaseMapper { List selectHistoryProjects(@Param("queryDTO") CcdiProjectQueryDTO queryDTO, @Param("scope") ProjectAccessScope scope); + /** + * 业务逻辑删除项目,仅更新项目主表状态和删除标记。 + * + * @param projectId 项目ID + * @param operator 操作人 + * @return 更新行数 + */ + int markProjectDeleted(@Param("projectId") Long projectId, @Param("operator") String operator); + + /** + * 恢复已删除项目为已完成。 + * + * @param projectId 项目ID + * @param operator 操作人 + * @return 更新行数 + */ + int restoreDeletedProject(@Param("projectId") Long projectId, @Param("operator") String operator); + + /** + * 统计已删除项目数量。 + * + * @param scope 项目访问范围 + * @return 已删除项目数量 + */ + Long selectDeletedProjectCount(@Param("scope") ProjectAccessScope scope); + /** * 更新项目总人数与风险人数 * diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/CcdiProjectAccessService.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/CcdiProjectAccessService.java index 88b0ab64..0ef8eb9a 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/CcdiProjectAccessService.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/CcdiProjectAccessService.java @@ -2,6 +2,7 @@ package com.ruoyi.ccdi.project.service; import com.ruoyi.ccdi.project.domain.CcdiProject; import com.ruoyi.ccdi.project.domain.ProjectAccessScope; +import com.ruoyi.ccdi.project.constants.CcdiProjectStatusConstants; import com.ruoyi.ccdi.project.domain.entity.CcdiBankStatement; import com.ruoyi.ccdi.project.domain.entity.CcdiEvidence; import com.ruoyi.ccdi.project.domain.entity.CcdiFileUploadRecord; @@ -58,8 +59,17 @@ public class CcdiProjectAccessService { return scope.isSuperAdmin() || Objects.equals(scope.getUsername(), project.getCreateBy()); } + public boolean canDelete(CcdiProject project) { + if (project == null) { + return false; + } + ProjectAccessScope scope = buildCurrentScope(); + return isProjectAdmin(scope) || Objects.equals(scope.getUsername(), project.getCreateBy()); + } + public void assertCanRead(Long projectId) { CcdiProject project = getRequiredProject(projectId); + assertProjectNotDeleted(project); ProjectAccessScope scope = buildCurrentScope(); if (scope.isViewAllProjects() || Objects.equals(scope.getUsername(), project.getCreateBy())) { return; @@ -69,12 +79,29 @@ public class CcdiProjectAccessService { public void assertCanOperate(Long projectId) { CcdiProject project = getRequiredProject(projectId); + assertProjectNotDeleted(project); if (canOperate(project)) { return; } throw new ServiceException("无权操作该项目"); } + public void assertCanDelete(Long projectId) { + CcdiProject project = getRequiredProject(projectId); + assertProjectNotDeleted(project); + if (canDelete(project)) { + return; + } + throw new ServiceException("无权删除该项目"); + } + + public void assertCanManageDeletedProjects() { + if (isProjectAdmin(buildCurrentScope())) { + return; + } + throw new ServiceException("无权管理已删除项目"); + } + public void assertCanReadByBankStatementId(Long bankStatementId) { CcdiBankStatement statement = getRequiredBankStatement(bankStatementId); assertCanRead(statement.getProjectId()); @@ -115,6 +142,19 @@ public class CcdiProjectAccessService { return project; } + private void assertProjectNotDeleted(CcdiProject project) { + if (project == null) { + return; + } + if (CcdiProjectStatusConstants.DELETED.equals(project.getStatus()) || "2".equals(project.getDelFlag())) { + throw new ServiceException("项目已删除"); + } + } + + private boolean isProjectAdmin(ProjectAccessScope scope) { + return scope != null && (scope.isSuperAdmin() || scope.isProjectManager()); + } + private CcdiBankStatement getRequiredBankStatement(Long bankStatementId) { if (bankStatementId == null) { throw new ServiceException("流水ID不能为空"); diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectService.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectService.java index fba89dba..9bcbcd09 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectService.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectService.java @@ -36,9 +36,18 @@ public interface ICcdiProjectService { * 删除项目 * * @param projectId 项目ID + * @param operator 操作人 * @return 是否成功 */ - boolean deleteProject(Long projectId); + boolean deleteProject(Long projectId, String operator); + + /** + * 恢复已删除项目 + * + * @param projectId 项目ID + * @param operator 操作人 + */ + void restoreProject(Long projectId, String operator); /** * 查询项目详情 diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectServiceImpl.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectServiceImpl.java index 467fe249..c773841b 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectServiceImpl.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectServiceImpl.java @@ -117,9 +117,22 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService { } @Override - public boolean deleteProject(Long projectId) { - projectAccessService.assertCanOperate(projectId); - return projectMapper.deleteById(projectId) > 0; + public boolean deleteProject(Long projectId, String operator) { + projectAccessService.assertCanDelete(projectId); + return projectMapper.markProjectDeleted(projectId, resolveOperator(operator)) > 0; + } + + @Override + public void restoreProject(Long projectId, String operator) { + projectAccessService.assertCanManageDeletedProjects(); + int rows = projectMapper.restoreDeletedProject(projectId, resolveOperator(operator)); + if (rows <= 0) { + throw new ServiceException("仅已删除项目允许恢复"); + } + log.info("【项目】项目状态变更: projectId={}, oldStatus={}, oldStatusLabel={}, newStatus={}, newStatusLabel={}, operator={}", + projectId, CcdiProjectStatusConstants.DELETED, resolveStatusLabel(CcdiProjectStatusConstants.DELETED), + CcdiProjectStatusConstants.COMPLETED, resolveStatusLabel(CcdiProjectStatusConstants.COMPLETED), + resolveOperator(operator)); } @Override @@ -201,6 +214,12 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService { .eq(CcdiProject::getStatus, CcdiProjectStatusConstants.TAG_FAILED)); vo.setStatus4(status4Count); + Long status5Count = 0L; + if (isProjectAdmin(scope)) { + status5Count = projectMapper.selectDeletedProjectCount(scope); + } + vo.setStatus5(status5Count); + return vo; } @@ -229,6 +248,9 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService { public void updateProjectStatus(Long projectId, String status, String operator) { CcdiProject project = getRequiredProject(projectId); String oldStatus = project.getStatus(); + if (CcdiProjectStatusConstants.DELETED.equals(project.getStatus())) { + throw new ServiceException("已删除项目不允许更新状态"); + } if (CcdiProjectStatusConstants.ARCHIVED.equals(project.getStatus()) && !CcdiProjectStatusConstants.ARCHIVED.equals(status)) { throw new ServiceException("已归档项目不允许重新进入打标流程"); @@ -247,6 +269,9 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService { @Override public void ensureProjectCanStartTagging(Long projectId) { CcdiProject project = getRequiredProject(projectId); + if (CcdiProjectStatusConstants.DELETED.equals(project.getStatus())) { + throw new ServiceException("已删除项目不允许重新进入打标流程"); + } if (CcdiProjectStatusConstants.ARCHIVED.equals(project.getStatus())) { throw new ServiceException("已归档项目不允许重新进入打标流程"); } @@ -255,6 +280,9 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService { @Override public void ensureProjectNotArchived(Long projectId, String message) { CcdiProject project = getRequiredProject(projectId); + if (CcdiProjectStatusConstants.DELETED.equals(project.getStatus())) { + throw new ServiceException("已删除项目暂不允许操作"); + } if (CcdiProjectStatusConstants.ARCHIVED.equals(project.getStatus())) { throw new ServiceException(message); } @@ -263,6 +291,9 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService { @Override public void ensureProjectWritable(Long projectId, String message) { CcdiProject project = getRequiredProject(projectId); + if (CcdiProjectStatusConstants.DELETED.equals(project.getStatus())) { + throw new ServiceException("已删除项目暂不允许操作"); + } if (CcdiProjectStatusConstants.TAGGING.equals(project.getStatus())) { throw new ServiceException(message); } @@ -283,6 +314,7 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService { case CcdiProjectStatusConstants.ARCHIVED -> "已归档"; case CcdiProjectStatusConstants.TAGGING -> "打标中"; case CcdiProjectStatusConstants.TAG_FAILED -> "打标失败"; + case CcdiProjectStatusConstants.DELETED -> "已删除"; default -> "未知"; }; } @@ -301,12 +333,17 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService { private LambdaQueryWrapper buildScopeWrapper(ProjectAccessScope scope) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.ne(CcdiProject::getStatus, CcdiProjectStatusConstants.DELETED); if (scope != null && !scope.isViewAllProjects()) { wrapper.eq(CcdiProject::getCreateBy, scope.getUsername()); } return wrapper; } + private boolean isProjectAdmin(ProjectAccessScope scope) { + return scope != null && (scope.isSuperAdmin() || scope.isProjectManager()); + } + private void fillProjectExtraFields(List records) { if (records == null || records.isEmpty()) { return; @@ -329,6 +366,7 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService { boolean ownedByCurrentUser = Objects.equals(scope.getUsername(), vo.getCreateBy()); vo.setOwnedByCurrentUser(ownedByCurrentUser); vo.setCanOperate(scope.isSuperAdmin() || ownedByCurrentUser); + vo.setCanDelete(isProjectAdmin(scope) || ownedByCurrentUser); } private String resolveOperator(String operator) { diff --git a/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectMapper.xml b/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectMapper.xml index be79b538..9a0443ec 100644 --- a/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectMapper.xml +++ b/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectMapper.xml @@ -40,13 +40,23 @@ FROM ccdi_project p LEFT JOIN sys_user u ON p.create_by = u.user_name AND u.del_flag = '0' + + + AND p.del_flag = '2' + AND p.status = '5' + + + AND p.del_flag = '0' + AND p.status != '5' + + AND p.create_by = #{scope.username} AND p.project_name LIKE CONCAT('%', #{queryDTO.projectName}, '%') - + AND p.status = #{queryDTO.status} @@ -64,6 +74,7 @@ FROM ccdi_project p p.status in ('1', '2') + AND p.del_flag = '0' AND p.create_by = #{scope.username} @@ -74,6 +85,41 @@ ORDER BY p.update_time DESC + + update ccdi_project + set status = '5', + del_flag = '2', + update_by = #{operator}, + update_time = now() + where project_id = #{projectId} + and del_flag = '0' + and status != '5' + + + + update ccdi_project + set status = '1', + del_flag = '0', + is_archived = 0, + update_by = #{operator}, + update_time = now() + where project_id = #{projectId} + and del_flag = '2' + and status = '5' + + + + update ccdi_project set target_count = #{targetCount}, diff --git a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiProjectControllerTest.java b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiProjectControllerTest.java index a0b7d296..18bf1ed5 100644 --- a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiProjectControllerTest.java +++ b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiProjectControllerTest.java @@ -1,28 +1,20 @@ package com.ruoyi.ccdi.project.controller; -import com.ruoyi.ccdi.project.domain.dto.CcdiProjectImportHistoryDTO; -import com.ruoyi.ccdi.project.domain.vo.CcdiProjectVO; import com.ruoyi.ccdi.project.service.ICcdiProjectService; -import com.ruoyi.common.core.domain.AjaxResult; import io.swagger.v3.oas.annotations.Operation; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PostMapping; import java.lang.reflect.Method; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.when; -import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) class CcdiProjectControllerTest { @@ -35,16 +27,6 @@ class CcdiProjectControllerTest { @Test void shouldArchiveProject() throws Exception { - try (MockedStatic mocked = mockStatic(com.ruoyi.common.utils.SecurityUtils.class)) { - mocked.when(com.ruoyi.common.utils.SecurityUtils::getUsername).thenReturn("tester"); - - AjaxResult result = controller.archiveProject(40L); - - assertEquals(200, result.get("code")); - assertEquals("项目归档成功", result.get("msg")); - verify(projectService).archiveProject(40L, "tester"); - } - Method method = CcdiProjectController.class.getMethod("archiveProject", Long.class); PostMapping postMapping = method.getAnnotation(PostMapping.class); PreAuthorize preAuthorize = method.getAnnotation(PreAuthorize.class); @@ -59,24 +41,32 @@ class CcdiProjectControllerTest { } @Test - void shouldImportFromHistoryAndReturnCreatedProject() { - CcdiProjectImportHistoryDTO dto = new CcdiProjectImportHistoryDTO(); - dto.setProjectName("新建项目"); + void shouldRestoreProject() throws Exception { + Method method = CcdiProjectController.class.getMethod("restoreProject", Long.class); + PostMapping postMapping = method.getAnnotation(PostMapping.class); + PreAuthorize preAuthorize = method.getAnnotation(PreAuthorize.class); + Operation operation = method.getAnnotation(Operation.class); - CcdiProjectVO project = new CcdiProjectVO(); - project.setProjectId(88L); - project.setProjectName("新建项目"); - when(projectService.importFromHistory(eq(dto), eq("tester"))).thenReturn(project); + assertNotNull(postMapping); + assertEquals("/{projectId}/restore", postMapping.value()[0]); + assertNotNull(preAuthorize); + assertEquals("@ss.hasPermi('ccdi:project:edit')", preAuthorize.value()); + assertNotNull(operation); + assertEquals("恢复项目", operation.summary()); + } - try (MockedStatic mocked = mockStatic(com.ruoyi.common.utils.SecurityUtils.class)) { - mocked.when(com.ruoyi.common.utils.SecurityUtils::getUsername).thenReturn("tester"); + @Test + void shouldAllowProjectListUsersToReachBusinessDeleteCheck() throws Exception { + Method method = CcdiProjectController.class.getMethod("deleteProject", Long.class); + DeleteMapping deleteMapping = method.getAnnotation(DeleteMapping.class); + PreAuthorize preAuthorize = method.getAnnotation(PreAuthorize.class); + Operation operation = method.getAnnotation(Operation.class); - AjaxResult result = controller.importFromHistory(dto); - - assertEquals(200, result.get("code")); - assertEquals("项目创建成功", result.get("msg")); - assertSame(project, result.get("data")); - verify(projectService).importFromHistory(dto, "tester"); - } + assertNotNull(deleteMapping); + assertEquals("/{projectId}", deleteMapping.value()[0]); + assertNotNull(preAuthorize); + assertEquals("@ss.hasPermi('ccdi:project:list')", preAuthorize.value()); + assertNotNull(operation); + assertEquals("删除项目", operation.summary()); } } diff --git a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectServiceImplTest.java b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectServiceImplTest.java index b98a7243..f23a873e 100644 --- a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectServiceImplTest.java +++ b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectServiceImplTest.java @@ -4,6 +4,7 @@ import ch.qos.logback.classic.Logger; import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.core.read.ListAppender; import com.ruoyi.ccdi.project.domain.CcdiProject; +import com.ruoyi.ccdi.project.domain.ProjectAccessScope; import com.ruoyi.ccdi.project.domain.dto.CcdiProjectImportHistoryDTO; import com.ruoyi.ccdi.project.domain.dto.CcdiProjectQueryDTO; import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSaveDTO; @@ -83,6 +84,48 @@ class CcdiProjectServiceImplTest { assertEquals(5L, counts.getStatus4()); } + @Test + void shouldCountDeletedProjectsSeparatelyForManagerScope() { + when(projectAccessService.buildCurrentScope()).thenReturn( + new ProjectAccessScope("manager", true, false, true) + ); + when(projectMapper.selectCount(any())).thenReturn(10L, 3L, 4L, 2L, 1L, 5L); + when(projectMapper.selectDeletedProjectCount(any())).thenReturn(6L); + + CcdiProjectStatusCountsVO counts = service.getStatusCounts(); + + assertEquals(6L, counts.getStatus5()); + } + + @Test + void shouldMarkProjectDeletedWithoutCallingBaseDelete() { + when(projectMapper.markProjectDeleted(40L, "tester")).thenReturn(1); + + boolean result = service.deleteProject(40L, "tester"); + + assertTrue(result); + verify(projectAccessService).assertCanDelete(40L); + verify(projectMapper).markProjectDeleted(40L, "tester"); + verify(projectMapper, never()).deleteById(40L); + } + + @Test + void shouldRestoreDeletedProjectToCompleted() { + when(projectMapper.restoreDeletedProject(40L, "tester")).thenReturn(1); + + service.restoreProject(40L, "tester"); + + verify(projectAccessService).assertCanManageDeletedProjects(); + verify(projectMapper).restoreDeletedProject(40L, "tester"); + } + + @Test + void shouldRejectRestoreWhenProjectIsNotDeleted() { + when(projectMapper.restoreDeletedProject(41L, "tester")).thenReturn(0); + + assertThrows(ServiceException.class, () -> service.restoreProject(41L, "tester")); + } + @Test void shouldReturnLatestFailedTagTaskOnFailedProjectDetail() { Date endTime = new Date(); @@ -127,6 +170,19 @@ class CcdiProjectServiceImplTest { () -> service.updateProjectStatus(99L, "3", "system")); } + @Test + void shouldRejectUpdatingDeletedProjectToTaggingOrCompleted() { + CcdiProject deleted = new CcdiProject(); + deleted.setProjectId(99L); + deleted.setStatus("5"); + when(projectMapper.selectById(99L)).thenReturn(deleted); + + assertThrows(ServiceException.class, + () -> service.updateProjectStatus(99L, "3", "system")); + assertThrows(ServiceException.class, + () -> service.updateProjectStatus(99L, "1", "system")); + } + @Test void shouldRejectWritingWhenProjectIsTagging() { CcdiProject tagging = new CcdiProject(); diff --git a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiProjectStatusSqlTest.java b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiProjectStatusSqlTest.java index eed03bf1..01e0d5f3 100644 --- a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiProjectStatusSqlTest.java +++ b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiProjectStatusSqlTest.java @@ -18,6 +18,8 @@ class CcdiProjectStatusSqlTest { String migrationSql = Files.readString(repoRoot.resolve("sql/migration/2026-03-18-add-project-tagging-status.sql")); String tagFailedMigrationSql = Files.readString(repoRoot.resolve("sql/migration/2026-05-27-add-project-tag-failed-status.sql")); + String deletedMigrationSql = + Files.readString(repoRoot.resolve("sql/migration/2026-07-02-add-project-deleted-status.sql")); assertTrue(initSql.contains("打标中")); assertTrue(initSql.contains("'3'")); @@ -34,5 +36,14 @@ class CcdiProjectStatusSqlTest { assertTrue(tagFailedMigrationSql.contains("'4'")); assertTrue(tagFailedMigrationSql.contains("latest_task.status = 'FAILED'")); assertTrue(tagFailedMigrationSql.contains("project.status IN ('0', '3')")); + + assertTrue(initSql.contains("已删除")); + assertTrue(initSql.contains("'5'")); + assertTrue(prodInitSql.contains("已删除")); + assertTrue(prodInitSql.contains("'5','ccdi_project_status'")); + assertTrue(deletedMigrationSql.contains("ccdi_project_status")); + assertTrue(deletedMigrationSql.contains("已删除")); + assertTrue(deletedMigrationSql.contains("'5'")); + assertTrue(deletedMigrationSql.contains("utf8mb4_general_ci")); } } diff --git a/docs/plans/backend/2026-07-02-project-delete-restore-backend-plan.md b/docs/plans/backend/2026-07-02-project-delete-restore-backend-plan.md new file mode 100644 index 00000000..e13c45b0 --- /dev/null +++ b/docs/plans/backend/2026-07-02-project-delete-restore-backend-plan.md @@ -0,0 +1,19 @@ +# 流程列表项目删除与恢复后端实施计划 + +## 目标 + +在项目管理后端将删除改为业务逻辑删除,只更新 `ccdi_project` 主表状态与删除标记,不删除项目内上传记录、流水、标签结果、证据等关联数据。管理员范围按 `admin` 与 `manager` 执行,普通用户仅允许删除本人创建项目。 + +## 实施内容 + +1. 项目状态新增 `5-已删除`,同步常量、实体注释、状态统计 VO、状态文案、初始化 SQL 与迁移 SQL。 +2. 项目删除接口保留原路径,入口权限允许拥有项目列表访问的用户进入,业务权限由 `CcdiProjectAccessService` 判断:`admin/manager` 可删除全部,普通用户仅可删除本人创建项目。 +3. 删除实现改为 Mapper 专用更新语句,仅设置 `status='5'`、`del_flag='2'`、`update_by`、`update_time`,不调用 MyBatis Plus `deleteById`,不操作关联表。 +4. 新增恢复接口 `POST /ccdi/project/{projectId}/restore`,仅 `admin/manager` 可恢复 `status='5' AND del_flag='2'` 的项目,恢复为 `status='1'`、`del_flag='0'`、`is_archived=0`。 +5. 列表查询增加 `includeDeleted`:默认只查 `del_flag='0'` 且排除 `status='5'`;仅管理员查询已删除列表时查 `del_flag='2' AND status='5'`。 +6. 状态统计增加 `status5`,仅管理员返回已删除数量,普通用户返回 0。 +7. 详情、操作、归档、打标状态流转等链路对已删除项目按不可读或不可操作处理,异步状态更新不得覆盖已删除状态。 + +## 验证范围 + +后端定向测试覆盖删除只更新项目主表、恢复到已完成、普通入口可进入业务删除校验、默认列表与已删除列表条件、状态字典与迁移 SQL、已删除状态不可被异步打标状态覆盖。 diff --git a/docs/plans/frontend/2026-07-02-project-delete-restore-frontend-plan.md b/docs/plans/frontend/2026-07-02-project-delete-restore-frontend-plan.md new file mode 100644 index 00000000..ffbc60d2 --- /dev/null +++ b/docs/plans/frontend/2026-07-02-project-delete-restore-frontend-plan.md @@ -0,0 +1,18 @@ +# 流程列表项目删除与恢复前端实施计划 + +## 目标 + +在流程列表页增加项目删除与恢复操作。默认列表不展示已删除项目;`admin` 和 `manager` 可切换到已删除列表并恢复项目;普通用户只能在非删除列表中删除自己有权限删除的项目。 + +## 实施内容 + +1. `ruoyi-ui/src/api/ccdiProject.js` 增加 `restoreProject(projectId)`,删除继续复用现有 `delProject(projectId)`。 +2. 列表页查询参数增加 `includeDeleted`,普通状态 tab 固定传 `includeDeleted=false`;管理员点击“已删除”入口时传 `includeDeleted=true` 且不传普通 `status`。 +3. 搜索条增加“已删除”tab,仅 `admin/manager` 可见,数量使用后端 `status5`。 +4. 项目表格增加删除和恢复按钮:普通列表中按 `canDelete` 且状态不是 `5` 展示删除;删除列表仅展示恢复按钮,不展示进入项目、查看结果、重新分析、归档。 +5. 删除与恢复均使用确认弹窗,删除文案明确项目内数据不会删除,恢复文案明确恢复为已完成状态;成功后刷新列表与统计。 +6. 状态颜色补充 `5-已删除` 的危险色展示,字典值由后端 SQL 提供。 + +## 验证范围 + +前端源码断言覆盖已删除入口管理员可见、`includeDeleted` 参数、删除/恢复 API 调用、普通列表删除按钮、删除列表仅恢复按钮;构建验证 Vue 模板与打包链路。 diff --git a/docs/reports/implementation/2026-07-02-project-delete-restore-implementation.md b/docs/reports/implementation/2026-07-02-project-delete-restore-implementation.md new file mode 100644 index 00000000..17278b97 --- /dev/null +++ b/docs/reports/implementation/2026-07-02-project-delete-restore-implementation.md @@ -0,0 +1,37 @@ +# 流程列表项目删除与恢复实施记录 + +## 修改内容 + +后端新增 `5-已删除` 项目状态,删除项目时仅更新 `ccdi_project.status='5'` 与 `del_flag='2'`,不删除任何项目关联数据;新增恢复接口,将已删除项目恢复为 `已完成(status=1)` 并清除删除标记。列表默认排除删除态,管理员通过 `includeDeleted=true` 查询删除列表并获取 `status5` 统计。 + +前端在流程列表中新增删除按钮和管理员“已删除”列表入口,删除列表只展示恢复按钮。删除、恢复都增加确认弹窗,并在成功后刷新列表和状态统计。 + +删除按钮已调整为红色文本按钮,悬停时使用浅红背景,便于和普通操作按钮区分。 + +## 影响范围 + +- 后端:项目 Controller、Service、权限服务、Mapper XML、项目状态常量、DTO/VO、项目初始化 SQL 与迁移 SQL。 +- 前端:项目列表页、搜索条、项目表格、项目 API。 +- 数据:新增项目状态字典 `5-已删除`,更新项目主表 `status` 字段注释,新增迁移脚本。 + +## 验证记录 + +1. `mvn -pl ccdi-project -am -Dtest=CcdiProjectServiceImplTest,CcdiProjectControllerTest,CcdiProjectMapperXmlTest,CcdiProjectStatusSqlTest -DfailIfNoTests=false -Dsurefire.failIfNoSpecifiedTests=false test` + - 结果:通过,26 个测试全部成功。 +2. `source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use && node tests/unit/project-list-archive-flow.test.js && node tests/unit/project-list-reanalyze-flow.test.js` + - 结果:通过。 +3. `source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use && node tests/unit/project-table-style.test.js && node tests/unit/project-list-archive-flow.test.js && node tests/unit/project-list-reanalyze-flow.test.js` + - 结果:通过,删除按钮红色样式断言通过。 +4. `source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use && npm run build:prod` + - 结果:通过,仅存在既有资源体积警告。 +5. 使用应用内浏览器打开真实页面 `http://localhost:1024/ccdiProject`,以 `admin` 登录验证项目 `删除恢复验证项目-20260702093910`: + - 默认列表可搜索到项目并显示“删除”按钮。 + - 删除确认弹窗提示“项目内数据不会删除”,确认后默认列表不再展示该项目。 + - “已删除”列表展示该项目且仅有“恢复”按钮,不展示进入项目、查看结果、重新分析、归档。 + - 恢复确认弹窗提示“项目将恢复为已完成状态”,确认后已删除列表不再展示该项目,接口复核默认列表中该项目状态为 `1`。 + - 验收结束后已删除本轮验证项目数据。 + - 验收期间启动的后端进程已通过 `bin/restart_java_backend.sh stop` 关闭;前端 1024 服务为验收前已存在进程,未额外关闭。 + +## 后续验收 + +已完成本地真实页面验收。后续上线前需在目标环境执行迁移脚本并使用管理员账号复核删除、已删除列表与恢复链路。 diff --git a/ruoyi-ui/src/api/ccdiProject.js b/ruoyi-ui/src/api/ccdiProject.js index 5bc27c6f..a65b220a 100644 --- a/ruoyi-ui/src/api/ccdiProject.js +++ b/ruoyi-ui/src/api/ccdiProject.js @@ -52,6 +52,14 @@ export function delProject(projectIds) { }) } +// 恢复已删除初核项目 +export function restoreProject(projectId) { + return request({ + url: '/ccdi/project/' + projectId + '/restore', + method: 'post' + }) +} + // 导出初核项目 export function exportProject(query) { return request({ diff --git a/ruoyi-ui/src/views/ccdiProject/components/ProjectTable.vue b/ruoyi-ui/src/views/ccdiProject/components/ProjectTable.vue index cf33e7bf..3b48bfc6 100644 --- a/ruoyi-ui/src/views/ccdiProject/components/ProjectTable.vue +++ b/ruoyi-ui/src/views/ccdiProject/components/ProjectTable.vue @@ -92,23 +92,66 @@ diff --git a/ruoyi-ui/tests/unit/project-list-archive-flow.test.js b/ruoyi-ui/tests/unit/project-list-archive-flow.test.js index 97527f19..4eb54b6e 100644 --- a/ruoyi-ui/tests/unit/project-list-archive-flow.test.js +++ b/ruoyi-ui/tests/unit/project-list-archive-flow.test.js @@ -10,6 +10,46 @@ const dialogPath = path.resolve( const pageSource = fs.readFileSync(pagePath, "utf8"); const dialogSource = fs.readFileSync(dialogPath, "utf8"); +assert( + pageSource.includes("delProject") && pageSource.includes("restoreProject"), + "项目列表页应引入删除和恢复接口" +); + +assert( + pageSource.includes("includeDeleted: false"), + "默认查询参数应不包含已删除项目" +); + +assert( + pageSource.includes(':show-deleted-tab="isProjectAdmin"'), + "已删除入口应仅对项目管理员角色展示" +); + +assert( + pageSource.includes("'5': counts.status5 || 0"), + "状态统计应接入已删除数量" +); + +assert( + pageSource.includes('await delProject(row.projectId)'), + "删除确认后应调用项目删除接口" +); + +assert( + pageSource.includes('await restoreProject(row.projectId)'), + "恢复确认后应调用项目恢复接口" +); + +assert( + pageSource.includes("项目内数据不会删除"), + "删除确认文案应明确项目内数据不会删除" +); + +assert( + pageSource.includes("项目将恢复为已完成状态"), + "恢复确认文案应明确恢复到已完成状态" +); + assert( pageSource.includes("await archiveProject(data.projectId)"), "确认归档后应调用真实归档接口" diff --git a/ruoyi-ui/tests/unit/project-list-reanalyze-flow.test.js b/ruoyi-ui/tests/unit/project-list-reanalyze-flow.test.js index ab8ea8ef..3a54ff1c 100644 --- a/ruoyi-ui/tests/unit/project-list-reanalyze-flow.test.js +++ b/ruoyi-ui/tests/unit/project-list-reanalyze-flow.test.js @@ -6,6 +6,48 @@ const pagePath = path.resolve(__dirname, "../../src/views/ccdiProject/index.vue" const tablePath = path.resolve(__dirname, "../../src/views/ccdiProject/components/ProjectTable.vue"); const pageSource = fs.readFileSync(pagePath, "utf8"); const tableSource = fs.readFileSync(tablePath, "utf8"); +const searchBarPath = path.resolve(__dirname, "../../src/views/ccdiProject/components/SearchBar.vue"); +const searchBarSource = fs.readFileSync(searchBarPath, "utf8"); + +assert( + searchBarSource.includes("{ label: '已删除', value: 'deleted'"), + "搜索条应提供独立的已删除列表入口" +); + +assert( + searchBarSource.includes("tab.value !== 'deleted' || this.showDeletedTab"), + "已删除入口应由 showDeletedTab 控制可见性" +); + +assert( + searchBarSource.includes("const includeDeleted = this.activeTab === 'deleted'"), + "切换已删除入口时应生成 includeDeleted 查询态" +); + +assert( + searchBarSource.includes("status: includeDeleted || this.activeTab === 'all' ? null : this.activeTab"), + "已删除列表不应和普通状态 tab 混用" +); + +assert( + tableSource.includes('v-if="deletedList"'), + "删除列表应使用独立操作区" +); + +assert( + tableSource.includes('@click="handleRestore(scope.row)"'), + "删除列表应提供恢复按钮" +); + +assert( + tableSource.includes('v-if="canDelete(scope.row) && scope.row.status !== \'5\'"'), + "普通列表应按 canDelete 展示删除按钮且排除已删除状态" +); + +assert( + /