实现流程项目逻辑删除与恢复

This commit is contained in:
wkc
2026-07-02 10:54:36 +08:00
parent 2f53fc4d1e
commit 57a33098c9
27 changed files with 690 additions and 97 deletions

View File

@@ -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() {
}

View File

@@ -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("项目恢复成功");
}
/**
* 查询项目详情
*/

View File

@@ -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;
/** 创建者 */

View File

@@ -14,4 +14,7 @@ public class CcdiProjectQueryDTO {
/** 项目状态 */
private String status;
/** 是否查询已删除项目列表 */
private Boolean includeDeleted;
}

View File

@@ -26,4 +26,7 @@ public class CcdiProjectStatusCountsVO {
/** 打标失败项目数(状态4) */
private Long status4;
/** 已删除项目数(状态5) */
private Long status5;
}

View File

@@ -67,4 +67,7 @@ public class CcdiProjectVO {
/** 当前用户是否可操作 */
private Boolean canOperate;
/** 当前用户是否可删除 */
private Boolean canDelete;
}

View File

@@ -39,6 +39,32 @@ public interface CcdiProjectMapper extends BaseMapper<CcdiProject> {
List<CcdiProjectHistoryListItemVO> 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);
/**
* 更新项目总人数与风险人数
*

View File

@@ -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不能为空");

View File

@@ -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);
/**
* 查询项目详情

View File

@@ -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<CcdiProject> buildScopeWrapper(ProjectAccessScope scope) {
LambdaQueryWrapper<CcdiProject> 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<CcdiProjectVO> 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) {

View File

@@ -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'
<where>
<choose>
<when test="queryDTO != null and queryDTO.includeDeleted == true and scope != null and scope.viewAllProjects">
AND p.del_flag = '2'
AND p.status = '5'
</when>
<otherwise>
AND p.del_flag = '0'
AND p.status != '5'
</otherwise>
</choose>
<if test="scope != null and !scope.viewAllProjects">
AND p.create_by = #{scope.username}
</if>
<if test="queryDTO.projectName != null and queryDTO.projectName != ''">
AND p.project_name LIKE CONCAT('%', #{queryDTO.projectName}, '%')
</if>
<if test="queryDTO.status != null and queryDTO.status != ''">
<if test="!(queryDTO != null and queryDTO.includeDeleted == true) and queryDTO.status != null and queryDTO.status != ''">
AND p.status = #{queryDTO.status}
</if>
</where>
@@ -64,6 +74,7 @@
FROM ccdi_project p
<where>
p.status in ('1', '2')
AND p.del_flag = '0'
<if test="scope != null and !scope.viewAllProjects">
AND p.create_by = #{scope.username}
</if>
@@ -74,6 +85,41 @@
ORDER BY p.update_time DESC
</select>
<update id="markProjectDeleted">
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>
<update id="restoreDeletedProject">
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>
<select id="selectDeletedProjectCount" resultType="java.lang.Long">
select count(1)
from ccdi_project p
<where>
p.del_flag = '2'
and p.status = '5'
<if test="scope != null and !scope.viewAllProjects">
AND p.create_by = #{scope.username}
</if>
</where>
</select>
<update id="updateRiskCountsByProjectId">
update ccdi_project
set target_count = #{targetCount},

View File

@@ -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<com.ruoyi.common.utils.SecurityUtils> 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<com.ruoyi.common.utils.SecurityUtils> 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());
}
}

View File

@@ -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();

View File

@@ -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"));
}
}