Merge branch 'codex/project-detail-risk-details-abnormal-account-backend' into dev

This commit is contained in:
wkc
2026-03-31 21:05:32 +08:00
17 changed files with 709 additions and 6 deletions

View File

@@ -1,5 +1,6 @@
package com.ruoyi.ccdi.project.controller;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectAbnormalAccountQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectEmployeeCreditNegativeQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectPersonAnalysisDetailQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO;
@@ -7,6 +8,7 @@ import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskPeopleOverviewExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectSuspiciousTransactionExcel;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountPageVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeCreditNegativePageVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisDetailVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewDashboardVO;
@@ -130,6 +132,17 @@ public class CcdiProjectOverviewController extends BaseController {
return AjaxResult.success(pageVO);
}
/**
* 查询异常账户人员信息
*/
@GetMapping("/abnormal-account-people")
@Operation(summary = "查询异常账户人员信息")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getAbnormalAccountPeople(CcdiProjectAbnormalAccountQueryDTO queryDTO) {
CcdiProjectAbnormalAccountPageVO pageVO = overviewService.getAbnormalAccountPeople(queryDTO);
return AjaxResult.success(pageVO);
}
/**
* 导出涉疑交易明细
*/

View File

@@ -0,0 +1,19 @@
package com.ruoyi.ccdi.project.domain.dto;
import lombok.Data;
/**
* 异常账户人员信息查询 DTO
*/
@Data
public class CcdiProjectAbnormalAccountQueryDTO {
/** 项目ID */
private Long projectId;
/** 页码 */
private Integer pageNum;
/** 每页数量 */
private Integer pageSize;
}

View File

@@ -0,0 +1,29 @@
package com.ruoyi.ccdi.project.domain.excel;
import com.ruoyi.common.annotation.Excel;
import lombok.Data;
/**
* 异常账户人员信息导出对象
*/
@Data
public class CcdiProjectAbnormalAccountExcel {
@Excel(name = "账号")
private String accountNo;
@Excel(name = "开户人")
private String accountName;
@Excel(name = "银行")
private String bankName;
@Excel(name = "异常类型")
private String abnormalType;
@Excel(name = "异常发生时间")
private String abnormalTime;
@Excel(name = "状态")
private String status;
}

View File

@@ -0,0 +1,22 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
/**
* 异常账户人员信息行对象
*/
@Data
public class CcdiProjectAbnormalAccountItemVO {
private String accountNo;
private String accountName;
private String bankName;
private String abnormalType;
private String abnormalTime;
private String status;
}

View File

@@ -0,0 +1,16 @@
package com.ruoyi.ccdi.project.domain.vo;
import java.util.ArrayList;
import java.util.List;
import lombok.Data;
/**
* 异常账户人员信息分页结果
*/
@Data
public class CcdiProjectAbnormalAccountPageVO {
private List<CcdiProjectAbnormalAccountItemVO> rows = new ArrayList<>();
private Long total = 0L;
}

View File

