修复涉疑交易筛选与标签错乱

This commit is contained in:
wjj
2026-07-16 18:46:51 +08:00
parent c3b56bdf2a
commit e0188b5e5b
24 changed files with 584 additions and 106 deletions

View File

@@ -72,9 +72,13 @@ public class CcdiBankStatementController extends BaseController {
*/
@GetMapping("/detail/{bankStatementId}")
@Operation(summary = "查询流水详情")
public AjaxResult getDetail(@PathVariable Long bankStatementId) {
public AjaxResult getDetail(@PathVariable Long bankStatementId, String modelCode, String suspiciousType) {
projectAccessService.assertCanReadByBankStatementId(bankStatementId);
CcdiBankStatementDetailVO detail = bankStatementService.getStatementDetail(bankStatementId);
CcdiBankStatementDetailVO detail = bankStatementService.getStatementDetail(
bankStatementId,
modelCode,
suspiciousType
);
return AjaxResult.success(detail);
}

View File

@@ -308,9 +308,12 @@ public class CcdiProjectOverviewController extends BaseController {
@PostMapping("/risk-details/export")
@Operation(summary = "导出风险明细")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public void exportRiskDetails(HttpServletResponse response, Long projectId) {
projectAccessService.assertCanRead(projectId);
overviewService.exportRiskDetails(response, projectId);
public void exportRiskDetails(
HttpServletResponse response,
CcdiProjectSuspiciousTransactionQueryDTO queryDTO
) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
overviewService.exportRiskDetails(response, queryDTO);
}
/**

View File

@@ -8,6 +8,9 @@ import lombok.Data;
@Data
public class CcdiBankStatementHitTagVO {
/** 模型编码 */
private String modelCode;
/** 规则编码 */
private String ruleCode;

View File

@@ -3,6 +3,8 @@ package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
/**
* 涉疑交易明细行
@@ -14,6 +16,14 @@ public class CcdiProjectSuspiciousTransactionItemVO {
private String trxDate;
private String leAccountNo;
private String leAccountName;
private String customerAccountName;
private String customerAccountNo;
private String suspiciousPersonName;
private String relatedPersonName;
@@ -35,4 +45,6 @@ public class CcdiProjectSuspiciousTransactionItemVO {
private Boolean hasNameListHit;
private String nameListHitType;
private List<CcdiBankStatementHitTagVO> hitTags = new ArrayList<>();
}

View File

@@ -26,11 +26,15 @@ public interface CcdiBankTagResultMapper extends BaseMapper<CcdiBankTagResult> {
*
* @param projectId 项目ID
* @param bankStatementIds 流水ID列表
* @param modelCode 模型编码
* @param suspiciousType 预警类型
* @return 命中的异常标签列表
*/
List<CcdiBankStatementHitTagVO> selectStatementTagsByProjectAndStatementIds(
@Param("projectId") Long projectId,
@Param("bankStatementIds") List<Long> bankStatementIds
@Param("bankStatementIds") List<Long> bankStatementIds,
@Param("modelCode") String modelCode,
@Param("suspiciousType") String suspiciousType
);
/**

View File

@@ -49,4 +49,18 @@ public interface ICcdiBankStatementService {
* @return 详情
*/
CcdiBankStatementDetailVO getStatementDetail(Long bankStatementId);
/**
* 按涉疑交易筛选范围查询流水详情
*
* @param bankStatementId 流水ID
* @param modelCode 模型编码
* @param suspiciousType 预警类型
* @return 详情
*/
CcdiBankStatementDetailVO getStatementDetail(
Long bankStatementId,
String modelCode,
String suspiciousType
);
}

View File

@@ -213,6 +213,18 @@ public interface ICcdiProjectOverviewService {
default void exportRiskDetails(HttpServletResponse response, Long projectId) {
}
/**
* 按涉疑交易筛选范围统一导出风险明细
*
* @param response 响应流
* @param queryDTO 涉疑交易筛选条件
*/
default void exportRiskDetails(
HttpServletResponse response,
CcdiProjectSuspiciousTransactionQueryDTO queryDTO
) {
}
/**
* 一键导出结果总览报告
*

View File

@@ -32,6 +32,8 @@ public class CcdiBankStatementServiceImpl implements ICcdiBankStatementService {
private static final Set<String> ALLOWED_TAB_TYPES = Set.of("all", "in", "out");
private static final Set<String> ALLOWED_ORDER_DIRECTIONS = Set.of("asc", "desc");
private static final Set<String> ALLOWED_SUSPICIOUS_TYPES =
Set.of("ALL", "MODEL_RULE", "EXTERNAL_PERSON", "NAME_LIST");
@Resource
private CcdiBankStatementMapper bankStatementMapper;
@@ -69,13 +71,24 @@ public class CcdiBankStatementServiceImpl implements ICcdiBankStatementService {
@Override
public CcdiBankStatementDetailVO getStatementDetail(Long bankStatementId) {
return getStatementDetail(bankStatementId, null, null);
}
@Override
public CcdiBankStatementDetailVO getStatementDetail(
Long bankStatementId,
String modelCode,
String suspiciousType
) {
CcdiBankStatementDetailVO detail = bankStatementMapper.selectStatementDetailById(bankStatementId);
if (detail == null || detail.getProjectId() == null || detail.getBankStatementId() == null) {
return detail;
}
Map<Long, List<CcdiBankStatementHitTagVO>> hitTagMap = loadHitTagMap(
detail.getProjectId(),
List.of(detail.getBankStatementId())
List.of(detail.getBankStatementId()),
normalizeUpperCase(modelCode),
normalizeSuspiciousType(suspiciousType)
);
detail.setHitTags(new ArrayList<>(hitTagMap.getOrDefault(detail.getBankStatementId(), Collections.emptyList())));
return detail;
@@ -93,18 +106,33 @@ public class CcdiBankStatementServiceImpl implements ICcdiBankStatementService {
if (bankStatementIds.isEmpty()) {
return;
}
Map<Long, List<CcdiBankStatementHitTagVO>> hitTagMap = loadHitTagMap(projectId, bankStatementIds);
Map<Long, List<CcdiBankStatementHitTagVO>> hitTagMap = loadHitTagMap(
projectId,
bankStatementIds,
null,
null
);
rows.forEach(row -> row.setHitTags(new ArrayList<>(
hitTagMap.getOrDefault(row.getBankStatementId(), Collections.emptyList())
)));
}
private Map<Long, List<CcdiBankStatementHitTagVO>> loadHitTagMap(Long projectId, List<Long> bankStatementIds) {
private Map<Long, List<CcdiBankStatementHitTagVO>> loadHitTagMap(
Long projectId,
List<Long> bankStatementIds,
String modelCode,
String suspiciousType
) {
if (projectId == null || bankStatementIds == null || bankStatementIds.isEmpty()) {
return Collections.emptyMap();
}
List<CcdiBankStatementHitTagVO> hitTags =
bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(projectId, bankStatementIds);
bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(
projectId,
bankStatementIds,
modelCode,
suspiciousType
);
if (hitTags == null || hitTags.isEmpty()) {
return Collections.emptyMap();
}
@@ -165,6 +193,16 @@ public class CcdiBankStatementServiceImpl implements ICcdiBankStatementService {
return normalized == null ? null : normalized.toLowerCase(Locale.ROOT);
}
private String normalizeUpperCase(String value) {
String normalized = normalizeText(value);
return normalized == null ? null : normalized.toUpperCase(Locale.ROOT);
}
private String normalizeSuspiciousType(String suspiciousType) {
String normalized = normalizeUpperCase(suspiciousType);
return normalized != null && ALLOWED_SUSPICIOUS_TYPES.contains(normalized) ? normalized : null;
}
private String normalizeText(String value) {
if (value == null) {
return null;

View File

@@ -375,9 +375,12 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
) {
ensureProjectExists(queryDTO.getProjectId());
normalizeSuspiciousTransactionQuery(queryDTO);
if (isSuspiciousTransactionScopeMismatch(queryDTO)) {
return emptySuspiciousTransactionPage();
}
prepareSuspiciousTransactionExternalBranch(queryDTO);
if (isExternalPersonOnlyWithoutSubject(queryDTO)) {
if (isExternalScopeWithoutSubject(queryDTO)) {
return emptySuspiciousTransactionPage();
}
@@ -387,9 +390,12 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
);
Page<CcdiProjectSuspiciousTransactionItemVO> resultPage =
overviewMapper.selectSuspiciousTransactionPage(page, queryDTO);
List<CcdiProjectSuspiciousTransactionItemVO> rows =
defaultList(resultPage == null ? null : resultPage.getRecords());
attachSuspiciousTransactionHitTags(rows, queryDTO);
CcdiProjectSuspiciousTransactionPageVO result = new CcdiProjectSuspiciousTransactionPageVO();
result.setRows(defaultList(resultPage == null ? null : resultPage.getRecords()));
result.setRows(rows);
result.setTotal(resultPage == null ? 0L : resultPage.getTotal());
return result;
}
@@ -400,9 +406,12 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
) {
ensureProjectExists(queryDTO.getProjectId());
normalizeSuspiciousTransactionQuery(queryDTO);
if (isSuspiciousTransactionScopeMismatch(queryDTO)) {
return List.of();
}
prepareSuspiciousTransactionExternalBranch(queryDTO);
if (isExternalPersonOnlyWithoutSubject(queryDTO)) {
if (isExternalScopeWithoutSubject(queryDTO)) {
return List.of();
}
@@ -469,9 +478,17 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
public void exportRiskDetails(HttpServletResponse response, Long projectId) {
CcdiProjectSuspiciousTransactionQueryDTO queryDTO = new CcdiProjectSuspiciousTransactionQueryDTO();
queryDTO.setProjectId(projectId);
queryDTO.setSuspiciousType("ALL");
queryDTO.setSuspiciousType(SUSPICIOUS_TYPE_ALL);
exportRiskDetails(response, queryDTO);
}
@Override
public void exportRiskDetails(
HttpServletResponse response,
CcdiProjectSuspiciousTransactionQueryDTO queryDTO
) {
List<CcdiProjectSuspiciousTransactionExcel> suspiciousRows = exportSuspiciousTransactions(queryDTO);
Long projectId = queryDTO.getProjectId();
List<CcdiProjectEmployeeCreditNegativeExcel> creditRows = exportEmployeeCreditNegative(projectId);
List<CcdiProjectAbnormalAccountExcel> abnormalRows = exportAbnormalAccountPeople(projectId);
try {
@@ -742,28 +759,44 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
}
private void prepareSuspiciousTransactionExternalBranch(CcdiProjectSuspiciousTransactionQueryDTO queryDTO) {
if (!shouldCheckExternalPersonBranch(queryDTO.getSuspiciousType())) {
if (!shouldCheckExternalPersonBranch(queryDTO)) {
queryDTO.setIncludeExternalPerson(false);
return;
}
queryDTO.setIncludeExternalPerson(hasExternalPersonSubject(queryDTO.getProjectId()));
}
private boolean shouldCheckExternalPersonBranch(String suspiciousType) {
return SUSPICIOUS_TYPE_ALL.equals(suspiciousType)
|| SUSPICIOUS_TYPE_MODEL_RULE.equals(suspiciousType)
|| SUSPICIOUS_TYPE_EXTERNAL_PERSON.equals(suspiciousType);
private boolean shouldCheckExternalPersonBranch(CcdiProjectSuspiciousTransactionQueryDTO queryDTO) {
if (SUSPICIOUS_TYPE_EXTERNAL_PERSON.equals(queryDTO.getSuspiciousType())) {
return true;
}
return SUSPICIOUS_TYPE_ALL.equals(queryDTO.getSuspiciousType())
&& (queryDTO.getModelCode() == null || isExternalModelCode(queryDTO.getModelCode()));
}
private boolean hasExternalPersonSubject(Long projectId) {
return overviewMapper.selectExternalPersonSubjectExistsByProjectId(projectId) != null;
}
private boolean isExternalPersonOnlyWithoutSubject(CcdiProjectSuspiciousTransactionQueryDTO queryDTO) {
return SUSPICIOUS_TYPE_EXTERNAL_PERSON.equals(queryDTO.getSuspiciousType())
private boolean isExternalScopeWithoutSubject(CcdiProjectSuspiciousTransactionQueryDTO queryDTO) {
return (SUSPICIOUS_TYPE_EXTERNAL_PERSON.equals(queryDTO.getSuspiciousType())
|| isExternalModelCode(queryDTO.getModelCode()))
&& !Boolean.TRUE.equals(queryDTO.getIncludeExternalPerson());
}
private boolean isSuspiciousTransactionScopeMismatch(CcdiProjectSuspiciousTransactionQueryDTO queryDTO) {
if (queryDTO.getModelCode() == null) {
return false;
}
boolean externalModel = isExternalModelCode(queryDTO.getModelCode());
return (SUSPICIOUS_TYPE_MODEL_RULE.equals(queryDTO.getSuspiciousType()) && externalModel)
|| (SUSPICIOUS_TYPE_EXTERNAL_PERSON.equals(queryDTO.getSuspiciousType()) && !externalModel);
}
private boolean isExternalModelCode(String modelCode) {
return modelCode != null && modelCode.startsWith("EXTERNAL_");
}
private CcdiProjectSuspiciousTransactionPageVO emptySuspiciousTransactionPage() {
CcdiProjectSuspiciousTransactionPageVO result = new CcdiProjectSuspiciousTransactionPageVO();
result.setRows(List.of());
@@ -976,7 +1009,12 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
return;
}
Map<Long, List<CcdiBankStatementHitTagVO>> hitTagMap = defaultList(
bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(projectId, bankStatementIds)
bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(
projectId,
bankStatementIds,
null,
null
)
).stream().filter(item -> item.getBankStatementId() != null)
.collect(Collectors.groupingBy(
CcdiBankStatementHitTagVO::getBankStatementId,
@@ -988,6 +1026,39 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
)));
}
private void attachSuspiciousTransactionHitTags(
List<CcdiProjectSuspiciousTransactionItemVO> rows,
CcdiProjectSuspiciousTransactionQueryDTO queryDTO
) {
if (rows.isEmpty()) {
return;
}
List<Long> bankStatementIds = rows.stream()
.map(CcdiProjectSuspiciousTransactionItemVO::getBankStatementId)
.filter(item -> item != null)
.distinct()
.toList();
if (bankStatementIds.isEmpty()) {
return;
}
Map<Long, List<CcdiBankStatementHitTagVO>> hitTagMap = defaultList(
bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(
queryDTO.getProjectId(),
bankStatementIds,
queryDTO.getModelCode(),
queryDTO.getSuspiciousType()
)
).stream().filter(item -> item.getBankStatementId() != null)
.collect(Collectors.groupingBy(
CcdiBankStatementHitTagVO::getBankStatementId,
LinkedHashMap::new,
Collectors.toList()
));
rows.forEach(row -> row.setHitTags(new ArrayList<>(
hitTagMap.getOrDefault(row.getBankStatementId(), Collections.emptyList())
)));
}
private void normalizeObjectRows(List<CcdiProjectPersonAnalysisObjectRecordVO> objectRows) {
objectRows.forEach(row -> {
if (row.getRiskTags() == null) {

View File

@@ -30,6 +30,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</resultMap>
<resultMap id="CcdiBankStatementHitTagVOResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementHitTagVO">
<result property="modelCode" column="model_code"/>
<result property="ruleCode" column="rule_code"/>
<result property="ruleName" column="rule_name"/>
<result property="riskLevel" column="risk_level"/>
@@ -47,6 +48,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<select id="selectStatementTagsByProjectAndStatementIds" resultMap="CcdiBankStatementHitTagVOResultMap">
select
model_code,
rule_code,
rule_name,
risk_level,
@@ -67,6 +69,17 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<foreach collection="bankStatementIds" item="item" open="(" separator="," close=")">
#{item}
</foreach>
<if test="modelCode != null and modelCode != ''">
and model_code = #{modelCode}
</if>
<choose>
<when test="suspiciousType == 'MODEL_RULE'">
and left(model_code, 9) != 'EXTERNAL_'
</when>
<when test="suspiciousType == 'EXTERNAL_PERSON'">
and left(model_code, 9) = 'EXTERNAL_'
</when>
</choose>
order by bank_statement_id asc, id asc
</select>

View File

@@ -68,6 +68,10 @@
<resultMap id="SuspiciousTransactionItemResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionItemVO">
<id property="bankStatementId" column="bankStatementId"/>
<result property="trxDate" column="trxDate"/>
<result property="leAccountNo" column="leAccountNo"/>
<result property="leAccountName" column="leAccountName"/>
<result property="customerAccountName" column="customerAccountName"/>
<result property="customerAccountNo" column="customerAccountNo"/>
<result property="suspiciousPersonName" column="suspiciousPersonName"/>
<result property="relatedPersonName" column="relatedPersonName"/>
<result property="relatedStaffName" column="relatedStaffName"/>
@@ -1024,9 +1028,21 @@
and ex.exclusion_type = 'STATEMENT'
and ex.bank_statement_id = tr.bank_statement_id
)
<include refid="suspiciousTransactionModelScopeSql"/>
</sql>
<sql id="suspiciousTransactionModelScopeSql">
<if test="query.modelCode != null and query.modelCode != ''">
and tr.model_code = #{query.modelCode}
</if>
<choose>
<when test="query.suspiciousType == 'MODEL_RULE'">
and left(tr.model_code, 9) != 'EXTERNAL_'
</when>
<when test="query.suspiciousType == 'EXTERNAL_PERSON'">
and left(tr.model_code, 9) = 'EXTERNAL_'
</when>
</choose>
</sql>
<sql id="suspiciousTransactionNameHitSql">
@@ -1287,6 +1303,10 @@
select
final_result.bankStatementId,
final_result.trxDate,
bs.LE_ACCOUNT_NO as leAccountNo,
bs.LE_ACCOUNT_NAME as leAccountName,
bs.CUSTOMER_ACCOUNT_NAME as customerAccountName,
bs.CUSTOMER_ACCOUNT_NO as customerAccountNo,
final_result.suspiciousPersonName,
final_result.relatedPersonName,
final_result.relatedStaffName,
@@ -1301,6 +1321,8 @@
from (
<include refid="suspiciousTransactionAggregatedSql"/>
) final_result
inner join ccdi_bank_statement bs
on bs.bank_statement_id = final_result.bankStatementId
<include refid="suspiciousTransactionFilterSql"/>
order by final_result.trxDate desc, final_result.bankStatementId desc
</select>
@@ -1309,6 +1331,10 @@
select
final_result.bankStatementId,
final_result.trxDate,
bs.LE_ACCOUNT_NO as leAccountNo,
bs.LE_ACCOUNT_NAME as leAccountName,
bs.CUSTOMER_ACCOUNT_NAME as customerAccountName,
bs.CUSTOMER_ACCOUNT_NO as customerAccountNo,
final_result.suspiciousPersonName,
final_result.relatedPersonName,
final_result.relatedStaffName,
@@ -1323,6 +1349,8 @@
from (
<include refid="suspiciousTransactionAggregatedSql"/>
) final_result
inner join ccdi_bank_statement bs
on bs.bank_statement_id = final_result.bankStatementId
<include refid="suspiciousTransactionFilterSql"/>
order by final_result.trxDate desc, final_result.bankStatementId desc
</select>
@@ -1375,6 +1403,7 @@
and ex.exclusion_type = 'STATEMENT'
and ex.bank_statement_id = tr.bank_statement_id
)
<include refid="suspiciousTransactionModelScopeSql"/>
group by tr.bank_statement_id
) tag_result on tag_result.bank_statement_id = final_result.bankStatementId
<include refid="suspiciousTransactionFilterSql"/>

View File

@@ -7,6 +7,7 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementDetailVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementFilterOptionsVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO;
import com.ruoyi.ccdi.project.service.ICcdiBankStatementService;
import com.ruoyi.ccdi.project.service.CcdiProjectAccessService;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import org.junit.jupiter.api.AfterEach;
@@ -39,6 +40,9 @@ class CcdiBankStatementControllerTest {
@Mock
private ICcdiBankStatementService bankStatementService;
@Mock
private CcdiProjectAccessService projectAccessService;
@AfterEach
void tearDown() {
RequestContextHolder.resetRequestAttributes();
@@ -80,12 +84,17 @@ class CcdiBankStatementControllerTest {
void detail_shouldReturnAjaxResultSuccess() {
CcdiBankStatementDetailVO detailVO = new CcdiBankStatementDetailVO();
detailVO.setBankStatementId(1000L);
when(bankStatementService.getStatementDetail(1000L)).thenReturn(detailVO);
when(bankStatementService.getStatementDetail(
1000L, "SUSPICIOUS_GAMBLING", "MODEL_RULE"
)).thenReturn(detailVO);
AjaxResult result = controller.getDetail(1000L);
AjaxResult result = controller.getDetail(1000L, "SUSPICIOUS_GAMBLING", "MODEL_RULE");
assertEquals(200, result.get("code"));
assertEquals(detailVO, result.get("data"));
verify(bankStatementService).getStatementDetail(
1000L, "SUSPICIOUS_GAMBLING", "MODEL_RULE"
);
}
@Test

View File

@@ -172,7 +172,7 @@ class CcdiProjectOverviewControllerContractTest {
Method method = controllerClass.getMethod(
"exportRiskDetails",
HttpServletResponse.class,
Long.class
Class.forName("com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO")
);
PostMapping postMapping = method.getAnnotation(PostMapping.class);
Operation operation = method.getAnnotation(Operation.class);
@@ -198,7 +198,7 @@ class CcdiProjectOverviewControllerContractTest {
assertEquals("/report/export", requestMapping.value()[0]);
assertEquals(List.of(RequestMethod.GET, RequestMethod.POST), Arrays.asList(requestMapping.method()));
assertNotNull(operation);
assertEquals("一键导出结果总览报告", operation.summary());
assertEquals("导出结果总览报告", operation.summary());
assertNotNull(preAuthorize);
assertEquals("@ss.hasPermi('ccdi:project:query')", preAuthorize.value());
}
@@ -211,7 +211,10 @@ class CcdiProjectOverviewControllerContractTest {
.map(Field::getName)
.collect(Collectors.toList());
assertEquals(List.of("projectId", "suspiciousType", "pageNum", "pageSize"), fieldNames);
assertEquals(
List.of("projectId", "modelCode", "suspiciousType", "pageNum", "pageSize", "includeExternalPerson"),
fieldNames
);
}
@Test

View File

@@ -18,6 +18,7 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskPeopleOverviewVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionPageVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleVO;
import com.ruoyi.ccdi.project.service.ICcdiProjectOverviewService;
import com.ruoyi.ccdi.project.service.CcdiProjectAccessService;
import com.ruoyi.common.annotation.Excel;
import com.ruoyi.common.core.domain.AjaxResult;
import io.swagger.v3.oas.annotations.Operation;
@@ -52,6 +53,9 @@ class CcdiProjectOverviewControllerTest {
@Mock
private ICcdiProjectOverviewService overviewService;
@Mock
private CcdiProjectAccessService projectAccessService;
@Test
void shouldExposeDashboardEndpoint() throws Exception {
when(overviewService.getDashboard(40L)).thenReturn(new CcdiProjectOverviewDashboardVO());
@@ -308,15 +312,20 @@ class CcdiProjectOverviewControllerTest {
@Test
void shouldExposeRiskDetailsExportEndpoint() throws Exception {
MockHttpServletResponse response = new MockHttpServletResponse();
CcdiProjectSuspiciousTransactionQueryDTO queryDTO =
new CcdiProjectSuspiciousTransactionQueryDTO();
queryDTO.setProjectId(40L);
queryDTO.setModelCode("SUSPICIOUS_GAMBLING");
queryDTO.setSuspiciousType("MODEL_RULE");
controller.exportRiskDetails(response, 40L);
controller.exportRiskDetails(response, queryDTO);
verify(overviewService).exportRiskDetails(same(response), same(40L));
verify(overviewService).exportRiskDetails(same(response), same(queryDTO));
Method method = CcdiProjectOverviewController.class.getMethod(
"exportRiskDetails",
jakarta.servlet.http.HttpServletResponse.class,
Long.class
CcdiProjectSuspiciousTransactionQueryDTO.class
);
PostMapping postMapping = method.getAnnotation(PostMapping.class);
Operation operation = method.getAnnotation(Operation.class);

View File

@@ -32,6 +32,7 @@ class CcdiBankTagResultMapperXmlTest {
.orElse(null);
assertNotNull(method, "应提供按项目和流水ID批量查询异常标签的方法");
assertTrue(method.getParameterCount() == 4, "标签批量查询应接收模型和预警类型范围");
}
@Test
@@ -42,6 +43,9 @@ class CcdiBankTagResultMapperXmlTest {
assertTrue(xml.contains("selectStatementTagsByProjectAndStatementIds"), xml);
assertTrue(xml.contains("bank_statement_id IN"), xml);
assertTrue(xml.contains("project_id = #{projectId}"), xml);
assertTrue(xml.contains("model_code = #{modelCode}"), xml);
assertTrue(xml.contains("left(model_code, 9) != 'EXTERNAL_'"), xml);
assertTrue(xml.contains("left(model_code, 9) = 'EXTERNAL_'"), xml);
}
}
}

View File

@@ -91,6 +91,7 @@ class CcdiProjectOverviewMapperSqlTest {
String xml = Files.readString(Path.of("src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml"));
String suspiciousSql = extractSelect(xml, "selectSuspiciousTransactionPage");
String modelHitSql = extractSqlFragment(xml, "suspiciousTransactionModelHitSql");
String modelScopeSql = extractSqlFragment(xml, "suspiciousTransactionModelScopeSql");
String mergedSql = extractSqlFragment(xml, "suspiciousTransactionMergedSql");
String aggregatedSql = extractSqlFragment(xml, "suspiciousTransactionAggregatedSql");
String externalSubjectExistsSql = extractSelect(xml, "selectExternalPersonSubjectExistsByProjectId");
@@ -99,6 +100,10 @@ class CcdiProjectOverviewMapperSqlTest {
assertTrue(modelHitSql.contains("tr.bank_statement_id is not null"), modelHitSql);
assertFalse(modelHitSql.contains("rule_name like '%可疑%'"), modelHitSql);
assertFalse(modelHitSql.contains("ABNORMAL_CUSTOMER_TRANSACTION"), modelHitSql);
assertTrue(modelHitSql.contains("suspiciousTransactionModelScopeSql"), modelHitSql);
assertTrue(modelScopeSql.contains("tr.model_code = #{query.modelCode}"), modelScopeSql);
assertTrue(modelScopeSql.contains("left(tr.model_code, 9) != 'EXTERNAL_'"), modelScopeSql);
assertTrue(modelScopeSql.contains("left(tr.model_code, 9) = 'EXTERNAL_'"), modelScopeSql);
assertTrue(suspiciousSql.contains("ccdi_biz_intermediary"), suspiciousSql);
assertTrue(suspiciousSql.contains("ccdi_enterprise_base_info"), suspiciousSql);
assertTrue(suspiciousSql.contains("group by merged.bankStatementId"), suspiciousSql);
@@ -106,6 +111,9 @@ class CcdiProjectOverviewMapperSqlTest {
assertTrue(suspiciousSql.contains("hasModelRuleHit"), suspiciousSql);
assertTrue(suspiciousSql.contains("hasNameListHit"), suspiciousSql);
assertTrue(suspiciousSql.contains("final_result.nameListHitType"), suspiciousSql);
assertTrue(suspiciousSql.contains("bs.LE_ACCOUNT_NO as leAccountNo"), suspiciousSql);
assertTrue(suspiciousSql.contains("bs.CUSTOMER_ACCOUNT_NO as customerAccountNo"), suspiciousSql);
assertTrue(suspiciousSql.contains("bs.bank_statement_id = final_result.bankStatementId"), suspiciousSql);
assertTrue(mergedSql.contains("<if test=\"query.includeExternalPerson == true\">"), mergedSql);
assertTrue(mergedSql.contains("<include refid=\"externalSuspiciousTransactionSql\"/>"), mergedSql);
assertTrue(externalSubjectExistsSql.contains("limit 1"), externalSubjectExistsSql);
@@ -117,6 +125,7 @@ class CcdiProjectOverviewMapperSqlTest {
assertTrue(reportSuspiciousSql.contains("疑似与中介往来"), reportSuspiciousSql);
assertTrue(reportSuspiciousSql.contains("final_result.nameListHitType = '信贷客户'"), reportSuspiciousSql);
assertTrue(reportSuspiciousSql.contains("与信贷客户之间非正常资金往来"), reportSuspiciousSql);
assertTrue(reportSuspiciousSql.contains("suspiciousTransactionModelScopeSql"), reportSuspiciousSql);
}
@Test

View File

@@ -129,7 +129,9 @@ class CcdiBankStatementServiceImplTest {
CcdiBankStatementHitTagVO hitTag = new CcdiBankStatementHitTagVO();
hitTag.setBankStatementId(51274L);
hitTag.setRuleName("大额存现交易");
when(bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(43L, List.of(51274L)))
when(bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(
43L, List.of(51274L), null, null
))
.thenReturn(List.of(hitTag));
List<CcdiBankStatementExcel> result = service.selectStatementListForExport(queryDTO);
@@ -152,7 +154,9 @@ class CcdiBankStatementServiceImplTest {
hitTag.setBankStatementId(51274L);
hitTag.setRuleCode("LARGE_CASH_DEPOSIT");
hitTag.setRuleName("大额存现交易");
when(bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(43L, List.of(51274L)))
when(bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(
43L, List.of(51274L), null, null
))
.thenReturn(List.of(hitTag));
Page<CcdiBankStatementListVO> result = service.selectStatementPage(page, queryDTO);
@@ -170,7 +174,9 @@ class CcdiBankStatementServiceImplTest {
CcdiBankStatementHitTagVO hitTag = new CcdiBankStatementHitTagVO();
hitTag.setBankStatementId(200L);
hitTag.setRuleName("大额存现交易");
when(bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(43L, List.of(200L)))
when(bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(
43L, List.of(200L), null, null
))
.thenReturn(List.of(hitTag));
CcdiBankStatementDetailVO result = service.getStatementDetail(200L);
@@ -179,4 +185,21 @@ class CcdiBankStatementServiceImplTest {
assertEquals(1, result.getHitTags().size());
assertEquals("大额存现交易", result.getHitTags().get(0).getRuleName());
}
@Test
void getStatementDetail_shouldApplySuspiciousTransactionScope() {
CcdiBankStatementDetailVO detailVO = new CcdiBankStatementDetailVO();
detailVO.setBankStatementId(200L);
detailVO.setProjectId(43L);
when(bankStatementMapper.selectStatementDetailById(200L)).thenReturn(detailVO);
when(bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(
43L, List.of(200L), "SUSPICIOUS_GAMBLING", "MODEL_RULE"
)).thenReturn(List.of());
service.getStatementDetail(200L, "suspicious_gambling", "model_rule");
verify(bankTagResultMapper).selectStatementTagsByProjectAndStatementIds(
43L, List.of(200L), "SUSPICIOUS_GAMBLING", "MODEL_RULE"
);
}
}

View File

@@ -387,7 +387,9 @@ class CcdiProjectOverviewServiceImplTest {
hitTag.setRuleCode("RULE_A");
hitTag.setRuleName("大额转账");
hitTag.setRiskLevel("HIGH");
when(bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(40L, List.of(1L)))
when(bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(
40L, List.of(1L), null, null
))
.thenReturn(List.of(hitTag));
CcdiProjectPersonAnalysisObjectRecordVO objectRow = new CcdiProjectPersonAnalysisObjectRecordVO();

View File

@@ -5,6 +5,7 @@ import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectSuspiciousTransactionExcel;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportSuspiciousTransactionVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementHitTagVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionPageVO;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagResultMapper;
@@ -120,6 +121,71 @@ class CcdiProjectOverviewServiceSuspiciousTransactionTest {
);
}
@Test
void shouldSkipExternalBranchAndAttachSelectedEmployeeModelTags() {
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
when(projectMapper.selectById(40L)).thenReturn(project);
CcdiProjectSuspiciousTransactionItemVO item = new CcdiProjectSuspiciousTransactionItemVO();
item.setBankStatementId(101L);
Page<CcdiProjectSuspiciousTransactionItemVO> page = new Page<>(1, 10);
page.setRecords(List.of(item));
page.setTotal(1);
when(overviewMapper.selectSuspiciousTransactionPage(
any(Page.class), any(CcdiProjectSuspiciousTransactionQueryDTO.class)
)).thenReturn(page);
CcdiBankStatementHitTagVO tag = new CcdiBankStatementHitTagVO();
tag.setBankStatementId(101L);
tag.setModelCode("SUSPICIOUS_GAMBLING");
tag.setRuleName("疑似赌博");
when(bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(
40L, List.of(101L), "SUSPICIOUS_GAMBLING", "ALL"
)).thenReturn(List.of(tag));
CcdiProjectSuspiciousTransactionQueryDTO queryDTO = new CcdiProjectSuspiciousTransactionQueryDTO();
queryDTO.setProjectId(40L);
queryDTO.setModelCode("suspicious_gambling");
queryDTO.setSuspiciousType("all");
CcdiProjectSuspiciousTransactionPageVO result = service.getSuspiciousTransactions(queryDTO);
assertEquals("疑似赌博", result.getRows().getFirst().getHitTags().getFirst().getRuleName());
verify(overviewMapper, never()).selectExternalPersonSubjectExistsByProjectId(40L);
verify(overviewMapper).selectSuspiciousTransactionPage(
any(Page.class),
argThat(query -> !Boolean.TRUE.equals(query.getIncludeExternalPerson()))
);
}
@Test
void shouldKeepEmployeeRuleScopeSeparateFromExternalModels() {
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
when(projectMapper.selectById(40L)).thenReturn(project);
Page<CcdiProjectSuspiciousTransactionItemVO> page = new Page<>(1, 10);
page.setRecords(List.of());
page.setTotal(0);
when(overviewMapper.selectSuspiciousTransactionPage(
any(Page.class), any(CcdiProjectSuspiciousTransactionQueryDTO.class)
)).thenReturn(page);
CcdiProjectSuspiciousTransactionQueryDTO queryDTO = new CcdiProjectSuspiciousTransactionQueryDTO();
queryDTO.setProjectId(40L);
queryDTO.setSuspiciousType("MODEL_RULE");
service.getSuspiciousTransactions(queryDTO);
verify(overviewMapper, never()).selectExternalPersonSubjectExistsByProjectId(40L);
verify(overviewMapper).selectSuspiciousTransactionPage(
any(Page.class),
argThat(query -> "MODEL_RULE".equals(query.getSuspiciousType())
&& !Boolean.TRUE.equals(query.getIncludeExternalPerson()))
);
}
@Test
void shouldReturnEmptyExternalSuspiciousTransactionsWhenExternalSubjectDoesNotExist() {
CcdiProject project = new CcdiProject();

View File

@@ -0,0 +1,81 @@
# 涉疑交易明细筛选一致性实施记录
## 修改内容
### 后端
- 涉疑交易模型命中 SQL 按预警类型隔离模型范围:
- `MODEL_RULE` 排除 `EXTERNAL_*` 外部模型。
- `EXTERNAL_PERSON` 仅保留 `EXTERNAL_*` 外部模型。
- 选择具体 `modelCode` 时精确匹配模型。
- 优化外部人员分支判断:`MODEL_RULE``ALL + 员工具体模型` 不再检查或拼接外部人员分支。
- 涉疑交易分页结果直接返回本方账户、对方账户等列表展示字段。
- 分页完成后,仅针对当前页流水 ID 批量查询一次筛选范围内的标签,并返回标签 `modelCode`
- 流水详情接口支持 `modelCode``suspiciousType`,详情标签与列表筛选一致。
- 风险明细统一导出接口接收当前涉疑筛选条件,涉疑交易工作表与页面口径一致。
- 涉疑交易导出标签聚合应用相同模型范围。
### 前端
- 模型或预警类型变化时立即清空旧列表、总数、详情和缓存,但不自动发起查询。
- 增加涉疑交易请求序号,只有最后一次请求可以更新列表、总数和加载状态。
- 父组件同项目数据更新时不再重置本地筛选或覆盖当前列表;仅项目切换时重新初始化。
- 删除涉疑列表当前页逐条请求流水详情的补全逻辑,直接使用列表接口返回字段和标签。
- 详情缓存键增加预警类型与模型编码,详情请求透传当前筛选条件。
- 风险明细导出透传当前涉疑交易筛选条件。
## 性能变化
- 浏览器列表加载由“1 次涉疑列表请求 + 当前页 N 次流水详情请求”降为 1 次涉疑列表请求。
- 后端每次分页固定执行分页查询和当前页标签批量查询,不按流水逐条查询标签。
- 员工具体模型查询不再执行外部人员主体判断和外部人员复杂关联分支。
- 下拉框变化不请求后端,只有点击“查询”才执行一次查询。
## 自动化验证
### 后端
执行:
```bash
mvn -pl ccdi-project -am "-Dtest=CcdiProjectOverviewServiceSuspiciousTransactionTest,CcdiProjectOverviewMapperSqlTest,CcdiBankStatementServiceImplTest,CcdiBankTagResultMapperXmlTest,CcdiBankStatementControllerTest,CcdiProjectOverviewControllerTest,CcdiProjectOverviewControllerContractTest" "-Dsurefire.failIfNoSpecifiedTests=false" test
```
结果:`Tests run: 55, Failures: 0, Errors: 0, Skipped: 0`
同时执行 `mvn -pl ruoyi-admin -am package -DskipTests`,后端打包通过。
### 前端
项目 `.nvmrc` 对应的 Node 14.21.3 本机安装缺少 npm因此按仓库规则使用已验证的 Node 22.22.3、npm 10.9.8。
执行以下定向脚本,全部通过:
```bash
node tests/unit/risk-detail-suspicious-filter-consistency.test.js
node tests/unit/risk-detail-suspicious-filter-linkage.test.js
node tests/unit/risk-detail-suspicious-transaction-detail-dialog.test.js
node tests/unit/preliminary-check-suspicious-transaction-load.test.js
node tests/unit/project-overview-api.test.js
```
执行 `npm run build:prod`,生产构建通过;仅保留仓库原有包体积警告。
## 真实页面验证
`browser-use` 所需的内置浏览器执行工具在当前会话不可用,按技能检查流程确认后,使用终端 Playwright 连接真实前端 `http://localhost:9528` 和真实 Java 后端 `http://localhost:62318` 验证,未使用原型或 Mock。
- 项目 42
- 切换模型时旧列表立即清空,未自动发起查询。
- `ALL + SUSPICIOUS_GAMBLING` 返回空结果,未保留上一轮大额交易列表。
- `ALL + LARGE_TRANSACTION` 返回标签全部为 `LARGE_TRANSACTION`
- 主动打开详情后,详情请求携带 `modelCode=LARGE_TRANSACTION&suspiciousType=ALL`
- 项目 90629002
- `MODEL_RULE + 全部模型` 仅返回 2 条员工流水,标签均为员工模型。
- `EXTERNAL_PERSON + 全部模型` 仅返回 3 条外部人员流水,标签均为 `EXTERNAL_*`
- `EXTERNAL_PERSON + EXTERNAL_LARGE_TRANSACTION` 仅返回外部人员大额交易标签。
- `EXTERNAL_PERSON + EXTERNAL_SUSPICIOUS_GAMBLING` 仅返回 1 条外部人员可疑赌博流水和对应标签,未混入外部人员大额交易标签。
- 列表查询期间未产生当前页逐条详情请求;仅主动点击“详情”后发起一条详情请求。
- 浏览器控制台错误数为 0。
测试结束后已关闭本轮启动的前端、后端和浏览器进程62318、9528 均无监听进程。

View File

@@ -18,9 +18,10 @@ export function getBankStatementOptions(projectId) {
});
}
export function getBankStatementDetail(bankStatementId) {
export function getBankStatementDetail(bankStatementId, params) {
return request({
url: `/ccdi/project/bank-statement/detail/${bankStatementId}`,
method: "get",
params,
});
}

View File

@@ -25,6 +25,7 @@
class="suspicious-filter-select"
:placeholder="allModelOptionLabel"
filterable
@change="handleSuspiciousModelSelectChange"
>
<el-option :label="allModelOptionLabel" value="" />
<el-option
@@ -513,6 +514,15 @@ const normalizeDetailData = (detail) => ({
hitTags: normalizeHitTags(detail && detail.hitTags),
});
const normalizeSuspiciousRows = (rows) => (
Array.isArray(rows)
? rows.map((row) => ({
...row,
hitTags: normalizeHitTags(row && row.hitTags, row),
}))
: []
);
export default {
name: "RiskDetailSection",
props: {
@@ -546,6 +556,8 @@ export default {
abnormalAccountList: [],
projectId: null,
statementDetailCache: {},
suspiciousRequestSeq: 0,
detailRequestSeq: 0,
};
},
computed: {
@@ -574,15 +586,11 @@ export default {
watch: {
sectionData: {
immediate: true,
deep: true,
handler(value) {
this.projectId = value && value.projectId ? value.projectId : null;
this.currentSuspiciousType = normalizeSuspiciousType(value && value.suspiciousType);
this.selectedModelCode = "";
const nextProjectId = value && value.projectId ? value.projectId : null;
const projectChanged = String(nextProjectId || "") !== String(this.projectId || "");
this.projectId = nextProjectId;
this.riskModelOptions = normalizeRiskModelOptions(value && value.riskModelOptions);
this.suspiciousPageNum = 1;
this.suspiciousPageSize = DEFAULT_RISK_DETAIL_PAGE_SIZE;
this.suspiciousTotal = Number(value && value.total) || 0;
this.employeeCreditNegativePageNum = 1;
this.employeeCreditNegativePageSize = DEFAULT_RISK_DETAIL_PAGE_SIZE;
this.employeeCreditNegativeTotal = Number(value && value.employeeCreditNegativeTotal) || 0;
@@ -590,19 +598,33 @@ export default {
this.abnormalAccountPageSize = DEFAULT_RISK_DETAIL_PAGE_SIZE;
this.abnormalAccountTotal = 0;
this.abnormalAccountList = [];
const rows = Array.isArray(value && value.suspiciousTransactionList)
? value.suspiciousTransactionList
: [];
this.employeeCreditNegativeList = Array.isArray(value && value.employeeCreditNegativeList)
? value.employeeCreditNegativeList
: [];
this.hydrateSuspiciousRows(rows);
if (projectChanged) {
this.suspiciousRequestSeq += 1;
this.detailRequestSeq += 1;
this.currentSuspiciousType = normalizeSuspiciousType(value && value.suspiciousType);
this.selectedModelCode = "";
this.suspiciousPageNum = 1;
this.suspiciousPageSize = DEFAULT_RISK_DETAIL_PAGE_SIZE;
this.suspiciousTotal = Number(value && value.total) || 0;
this.suspiciousTransactionList = normalizeSuspiciousRows(
value && value.suspiciousTransactionList
);
this.suspiciousLoading = false;
this.statementDetailCache = {};
this.closeDetailDialog();
}
this.loadAbnormalAccountPeople();
},
},
},
methods: {
buildFlowEvidenceFingerprint,
handleSuspiciousModelSelectChange() {
this.resetSuspiciousFilterResults();
},
handleSuspiciousTypeSelectChange(value) {
if (value === "EXTERNAL_PERSON") {
const hasExternalModel = EXTERNAL_RISK_MODEL_OPTIONS
@@ -610,11 +632,20 @@ export default {
if (!hasExternalModel) {
this.selectedModelCode = "";
}
return;
}
if (value === "MODEL_RULE" && isExternalModelCode(this.selectedModelCode)) {
} else if (value === "MODEL_RULE" && isExternalModelCode(this.selectedModelCode)) {
this.selectedModelCode = "";
}
this.resetSuspiciousFilterResults();
},
resetSuspiciousFilterResults() {
this.suspiciousRequestSeq += 1;
this.detailRequestSeq += 1;
this.suspiciousPageNum = 1;
this.suspiciousTotal = 0;
this.suspiciousTransactionList = [];
this.suspiciousLoading = false;
this.statementDetailCache = {};
this.closeDetailDialog();
},
async handleSuspiciousQuery() {
this.suspiciousPageNum = 1;
@@ -663,6 +694,7 @@ export default {
await this.loadAbnormalAccountPeople();
},
async loadSuspiciousTransactions() {
const requestSeq = ++this.suspiciousRequestSeq;
if (!this.projectId) {
this.suspiciousTransactionList = [];
this.suspiciousTotal = 0;
@@ -670,25 +702,35 @@ export default {
return;
}
this.suspiciousLoading = true;
try {
const response = await getOverviewSuspiciousTransactions({
const query = {
projectId: this.projectId,
modelCode: this.selectedModelCode,
suspiciousType: this.currentSuspiciousType,
pageNum: this.suspiciousPageNum,
pageSize: this.suspiciousPageSize,
});
};
this.suspiciousLoading = true;
try {
const response = await getOverviewSuspiciousTransactions(query);
if (requestSeq !== this.suspiciousRequestSeq) {
return;
}
const data = (response && response.data) || {};
this.suspiciousTotal = Number(data.total) || 0;
await this.hydrateSuspiciousRows(Array.isArray(data.rows) ? data.rows : []);
this.suspiciousTransactionList = normalizeSuspiciousRows(data.rows);
} catch (error) {
if (requestSeq !== this.suspiciousRequestSeq) {
return;
}
this.suspiciousTransactionList = [];
this.suspiciousTotal = 0;
this.$message.error("加载涉疑交易明细失败");
console.error("加载涉疑交易明细失败", error);
} finally {
if (requestSeq === this.suspiciousRequestSeq) {
this.suspiciousLoading = false;
}
}
},
async loadEmployeeCreditNegative() {
if (!this.projectId) {
@@ -744,57 +786,25 @@ export default {
this.abnormalAccountLoading = false;
}
},
async hydrateSuspiciousRows(rows) {
const safeRows = Array.isArray(rows) ? rows : [];
if (!safeRows.length) {
this.suspiciousTransactionList = [];
this.suspiciousLoading = false;
return;
}
this.suspiciousLoading = true;
try {
const enrichedRows = await Promise.all(
safeRows.map(async (row) => {
if (!row || !row.bankStatementId) {
return {
...row,
hitTags: [],
};
}
try {
const detail = await this.fetchStatementDetail(row.bankStatementId, true);
return {
...detail,
...row,
hitTags: normalizeHitTags(detail.hitTags, row),
};
} catch (error) {
return {
...row,
hitTags: [],
};
}
})
);
this.suspiciousTransactionList = enrichedRows;
} finally {
this.suspiciousLoading = false;
}
},
async fetchStatementDetail(bankStatementId, silent) {
async fetchStatementDetail(bankStatementId, silent, scope = {}) {
if (!bankStatementId) {
return createEmptyDetailData();
}
if (this.statementDetailCache[bankStatementId]) {
return this.statementDetailCache[bankStatementId];
const modelCode = scope.modelCode !== undefined ? scope.modelCode : this.selectedModelCode;
const suspiciousType = scope.suspiciousType || this.currentSuspiciousType;
const cacheKey = `${bankStatementId}|${suspiciousType || "ALL"}|${modelCode || "ALL"}`;
if (this.statementDetailCache[cacheKey]) {
return this.statementDetailCache[cacheKey];
}
try {
const response = await getBankStatementDetail(bankStatementId);
const response = await getBankStatementDetail(bankStatementId, {
modelCode,
suspiciousType,
});
const detail = normalizeDetailData(response && response.data);
this.$set(this.statementDetailCache, bankStatementId, detail);
this.$set(this.statementDetailCache, cacheKey, detail);
return detail;
} catch (error) {
if (!silent) {
@@ -808,10 +818,18 @@ export default {
return;
}
const requestSeq = ++this.detailRequestSeq;
const scope = {
modelCode: this.selectedModelCode,
suspiciousType: this.currentSuspiciousType,
};
this.detailVisible = true;
this.detailLoading = true;
try {
const detail = await this.fetchStatementDetail(row.bankStatementId, false);
const detail = await this.fetchStatementDetail(row.bankStatementId, false, scope);
if (requestSeq !== this.detailRequestSeq) {
return;
}
this.detailData = {
...detail,
...row,
@@ -820,12 +838,17 @@ export default {
: normalizeHitTags(detail.hitTags, row),
};
} catch (error) {
if (requestSeq !== this.detailRequestSeq) {
return;
}
this.detailData = createEmptyDetailData();
this.$message.error("加载流水详情失败");
console.error("加载流水详情失败", error);
} finally {
if (requestSeq === this.detailRequestSeq) {
this.detailLoading = false;
}
}
},
closeDetailDialog() {
this.detailVisible = false;
@@ -865,6 +888,8 @@ export default {
"ccdi/project/overview/risk-details/export",
{
projectId: this.projectId,
modelCode: this.selectedModelCode,
suspiciousType: this.currentSuspiciousType,
},
`风险明细_${this.projectId}_${new Date().getTime()}.xlsx`
);

View File

@@ -0,0 +1,43 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const componentSource = fs.readFileSync(
path.resolve(
__dirname,
"../../src/views/ccdiProject/components/detail/RiskDetailSection.vue"
),
"utf8"
);
const statementApiSource = fs.readFileSync(
path.resolve(__dirname, "../../src/api/ccdiProjectBankStatement.js"),
"utf8"
);
[
'@change="handleSuspiciousModelSelectChange"',
"resetSuspiciousFilterResults()",
"const requestSeq = ++this.suspiciousRequestSeq;",
"if (requestSeq !== this.suspiciousRequestSeq)",
"if (requestSeq === this.suspiciousRequestSeq)",
"this.suspiciousTransactionList = normalizeSuspiciousRows(data.rows);",
"const projectChanged =",
"if (projectChanged) {",
"modelCode: this.selectedModelCode",
"suspiciousType: this.currentSuspiciousType",
"const cacheKey = `${bankStatementId}|${suspiciousType || \"ALL\"}|${modelCode || \"ALL\"}`;",
].forEach((token) => assert(componentSource.includes(token), token));
assert(
!componentSource.includes("async hydrateSuspiciousRows(rows)"),
"涉疑列表不得再按行请求详情补标签"
);
assert(
!componentSource.includes("deep: true"),
"父页面同项目数据更新不得通过深度监听重置筛选"
);
[
"getBankStatementDetail(bankStatementId, params)",
"params,",
].forEach((token) => assert(statementApiSource.includes(token), token));

View File

@@ -15,7 +15,7 @@ const source = fs.readFileSync(
"detailVisible",
"handleViewDetail",
"this.download(",
"ccdi/project/overview/suspicious-transactions/export",
"ccdi/project/overview/risk-details/export",
"原始文件",
"命中异常标签",
"formatOriginalFileName",