实现项目打标状态联动并执行前后端适配

This commit is contained in:
wkc
2026-03-18 15:55:55 +08:00
parent e9394939c9
commit c0ce5ca7f9
27 changed files with 651 additions and 14 deletions

View File

@@ -0,0 +1,15 @@
package com.ruoyi.ccdi.project.constants;
/**
* 项目状态常量
*/
public final class CcdiProjectStatusConstants {
public static final String PROCESSING = "0";
public static final String COMPLETED = "1";
public static final String ARCHIVED = "2";
public static final String TAGGING = "3";
private CcdiProjectStatusConstants() {
}
}

View File

@@ -32,7 +32,7 @@ public class CcdiProject implements Serializable {
/** 配置方式default-全局默认custom-自定义 */
private String configType;
/** 项目状态0-进行中1-已完成2-已归档 */
/** 项目状态0-进行中1-已完成2-已归档3-打标中 */
private String status;
/** 是否归档0-未归档1-已归档 */

View File

@@ -20,4 +20,7 @@ public class CcdiProjectStatusCountsVO {
/** 已归档项目数(状态2) */
private Long status2;
/** 打标中项目数(状态3) */
private Long status3;
}

View File

@@ -59,4 +59,28 @@ public interface ICcdiProjectService {
* @return 状态统计
*/
CcdiProjectStatusCountsVO getStatusCounts();
/**
* 更新项目状态
*
* @param projectId 项目ID
* @param status 状态编码
* @param operator 操作人
*/
void updateProjectStatus(Long projectId, String status, String operator);
/**
* 校验项目是否允许进入打标流程
*
* @param projectId 项目ID
*/
void ensureProjectCanStartTagging(Long projectId);
/**
* 校验项目是否允许写入
*
* @param projectId 项目ID
* @param message 拒绝文案
*/
void ensureProjectWritable(Long projectId, String message);
}

View File

@@ -1,5 +1,6 @@
package com.ruoyi.ccdi.project.service.impl;
import com.ruoyi.ccdi.project.constants.CcdiProjectStatusConstants;
import com.ruoyi.ccdi.project.domain.dto.CcdiBankTagRebuildDTO;
import com.ruoyi.ccdi.project.domain.entity.CcdiBankTagResult;
import com.ruoyi.ccdi.project.domain.entity.CcdiBankTagRule;
@@ -13,6 +14,7 @@ import com.ruoyi.ccdi.project.mapper.CcdiBankTagResultMapper;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagRuleMapper;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagTaskMapper;
import com.ruoyi.ccdi.project.service.ICcdiBankTagService;
import com.ruoyi.ccdi.project.service.ICcdiProjectService;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
@@ -54,6 +56,9 @@ public class CcdiBankTagServiceImpl implements ICcdiBankTagService {
@Resource
private BankTagRuleConfigResolver configResolver;
@Resource
private ICcdiProjectService projectService;
@Resource
@Qualifier("tagRuleExecutor")
private Executor tagRuleExecutor;
@@ -88,6 +93,7 @@ public class CcdiBankTagServiceImpl implements ICcdiBankTagService {
*/
public Long rebuildProject(Long projectId, String modelCode, String operator, TriggerType triggerType) {
long taskStartTime = System.currentTimeMillis();
projectService.updateProjectStatus(projectId, CcdiProjectStatusConstants.TAGGING, operator);
CcdiBankTagTask task = buildRunningTask(projectId, modelCode, operator, triggerType);
taskMapper.insertTask(task);
log.info("【流水标签】任务创建成功: taskId={}, projectId={}, modelCode={}, triggerType={}, operator={}",
@@ -128,6 +134,7 @@ public class CcdiBankTagServiceImpl implements ICcdiBankTagService {
task.setUpdateBy(operator);
task.setUpdateTime(new Date());
taskMapper.updateTask(task);
projectService.updateProjectStatus(projectId, CcdiProjectStatusConstants.COMPLETED, operator);
log.info("【流水标签】任务执行成功: taskId={}, projectId={}, modelCode={}, triggerType={}, ruleCount={}, hitCount={}, costMs={}",
task.getId(), projectId, modelCode, triggerType, rules.size(), allResults.size(),
System.currentTimeMillis() - taskStartTime);
@@ -140,6 +147,7 @@ public class CcdiBankTagServiceImpl implements ICcdiBankTagService {
task.setUpdateBy(operator);
task.setUpdateTime(new Date());
taskMapper.updateTask(task);
projectService.updateProjectStatus(projectId, CcdiProjectStatusConstants.PROCESSING, operator);
log.error("【流水标签】任务执行失败: taskId={}, projectId={}, modelCode={}, triggerType={}, error={}",
task.getId(), projectId, modelCode, triggerType, ex.getMessage(), ex);
throw ex;

View File

@@ -15,6 +15,7 @@ import com.ruoyi.ccdi.project.mapper.CcdiFileUploadRecordMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.ccdi.project.service.ICcdiBankTagService;
import com.ruoyi.ccdi.project.service.ICcdiFileUploadService;
import com.ruoyi.ccdi.project.service.ICcdiProjectService;
import com.ruoyi.lsfx.client.LsfxAnalysisClient;
import com.ruoyi.lsfx.constants.LsfxConstants;
import com.ruoyi.lsfx.domain.request.FetchInnerFlowRequest;
@@ -96,6 +97,9 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
@Resource
private ICcdiBankTagService bankTagService;
@Resource
private ICcdiProjectService projectService;
/**
* 获取临时文件存储目录
*/
@@ -165,6 +169,8 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
throw new IllegalArgumentException("开始日期不能晚于结束日期");
}
projectService.ensureProjectWritable(projectId, "当前项目正在进行银行流水打标,暂不允许上传或拉取数据");
CcdiProject project = projectMapper.selectById(projectId);
if (project == null) {
throw new IllegalArgumentException("项目不存在: projectId=" + projectId);
@@ -311,6 +317,8 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
log.info("【文件上传】开始批量上传: projectId={}, 文件数量={}, username={}",
projectId, files.length, username);
projectService.ensureProjectWritable(projectId, "当前项目正在进行银行流水打标,暂不允许上传或拉取数据");
// 1. 生成批次ID
String batchId = UUID.randomUUID().toString().replace("-", "");

View File

@@ -17,6 +17,7 @@ import com.ruoyi.ccdi.project.domain.vo.ModelGroupVO;
import com.ruoyi.ccdi.project.mapper.CcdiModelParamMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.ccdi.project.service.ICcdiModelParamService;
import com.ruoyi.ccdi.project.service.ICcdiProjectService;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -46,6 +47,9 @@ public class CcdiModelParamServiceImpl implements ICcdiModelParamService {
@Resource
private CcdiProjectMapper projectMapper;
@Resource
private ICcdiProjectService projectService;
@Override
public List<ModelListVO> selectModelList(Long projectId) {
log.info("selectModelList 被调用projectId={}", projectId);
@@ -102,6 +106,7 @@ public class CcdiModelParamServiceImpl implements ICcdiModelParamService {
Long projectId = saveDTO.getProjectId();
if (projectId > 0) {
projectService.ensureProjectWritable(projectId, "当前项目正在进行银行流水打标,暂不允许修改参数");
switchToCustomConfigIfNeeded(getRequiredProject(projectId));
}
@@ -178,6 +183,7 @@ public class CcdiModelParamServiceImpl implements ICcdiModelParamService {
Long projectId = saveAllDTO.getProjectId();
if (projectId > 0) {
projectService.ensureProjectWritable(projectId, "当前项目正在进行银行流水打标,暂不允许修改参数");
switchToCustomConfigIfNeeded(getRequiredProject(projectId));
}

View File

@@ -2,6 +2,7 @@ package com.ruoyi.ccdi.project.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.constants.CcdiProjectStatusConstants;
import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSaveDTO;
@@ -18,6 +19,8 @@ import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Date;
/**
* 项目Service实现类
*
@@ -43,7 +46,7 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
BeanUtils.copyProperties(dto, project);
// 3. 设置默认值和流水分析平台ID
project.setStatus("0"); // 进行中
project.setStatus(CcdiProjectStatusConstants.PROCESSING);
project.setIsArchived(0); // 未归档
project.setTargetCount(0);
project.setHighRiskCount(0);
@@ -115,27 +118,70 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
// 统计进行中项目状态0
Long status0Count = projectMapper.selectCount(
new LambdaQueryWrapper<CcdiProject>()
.eq(CcdiProject::getStatus, "0")
.eq(CcdiProject::getStatus, CcdiProjectStatusConstants.PROCESSING)
);
vo.setStatus0(status0Count);
// 统计已完成项目状态1
Long status1Count = projectMapper.selectCount(
new LambdaQueryWrapper<CcdiProject>()
.eq(CcdiProject::getStatus, "1")
.eq(CcdiProject::getStatus, CcdiProjectStatusConstants.COMPLETED)
);
vo.setStatus1(status1Count);
// 统计已归档项目状态2
Long status2Count = projectMapper.selectCount(
new LambdaQueryWrapper<CcdiProject>()
.eq(CcdiProject::getStatus, "2")
.eq(CcdiProject::getStatus, CcdiProjectStatusConstants.ARCHIVED)
);
vo.setStatus2(status2Count);
Long status3Count = projectMapper.selectCount(
new LambdaQueryWrapper<CcdiProject>()
.eq(CcdiProject::getStatus, CcdiProjectStatusConstants.TAGGING)
);
vo.setStatus3(status3Count);
return vo;
}
@Override
public void updateProjectStatus(Long projectId, String status, String operator) {
CcdiProject project = getRequiredProject(projectId);
if (CcdiProjectStatusConstants.ARCHIVED.equals(project.getStatus())
&& !CcdiProjectStatusConstants.ARCHIVED.equals(status)) {
throw new ServiceException("已归档项目不允许重新进入打标流程");
}
project.setStatus(status);
project.setUpdateBy(operator);
project.setUpdateTime(new Date());
projectMapper.updateById(project);
}
@Override
public void ensureProjectCanStartTagging(Long projectId) {
CcdiProject project = getRequiredProject(projectId);
if (CcdiProjectStatusConstants.ARCHIVED.equals(project.getStatus())) {
throw new ServiceException("已归档项目不允许重新进入打标流程");
}
}
@Override
public void ensureProjectWritable(Long projectId, String message) {
CcdiProject project = getRequiredProject(projectId);
if (CcdiProjectStatusConstants.TAGGING.equals(project.getStatus())) {
throw new ServiceException(message);
}
}
private CcdiProject getRequiredProject(Long projectId) {
CcdiProject project = projectMapper.selectById(projectId);
if (project == null) {
throw new ServiceException("项目不存在");
}
return project;
}
/**
* 调用流水分析平台获取projectId
*

View File

@@ -3,6 +3,7 @@ package com.ruoyi.ccdi.project.service.impl;
import com.ruoyi.ccdi.project.domain.entity.CcdiBankTagTask;
import com.ruoyi.ccdi.project.domain.enums.TriggerType;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagTaskMapper;
import com.ruoyi.ccdi.project.service.ICcdiProjectService;
import com.ruoyi.common.exception.ServiceException;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
@@ -27,6 +28,9 @@ public class ProjectBankTagRebuildCoordinator {
@Resource
private CcdiBankTagServiceImpl bankTagService;
@Resource
private ICcdiProjectService projectService;
/**
* 提交手动重算
*
@@ -43,6 +47,7 @@ public class ProjectBankTagRebuildCoordinator {
throw new ServiceException("当前项目标签正在重算中,请稍后再试");
}
projectService.ensureProjectCanStartTagging(projectId);
executeWithLock(projectId, () -> bankTagService.rebuildProject(projectId, modelCode, operator, TriggerType.MANUAL));
}
@@ -62,6 +67,7 @@ public class ProjectBankTagRebuildCoordinator {
}
executeWithLock(projectId, () -> {
projectService.ensureProjectCanStartTagging(projectId);
boolean needRerun;
do {
Long taskId = bankTagService.rebuildProject(projectId, null, "system", triggerType);

View File

@@ -12,6 +12,7 @@ import com.ruoyi.ccdi.project.mapper.CcdiBankTagAnalysisMapper;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagResultMapper;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagRuleMapper;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagTaskMapper;
import com.ruoyi.ccdi.project.service.ICcdiProjectService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InOrder;
@@ -56,6 +57,9 @@ class CcdiBankTagServiceImplTest {
@Mock
private BankTagRuleConfigResolver configResolver;
@Mock
private ICcdiProjectService projectService;
@Test
void rebuildProject_shouldDeleteOldResultsBeforeSubmittingRuleTasks() {
ReflectionTestUtils.setField(service, "tagRuleExecutor", (Executor) Runnable::run);
@@ -218,6 +222,42 @@ class CcdiBankTagServiceImplTest {
verify(taskMapper).updateTask(argThat(task -> "SUCCESS".equals(task.getStatus()) && task.getFailedRuleCount() == 0));
}
@Test
void shouldMarkProjectTaggingBeforeExecutingAndCompletedAfterSuccess() {
ReflectionTestUtils.setField(service, "tagRuleExecutor", (Executor) Runnable::run);
CcdiBankTagRule rule = buildRule("LARGE_TRANSACTION", "大额交易",
"HOUSE_OR_CAR_EXPENSE", "房车消费支出交易", "STATEMENT");
BankTagRuleExecutionConfig config = buildConfig(40L, rule);
when(ruleMapper.selectEnabledRules(null)).thenReturn(List.of(rule));
when(configResolver.resolve(40L, rule)).thenReturn(config);
when(analysisMapper.selectHouseOrCarExpenseStatements(40L)).thenReturn(List.of());
service.rebuildProject(40L, null, "tester", TriggerType.MANUAL);
InOrder inOrder = inOrder(projectService, taskMapper);
inOrder.verify(projectService).updateProjectStatus(40L, "3", "tester");
inOrder.verify(taskMapper).updateTask(argThat(task -> "SUCCESS".equals(task.getStatus())));
inOrder.verify(projectService).updateProjectStatus(40L, "1", "tester");
}
@Test
void shouldRollbackProjectStatusToProcessingWhenRebuildFails() {
ReflectionTestUtils.setField(service, "tagRuleExecutor", (Executor) Runnable::run);
CcdiBankTagRule rule = buildRule("LARGE_TRANSACTION", "大额交易",
"HOUSE_OR_CAR_EXPENSE", "房车消费支出交易", "STATEMENT");
when(ruleMapper.selectEnabledRules(null)).thenReturn(List.of(rule));
when(configResolver.resolve(40L, rule)).thenThrow(new RuntimeException("threshold missing"));
assertThrows(RuntimeException.class,
() -> service.rebuildProject(40L, null, "tester", TriggerType.MANUAL));
verify(projectService).updateProjectStatus(40L, "0", "tester");
}
private CcdiBankTagRule buildRule(String modelCode, String modelName, String ruleCode, String ruleName, String resultType) {
CcdiBankTagRule rule = new CcdiBankTagRule();
rule.setModelCode(modelCode);

View File

@@ -12,6 +12,8 @@ import com.ruoyi.ccdi.project.mapper.CcdiBankStatementMapper;
import com.ruoyi.ccdi.project.mapper.CcdiFileUploadRecordMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.ccdi.project.service.ICcdiBankTagService;
import com.ruoyi.ccdi.project.service.ICcdiProjectService;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.lsfx.client.LsfxAnalysisClient;
import com.ruoyi.lsfx.domain.request.GetBankStatementRequest;
import com.ruoyi.lsfx.domain.response.CheckParseStatusResponse;
@@ -89,6 +91,9 @@ class CcdiFileUploadServiceImplTest {
@Mock
private ICcdiBankTagService bankTagService;
@Mock
private ICcdiProjectService projectService;
@TempDir
Path tempDir;
@@ -154,6 +159,38 @@ class CcdiFileUploadServiceImplTest {
}
}
@Test
void shouldRejectPullBankInfoWhenProjectIsTagging() {
org.mockito.Mockito.doThrow(new ServiceException("当前项目正在进行银行流水打标,暂不允许上传或拉取数据"))
.when(projectService).ensureProjectWritable(PROJECT_ID, "当前项目正在进行银行流水打标,暂不允许上传或拉取数据");
assertThrows(ServiceException.class,
() -> service.submitPullBankInfo(
PROJECT_ID,
List.of("3301"),
"2026-01-01",
"2026-01-31",
1L,
"tester"
));
}
@Test
void shouldRejectBatchUploadWhenProjectIsTagging() {
org.mockito.Mockito.doThrow(new ServiceException("当前项目正在进行银行流水打标,暂不允许上传或拉取数据"))
.when(projectService).ensureProjectWritable(PROJECT_ID, "当前项目正在进行银行流水打标,暂不允许上传或拉取数据");
MockMultipartFile file = new MockMultipartFile(
"files",
"test.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"content".getBytes()
);
assertThrows(ServiceException.class,
() -> service.batchUploadFiles(PROJECT_ID, new MultipartFile[]{file}, "tester"));
}
@Test
void submitTasksAsync_shouldNotCreateLocalBatchLogFiles() throws Exception {
setField("uploadPath", tempDir.toString());

View File

@@ -2,10 +2,15 @@ package com.ruoyi.ccdi.project.service.impl;
import com.ruoyi.ccdi.project.domain.CcdiModelParam;
import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.dto.ModelParamGroupDTO;
import com.ruoyi.ccdi.project.domain.dto.ModelParamSaveDTO;
import com.ruoyi.ccdi.project.domain.dto.ModelParamSaveAllDTO;
import com.ruoyi.ccdi.project.domain.dto.ParamValueItem;
import com.ruoyi.ccdi.project.domain.vo.ModelParamAllVO;
import com.ruoyi.ccdi.project.mapper.CcdiModelParamMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.ccdi.project.service.ICcdiProjectService;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -18,6 +23,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.eq;
@@ -37,6 +43,9 @@ class CcdiModelParamServiceImplTest {
@Mock
private CcdiProjectMapper projectMapper;
@Mock
private ICcdiProjectService projectService;
@Test
void selectAllParams_shouldReadSystemDefaultsForDefaultProject() {
CcdiProject project = new CcdiProject();
@@ -95,6 +104,30 @@ class CcdiModelParamServiceImplTest {
verify(modelParamMapper).updateParamValue(123L, "LARGE_TRANSACTION", "SINGLE_TRANSACTION_AMOUNT", "2222", "admin");
}
@Test
void shouldRejectSaveAllParamsWhenProjectIsTagging() {
org.mockito.Mockito.doThrow(new ServiceException("当前项目正在进行银行流水打标,暂不允许修改参数"))
.when(projectService).ensureProjectWritable(40L, "当前项目正在进行银行流水打标,暂不允许修改参数");
assertThrows(ServiceException.class, () -> service.saveAllParams(buildSaveAllDto()));
}
@Test
void shouldRejectSaveParamsWhenProjectIsTagging() {
ModelParamSaveDTO saveDTO = new ModelParamSaveDTO();
saveDTO.setProjectId(40L);
saveDTO.setModelCode("LARGE_TRANSACTION");
ModelParamSaveDTO.ParamValueItem item = new ModelParamSaveDTO.ParamValueItem();
item.setParamCode("SINGLE_TRANSACTION_AMOUNT");
item.setParamValue("2000");
saveDTO.setParams(List.of(item));
org.mockito.Mockito.doThrow(new ServiceException("当前项目正在进行银行流水打标,暂不允许修改参数"))
.when(projectService).ensureProjectWritable(40L, "当前项目正在进行银行流水打标,暂不允许修改参数");
assertThrows(ServiceException.class, () -> service.saveParams(saveDTO));
}
private CcdiModelParam buildParam(
Long id,
Long projectId,
@@ -114,4 +147,19 @@ class CcdiModelParamServiceImplTest {
param.setSortOrder(1);
return param;
}
private ModelParamSaveAllDTO buildSaveAllDto() {
ParamValueItem item = new ParamValueItem();
item.setParamCode("SINGLE_TRANSACTION_AMOUNT");
item.setParamValue("2000");
ModelParamGroupDTO group = new ModelParamGroupDTO();
group.setModelCode("LARGE_TRANSACTION");
group.setParams(List.of(item));
ModelParamSaveAllDTO dto = new ModelParamSaveAllDTO();
dto.setProjectId(40L);
dto.setModels(List.of(group));
return dto;
}
}

View File

@@ -0,0 +1,61 @@
package com.ruoyi.ccdi.project.service.impl;
import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectStatusCountsVO;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.lsfx.client.LsfxAnalysisClient;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CcdiProjectServiceImplTest {
@InjectMocks
private CcdiProjectServiceImpl service;
@Mock
private CcdiProjectMapper projectMapper;
@Mock
private LsfxAnalysisClient lsfxAnalysisClient;
@Test
void shouldCountTaggingProjectsSeparately() {
when(projectMapper.selectCount(any())).thenReturn(10L, 3L, 4L, 2L, 1L);
CcdiProjectStatusCountsVO counts = service.getStatusCounts();
assertEquals(1L, counts.getStatus3());
}
@Test
void shouldRejectUpdatingArchivedProjectToTagging() {
CcdiProject archived = new CcdiProject();
archived.setProjectId(99L);
archived.setStatus("2");
when(projectMapper.selectById(99L)).thenReturn(archived);
assertThrows(ServiceException.class,
() -> service.updateProjectStatus(99L, "3", "system"));
}
@Test
void shouldRejectWritingWhenProjectIsTagging() {
CcdiProject tagging = new CcdiProject();
tagging.setProjectId(40L);
tagging.setStatus("3");
when(projectMapper.selectById(40L)).thenReturn(tagging);
assertThrows(ServiceException.class,
() -> service.ensureProjectWritable(40L, "当前项目正在进行银行流水打标,暂不允许修改参数"));
}
}

View File

@@ -6,6 +6,7 @@ import ch.qos.logback.core.read.ListAppender;
import com.ruoyi.ccdi.project.domain.entity.CcdiBankTagTask;
import com.ruoyi.ccdi.project.domain.enums.TriggerType;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagTaskMapper;
import com.ruoyi.ccdi.project.service.ICcdiProjectService;
import com.ruoyi.common.exception.ServiceException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -33,6 +34,9 @@ class ProjectBankTagRebuildCoordinatorTest {
@Mock
private CcdiBankTagServiceImpl bankTagService;
@Mock
private ICcdiProjectService projectService;
@Test
void submitManualRebuild_shouldRejectWhenProjectAlreadyRunning() {
CcdiBankTagTask runningTask = new CcdiBankTagTask();
@@ -109,4 +113,13 @@ class ProjectBankTagRebuildCoordinatorTest {
logger.detachAppender(logAppender);
}
}
@Test
void shouldRejectSubmittingRebuildForArchivedProject() {
org.mockito.Mockito.doThrow(new ServiceException("已归档项目不允许重新进入打标流程"))
.when(projectService).ensureProjectCanStartTagging(40L);
assertThrows(ServiceException.class,
() -> coordinator.submitManual(40L, null, "tester"));
}
}

View File

@@ -0,0 +1,25 @@
package com.ruoyi.ccdi.project.sql;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CcdiProjectStatusSqlTest {
@Test
void shouldContainTaggingStatusInInitAndMigrationSql() throws IOException {
Path repoRoot = Path.of("..");
String initSql = Files.readString(repoRoot.resolve("sql/ccdi_project.sql"));
String migrationSql = Files.readString(repoRoot.resolve("sql/migration/2026-03-18-add-project-tagging-status.sql"));
assertTrue(initSql.contains("打标中"));
assertTrue(initSql.contains("'3'"));
assertTrue(migrationSql.contains("ccdi_project_status"));
assertTrue(migrationSql.contains("打标中"));
assertTrue(migrationSql.contains("'3'"));
}
}

View File

@@ -0,0 +1,38 @@
# 项目打标状态联动后端实施记录
## 本次改动
- 新增项目状态常量 `3-打标中`,并补齐初始化 SQL 与增量 SQL。
- 在项目服务中新增统一状态更新、打标准入校验、打标中写保护能力,并把状态统计扩展到 `status3`
- 在银行流水打标主链路中接入项目状态流转:
- 开始打标前置为 `3`
- 打标成功后置为 `1`
- 打标失败后回退为 `0`
- 在上传流水、拉取本行信息、参数保存链路中统一接入项目写保护,打标中直接拒绝写操作。
- 增补 SQL 基线测试、项目状态服务测试、打标状态流转测试、上传/参数写保护测试。
## 影响范围
- `sql/ccdi_project.sql`
- `sql/migration/2026-03-18-add-project-tagging-status.sql`
- `ccdi-project` 模块中的项目服务、打标服务、协调器、文件上传服务、模型参数服务及对应测试
## 验证结果
- 后端聚焦回归命令执行通过:
```bash
mvn -pl ccdi-project -Dtest=CcdiProjectServiceImplTest,CcdiBankTagServiceImplTest,ProjectBankTagRebuildCoordinatorTest,CcdiFileUploadServiceImplTest,CcdiModelParamServiceImplTest,CcdiProjectStatusSqlTest test
```
- 测试结果:`Tests run: 44, Failures: 0, Errors: 0, Skipped: 0`
## SQL 执行方式
- 联调或生产前执行状态增量脚本时,必须使用仓库脚本:
```bash
bin/mysql_utf8_exec.sh sql/migration/2026-03-18-add-project-tagging-status.sql
```
- 这样可以保证中文字典值“打标中”在 MySQL 会话中按 `utf8mb4` 正确写入,避免乱码。

View File

@@ -0,0 +1,46 @@
# 项目打标状态联动前端实施记录
## 本次改动
- 在项目列表、详情页和顶部状态统计中补充 `3-打标中` 展示。
- 在上传数据页增加统一受限态:
- “拉取本行信息”按钮禁用
- “上传流水”入口禁用
- 页面顶部展示受限提示文案
- 在参数模型页增加只读态:
- 参数输入框禁用
- “保存所有修改”按钮禁用
- 页面顶部展示只读提示文案
- 在项目详情页接入 `refresh-project` 事件,上传页在以下时机触发父组件刷新项目状态:
- 批量上传提交成功后
- 拉取本行信息提交成功后
- 文件轮询检测到状态变化后
- 手工刷新文件列表后
## 影响范围
- `ruoyi-ui/src/views/ccdiProject/detail.vue`
- `ruoyi-ui/src/views/ccdiProject/index.vue`
- `ruoyi-ui/src/views/ccdiProject/components/ProjectTable.vue`
- `ruoyi-ui/src/views/ccdiProject/components/SearchBar.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/ParamConfig.vue`
- `docs/tests/records/2026-03-18-project-bank-tag-status-lock-frontend-verification.md`
## 验证结果
- 基线构建通过:
```bash
cd ruoyi-ui
npm run build:prod
```
- 改动后构建通过:
```bash
cd ruoyi-ui
npm run build:prod
```
- 当前已完成构建验证,尚未在本次记录中执行真实页面联调。

View File

@@ -0,0 +1,29 @@
# 项目打标状态联动后端验证记录
## 验证项
- [x] 状态 `3-打标中` SQL 已同步
- [x] 打标成功后状态为 `1`
- [x] 打标失败后状态回退为 `0`
- [x] 打标中拒绝上传/拉取本行信息
- [x] 打标中拒绝参数保存
## 自动化验证
- 执行时间2026-03-18 14:56:22 +08:00
- 执行命令:
```bash
mvn -pl ccdi-project -Dtest=CcdiProjectServiceImplTest,CcdiBankTagServiceImplTest,ProjectBankTagRebuildCoordinatorTest,CcdiFileUploadServiceImplTest,CcdiModelParamServiceImplTest,CcdiProjectStatusSqlTest test
```
- 结果:`PASS`
- 统计:`Tests run: 44, Failures: 0, Errors: 0, Skipped: 0`
## SQL 执行说明
- 联调环境执行增量脚本时,必须使用以下命令,确保中文内容以 `utf8mb4` 写入:
```bash
bin/mysql_utf8_exec.sh sql/migration/2026-03-18-add-project-tagging-status.sql
```

View File

@@ -0,0 +1,38 @@
# 项目打标状态联动前端验证记录
## 验证范围
- 列表页状态与统计显示
- 详情页状态标签
- 上传数据页禁用
- 参数模型页只读
## 验证结果
- [ ] 列表页出现“打标中”
- [ ] 顶部统计出现“打标中”
- [ ] 详情页出现“打标中”
- [ ] 打标中时“拉取本行信息”按钮禁用
- [ ] 打标中时“上传流水”按钮禁用
- [ ] 打标中时仍可查看上传记录列表
- [ ] 页面有明确提示文案
- [ ] 打标中时参数输入框禁用
- [ ] 打标中时“保存所有修改”按钮禁用
- [ ] 参数页有只读提示文案
- [ ] 提交上传或拉取任务后,详情页能重新获取项目状态
- [ ] 文件轮询期间如后端状态切为“打标中”,页面会同步受限
## 构建验证
- [x] `npm run build:prod` 基线构建通过2026-03-18 14:56 +08:00
- [x] `npm run build:prod` 改动后构建通过2026-03-18 15:00 +08:00
## 手工联调说明
- [ ] 已启动前后端与依赖服务进行联调
- [ ] 联调完成后已关闭测试过程中启动的进程
## 说明
- 本次已完成前端静态实现与两轮生产构建验证。
- 真实页面联调尚未在本记录中勾选,如需补齐,请按计划启动前后端与依赖服务后继续验证。

View File

@@ -98,7 +98,7 @@
>
<template slot-scope="scope">
<el-button
v-if="scope.row.status === '0'"
v-if="scope.row.status === '0' || scope.row.status === '3'"
size="mini"
type="text"
icon="el-icon-right"
@@ -192,6 +192,7 @@ export default {
0: "#1890ff",
1: "#52c41a",
2: "#8c8c8c",
3: "#fa8c16",
};
return colorMap[status] || "#8c8c8c";
},

View File

@@ -39,7 +39,8 @@ export default {
all: 0,
'0': 0,
'1': 0,
'2': 0
'2': 0,
'3': 0
})
}
},
@@ -51,7 +52,8 @@ export default {
{ label: '全部项目', value: 'all', count: 0 },
{ label: '进行中', value: '0', count: 0 },
{ label: '已完成', value: '1', count: 0 },
{ label: '已归档', value: '2', count: 0 }
{ label: '已归档', value: '2', count: 0 },
{ label: '打标中', value: '3', count: 0 }
]
}
},

View File

@@ -1,5 +1,9 @@
<template>
<div class="param-config-container" v-loading="loading" element-loading-text="加载中...">
<div v-if="isProjectTagging" class="readonly-tip">
项目正在进行银行流水打标参数暂不可修改
</div>
<!-- 模型参数卡片组垂直堆叠 -->
<div class="model-cards-container" v-if="!loading && modelGroups.length > 0">
<div
@@ -20,6 +24,7 @@
<template #default="{ row }">
<el-input
v-model="row.paramValue"
:disabled="isProjectTagging"
placeholder="请输入阈值"
@input="markAsModified(model.modelCode, row)"
/>
@@ -37,7 +42,7 @@
<!-- 统一保存按钮 -->
<div class="button-section" v-if="!loading && modelGroups.length > 0">
<el-button type="primary" @click="handleSaveAll" :loading="saving">
<el-button type="primary" :disabled="isProjectTagging || saving" @click="handleSaveAll" :loading="saving">
保存所有修改
</el-button>
<span v-if="modifiedCount > 0" class="modified-tip">
@@ -74,6 +79,9 @@ export default {
computed: {
modifiedCount() {
return Object.keys(this.modifiedParams).length;
},
isProjectTagging() {
return String(this.projectInfo.projectStatus) === "3";
}
},
watch: {
@@ -151,6 +159,10 @@ export default {
},
async handleSaveAll() {
if (this.isProjectTagging) {
this.$message.warning("项目正在进行银行流水打标,参数暂不可修改");
return;
}
if (this.modifiedCount === 0) {
this.$message.info('没有需要保存的修改');
return;
@@ -201,6 +213,15 @@ export default {
min-height: 400px;
}
.readonly-tip {
margin-bottom: 16px;
padding: 12px 16px;
color: #ad6800;
background: #fff7e6;
border: 1px solid #ffd591;
border-radius: 6px;
}
.model-cards-container {
margin-bottom: 20px;
min-height: 300px;

View File

@@ -17,6 +17,7 @@
<el-button
size="small"
icon="el-icon-download"
:disabled="isProjectTagging"
@click="handleFetchBankInfo"
>
拉取本行信息
@@ -24,6 +25,10 @@
</div>
</div>
<div v-if="isProjectTagging" class="tagging-lock-tip">
项目正在进行银行流水打标暂不可上传或拉取数据
</div>
<!-- 上传模块 -->
<div class="upload-section">
<div class="upload-cards">
@@ -165,6 +170,7 @@
class="upload-area"
drag
action="#"
:disabled="isProjectTagging"
:auto-upload="false"
:on-change="handleFileChange"
:file-list="fileList"
@@ -179,6 +185,7 @@
<el-button @click="showUploadDialog = false">取消</el-button>
<el-button
type="primary"
:disabled="isProjectTagging"
@click="handleConfirmUpload"
:loading="uploading"
>确定</el-button
@@ -231,6 +238,7 @@
v-model="pullBankInfoForm.idCardText"
type="textarea"
:rows="5"
:disabled="isProjectTagging"
placeholder="支持逗号、中文逗号、换行分隔"
/>
<div class="pull-bank-field-tip">
@@ -248,6 +256,7 @@
:show-file-list="false"
:file-list="idCardFileList"
accept=".xls,.xlsx"
:disabled="isProjectTagging"
:on-change="handleIdCardFileChange"
:on-remove="handleIdCardFileRemove"
>
@@ -279,6 +288,7 @@
<el-date-picker
class="pull-bank-range-picker"
v-model="pullBankInfoForm.dateRange"
:disabled="isProjectTagging"
type="daterange"
value-format="yyyy-MM-dd"
:picker-options="pullBankInfoDatePickerOptions"
@@ -292,6 +302,7 @@
<el-button @click="pullBankInfoDialogVisible = false">取消</el-button>
<el-button
type="primary"
:disabled="isProjectTagging"
:loading="pullBankInfoLoading"
@click="handleConfirmPullBankInfo"
>
@@ -312,6 +323,7 @@
drag
action="#"
multiple
:disabled="isProjectTagging"
:auto-upload="false"
:on-change="handleBatchFileChange"
:show-file-list="false"
@@ -350,8 +362,8 @@
<el-button @click="batchUploadDialogVisible = false">取消</el-button>
<el-button
type="primary"
:disabled="isProjectTagging || selectedFiles.length === 0"
:loading="uploadLoading"
:disabled="selectedFiles.length === 0"
@click="handleBatchUpload"
>开始上传</el-button
>
@@ -518,6 +530,7 @@ export default {
pollingTimer: null,
pollingEnabled: false,
pollingInterval: 5000,
lastFileStatusSignature: "",
};
},
computed: {
@@ -526,12 +539,21 @@ export default {
disabledDate: (time) => this.isPullBankInfoDateDisabled(time),
};
},
isProjectTagging() {
return String(this.projectInfo.projectStatus) === "3";
},
},
watch: {
"projectInfo.projectStatus"() {
this.syncUploadCardDisabledState();
},
},
created() {
// 加载初始数据
// this.loadInitialData();
// 监听路由变化更新选中菜单
this.updateActiveMenu();
this.syncUploadCardDisabledState();
},
mounted() {
// 组件挂载后监听项目ID变化
@@ -597,6 +619,19 @@ export default {
: card.btnText;
}
});
this.syncUploadCardDisabledState();
},
syncUploadCardDisabledState() {
this.uploadCards = this.uploadCards.map((card) => {
if (card.key === "transaction") {
return {
...card,
disabled: this.isProjectTagging,
};
}
return card;
});
},
/** 更新质量指标 */
@@ -660,6 +695,10 @@ export default {
},
/** 确认上传 */
async handleConfirmUpload() {
if (this.isProjectTagging) {
this.$message.warning("项目正在进行银行流水打标,暂不可上传或拉取数据");
return;
}
if (this.fileList.length === 0) {
this.$message.warning("请选择要上传的文件");
return;
@@ -919,6 +958,10 @@ export default {
return this.parseIdCardText(this.pullBankInfoForm.idCardText);
},
async handleConfirmPullBankInfo() {
if (this.isProjectTagging) {
this.$message.warning("项目正在进行银行流水打标,暂不可上传或拉取数据");
return;
}
const idCards = this.buildFinalIdCardList();
const [startDate, endDate] = this.pullBankInfoForm.dateRange || [];
@@ -953,6 +996,8 @@ export default {
this.$message.success((res && res.msg) || "拉取任务已提交");
await Promise.all([this.loadStatistics(), this.loadFileList()]);
this.lastFileStatusSignature = this.buildFileStatusSignature();
this.$emit("refresh-project");
const hasPollingRecords =
this.statistics.uploading > 0 ||
@@ -973,6 +1018,10 @@ export default {
},
/** 拉取本行信息 */
handleFetchBankInfo() {
if (this.isProjectTagging) {
this.$message.warning("项目正在进行银行流水打标,暂不可上传或拉取数据");
return;
}
this.resetPullBankInfoForm();
this.openPullBankInfoDialog();
},
@@ -1009,6 +1058,7 @@ export default {
0: "processing",
1: "success",
2: "archived",
3: "tagging",
};
return statusMap[status] || "processing";
},
@@ -1019,6 +1069,7 @@ export default {
0: "进行中",
1: "已完成",
2: "已归档",
3: "打标中",
};
return statusMap[status] || "未知";
},
@@ -1059,6 +1110,10 @@ export default {
/** 开始批量上传 */
async handleBatchUpload() {
if (this.isProjectTagging) {
this.$message.warning("项目正在进行银行流水打标,暂不可上传或拉取数据");
return;
}
if (this.selectedFiles.length === 0) {
this.$message.warning("请选择要上传的文件");
return;
@@ -1079,6 +1134,8 @@ export default {
// 刷新数据并启动轮询
await Promise.all([this.loadStatistics(), this.loadFileList()]);
this.lastFileStatusSignature = this.buildFileStatusSignature();
this.$emit("refresh-project");
this.startPolling();
} catch (error) {
@@ -1118,6 +1175,9 @@ export default {
const res = await getFileUploadList(params);
this.fileUploadList = res.rows || [];
this.total = res.total || 0;
if (!this.lastFileStatusSignature) {
this.lastFileStatusSignature = this.buildFileStatusSignature();
}
} catch (error) {
this.$message.error("加载文件列表失败");
console.error(error);
@@ -1138,6 +1198,11 @@ export default {
Promise.all([this.loadStatistics(), this.loadFileList()])
.then(() => {
const currentSignature = this.buildFileStatusSignature();
if (currentSignature !== this.lastFileStatusSignature) {
this.lastFileStatusSignature = currentSignature;
this.$emit("refresh-project");
}
if (
this.statistics.uploading === 0 &&
this.statistics.parsing === 0
@@ -1168,6 +1233,8 @@ export default {
/** 手动刷新 */
async handleManualRefresh() {
await Promise.all([this.loadStatistics(), this.loadFileList()]);
this.lastFileStatusSignature = this.buildFileStatusSignature();
this.$emit("refresh-project");
this.$message.success("刷新成功");
@@ -1183,6 +1250,11 @@ export default {
this.queryParams.pageNum = pageNum;
this.loadFileList();
},
buildFileStatusSignature() {
return (this.fileUploadList || [])
.map((item) => `${item.id || ""}:${item.fileStatus || ""}`)
.join("|");
},
getRowAction(row) {
return getUploadFileAction(row.fileStatus);
@@ -1401,6 +1473,15 @@ export default {
}
}
.tagging-lock-tip {
margin-bottom: 16px;
padding: 12px 16px;
color: #ad6800;
background: #fff7e6;
border: 1px solid #ffd591;
border-radius: 6px;
}
// 上传模块
.upload-section {
background: #ffffff;

View File

@@ -54,6 +54,7 @@
:project-id="projectId"
:project-info="projectInfo"
@menu-change="handleMenuChange"
@refresh-project="handleRefreshProject"
@data-uploaded="handleDataUploaded"
@name-selected="handleNameSelected"
@generate-report="handleGenerateReport"
@@ -215,6 +216,7 @@ export default {
0: "primary", // 进行中
1: "success", // 已完成
2: "info", // 已归档
3: "warning", // 打标中
};
return statusMap[status] || "info";
},
@@ -224,6 +226,7 @@ export default {
0: "进行中",
1: "已完成",
2: "已归档",
3: "打标中",
};
return statusMap[status] || "未知";
},
@@ -292,6 +295,9 @@ export default {
this.initPageData();
this.$message.success("刷新成功");
},
handleRefreshProject() {
this.initPageData();
},
/** 导出报告 */
handleExport() {
console.log("导出报告");

View File

@@ -101,7 +101,8 @@ export default {
all: 0,
'0': 0,
'1': 0,
'2': 0
'2': 0,
'3': 0
},
// 新增/编辑弹窗
addDialogVisible: false,
@@ -137,7 +138,8 @@ export default {
all: counts.all || 0,
'0': counts.status0 || 0,
'1': counts.status1 || 0,
'2': counts.status2 || 0
'2': counts.status2 || 0,
'3': counts.status3 || 0
}
this.loading = false

View File

@@ -11,7 +11,7 @@ CREATE TABLE `ccdi_project` (
`project_name` VARCHAR(200) NOT NULL COMMENT '项目名称',
`description` VARCHAR(500) DEFAULT NULL COMMENT '项目描述',
`config_type` VARCHAR(20) NOT NULL DEFAULT 'default' COMMENT '配置方式default-全局默认custom-自定义',
`status` CHAR(1) NOT NULL DEFAULT '0' COMMENT '项目状态0-进行中1-已完成2-已归档',
`status` CHAR(1) NOT NULL DEFAULT '0' COMMENT '项目状态0-进行中1-已完成2-已归档3-打标中',
`is_archived` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否归档0-未归档1-已归档',
`target_count` INT NOT NULL DEFAULT 0 COMMENT '目标人数',
`high_risk_count` INT NOT NULL DEFAULT 0 COMMENT '高风险人数',
@@ -41,7 +41,8 @@ INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, css_cla
VALUES
(1, '进行中', '0', 'ccdi_project_status', '', 'primary', 'Y', '0', 'admin', NOW()),
(2, '已完成', '1', 'ccdi_project_status', '', 'success', 'N', '0', 'admin', NOW()),
(3, '已归档', '2', 'ccdi_project_status', '', 'info', 'N', '0', 'admin', NOW());
(3, '已归档', '2', 'ccdi_project_status', '', 'info', 'N', '0', 'admin', NOW()),
(4, '打标中', '3', 'ccdi_project_status', '', 'warning', 'N', '0', 'admin', NOW());
-- ----------------------------
-- 4. 插入配置方式字典

View File

@@ -0,0 +1,32 @@
ALTER TABLE ccdi_project
MODIFY COLUMN status CHAR(1) NOT NULL DEFAULT '0' COMMENT '项目状态0-进行中1-已完成2-已归档3-打标中';
INSERT INTO sys_dict_data (
dict_sort,
dict_label,
dict_value,
dict_type,
css_class,
list_class,
is_default,
status,
create_by,
create_time
)
SELECT
4,
'打标中',
'3',
'ccdi_project_status',
'',
'warning',
'N',
'0',
'admin',
NOW()
WHERE NOT EXISTS (
SELECT 1
FROM sys_dict_data
WHERE dict_type = 'ccdi_project_status'
AND dict_value = '3'
);