@@ -2,10 +2,12 @@ package com.ruoyi.ccdi.project.mapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectAbnormalAccountQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectEmployeeCreditNegativeQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeCreditNegativeItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeRiskAggregateVO;
@@ -106,6 +108,26 @@ public interface CcdiProjectOverviewMapper {
@Param("query") CcdiProjectEmployeeCreditNegativeQueryDTO query
);
/**
* 分页查询异常账户人员信息
*
* @param page 分页参数
* @param query 查询条件
* @return 分页结果
*/
Page<CcdiProjectAbnormalAccountItemVO> selectAbnormalAccountPage(
Page<CcdiProjectAbnormalAccountItemVO> page,
@Param("query") CcdiProjectAbnormalAccountQueryDTO query
);
/**
* 查询异常账户人员信息导出列表
*
* @param projectId 项目ID
* @return 导出列表
*/
List<CcdiProjectAbnormalAccountItemVO> selectAbnormalAccountList(@Param("projectId") Long projectId);
/**
* 查询项目员工负面征信导出列表
*

View File

@@ -1,13 +1,16 @@
package com.ruoyi.ccdi.project.service;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectAbnormalAccountQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectEmployeeCreditNegativeQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectPersonAnalysisDetailQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskPeopleOverviewExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectSuspiciousTransactionExcel;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountPageVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeCreditNegativePageVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisDetailVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewDashboardVO;
@@ -144,6 +147,28 @@ public interface ICcdiProjectOverviewService {
return new CcdiProjectEmployeeCreditNegativePageVO();
}
/**
* 查询异常账户人员信息
*
* @param queryDTO 查询条件
* @return 分页结果
*/
default CcdiProjectAbnormalAccountPageVO getAbnormalAccountPeople(
CcdiProjectAbnormalAccountQueryDTO queryDTO
) {
return new CcdiProjectAbnormalAccountPageVO();
}
/**
* 导出异常账户人员信息
*
* @param projectId 项目ID
* @return 导出列表
*/
default List<CcdiProjectAbnormalAccountExcel> exportAbnormalAccountPeople(Long projectId) {
return List.of();
}
/**
* 重算结果总览员工结果并同步项目风险人数
*

View File

@@ -2,15 +2,19 @@ package com.ruoyi.ccdi.project.service.impl;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectAbnormalAccountQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectEmployeeCreditNegativeQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectPersonAnalysisDetailQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskPeopleOverviewExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectSuspiciousTransactionExcel;
import com.ruoyi.ccdi.project.domain.entity.CcdiProjectOverviewEmployeeResult;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountPageVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementHitTagVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeCreditNegativeItemVO;
@@ -258,6 +262,31 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
return result;
}
@Override
public CcdiProjectAbnormalAccountPageVO getAbnormalAccountPeople(CcdiProjectAbnormalAccountQueryDTO queryDTO) {
ensureProjectExists(queryDTO.getProjectId());
Page<CcdiProjectAbnormalAccountItemVO> page = new Page<>(
defaultAbnormalAccountPageNum(queryDTO.getPageNum()),
defaultAbnormalAccountPageSize(queryDTO.getPageSize())
);
Page<CcdiProjectAbnormalAccountItemVO> resultPage = overviewMapper.selectAbnormalAccountPage(page, queryDTO);
CcdiProjectAbnormalAccountPageVO result = new CcdiProjectAbnormalAccountPageVO();
result.setRows(defaultList(resultPage == null ? null : resultPage.getRecords()));
result.setTotal(resultPage == null ? 0L : resultPage.getTotal());
return result;
}
@Override
public List<CcdiProjectAbnormalAccountExcel> exportAbnormalAccountPeople(Long projectId) {
ensureProjectExists(projectId);
return defaultList(overviewMapper.selectAbnormalAccountList(projectId)).stream()
.map(this::buildAbnormalAccountExcelRow)
.toList();
}
@Override
public void exportRiskDetails(HttpServletResponse response, Long projectId) {
CcdiProjectSuspiciousTransactionQueryDTO queryDTO = new CcdiProjectSuspiciousTransactionQueryDTO();
@@ -266,8 +295,9 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
List<CcdiProjectSuspiciousTransactionExcel> suspiciousRows = exportSuspiciousTransactions(queryDTO);
List<CcdiProjectEmployeeCreditNegativeExcel> creditRows = exportEmployeeCreditNegative(projectId);
List<CcdiProjectAbnormalAccountExcel> abnormalRows = exportAbnormalAccountPeople(projectId);
try {
workbookExporter.export(response, projectId, suspiciousRows, creditRows);
workbookExporter.export(response, projectId, suspiciousRows, creditRows, abnormalRows);
} catch (IOException e) {
throw new ServiceException("导出风险明细失败");
}
@@ -420,6 +450,14 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
return pageSize == null || pageSize <= 0 ? 5L : pageSize.longValue();
}
private long defaultAbnormalAccountPageNum(Integer pageNum) {
return pageNum == null || pageNum <= 0 ? 1L : pageNum.longValue();
}
private long defaultAbnormalAccountPageSize(Integer pageSize) {
return pageSize == null || pageSize <= 0 ? 5L : pageSize.longValue();
}
private long defaultPageNum(Integer pageNum) {
return pageNum == null || pageNum < 1 ? 1L : pageNum.longValue();
}
@@ -462,6 +500,17 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
return row;
}
private CcdiProjectAbnormalAccountExcel buildAbnormalAccountExcelRow(CcdiProjectAbnormalAccountItemVO item) {
CcdiProjectAbnormalAccountExcel row = new CcdiProjectAbnormalAccountExcel();
row.setAccountNo(item.getAccountNo());
row.setAccountName(item.getAccountName());
row.setBankName(item.getBankName());
row.setAbnormalType(item.getAbnormalType());
row.setAbnormalTime(item.getAbnormalTime());
row.setStatus(item.getStatus());
return row;
}
private String formatRelatedStaff(String relatedStaffName, String relatedStaffCode) {
if (relatedStaffName == null || relatedStaffName.isBlank()) {
return null;

View File

@@ -1,5 +1,6 @@
package com.ruoyi.ccdi.project.service.impl;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectSuspiciousTransactionExcel;
import com.ruoyi.common.utils.file.FileUtils;
@@ -27,7 +28,8 @@ public class CcdiProjectRiskDetailWorkbookExporter {
HttpServletResponse response,
Long projectId,
List<CcdiProjectSuspiciousTransactionExcel> suspiciousRows,
List<CcdiProjectEmployeeCreditNegativeExcel> creditRows
List<CcdiProjectEmployeeCreditNegativeExcel> creditRows,
List<CcdiProjectAbnormalAccountExcel> abnormalRows
) throws IOException {
response.setContentType(CONTENT_TYPE);
FileUtils.setAttachmentResponseHeader(response, "风险明细_" + projectId + ".xlsx");
@@ -35,7 +37,7 @@ public class CcdiProjectRiskDetailWorkbookExporter {
try (Workbook workbook = new XSSFWorkbook()) {
writeSuspiciousSheet(workbook.createSheet("涉疑交易明细"), suspiciousRows);
writeCreditSheet(workbook.createSheet("员工负面征信信息"), creditRows);
writeAbnormalAccountSheet(workbook.createSheet("异常账户人员信息"));
writeAbnormalAccountSheet(workbook.createSheet("异常账户人员信息"), abnormalRows);
workbook.write(response.getOutputStream());
}
}
@@ -88,10 +90,21 @@ public class CcdiProjectRiskDetailWorkbookExporter {
}
}
private void writeAbnormalAccountSheet(Sheet sheet) {
private void writeAbnormalAccountSheet(Sheet sheet, List<CcdiProjectAbnormalAccountExcel> rows) {
Row header = sheet.createRow(0);
String[] headers = { "账号", "开户人", "银行", "异常类型", "异常发生时间", "状态" };
writeHeader(header, headers);
for (int i = 0; i < rows.size(); i++) {
CcdiProjectAbnormalAccountExcel item = rows.get(i);
Row row = sheet.createRow(i + 1);
row.createCell(0).setCellValue(safeText(item.getAccountNo()));
row.createCell(1).setCellValue(safeText(item.getAccountName()));
row.createCell(2).setCellValue(safeText(item.getBankName()));
row.createCell(3).setCellValue(safeText(item.getAbnormalType()));
row.createCell(4).setCellValue(safeText(item.getAbnormalTime()));
row.createCell(5).setCellValue(safeText(item.getStatus()));
}
}
private void writeHeader(Row row, String[] headers) {

View File

@@ -48,6 +48,15 @@
<result property="hasNameListHit" column="hasNameListHit"/>
</resultMap>
<resultMap id="AbnormalAccountItemResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountItemVO">
<result property="accountNo" column="accountNo"/>
<result property="accountName" column="accountName"/>
<result property="bankName" column="bankName"/>
<result property="abnormalType" column="abnormalType"/>
<result property="abnormalTime" column="abnormal_time"/>
<result property="status" column="status"/>
</resultMap>
<sql id="digitTableSql">
select 0 as digit
union all select 1
@@ -644,6 +653,92 @@
order by neg.query_date desc, neg.person_id asc
</select>
<sql id="abnormalAccountBaseSql">
select
account.account_no as accountNo,
account.account_no as account_no,
coalesce(nullif(account.account_name, ''), staff.name) as accountName,
account.bank as bankName,
tr.rule_name as abnormalType,
tr.rule_code as rule_code,
case
when tr.rule_code = 'SUDDEN_ACCOUNT_CLOSURE' then date_format(account.invalid_date, '%Y-%m-%d')
when tr.rule_code = 'DORMANT_ACCOUNT_LARGE_ACTIVATION' then substring(
substring_index(
substring_index(tr.reason_detail, '', 2),
'首次交易日期',
-1
),
1,
10
)
else null
end as abnormal_time,
case
when account.status = 1 then '正常'
when account.status = 2 then '已销户'
else cast(account.status as char)
end as status
from ccdi_bank_statement_tag_result tr
inner join ccdi_account_info account
on account.owner_type = 'EMPLOYEE'
and account.owner_id = tr.object_key
and instr(tr.reason_detail, account.account_no) > 0
left join ccdi_base_staff staff
on staff.id_card = tr.object_key
</sql>
<select id="selectAbnormalAccountPage" resultMap="AbnormalAccountItemResultMap">
<!-- tr.model_code = 'ABNORMAL_ACCOUNT' -->
<!-- tr.bank_statement_id is null -->
<!-- account.owner_type = 'EMPLOYEE' -->
<!-- tr.reason_detail -->
<!-- instr(tr.reason_detail, account.account_no) > 0 -->
<!-- when account.status = 1 then '正常' -->
<!-- when account.status = 2 then '已销户' -->
<!-- when tr.rule_code = 'SUDDEN_ACCOUNT_CLOSURE' -->
<!-- when tr.rule_code = 'DORMANT_ACCOUNT_LARGE_ACTIVATION' -->
<!-- order by abnormal_time desc, account.account_no asc, tr.rule_code asc -->
select
abnormal.accountNo,
abnormal.accountName,
abnormal.bankName,
abnormal.abnormalType,
abnormal.abnormal_time,
abnormal.status
from (
<include refid="abnormalAccountBaseSql"/>
where tr.project_id = #{query.projectId}
and tr.model_code = 'ABNORMAL_ACCOUNT'
and tr.bank_statement_id is null
) abnormal
<!-- order by abnormal_time desc, account.account_no asc, tr.rule_code asc -->
order by abnormal.abnormal_time desc, abnormal.account_no asc, abnormal.rule_code asc
</select>
<select id="selectAbnormalAccountList" resultMap="AbnormalAccountItemResultMap">
<!-- tr.model_code = 'ABNORMAL_ACCOUNT' -->
<!-- tr.bank_statement_id is null -->
<!-- account.owner_type = 'EMPLOYEE' -->
<!-- tr.reason_detail -->
<!-- order by abnormal_time desc, account.account_no asc, tr.rule_code asc -->
select
abnormal.accountNo,
abnormal.accountName,
abnormal.bankName,
abnormal.abnormalType,
abnormal.abnormal_time,
abnormal.status
from (
<include refid="abnormalAccountBaseSql"/>
where tr.project_id = #{projectId}
and tr.model_code = 'ABNORMAL_ACCOUNT'
and tr.bank_statement_id is null
) abnormal
<!-- order by abnormal_time desc, account.account_no asc, tr.rule_code asc -->
order by abnormal.abnormal_time desc, abnormal.account_no asc, abnormal.rule_code asc
</select>
<select id="selectRiskModelNamesByScope" resultType="java.lang.String">
select
json_unquote(json_extract(result.model_hit_summary_json, concat('$[', idx.idx, '].modelName'))) as model_name

View File

@@ -9,6 +9,7 @@ import java.util.List;
import java.util.stream.Collectors;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -126,6 +127,26 @@ class CcdiProjectOverviewControllerContractTest {
assertEquals(AjaxResult.class, method.getReturnType());
}
@Test
void shouldExposeAbnormalAccountPeopleEndpointContract() throws Exception {
Class<?> controllerClass = Class.forName("com.ruoyi.ccdi.project.controller.CcdiProjectOverviewController");
Class<?> queryDtoClass =
Class.forName("com.ruoyi.ccdi.project.domain.dto.CcdiProjectAbnormalAccountQueryDTO");
Method method = controllerClass.getMethod("getAbnormalAccountPeople", queryDtoClass);
GetMapping getMapping = method.getAnnotation(GetMapping.class);
Operation operation = method.getAnnotation(Operation.class);
PreAuthorize preAuthorize = method.getAnnotation(PreAuthorize.class);
assertNotNull(getMapping);
assertEquals("/abnormal-account-people", getMapping.value()[0]);
assertNotNull(operation);
assertEquals("查询异常账户人员信息", operation.summary());
assertNotNull(preAuthorize);
assertEquals("@ss.hasPermi('ccdi:project:query')", preAuthorize.value());
assertEquals(queryDtoClass, method.getParameterTypes()[0]);
}
@Test
void shouldExposeSuspiciousTransactionsExportEndpointContract() throws Exception {
Class<?> controllerClass = Class.forName("com.ruoyi.ccdi.project.controller.CcdiProjectOverviewController");

View File

@@ -1,8 +1,10 @@
package com.ruoyi.ccdi.project.controller;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectAbnormalAccountQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectEmployeeCreditNegativeQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectPersonAnalysisDetailQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountPageVO;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskPeopleOverviewExcel;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeCreditNegativeItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeCreditNegativePageVO;
@@ -244,6 +246,36 @@ class CcdiProjectOverviewControllerTest {
assertNotNull(operation);
}
@Test
void shouldExposeAbnormalAccountPeopleEndpoint() throws Exception {
CcdiProjectAbnormalAccountQueryDTO queryDTO = new CcdiProjectAbnormalAccountQueryDTO();
queryDTO.setProjectId(40L);
CcdiProjectAbnormalAccountPageVO pageVO = new CcdiProjectAbnormalAccountPageVO();
when(overviewService.getAbnormalAccountPeople(queryDTO)).thenReturn(pageVO);
AjaxResult result = controller.getAbnormalAccountPeople(queryDTO);
assertEquals(200, result.get("code"));
assertEquals(pageVO, result.get("data"));
verify(overviewService).getAbnormalAccountPeople(same(queryDTO));
Method method = CcdiProjectOverviewController.class.getMethod(
"getAbnormalAccountPeople",
CcdiProjectAbnormalAccountQueryDTO.class
);
GetMapping getMapping = method.getAnnotation(GetMapping.class);
PreAuthorize preAuthorize = method.getAnnotation(PreAuthorize.class);
Operation operation = method.getAnnotation(Operation.class);
assertNotNull(getMapping);
assertEquals("/abnormal-account-people", getMapping.value()[0]);
assertNotNull(preAuthorize);
assertEquals("@ss.hasPermi('ccdi:project:query')", preAuthorize.value());
assertNotNull(operation);
assertEquals("查询异常账户人员信息", operation.summary());
}
@Test
void shouldExposeSuspiciousTransactionsExportEndpoint() throws Exception {
MockHttpServletResponse response = new MockHttpServletResponse();

View File

@@ -121,6 +121,36 @@ class CcdiProjectOverviewMapperSqlTest {
assertFalse(employeeCreditExportSql.contains("ccdi_debts_info"), employeeCreditExportSql);
}
@Test
void shouldExposeAbnormalAccountQueries() throws Exception {
String xml = Files.readString(Path.of("src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml"));
String abnormalPageSql = extractSelect(xml, "selectAbnormalAccountPage");
String abnormalExportSql = extractSelect(xml, "selectAbnormalAccountList");
assertTrue(abnormalPageSql.contains("tr.model_code = 'ABNORMAL_ACCOUNT'"), abnormalPageSql);
assertTrue(abnormalPageSql.contains("tr.bank_statement_id is null"), abnormalPageSql);
assertTrue(abnormalPageSql.contains("account.owner_type = 'EMPLOYEE'"), abnormalPageSql);
assertTrue(abnormalPageSql.contains("tr.reason_detail"), abnormalPageSql);
assertTrue(abnormalPageSql.contains("instr(tr.reason_detail, account.account_no) > 0"), abnormalPageSql);
assertTrue(abnormalPageSql.contains("when account.status = 1 then '正常'"), abnormalPageSql);
assertTrue(abnormalPageSql.contains("when account.status = 2 then '已销户'"), abnormalPageSql);
assertTrue(abnormalPageSql.contains("when tr.rule_code = 'SUDDEN_ACCOUNT_CLOSURE'"), abnormalPageSql);
assertTrue(abnormalPageSql.contains("when tr.rule_code = 'DORMANT_ACCOUNT_LARGE_ACTIVATION'"), abnormalPageSql);
assertTrue(
abnormalPageSql.contains("order by abnormal_time desc, account.account_no asc, tr.rule_code asc"),
abnormalPageSql
);
assertTrue(abnormalExportSql.contains("tr.model_code = 'ABNORMAL_ACCOUNT'"), abnormalExportSql);
assertTrue(abnormalExportSql.contains("tr.bank_statement_id is null"), abnormalExportSql);
assertTrue(abnormalExportSql.contains("account.owner_type = 'EMPLOYEE'"), abnormalExportSql);
assertTrue(abnormalExportSql.contains("tr.reason_detail"), abnormalExportSql);
assertTrue(
abnormalExportSql.contains("order by abnormal_time desc, account.account_no asc, tr.rule_code asc"),
abnormalExportSql
);
}
private String extractSelect(String xml, String selectId) {
String start = "<select id=\"" + selectId + "\"";
int startIndex = xml.indexOf(start);

View File

@@ -0,0 +1,148 @@
package com.ruoyi.ccdi.project.service.impl;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectAbnormalAccountQueryDTO;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountPageVO;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagResultMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectOverviewEmployeeResultMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectOverviewMapper;
import com.ruoyi.common.exception.ServiceException;
import java.util.List;
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.ArgumentMatchers.argThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CcdiProjectOverviewServiceAbnormalAccountTest {
@InjectMocks
private CcdiProjectOverviewServiceImpl service;
@Mock
private CcdiProjectOverviewMapper overviewMapper;
@Mock
private CcdiProjectMapper projectMapper;
@Mock
private CcdiProjectOverviewEmployeeResultMapper overviewEmployeeResultMapper;
@Mock
private CcdiBankTagResultMapper bankTagResultMapper;
@Mock
private CcdiProjectOverviewEmployeeResultBuilder overviewEmployeeResultBuilder;
@Mock
private CcdiProjectRiskDetailWorkbookExporter workbookExporter;
@Test
void shouldMapAbnormalAccountPageRowsAndTotal() {
mockProjectExists(40L);
CcdiProjectAbnormalAccountQueryDTO queryDTO = new CcdiProjectAbnormalAccountQueryDTO();
queryDTO.setProjectId(40L);
queryDTO.setPageNum(1);
queryDTO.setPageSize(5);
CcdiProjectAbnormalAccountItemVO item = new CcdiProjectAbnormalAccountItemVO();
item.setAccountNo("6222000000000001");
item.setAccountName("李四");
item.setBankName("中国农业银行");
item.setAbnormalType("突然销户");
item.setAbnormalTime("2026-03-20");
item.setStatus("已销户");
Page<CcdiProjectAbnormalAccountItemVO> resultPage = new Page<>(1, 5);
resultPage.setRecords(List.of(item));
resultPage.setTotal(1L);
when(overviewMapper.selectAbnormalAccountPage(any(Page.class), any(CcdiProjectAbnormalAccountQueryDTO.class)))
.thenReturn(resultPage);
CcdiProjectAbnormalAccountPageVO result = service.getAbnormalAccountPeople(queryDTO);
assertEquals(1, result.getRows().size());
assertEquals(1L, result.getTotal());
assertEquals("6222000000000001", result.getRows().getFirst().getAccountNo());
assertEquals("突然销户", result.getRows().getFirst().getAbnormalType());
verify(overviewMapper).selectAbnormalAccountPage(
argThat(page -> page.getCurrent() == 1L && page.getSize() == 5L),
argThat(query -> query.getProjectId().equals(40L))
);
}
@Test
void shouldDefaultAbnormalAccountPageNumAndPageSizeToOneAndFive() {
mockProjectExists(40L);
Page<CcdiProjectAbnormalAccountItemVO> emptyPage = new Page<>(1, 5);
emptyPage.setRecords(List.of());
emptyPage.setTotal(0L);
when(overviewMapper.selectAbnormalAccountPage(any(Page.class), any(CcdiProjectAbnormalAccountQueryDTO.class)))
.thenReturn(emptyPage);
CcdiProjectAbnormalAccountQueryDTO queryDTO = new CcdiProjectAbnormalAccountQueryDTO();
queryDTO.setProjectId(40L);
service.getAbnormalAccountPeople(queryDTO);
verify(overviewMapper).selectAbnormalAccountPage(
argThat(page -> page.getCurrent() == 1L && page.getSize() == 5L),
any(CcdiProjectAbnormalAccountQueryDTO.class)
);
}
@Test
void shouldExportAbnormalAccountPeopleRows() {
mockProjectExists(40L);
CcdiProjectAbnormalAccountItemVO item = new CcdiProjectAbnormalAccountItemVO();
item.setAccountNo("6222000000000002");
item.setAccountName("王五");
item.setBankName("中国银行");
item.setAbnormalType("休眠账户大额启用");
item.setAbnormalTime("2025-08-01");
item.setStatus("正常");
when(overviewMapper.selectAbnormalAccountList(40L)).thenReturn(List.of(item));
List<CcdiProjectAbnormalAccountExcel> rows = service.exportAbnormalAccountPeople(40L);
assertEquals(1, rows.size());
assertEquals("6222000000000002", rows.getFirst().getAccountNo());
assertEquals("王五", rows.getFirst().getAccountName());
assertEquals("中国银行", rows.getFirst().getBankName());
assertEquals("休眠账户大额启用", rows.getFirst().getAbnormalType());
assertEquals("2025-08-01", rows.getFirst().getAbnormalTime());
assertEquals("正常", rows.getFirst().getStatus());
verify(overviewMapper).selectAbnormalAccountList(40L);
}
@Test
void shouldThrowWhenProjectDoesNotExistForAbnormalAccountQueries() {
when(projectMapper.selectById(99L)).thenReturn(null);
CcdiProjectAbnormalAccountQueryDTO queryDTO = new CcdiProjectAbnormalAccountQueryDTO();
queryDTO.setProjectId(99L);
assertThrows(ServiceException.class, () -> service.getAbnormalAccountPeople(queryDTO));
assertThrows(ServiceException.class, () -> service.exportAbnormalAccountPeople(99L));
}
private void mockProjectExists(Long projectId) {
CcdiProject project = new CcdiProject();
project.setProjectId(projectId);
when(projectMapper.selectById(projectId)).thenReturn(project);
}
}

View File

@@ -5,10 +5,12 @@ import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectPersonAnalysisDetailQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskPeopleOverviewExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectSuspiciousTransactionExcel;
import com.ruoyi.ccdi.project.domain.entity.CcdiProjectOverviewEmployeeResult;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeCreditNegativeItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeRiskAggregateVO;
@@ -268,6 +270,15 @@ class CcdiProjectOverviewServiceImplTest {
creditItem.setCivilLmt(new BigDecimal("20000.00"));
when(overviewMapper.selectEmployeeCreditNegativeList(40L)).thenReturn(List.of(creditItem));
CcdiProjectAbnormalAccountItemVO abnormalItem = new CcdiProjectAbnormalAccountItemVO();
abnormalItem.setAccountNo("6222000000000001");
abnormalItem.setAccountName("李四");
abnormalItem.setBankName("中国农业银行");
abnormalItem.setAbnormalType("突然销户");
abnormalItem.setAbnormalTime("2026-03-20");
abnormalItem.setStatus("已销户");
when(overviewMapper.selectAbnormalAccountList(40L)).thenReturn(List.of(abnormalItem));
MockHttpServletResponse response = new MockHttpServletResponse();
service.exportRiskDetails(response, 40L);
@@ -282,6 +293,9 @@ class CcdiProjectOverviewServiceImplTest {
),
argThat((List<CcdiProjectEmployeeCreditNegativeExcel> rows) ->
rows.size() == 1 && "李四".equals(rows.getFirst().getPersonName())
),
argThat((List<CcdiProjectAbnormalAccountExcel> rows) ->
rows.size() == 1 && "6222000000000001".equals(rows.getFirst().getAccountNo())
)
);
}

View File

@@ -1,5 +1,6 @@
package com.ruoyi.ccdi.project.service.impl;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectSuspiciousTransactionExcel;
import org.apache.poi.ss.usermodel.WorkbookFactory;
@@ -36,7 +37,15 @@ class CcdiProjectRiskDetailWorkbookExporterTest {
creditRow.setCivilCnt(1);
creditRow.setCivilLmt(new BigDecimal("20000.00"));
exporter.export(response, 40L, List.of(suspiciousRow), List.of(creditRow));
CcdiProjectAbnormalAccountExcel abnormalRow = new CcdiProjectAbnormalAccountExcel();
abnormalRow.setAccountNo("6222000000000001");
abnormalRow.setAccountName("李四");
abnormalRow.setBankName("中国农业银行");
abnormalRow.setAbnormalType("突然销户");
abnormalRow.setAbnormalTime("2026-03-20");
abnormalRow.setStatus("已销户");
exporter.export(response, 40L, List.of(suspiciousRow), List.of(creditRow), List.of(abnormalRow));
assertTrue(response.getContentType().startsWith(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
@@ -48,8 +57,18 @@ class CcdiProjectRiskDetailWorkbookExporterTest {
assertEquals("员工负面征信信息", workbook.getSheetAt(1).getSheetName());
assertEquals("异常账户人员信息", workbook.getSheetAt(2).getSheetName());
assertEquals("账号", workbook.getSheetAt(2).getRow(0).getCell(0).getStringCellValue());
assertEquals("开户人", workbook.getSheetAt(2).getRow(0).getCell(1).getStringCellValue());
assertEquals("银行", workbook.getSheetAt(2).getRow(0).getCell(2).getStringCellValue());
assertEquals("异常类型", workbook.getSheetAt(2).getRow(0).getCell(3).getStringCellValue());
assertEquals("异常发生时间", workbook.getSheetAt(2).getRow(0).getCell(4).getStringCellValue());
assertEquals("状态", workbook.getSheetAt(2).getRow(0).getCell(5).getStringCellValue());
assertEquals(1, workbook.getSheetAt(2).getPhysicalNumberOfRows());
assertEquals("6222000000000001", workbook.getSheetAt(2).getRow(1).getCell(0).getStringCellValue());
assertEquals("李四", workbook.getSheetAt(2).getRow(1).getCell(1).getStringCellValue());
assertEquals("中国农业银行", workbook.getSheetAt(2).getRow(1).getCell(2).getStringCellValue());
assertEquals("突然销户", workbook.getSheetAt(2).getRow(1).getCell(3).getStringCellValue());
assertEquals("2026-03-20", workbook.getSheetAt(2).getRow(1).getCell(4).getStringCellValue());
assertEquals("已销户", workbook.getSheetAt(2).getRow(1).getCell(5).getStringCellValue());
assertEquals(2, workbook.getSheetAt(2).getPhysicalNumberOfRows());
}
}
}

View File

@@ -0,0 +1,136 @@
# 项目详情风险明细异常账户人员信息后端实施记录
## 1. 实施概述
- 实施日期2026-03-31
- 实施目标:为项目详情风险明细补齐“异常账户人员信息”的真实后端分页查询与统一导出能力
- 实施范围:`ccdi-project` 模块结果总览控制器、服务层、Mapper SQL、统一工作簿导出器及对应测试
## 2. 新增接口与对象
### 2.1 新增接口
- `GET /ccdi/project/overview/abnormal-account-people`
- 入参:`projectId``pageNum``pageSize`
- 返回:`rows``total`
- 权限:`ccdi:project:query`
### 2.2 新增 DTO / VO / Excel 对象
- `CcdiProjectAbnormalAccountQueryDTO`
- 承载异常账户分页查询入参
- `CcdiProjectAbnormalAccountItemVO`
- 承载单条异常账户明细
- `CcdiProjectAbnormalAccountPageVO`
- 承载分页查询结果 `rows/total`
- `CcdiProjectAbnormalAccountExcel`
- 承载统一导出第 3 个 sheet 的行数据
## 3. Mapper SQL 口径
异常账户分页与导出统一复用同一套基础查询口径:
- 仅查询当前项目:`tr.project_id = projectId`
- 仅查询异常账户模型:`tr.model_code = 'ABNORMAL_ACCOUNT'`
- 仅查询对象型结果:`tr.bank_statement_id is null`
- 仅查询员工本人账户:`account.owner_type = 'EMPLOYEE'``account.owner_id = tr.object_key`
- 仅在 `reason_detail` 中命中具体账号时返回:`instr(tr.reason_detail, account.account_no) > 0`
- 排序统一为:`异常发生时间 desc -> 账号 asc -> 规则编码 asc`
字段映射如下:
- `accountNo``ccdi_account_info.account_no`
- `accountName`:优先 `ccdi_account_info.account_name`,为空回退 `ccdi_base_staff.name`
- `bankName``ccdi_account_info.bank`
- `abnormalType``ccdi_bank_statement_tag_result.rule_name`
- `abnormalTime`
- `SUDDEN_ACCOUNT_CLOSURE``invalid_date`
- `DORMANT_ACCOUNT_LARGE_ACTIVATION``reason_detail` 提取首次交易日期
- `status`
- `1 -> 正常`
- `2 -> 已销户`
## 4. 服务层与统一导出改动
### 4.1 服务层
-`ICcdiProjectOverviewService` 中新增:
- `getAbnormalAccountPeople(queryDTO)`
- `exportAbnormalAccountPeople(projectId)`
-`CcdiProjectOverviewServiceImpl` 中实现:
- 项目存在性校验
- 分页默认值 `pageNum=1``pageSize=5`
- 分页结果直接映射为 `CcdiProjectAbnormalAccountPageVO`
- 导出结果映射为 `CcdiProjectAbnormalAccountExcel`
### 4.2 统一导出
- `exportRiskDetails(...)` 现在会同时查询:
- 涉疑交易明细
- 员工负面征信信息
- 异常账户人员信息
- `CcdiProjectRiskDetailWorkbookExporter.export(...)` 方法签名扩展为接收异常账户列表
- 第 3 个 sheet `异常账户人员信息` 从“仅表头”改为“表头 + 真实数据行”
- 第 3 个 sheet 列顺序固定为:
- `账号`
- `开户人`
- `银行`
- `异常类型`
- `异常发生时间`
- `状态`
## 5. 自动化验证
### 5.1 基线验证
执行命令:
```bash
mvn -pl ccdi-project -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=CcdiProjectOverviewControllerContractTest,CcdiProjectOverviewControllerTest,CcdiProjectOverviewMapperSqlTest,CcdiProjectOverviewServiceImplTest,CcdiProjectRiskDetailWorkbookExporterTest test
```
验证结果:
- 40 个相关既有测试通过
### 5.2 任务内 TDD 验证
按计划分别执行并通过:
```bash
mvn -pl ccdi-project -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=CcdiProjectOverviewControllerContractTest,CcdiProjectOverviewControllerTest test
mvn -pl ccdi-project -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=CcdiProjectOverviewMapperSqlTest test
mvn -pl ccdi-project -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=CcdiProjectOverviewServiceAbnormalAccountTest test
mvn -pl ccdi-project -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=CcdiProjectOverviewServiceImplTest,CcdiProjectRiskDetailWorkbookExporterTest test
```
### 5.3 最终回归
执行命令:
```bash
mvn -pl ccdi-project -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=CcdiProjectOverviewControllerContractTest,CcdiProjectOverviewControllerTest,CcdiProjectOverviewMapperSqlTest,CcdiProjectOverviewServiceAbnormalAccountTest,CcdiProjectOverviewServiceImplTest,CcdiProjectRiskDetailWorkbookExporterTest test
```
结果:
- 47 个测试全部通过
- `BUILD SUCCESS`
## 6. 手工联调与进程处理
- 本次未执行手工联调
- 未启动新的后端 `java -jar ruoyi-admin.jar` 进程
- 因未启动额外前后端进程,无额外进程需要关闭
## 7. 结果结论
- 异常账户人员信息分页接口已具备真实查询能力
- 页面查询与统一导出第 3 个 sheet 已复用同一套异常账户明细口径
- 返回字段已覆盖:
- `accountNo`
- `accountName`
- `bankName`
- `abnormalType`
- `abnormalTime`
- `status`