Compare commits

...

36 Commits

Author SHA1 Message Date
wkc
8798aa9230 调整lsfx-mock默认数据库配置并更新NAS部署环境 2026-03-31 23:03:14 +08:00
wkc
2fdf5f1546 记录异常账户基线同步后端实施 2026-03-31 22:18:06 +08:00
wkc
a32be65bf1 锁定异常账户流水与账户事实一致性 2026-03-31 22:16:48 +08:00
wkc
51810a325e 新增异常账户基线写库服务 2026-03-31 22:15:41 +08:00
wkc
6b24e02ba9 接入异常账户基线同步触发点 2026-03-31 22:14:03 +08:00
wkc
d831edcaa4 补充异常账户基线同步实施计划 2026-03-31 22:11:21 +08:00
wkc
af63607069 补充异常账户基线同步设计文档 2026-03-31 22:07:05 +08:00
wkc
0abc84c571 记录异常账户人员信息前端实施 2026-03-31 21:09:14 +08:00
wkc
7dafabf7cb 调整异常账户人员信息前端展示列 2026-03-31 21:08:04 +08:00
wkc
4dca2b2b63 补充异常账户人员前端查询状态 2026-03-31 21:07:24 +08:00
wkc
001597d5e8 Merge branch 'codex/project-detail-risk-details-abnormal-account-backend' into dev 2026-03-31 21:05:32 +08:00
wkc
4b5ac7388c 记录异常账户人员信息后端实施 2026-03-31 21:04:06 +08:00
wkc
1e0813a84c 补充风险明细异常账户统一导出 2026-03-31 21:03:13 +08:00
wkc
c8d45416cf 补充异常账户人员服务映射 2026-03-31 21:02:00 +08:00
wkc
09119a2365 补充异常账户人员查询SQL 2026-03-31 21:00:24 +08:00
wkc
5de46eabc5 修正异常账户流水返回账号覆盖 2026-03-31 20:58:44 +08:00
wkc
bcb2e39099 补充异常账户人员查询接口契约 2026-03-31 20:57:20 +08:00
wkc
09b4cfe3c4 Merge branch 'codex/lsfx-mock-server-abnormal-account-backend' into dev 2026-03-31 20:54:05 +08:00
wkc
c5a00f26ad 补充风险明细异常账户实施计划 2026-03-31 20:53:32 +08:00
wkc
d4dc66a514 完成异常账户Mock服务后端实施记录 2026-03-31 20:49:27 +08:00
wkc
2877e26fa5 接入异常账户命中流水主链路 2026-03-31 20:45:25 +08:00
wkc
1a19dcbc13 补充风险明细异常账户人员信息设计文档 2026-03-31 20:43:55 +08:00
wkc
f981dc9906 补充异常账户规则样本生成器 2026-03-31 20:42:22 +08:00
wkc
f0e2595a2b 补充异常账户命中计划与账户事实 2026-03-31 20:40:38 +08:00
wkc
37e0c231a7 补充异常账户Mock造数实施计划 2026-03-31 20:33:22 +08:00
wkc
1397f12057 补充异常账户Mock造数设计文档 2026-03-31 20:30:55 +08:00
wkc
46e476e35b 完成异常账户模型前端实施记录 2026-03-31 16:46:20 +08:00
wkc
bfac1f10d2 修正异常账户规则金额口径并补充后端验证记录 2026-03-31 16:46:05 +08:00
wkc
d01362cc72 补充异常账户规则SQL校验记录 2026-03-31 16:37:17 +08:00
wkc
2aee9ff76e 补充异常账户规则测试数据 2026-03-31 16:34:45 +08:00
wkc
5b91cee935 实现休眠账户大额启用打标规则 2026-03-31 16:32:52 +08:00
wkc
a3f49dc176 实现突然销户打标规则 2026-03-31 16:31:58 +08:00
wkc
127a59bf78 补充异常账户模型建表和规则元数据 2026-03-31 16:29:48 +08:00
wkc
988c2d3572 补充异常账户模型规则骨架 2026-03-31 16:28:37 +08:00
wkc
f4a72a6110 补充异常账户模型实施计划 2026-03-31 16:18:20 +08:00
wkc
3741ef5fe4 补充异常账户模型打标设计文档 2026-03-31 16:12:45 +08:00
76 changed files with 7272 additions and 43 deletions

View File

@@ -43,6 +43,12 @@
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
</dependency>
<!-- easyexcel工具 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
</dependency>
<!-- 测试依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>

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

@@ -292,6 +292,22 @@ public interface CcdiBankTagAnalysisMapper {
*/
List<BankTagObjectHitVO> selectSalaryUnusedObjects(@Param("projectId") Long projectId);
/**
* 突然销户
*
* @param projectId 项目ID
* @return 对象命中结果
*/
List<BankTagObjectHitVO> selectSuddenAccountClosureObjects(@Param("projectId") Long projectId);
/**
* 休眠账户大额启用
*
* @param projectId 项目ID
* @return 对象命中结果
*/
List<BankTagObjectHitVO> selectDormantAccountLargeActivationObjects(@Param("projectId") Long projectId);
/**
* 大额炒股
*

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

@@ -288,6 +288,8 @@ public class CcdiBankTagServiceImpl implements ICcdiBankTagService {
case "WITHDRAW_AMT" -> analysisMapper.selectWithdrawAmtObjects(projectId);
case "SALARY_QUICK_TRANSFER" -> analysisMapper.selectSalaryQuickTransferObjects(projectId);
case "SALARY_UNUSED" -> analysisMapper.selectSalaryUnusedObjects(projectId);
case "SUDDEN_ACCOUNT_CLOSURE" -> analysisMapper.selectSuddenAccountClosureObjects(projectId);
case "DORMANT_ACCOUNT_LARGE_ACTIVATION" -> analysisMapper.selectDormantAccountLargeActivationObjects(projectId);
case "PROXY_ACCOUNT_OPERATION" -> analysisMapper.selectProxyAccountOperationObjects(projectId);
default -> 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

@@ -1211,6 +1211,101 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
) t
</select>
<select id="selectSuddenAccountClosureObjects" resultMap="BankTagObjectHitResultMap">
select
'STAFF_ID_CARD' AS objectType,
t.objectKey AS objectKey,
CONCAT(
'账户', t.accountNo,
'于', DATE_FORMAT(t.invalidDate, '%Y-%m-%d'),
'销户销户前30天内最后交易日', DATE_FORMAT(t.lastTxDate, '%Y-%m-%d'),
',累计交易金额', CAST(t.windowTotalAmount AS CHAR),
'元,单笔最大金额', CAST(t.windowMaxSingleAmount AS CHAR),
'元'
) AS reasonDetail
from (
select
staff.id_card AS objectKey,
ai.account_no AS accountNo,
ai.invalid_date AS invalidDate,
max(tx.txDate) AS lastTxDate,
round(sum(tx.tradeTotalAmount), 2) AS windowTotalAmount,
round(max(tx.tradeMaxSingleAmount), 2) AS windowMaxSingleAmount
from ccdi_account_info ai
inner join ccdi_base_staff staff
on staff.id_card = ai.owner_id
inner join (
select
trim(bs.LE_ACCOUNT_NO) AS accountNo,
COALESCE(
STR_TO_DATE(LEFT(TRIM(bs.TRX_DATE), 19), '%Y-%m-%d %H:%i:%s'),
STR_TO_DATE(LEFT(TRIM(bs.TRX_DATE), 10), '%Y-%m-%d')
) AS txDate,
IFNULL(bs.AMOUNT_DR, 0) + IFNULL(bs.AMOUNT_CR, 0) AS tradeTotalAmount,
GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS tradeMaxSingleAmount
from ccdi_bank_statement bs
where bs.project_id = #{projectId}
and trim(IFNULL(bs.LE_ACCOUNT_NO, '')) != ''
) tx
on tx.accountNo = trim(ai.account_no)
where ai.owner_type = 'EMPLOYEE'
and ai.status = 2
and ai.invalid_date is not null
and tx.txDate >= DATE_SUB(ai.invalid_date, INTERVAL 30 DAY)
and tx.txDate &lt; ai.invalid_date
group by staff.id_card, ai.account_no, ai.invalid_date
) t
</select>
<select id="selectDormantAccountLargeActivationObjects" resultMap="BankTagObjectHitResultMap">
select
'STAFF_ID_CARD' AS objectType,
t.objectKey AS objectKey,
CONCAT(
'账户', t.accountNo,
'开户于', DATE_FORMAT(t.effectiveDate, '%Y-%m-%d'),
',首次交易日期', DATE_FORMAT(t.firstTxDate, '%Y-%m-%d'),
',沉睡时长', CAST(t.dormantMonths AS CHAR),
'个月,启用后累计交易金额', CAST(t.windowTotalAmount AS CHAR),
'元,单笔最大金额', CAST(t.windowMaxSingleAmount AS CHAR),
'元'
) AS reasonDetail
from (
select
staff.id_card AS objectKey,
ai.account_no AS accountNo,
ai.effective_date AS effectiveDate,
min(tx.txDate) AS firstTxDate,
timestampdiff(MONTH, ai.effective_date, min(tx.txDate)) AS dormantMonths,
round(sum(tx.tradeTotalAmount), 2) AS windowTotalAmount,
round(max(tx.tradeMaxSingleAmount), 2) AS windowMaxSingleAmount
from ccdi_account_info ai
inner join ccdi_base_staff staff
on staff.id_card = ai.owner_id
inner join (
select
trim(bs.LE_ACCOUNT_NO) AS accountNo,
COALESCE(
STR_TO_DATE(LEFT(TRIM(bs.TRX_DATE), 19), '%Y-%m-%d %H:%i:%s'),
STR_TO_DATE(LEFT(TRIM(bs.TRX_DATE), 10), '%Y-%m-%d')
) AS txDate,
IFNULL(bs.AMOUNT_DR, 0) + IFNULL(bs.AMOUNT_CR, 0) AS tradeTotalAmount,
GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS tradeMaxSingleAmount
from ccdi_bank_statement bs
where bs.project_id = #{projectId}
and trim(IFNULL(bs.LE_ACCOUNT_NO, '')) != ''
) tx
on tx.accountNo = trim(ai.account_no)
where ai.owner_type = 'EMPLOYEE'
and ai.status = 1
and ai.effective_date is not null
group by staff.id_card, ai.account_no, ai.effective_date
having min(tx.txDate) >= DATE_ADD(ai.effective_date, INTERVAL 6 MONTH)
) t
where t.windowTotalAmount >= 500000
or t.windowMaxSingleAmount >= 100000
</select>
<select id="selectLargeStockTradingStatements" resultMap="BankTagStatementHitResultMap">
select
bs.bank_statement_id AS bankStatementId,

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

@@ -28,6 +28,8 @@ import java.util.List;
import java.util.Map;
import java.util.concurrent.Executor;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -406,6 +408,112 @@ class CcdiBankTagServiceImplTest {
verify(analysisMapper).selectSalaryUnusedObjects(40L);
}
@Test
void rebuildProject_shouldDispatchSuddenAccountClosureObjectRule() {
ReflectionTestUtils.setField(service, "tagRuleExecutor", (Executor) Runnable::run);
CcdiBankTagRule rule = buildRule("ABNORMAL_ACCOUNT", "异常账户",
"SUDDEN_ACCOUNT_CLOSURE", "突然销户", "OBJECT");
when(ruleMapper.selectEnabledRules("ABNORMAL_ACCOUNT")).thenReturn(List.of(rule));
when(configResolver.resolve(40L, rule)).thenReturn(buildConfig(40L, rule));
when(analysisMapper.selectSuddenAccountClosureObjects(40L)).thenReturn(List.of());
service.rebuildProject(40L, "ABNORMAL_ACCOUNT", "admin", TriggerType.MANUAL);
verify(analysisMapper).selectSuddenAccountClosureObjects(40L);
}
@Test
void rebuildProject_shouldDispatchDormantAccountLargeActivationObjectRule() {
ReflectionTestUtils.setField(service, "tagRuleExecutor", (Executor) Runnable::run);
CcdiBankTagRule rule = buildRule("ABNORMAL_ACCOUNT", "异常账户",
"DORMANT_ACCOUNT_LARGE_ACTIVATION", "休眠账户大额启用", "OBJECT");
when(ruleMapper.selectEnabledRules("ABNORMAL_ACCOUNT")).thenReturn(List.of(rule));
when(configResolver.resolve(40L, rule)).thenReturn(buildConfig(40L, rule));
when(analysisMapper.selectDormantAccountLargeActivationObjects(40L)).thenReturn(List.of());
service.rebuildProject(40L, "ABNORMAL_ACCOUNT", "admin", TriggerType.MANUAL);
verify(analysisMapper).selectDormantAccountLargeActivationObjects(40L);
}
@Test
void rebuildProject_shouldInsertSuddenAccountClosureObjectResults() {
ReflectionTestUtils.setField(service, "tagRuleExecutor", (Executor) Runnable::run);
CcdiBankTagRule rule = buildRule("ABNORMAL_ACCOUNT", "异常账户",
"SUDDEN_ACCOUNT_CLOSURE", "突然销户", "OBJECT");
BankTagRuleExecutionConfig config = buildConfig(40L, rule);
BankTagObjectHitVO hit = new BankTagObjectHitVO();
hit.setObjectType("STAFF_ID_CARD");
hit.setObjectKey("330101199001011234");
hit.setReasonDetail("账户62220001于2026-03-15销户销户前30天内最后交易日2026-03-10累计交易金额120000元单笔最大金额80000元");
when(ruleMapper.selectEnabledRules("ABNORMAL_ACCOUNT")).thenReturn(List.of(rule));
when(configResolver.resolve(40L, rule)).thenReturn(config);
when(analysisMapper.selectSuddenAccountClosureObjects(40L)).thenReturn(List.of(hit));
service.rebuildProject(40L, "ABNORMAL_ACCOUNT", "admin", TriggerType.MANUAL);
verify(resultMapper).insertBatch(argThat(results -> results.stream().anyMatch(item ->
"ABNORMAL_ACCOUNT".equals(item.getModelCode())
&& "SUDDEN_ACCOUNT_CLOSURE".equals(item.getRuleCode())
&& "OBJECT".equals(item.getResultType())
&& "STAFF_ID_CARD".equals(item.getObjectType())
)));
}
@Test
void rebuildProject_shouldInsertDormantAccountLargeActivationObjectResults() {
ReflectionTestUtils.setField(service, "tagRuleExecutor", (Executor) Runnable::run);
CcdiBankTagRule rule = buildRule("ABNORMAL_ACCOUNT", "异常账户",
"DORMANT_ACCOUNT_LARGE_ACTIVATION", "休眠账户大额启用", "OBJECT");
BankTagRuleExecutionConfig config = buildConfig(40L, rule);
BankTagObjectHitVO hit = new BankTagObjectHitVO();
hit.setObjectType("STAFF_ID_CARD");
hit.setObjectKey("330101199001011235");
hit.setReasonDetail("账户62220002开户于2025-01-01首次交易日期2025-08-01沉睡时长7个月启用后累计交易金额500000元单笔最大金额120000元");
when(ruleMapper.selectEnabledRules("ABNORMAL_ACCOUNT")).thenReturn(List.of(rule));
when(configResolver.resolve(40L, rule)).thenReturn(config);
when(analysisMapper.selectDormantAccountLargeActivationObjects(40L)).thenReturn(List.of(hit));
service.rebuildProject(40L, "ABNORMAL_ACCOUNT", "admin", TriggerType.MANUAL);
verify(resultMapper).insertBatch(argThat(results -> results.stream().anyMatch(item ->
"ABNORMAL_ACCOUNT".equals(item.getModelCode())
&& "DORMANT_ACCOUNT_LARGE_ACTIVATION".equals(item.getRuleCode())
&& "OBJECT".equals(item.getResultType())
&& "STAFF_ID_CARD".equals(item.getObjectType())
)));
}
@Test
void abnormalAccountMapperXml_shouldDeclareObjectSelects() throws Exception {
String xml = Files.readString(Path.of("src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml"));
assertTrue(xml.contains("select id=\"selectSuddenAccountClosureObjects\""));
assertTrue(xml.contains("select id=\"selectDormantAccountLargeActivationObjects\""));
}
@Test
void dormantAccountLargeActivationMapperXml_shouldContainDormantAccountConditions() throws Exception {
String xml = Files.readString(Path.of("src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml"));
assertTrue(xml.contains("select id=\"selectDormantAccountLargeActivationObjects\""));
assertTrue(xml.contains("ai.owner_type = 'EMPLOYEE'"));
assertTrue(xml.contains("ai.status = 1"));
assertTrue(xml.contains("ai.effective_date is not null"));
assertTrue(xml.contains("DATE_ADD(ai.effective_date, INTERVAL 6 MONTH)"));
assertTrue(xml.contains("windowTotalAmount >= 500000") || xml.contains("windowMaxSingleAmount >= 100000"));
}
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,7 @@ import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CcdiProjectOverviewEmployeeResultBuilderTest {
@@ -38,7 +39,11 @@ class CcdiProjectOverviewEmployeeResultBuilderTest {
buildHitRow(rowClass, "330000000000000001", "李四", "E1001", 12L, "信息二部",
"SUSPICIOUS_PART_TIME", "可疑兼职", "MONTHLY_FIXED_INCOME", "疑似兼职", "MEDIUM"),
buildHitRow(rowClass, "330000000000000001", "李四", "E1001", 12L, "信息二部",
"SUSPICIOUS_PROPERTY", "可疑财产", "HOUSE_REGISTRATION_MISMATCH", "房产登记不匹配", "LOW")
"SUSPICIOUS_PROPERTY", "可疑财产", "HOUSE_REGISTRATION_MISMATCH", "房产登记不匹配", "LOW"),
buildHitRow(rowClass, "330000000000000001", "李四", "E1001", 12L, "信息二部",
"ABNORMAL_ACCOUNT", "异常账户", "SUDDEN_ACCOUNT_CLOSURE", "突然销户", "HIGH"),
buildHitRow(rowClass, "330000000000000001", "李四", "E1001", 12L, "信息二部",
"ABNORMAL_ACCOUNT", "异常账户", "DORMANT_ACCOUNT_LARGE_ACTIVATION", "休眠账户大额启用", "HIGH")
);
List<CcdiProjectOverviewEmployeeResult> results =
@@ -52,20 +57,22 @@ class CcdiProjectOverviewEmployeeResultBuilderTest {
assertEquals("E1001", result.getStaffCode());
assertEquals(12L, result.getDeptId());
assertEquals("信息二部", result.getDeptName());
assertEquals(5, result.getRuleCount());
assertEquals(4, result.getModelCount());
assertEquals(7, result.getHitCount());
assertEquals(7, result.getRuleCount());
assertEquals(5, result.getModelCount());
assertEquals(9, result.getHitCount());
assertEquals("HIGH", result.getRiskLevelCode());
assertEquals("ABNORMAL_TRANSACTION,LARGE_TRANSACTION,SUSPICIOUS_PART_TIME,SUSPICIOUS_PROPERTY",
assertEquals("ABNORMAL_ACCOUNT,ABNORMAL_TRANSACTION,LARGE_TRANSACTION,SUSPICIOUS_PART_TIME,SUSPICIOUS_PROPERTY",
result.getModelCodesCsv());
assertNotNull(result.getRiskPoint());
JSONArray modelNames = JSON.parseArray(result.getModelNamesJson());
assertEquals(List.of("异常交易", "大额交易", "可疑兼职", "可疑财产"),
assertEquals(List.of("异常账户", "异常交易", "大额交易", "可疑兼职", "可疑财产"),
modelNames.toList(String.class));
JSONArray hitRules = JSON.parseArray(result.getHitRulesJson());
assertEquals(5, hitRules.size());
assertEquals(7, hitRules.size());
assertTrue(result.getHitRulesJson().contains("SUDDEN_ACCOUNT_CLOSURE"));
assertTrue(result.getHitRulesJson().contains("DORMANT_ACCOUNT_LARGE_ACTIVATION"));
JSONObject firstRule = hitRules.getJSONObject(0);
assertEquals("ABNORMAL_CUSTOMER_TRANSACTION", firstRule.getString("ruleCode"));
assertEquals("异常客户交易", firstRule.getString("ruleName"));
@@ -78,6 +85,7 @@ class CcdiProjectOverviewEmployeeResultBuilderTest {
item -> item.getString("modelCode"),
item -> item.getIntValue("warningCount")
));
assertEquals(2, warningCountByModel.get("ABNORMAL_ACCOUNT"));
assertEquals(2, warningCountByModel.get("ABNORMAL_TRANSACTION"));
assertEquals(3, warningCountByModel.get("LARGE_TRANSACTION"));
assertEquals(1, warningCountByModel.get("SUSPICIOUS_PROPERTY"));

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,47 @@
package com.ruoyi.ccdi.project.sql;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CcdiAbnormalAccountRuleSqlMetadataTest {
@Test
void abnormalAccountMetadataSql_shouldContainModelAndRuleDefinitions() throws IOException {
Path path = Path.of("..", "sql", "migration",
"2026-03-31-create-ccdi-account-info-and-abnormal-account-rules.sql");
assertTrue(Files.exists(path), "异常账户模型迁移脚本应存在");
String sql = Files.readString(path, StandardCharsets.UTF_8);
assertAll(
() -> assertTrue(sql.contains("ABNORMAL_ACCOUNT")),
() -> assertTrue(sql.contains("SUDDEN_ACCOUNT_CLOSURE")),
() -> assertTrue(sql.contains("DORMANT_ACCOUNT_LARGE_ACTIVATION")),
() -> assertTrue(sql.contains("'OBJECT'"))
);
}
@Test
void abnormalAccountMetadataSql_shouldContainAccountInfoTableDefinition() throws IOException {
Path path = Path.of("..", "sql", "migration",
"2026-03-31-create-ccdi-account-info-and-abnormal-account-rules.sql");
assertTrue(Files.exists(path), "异常账户模型迁移脚本应存在");
String sql = Files.readString(path, StandardCharsets.UTF_8).toLowerCase();
assertAll(
() -> assertTrue(sql.contains("create table if not exists `ccdi_account_info`")),
() -> assertTrue(sql.contains("`account_no`")),
() -> assertTrue(sql.contains("`owner_type`")),
() -> assertTrue(sql.contains("`effective_date`")),
() -> assertTrue(sql.contains("`invalid_date`"))
);
}
}

View File

@@ -30,6 +30,23 @@ class CcdiBankTagRuleSqlMetadataTest {
assertPhase2Metadata(migrationSql);
}
@Test
void abnormalAccountMetadataSql_shouldContainBusinessCaliberAndRuleRemark() throws IOException {
String migrationSql = readProjectFile("sql", "migration",
"2026-03-31-create-ccdi-account-info-and-abnormal-account-rules.sql");
assertAll(
() -> assertTrue(migrationSql.contains("员工本人账户已销户且销户日前30天内仍存在交易记录。"),
"SUDDEN_ACCOUNT_CLOSURE 应使用设计文档中的业务口径"),
() -> assertTrue(migrationSql.contains("员工本人账户开户后长期未使用,首次启用后出现大额资金流动。"),
"DORMANT_ACCOUNT_LARGE_ACTIVATION 应使用设计文档中的业务口径"),
() -> assertTrue(migrationSql.contains("真实规则识别员工本人账户销户前30天内仍有交易的员工对象"),
"SUDDEN_ACCOUNT_CLOSURE 应同步真实规则说明"),
() -> assertTrue(migrationSql.contains("真实规则:识别长期休眠后首次启用即出现大额资金流动的员工对象"),
"DORMANT_ACCOUNT_LARGE_ACTIVATION 应同步真实规则说明")
);
}
private void assertPhase1Metadata(String sqlContent) {
assertAll(
() -> assertTrue(sqlContent.contains("'FOREX_BUY_AMT'")

View File

@@ -104,6 +104,9 @@ copy_path "${REPO_ROOT}/ruoyi-ui/dist" "${STAGE_ROOT}/frontend/dist"
copy_path "${REPO_ROOT}/docker-compose.yml" "${STAGE_ROOT}/docker-compose.yml"
copy_path "${REPO_ROOT}/.env.example" "${STAGE_ROOT}/.env.example"
copy_path "${REPO_ROOT}/ruoyi-admin/target/ruoyi-admin.jar" "${STAGE_ROOT}/backend/ruoyi-admin.jar"
python3 "${SCRIPT_DIR}/render_nas_env.py" \
--template "${REPO_ROOT}/.env.example" \
--output "${STAGE_ROOT}/.env"
echo "[5/5] 上传并远端部署"
ensure_paramiko

View File

@@ -95,6 +95,12 @@ Copy-ItemSafe (Join-Path $repoRoot "ruoyi-ui\\dist") (Join-Path $stageRoot "fron
Copy-ItemSafe (Join-Path $repoRoot "docker-compose.yml") (Join-Path $stageRoot "docker-compose.yml")
Copy-ItemSafe (Join-Path $repoRoot ".env.example") (Join-Path $stageRoot ".env.example")
Copy-ItemSafe (Join-Path $repoRoot "ruoyi-admin\\target\\ruoyi-admin.jar") (Join-Path $stageRoot "backend\\ruoyi-admin.jar")
python (Join-Path $scriptDir "render_nas_env.py") `
--template (Join-Path $repoRoot ".env.example") `
--output (Join-Path $stageRoot ".env")
if ($LASTEXITCODE -ne 0) {
throw "生成 NAS 部署 .env 失败"
}
Write-Host "[5/5] 上传并远端部署"
$paramikoCheck = @'

47
deploy/render_nas_env.py Normal file
View File

@@ -0,0 +1,47 @@
import argparse
from pathlib import Path
NAS_ENV_OVERRIDES = {
"CCDI_DB_HOST": "192.168.0.111",
"CCDI_DB_PORT": "40628",
}
def parse_args():
parser = argparse.ArgumentParser(description="Render NAS deployment .env for CCDI docker compose.")
parser.add_argument("--template", required=True)
parser.add_argument("--output", required=True)
return parser.parse_args()
def render_env_text(template_text: str) -> str:
rendered_lines = []
replaced_keys = set()
for line in template_text.splitlines():
key, separator, value = line.partition("=")
if separator and key in NAS_ENV_OVERRIDES:
rendered_lines.append(f"{key}={NAS_ENV_OVERRIDES[key]}")
replaced_keys.add(key)
continue
rendered_lines.append(line)
for key, value in NAS_ENV_OVERRIDES.items():
if key not in replaced_keys:
rendered_lines.append(f"{key}={value}")
return "\n".join(rendered_lines) + "\n"
def main():
args = parse_args()
template_path = Path(args.template)
output_path = Path(args.output)
template_text = template_path.read_text(encoding="utf-8")
output_path.write_text(render_env_text(template_text), encoding="utf-8")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,382 @@
# 异常账户模型接入银行流水打标设计文档
**模块**: 银行流水打标
**日期**: 2026-03-31
## 一、背景
当前银行流水打标主链路已经具备以下基础能力:
- 规则元数据管理与启用控制
- `CcdiBankTagServiceImpl` 统一执行入口
- `CcdiBankTagAnalysisMapper.xml` 承载真实规则 SQL
- `ccdi_bank_statement_tag_result` 统一承载 `STATEMENT / OBJECT` 命中结果
- 项目风险总览按对象型结果聚合员工风险情况
根据 [异常账户.xlsx](/Users/wkc/Desktop/ccdi/ccdi/assets/异常账户.xlsx) 与 [员工账户.xlsx](/Users/wkc/Desktop/ccdi/ccdi/assets/员工账户.xlsx),本次需要新增独立模型“异常账户”,并正式接入以下两条规则:
- `SUDDEN_ACCOUNT_CLOSURE`:突然销户
- `DORMANT_ACCOUNT_LARGE_ACTIVATION`:休眠账户大额启用
这两条规则均依赖新增账户信息表 `ccdi_account_info`,且风险筛查对象明确为“员工本人”。本次目标是在不改造现有打标架构的前提下,将两条规则纳入现有项目打标主链路,并补充能够稳定命中的测试数据与验证手段。
## 二、目标
本次设计目标如下:
1. 新增账户信息表 `ccdi_account_info`,支撑异常账户规则计算。
2. 新增独立模型 `ABNORMAL_ACCOUNT`,并接入 2 条对象型规则。
3. 将两条规则接入现有 `executeObjectRule(...)` 打标链路,不新增平行处理模块。
4. 补充最小可命中的测试数据 SQL并覆盖正样本与反样本。
5. 保留 Java 自动化测试,同时在验证阶段使用 MySQL MCP 执行真实 SQL确认命中结果符合业务口径。
6. 在设计确认后,分别产出后端与前端实施计划文档。
## 三、范围
### 3.1 本次范围
- 新增 `ccdi_account_info` 建表 SQL
- 新增模型 `ABNORMAL_ACCOUNT`
- 新增规则元数据 `SUDDEN_ACCOUNT_CLOSURE``DORMANT_ACCOUNT_LARGE_ACTIVATION`
- `CcdiBankTagServiceImpl` 新增对象型规则分发
- `CcdiBankTagAnalysisMapper.java/.xml` 新增 2 条对象型查询
- 新增测试数据 SQL
- 新增 Java 自动化测试
- 新增基于 MySQL MCP 的真实 SQL 验证步骤
- 新增设计文档、后端实施计划、前端实施计划
### 3.2 不在本次范围
- 不开发“异常账户人员信息”独立查询、分页、详情、导出真实数据链路
- 不改前端页面展示逻辑
- 不扩展到关系人或外部账户
- 不新增动态规则引擎、DSL 或兼容性补丁方案
- 不改造 `lsfx-mock-server`
- 不将固定阈值改造成项目可配置参数
## 四、现状分析
### 4.1 当前主链路
当前项目级银行流水打标流程为:
1. `CcdiBankTagServiceImpl.rebuildProject(...)` 加载启用规则。
2. 规则按 `rule_code` 分发到 `executeStatementRule(...)``executeObjectRule(...)`
3. `CcdiBankTagAnalysisMapper.xml` 执行真实 SQL返回流水型或对象型命中结果。
4. Service 将命中结果组装为 `CcdiBankTagResult` 并写入 `ccdi_bank_statement_tag_result`
5. 项目结果总览再按对象维度聚合风险人数和命中规则快照。
### 4.2 当前缺口
当前仓库中“异常账户人员信息”仍为占位展示,且主打标规则中尚无“异常账户”模型与对应规则编码。也就是说,本次缺口主要是:
- 缺少账户信息基础表
- 缺少异常账户模型与规则元数据
- 缺少两条规则的对象型 SQL
- 缺少最小可命中的测试样本与真实 SQL 验证
## 五、方案对比
### 5.1 方案一:最小闭环接入现有对象型打标链路
做法:
- 新增独立模型 `ABNORMAL_ACCOUNT`
- 两条规则均按 `OBJECT` 结果类型落到员工维度
- 通过 `CcdiBankTagAnalysisMapper.xml` 计算命中结果
- 结果继续写入 `ccdi_bank_statement_tag_result`
优点:
- 改动最小
- 完全复用现有打标主链路
- 能直接进入现有员工风险总览聚合
缺点:
- 本轮不打通“异常账户人员信息”独立详情链路
### 5.2 方案二:在方案一基础上同时打通异常账户独立结果链路
优点:
- 风险详情中的“异常账户人员信息”可展示真实数据
缺点:
- 改动范围明显扩大
- 超出本次需求
- 不符合最短路径实现要求
### 5.3 方案三:仅补 SQL 验证,不接入主系统打标链路
优点:
- 开发最省
缺点:
- 无法满足“正式接入主系统打标链路”的需求
### 5.4 结论
采用方案一:
- 新增独立模型 `ABNORMAL_ACCOUNT`
- 两条规则均按对象型规则接入现有打标链路
- 结果沉淀到现有结果表
- 后续如需开发异常账户独立查询能力,再以此为基础扩展
## 六、总体设计
### 6.1 模型与规则设计
本次新增如下模型与规则:
- 模型编码:`ABNORMAL_ACCOUNT`
- 模型名称:`异常账户`
- 规则一:`SUDDEN_ACCOUNT_CLOSURE` / `突然销户`
- 规则二:`DORMANT_ACCOUNT_LARGE_ACTIVATION` / `休眠账户大额启用`
两条规则统一定义为:
- `result_type = OBJECT`
- `object_type = STAFF_ID_CARD`
- `object_key = 员工身份证号`
### 6.2 结果落库
两条规则命中后继续写入现有结果表 `ccdi_bank_statement_tag_result`,不新增单独结果表。
结果字段约束如下:
- `model_code = ABNORMAL_ACCOUNT`
- `rule_code` 使用全大写风格
- `result_type = OBJECT`
- `bank_statement_id = null`
- `object_type = STAFF_ID_CARD`
- `object_key = 员工身份证号`
- `reason_detail` 存储账户号、异常日期与统计快照
### 6.3 数据流
数据流保持为:
1. 项目级打标入口加载启用规则。
2. 当规则编码为 `SUDDEN_ACCOUNT_CLOSURE``DORMANT_ACCOUNT_LARGE_ACTIVATION` 时,进入 `executeObjectRule(...)`
3. Mapper SQL 在项目范围内将 `ccdi_bank_statement``ccdi_account_info``ccdi_base_staff` 关联。
4. SQL 返回员工身份证号维度的对象型命中结果。
5. Service 将命中结果写入 `ccdi_bank_statement_tag_result`
6. 员工风险聚合继续从该结果表汇总,无需新建平行链路。
## 七、表结构设计
### 7.1 新增表 `ccdi_account_info`
以 [员工账户.xlsx](/Users/wkc/Desktop/ccdi/ccdi/assets/员工账户.xlsx) 为准,新增表 `ccdi_account_info`,核心字段如下:
- `account_id`
- `account_no`
- `account_type`
- `account_name`
- `owner_type`
- `owner_id`
- `bank`
- `bank_code`
- `currency`
- `is_self_account`
- `monthly_avg_trans_count`
- `monthly_avg_trans_amount`
- `trans_freq_type`
- `dr_max_single_amount`
- `cr_max_single_amount`
- `dr_max_daily_amount`
- `cr_max_daily_amount`
- `trans_risk_level`
- `status`
- `effective_date`
- `invalid_date`
- `created_by`
- `updated_by`
- `create_time`
- `update_time`
### 7.2 关联约束
本次规则只识别员工本人账户,关联口径固定为:
- `ccdi_account_info.owner_type = 'EMPLOYEE'`
- `ccdi_account_info.owner_id = ccdi_base_staff.id_card`
- `ccdi_account_info.account_no = ccdi_bank_statement.LE_ACCOUNT_NO`
说明:
- 仓库中当前未见单独的账号加解密或标准化链路,因此本次设计要求建表脚本、测试数据与流水数据直接使用一致账号值
- 本次不将关系人账户纳入规则范围
## 八、规则 SQL 口径
### 8.1 `SUDDEN_ACCOUNT_CLOSURE`
业务口径:
- 员工本人账户已销户
- 销户日前 30 天内仍存在交易记录
SQL 设计约束:
- 仅统计项目内流水
- 统计窗口限定为 `[invalid_date - 30天, invalid_date)`
- 按“员工身份证号 + 账号”粒度聚合,再映射回员工对象
命中条件:
- `status = 2`
- `invalid_date is not null`
- 窗口内存在至少 1 笔交易
返回结果:
- `objectType = STAFF_ID_CARD`
- `objectKey = 员工身份证号`
`reasonDetail` 结构:
- `账户{account_no}于{invalid_date}销户销户前30天内最后交易日{last_tx_date},累计交易金额{window_total_amount}元,单笔最大金额{window_max_single_amount}元`
### 8.2 `DORMANT_ACCOUNT_LARGE_ACTIVATION`
业务口径:
- 员工本人账户状态正常
- 开户后长期未使用
- 首次启用后出现大额资金流动
SQL 设计约束:
- 仅统计项目内流水
- 以该账户在项目内的首次流水日期作为“启用时间”
- “沉睡时长”按开户日期到首次交易日期计算
命中条件:
- `status = 1`
- `effective_date is not null`
- `first_tx_date >= effective_date + 6个月`
- 且满足以下任一:
- 启用后累计交易总额 `>= 500000`
- 启用后单笔最大交易金额 `>= 100000`
返回结果:
- `objectType = STAFF_ID_CARD`
- `objectKey = 员工身份证号`
`reasonDetail` 结构:
- `账户{account_no}开户于{effective_date},首次交易日期{first_tx_date},沉睡时长{dormant_months}个月,启用后累计交易金额{total_amount}元,单笔最大金额{max_single_amount}元`
### 8.3 公共规则约束
- 仅识别员工本人账户,不识别关系人和外部账户
- 仅按项目内流水计算,不跨项目拼接历史流水
- 累计金额使用 `amount_dr + amount_cr`
- 单笔最大金额使用 `greatest(amount_dr, amount_cr)`
- 同一员工多个账户分别判断,允许同一规则写入多条结果,避免强行合并后丢失账户级快照
## 九、测试数据设计
### 9.1 测试数据组织原则
新增一份独立增量 SQL放在 `sql/migration/`,仅构造本次规则所需最小样本。
### 9.2 样本设计
建议最少包含以下样本:
- 员工 A命中 `SUDDEN_ACCOUNT_CLOSURE`
- 账户已销户
- 销户前 30 天内有 2 到 3 笔项目流水
- 员工 B命中 `DORMANT_ACCOUNT_LARGE_ACTIVATION`
- 开户日期早于首次交易至少 6 个月
- 启用后累计金额超过 50 万
- 员工 C休眠不足 6 个月,不命中
- 员工 D已销户但销户前 30 天无流水,不命中
### 9.3 数据一致性要求
- `ccdi_account_info.account_no``ccdi_bank_statement.LE_ACCOUNT_NO` 必须一致
- `owner_id` 与员工身份证号一致
- 正样本与反样本必须处于同一项目验证口径下,避免跨项目误差
## 十、测试与验证设计
### 10.1 Java 自动化测试
保留两层自动化测试:
1. Service 分发测试
- 新规则能进入 `executeObjectRule(...)`
2. Mapper / SQL 结构测试
- 新 Mapper 方法存在
- XML 中存在对应 `<select>`
- 规则元数据和模型编码无拼写错误
3. 结果聚合测试
- 新规则写入后,员工风险总览可正常聚合
### 10.2 MySQL MCP 真实 SQL 验证
本次新增一层真实 SQL 验证,要求在测试阶段直接通过 MySQL MCP 执行规则 SQL确认结果符合口径。
验证要求:
- 使用项目数据库连接信息
- 不手写 `mysql -e`
- 直接执行对象型规则对应 SQL
- 校验命中员工身份证号是否与测试样本一致
- 校验反样本不会被查出
- 校验 `reason_detail` 中异常日期、累计金额、单笔最大金额等关键快照是否符合预期
### 10.3 测试结束清理
若验证阶段启动了本地前后端、Mock 服务或其他辅助进程,测试结束后需主动关闭,避免残留占用端口。
## 十一、实施边界
### 11.1 后端实施内容
- 新增建表与规则元数据 SQL
- 新增 Mapper 方法和 XML SQL
- 新增 Service 分发
- 新增测试数据 SQL
- 新增自动化测试
- 执行 MySQL MCP SQL 验证
### 11.2 前端实施内容
本轮前端原则上不改代码,但仍需产出一份前端实施计划,明确说明:
- 现有页面继续复用项目总览对象聚合结果
- 本轮不开发异常账户独立列表与详情
- 前端无需新增接口或交互
## 十二、验收标准
验收标准如下:
1. `ccdi_account_info` 建表脚本存在且字段与 Excel 一致。
2. 模型 `ABNORMAL_ACCOUNT` 与两条规则元数据已落库,编码统一全大写。
3. `CcdiBankTagServiceImpl` 已接入两条规则对象型执行分支。
4. 规则命中结果成功写入 `ccdi_bank_statement_tag_result`
5. 员工风险总览聚合后可看到新增模型与规则命中。
6. 测试数据中的正样本可命中,反样本不命中。
7. MySQL MCP 真实 SQL 验证结果与业务口径一致。
## 十三、后续文档规划
设计确认后,继续补充以下文档:
- `docs/plans/backend/` 下的后端实施计划
- `docs/plans/frontend/` 下的前端实施计划
- `docs/reports/implementation/` 下的后端实施记录
- `docs/reports/implementation/` 下的前端实施记录

View File

@@ -0,0 +1,350 @@
# LSFX Mock Server 异常账户基线同步设计文档
**模块**: `lsfx-mock-server`
**日期**: 2026-03-31
## 一、背景
当前 `lsfx-mock-server` 已完成异常账户命中流水的主链路接入:
- `FileService` 可为 `logId` 生成稳定的 `abnormal_account_hit_rules`
- `FileRecord` 内已保存 `abnormal_accounts`
- `StatementService` 已能按异常账户事实拼接 `SUDDEN_ACCOUNT_CLOSURE``DORMANT_ACCOUNT_LARGE_ACTIVATION` 命中流水
但现阶段异常账户事实仅存在于 Mock 进程内存中,尚未同步到主项目真实规则依赖的关联表 `ccdi_account_info`。这会导致两个问题:
1. Mock 返回的流水看起来满足异常账户规则,但真实打标 SQL 缺少账户事实,命中不稳定
2. 同一个 `logId` 下,“命中流水”与“真实账户事实”没有形成完整闭环
本次需求要求在生成可以命中异常账户的流水时,同时向关联表插入最小事实数据,保证真实规则命中条件成立。
## 二、目标
-`fetch_inner_flow(...)` / 上传创建 `logId` 时一次性同步异常账户事实到 `ccdi_account_info`
- 保持同一个 `logId` 的异常账户事实、返回流水、真实打标条件三者一致
- 保持现有 `/watson/api/project/getBSByLogId` 接口协议不变
- 保持 `StatementService` 只负责读 `FileRecord` 生成流水,不新增写库副作用
- 对异常账户基线写库失败采用显式失败语义,不返回半成功 `logId`
## 三、非目标
- 不新增异常账户独立接口
- 不修改现有随机规则命中策略
- 不扩展 `ccdi_account_info` 为完整账户域模型
- 不在 `getBSByLogId` 首次查询时补做异常账户写库
- 不新增兜底、补丁或降级链路
## 四、方案对比
### 4.1 方案 A在创建 `logId` 时同步异常账户基线,推荐
做法:
- `FileService` 生成 `FileRecord``abnormal_accounts`
- 在保存 `file_records[log_id]` 之前,同步将异常账户事实幂等写入 `ccdi_account_info`
- 后续 `StatementService` 只读内存事实生成流水
优点:
- 触发点单一,同一个 `logId` 只写一次
- 不把写库副作用混进读接口
- “命中前提未建好就不返回 `logId`” 的语义最清晰
- 与现有 `fetch_inner_flow -> getBSByLogId` 主链路最一致
缺点:
- 需要新增一个很小的异常账户基线写库服务
### 4.2 方案 B在 `getBSByLogId` 首次生成流水时再写库
优点:
- 只有真正查询流水时才落库
缺点:
- 读接口承担写库副作用,职责变重
- 缓存、重试和并发下更容易出现重复写库或半成功状态
- 不符合当前 Mock 服务“先建上传记录,再查流水”的链路习惯
### 4.3 方案 C继续只保留内存事实不做运行时写库
优点:
- 实现最省事
缺点:
- 无法保证真实规则稳定命中
- 不满足当前需求
## 五、结论
采用方案 A。
原因如下:
- 最短路径实现真实闭环
- 不破坏现有服务职责边界
- 最容易保证“同一个 `logId` 一次建好全部命中前提”
- 最符合你要求的“生成可以命中的流水时,同时向关联表插入数据”
## 六、总体设计
### 6.1 新增服务边界
新增 `AbnormalAccountBaselineService`,职责仅有一项:
-`FileRecord.abnormal_accounts` 幂等同步到 `ccdi_account_info`
职责划分如下:
- `FileService`
- 生成 `logId`
- 选择员工身份
- 生成异常账户命中计划
- 生成 `abnormal_accounts`
- 调用异常账户基线同步服务
- `AbnormalAccountBaselineService`
- 连接数据库
-`account_no` 为键执行幂等写入
- `StatementService`
- 继续只根据 `FileRecord` 生成命中流水
- 不负责数据库写入
### 6.2 调用顺序
改造后的 `fetch_inner_flow(...)` 主链路如下:
1. 生成 `logId`
2. 生成规则命中计划
3. 创建 `FileRecord`
4. 生成 `record.abnormal_accounts`
5. 调用 `_apply_abnormal_account_baselines(file_record)`
6. 基线写库成功后,再将 `file_record` 放入 `self.file_records`
7. 继续后续现有逻辑并返回响应
这个顺序的关键点是:
- 不把异常账户写库放到 `StatementService`
- 不在基线未落库成功时返回可用 `logId`
### 6.3 失败语义
-`abnormal_account_hit_rules` 为空:直接跳过,不写库
- 若命中了异常账户规则但 `abnormal_accounts` 为空:视为内部状态异常,直接失败
- 若数据库连接失败或 upsert 失败:`fetch_inner_flow(...)` 直接失败,本次 `logId` 不写入内存
- 不做补丁式重试,不返回半成功结果
## 七、数据模型设计
### 7.1 内存事实结构
继续复用当前 `FileRecord.abnormal_accounts` 结构,最小字段为:
- `account_no`
- `owner_id_card`
- `account_name`
- `status`
- `effective_date`
- `invalid_date`
- `rule_code`
说明:
- `rule_code` 仅作为 Mock 内部路由字段使用
- 对外接口不返回这批事实
### 7.2 `ccdi_account_info` 同步字段
本次只同步真实规则命中所需的最小字段:
- `account_no`
- `account_type`
- `account_name`
- `owner_type`
- `owner_id`
- `bank`
- `bank_code`
- `currency`
- `is_self_account`
- `trans_risk_level`
- `status`
- `effective_date`
- `invalid_date`
- `create_by`
- `update_by`
其中字段值约束如下:
- `account_no`
- 直接使用 `record.abnormal_accounts[*].account_no`
- `account_name`
- 直接使用 `record.abnormal_accounts[*].account_name`
- `owner_type`
- 固定写 `EMPLOYEE`
- `owner_id`
-`owner_id_card`
- `bank`
- 固定写当前异常账户样本对齐的银行名称
- `bank_code`
- 固定写当前异常账户样本对齐的银行编码
- `currency`
- 固定 `CNY`
- `is_self_account`
- 固定 `1`
- `trans_risk_level`
- 固定 `HIGH`
- `status`
- 由规则事实决定
- `effective_date`
- 由规则事实决定
- `invalid_date`
- 仅销户规则写值
本次不补充 `monthly_avg_trans_count``monthly_avg_trans_amount``dr_max_single_amount` 等推导型字段,因为当前两条真实规则命中依赖的是账户状态与流水窗口,不依赖这些预统计字段。
## 八、幂等策略设计
### 8.1 唯一定位键
`account_no` 作为异常账户事实的唯一定位键。
原因:
- Mock 内部异常账户事实和异常账户样本流水都以账号为唯一桥梁
- 同一个员工可能存在多个账户,按 `owner_id` 先删后插会扩大影响面
- 账号粒度最符合异常账户明细展示与后续回溯链路
### 8.2 Upsert 规则
对每条异常账户事实执行单条幂等 upsert
- 若账号不存在:插入
- 若账号已存在:覆盖本次 Mock 负责的核心字段
覆盖范围仅限:
- `account_name`
- `owner_type`
- `owner_id`
- `bank`
- `bank_code`
- `currency`
- `is_self_account`
- `trans_risk_level`
- `status`
- `effective_date`
- `invalid_date`
- `update_by`
- `update_time`
明确不做的事:
- 不按员工先删整批账户
- 不清空其他来源的账户数据
- 不以 `owner_id` 做批量覆盖
## 九、一致性约束
必须同时满足以下约束:
1. `record.abnormal_accounts[*].account_no` 必须等于对应异常账户样本流水的 `accountMaskNo`
2. `record.abnormal_accounts[*].owner_id_card` 必须等于对应异常账户样本流水的 `cretNo`
3. 同一个 `logId` 下,异常账户事实与异常账户流水必须来自同一份 `FileRecord`
4. `StatementService` 返回流水时不得覆盖已存在的异常账户样本账号
这意味着“内存事实 -> 返回流水 -> 数据库账户事实”三者会围绕同一个 `account_no` 对齐,后端真实 SQL 与结果回溯链路不会漂移。
## 十、模块改动设计
### 10.1 `lsfx-mock-server/services/file_service.py`
改动点:
- 注入新的 `abnormal_account_baseline_service`
- 新增 `_apply_abnormal_account_baselines(file_record)` 封装方法
-`fetch_inner_flow(...)` 与上传建档链路中,于 `self.file_records[log_id] = file_record` 之前调用该方法
职责保持:
- 仍是异常账户规则计划和事实的唯一生成入口
- 不直接拼装 SQL 字符串,数据库写入交给独立服务
### 10.2 `lsfx-mock-server/services/abnormal_account_baseline_service.py`
新增文件,提供:
- 数据库连接
- 输入校验
- 单条异常账户事实 upsert
- 批量 apply 入口
建议方法签名:
```python
def apply(self, staff_id_card: str, abnormal_accounts: List[dict]) -> None:
...
```
说明:
- `staff_id_card` 用于做最小一致性校验
- `abnormal_accounts` 为当前 `logId` 已生成好的异常账户事实列表
### 10.3 `lsfx-mock-server/services/statement_service.py`
本次不新增写库逻辑,仅维持现有一致性保证:
- 继续从 `FileRecord` 读取 `abnormal_accounts`
- 继续根据 `rule_code` 选择异常账户样本构造器
- 保持 `_apply_primary_binding(...)` 只兜底缺失账号,不覆盖异常账户样本账号
## 十一、测试设计
### 11.1 `tests/test_file_service.py`
补充断言:
- 命中异常账户规则时,`fetch_inner_flow(...)` 会调用异常账户基线同步服务
- 同步服务收到的账号、员工身份证、状态、生效日、销户日与 `record.abnormal_accounts` 完全一致
- 基线同步失败时,`file_records` 中不会残留该 `logId`
这里优先使用 fake service / stub 断言调用参数,不直接依赖真实数据库。
### 11.2 `tests/test_statement_service.py`
保留现有异常账户流水样本测试,再补充链路一致性断言:
- 同一个 `logId` 下,异常账户样本流水中的 `accountMaskNo` 必须全部来自 `record.abnormal_accounts`
- `StatementService` 不会因本次改造新增数据库写入副作用
### 11.3 `tests/test_abnormal_account_baseline_service.py`
新增服务层单测,覆盖:
- 空异常账户列表直接跳过
- 命中规则但事实为空时报错
- 新账号插入
- 已有账号按 `account_no` 幂等更新
## 十二、验收标准
本次设计实施后,应满足以下验收结果:
1. 创建 `logId` 时,命中的异常账户事实会一次性写入 `ccdi_account_info`
2. 同一个 `logId` 后续查询流水不会再次写库
3. `ccdi_account_info.account_no` 与异常账户样本流水 `accountMaskNo` 完全一致
4. 写库失败时,不返回半成功 `logId`
5. 现有异常账户命中流水生成、分页与缓存语义保持不变
## 十三、结论
本次采用“创建 `logId` 时一次性同步异常账户基线”的方式改造 `lsfx-mock-server`
- 让异常账户命中样本不再停留在 Mock 进程内存
-`ccdi_account_info` 与返回流水围绕同一个账号闭环
- 保持现有接口不变
- 保持最短路径实现,不引入兼容性和补丁式方案
这能确保 Mock 生成的异常账户流水不仅“看起来能命中”,而且“真实规则一定具备命中所需的账户事实前提”。

View File

@@ -0,0 +1,285 @@
# LSFX Mock Server 异常账户命中流水设计文档
**模块**: `lsfx-mock-server`
**日期**: 2026-03-31
## 一、背景
当前仓库中的异常账户模型已经在主系统后端完成规则接入,包含以下两条对象型规则:
- `SUDDEN_ACCOUNT_CLOSURE`
- `DORMANT_ACCOUNT_LARGE_ACTIVATION`
根据已落地的后端实施计划与实现结果,这两条规则的命中依赖两类事实:
1. 账户事实:来自 `ccdi_account_info` 的账户状态、开户日、销户日、账户归属人
2. 流水事实:来自 `ccdi_bank_statement` 的账号维度交易时间与交易金额
当前 [lsfx-mock-server](/Users/wkc/Desktop/ccdi/ccdi/lsfx-mock-server) 已具备以下能力:
- `FileService` 为每个 `logId` 生成稳定的规则命中计划
- `StatementService` 根据命中计划拼接规则样本流水
- `/watson/api/project/getBSByLogId` 返回分页流水列表
但 Mock 服务现阶段尚未支持异常账户模型对应的“账户事实 + 命中流水”闭环,因此后端即使接入了真实规则 SQL也无法通过现有 Mock 数据稳定命中异常账户规则。
本次目标是在不改动现有接口协议的前提下,为 `lsfx-mock-server` 补齐异常账户规则的最小闭环造数能力,让同一个 `logId` 下既有可命中后端 SQL 的账户事实,也有与之匹配的流水样本。
## 二、目标
- 在现有 Mock 规则计划体系中新增异常账户命中计划。
- 为每个命中异常账户规则的 `logId` 生成稳定的异常账户事实。
- 按后端 SQL 口径生成两条规则对应的流水样本。
- 保持现有 `/watson/api/project/getBSByLogId` 返回结构不变。
- 保持现有 `FileService -> StatementService` 主链路不变,不引入平行造数机制。
- 补充测试,确保命中计划、账户事实和流水样本三者一致。
## 三、非目标
- 不新增异常账户独立接口。
- 不修改现有上传、拉取行内流水、查询流水接口的请求参数与响应结构。
- 不对外直接返回异常账户事实列表。
- 不模拟 `ccdi_account_info` 全字段,只保留两条规则所需最小字段。
- 不扩展异常账户详情页、分页查询或导出链路。
- 不引入动态配置平台、DSL 或补丁式兼容逻辑。
## 四、方案对比
### 4.1 方案一:并入现有 `rule_hit_plan` 体系
做法:
-`FileRecord` 中新增异常账户命中计划与账户事实
-`build_seed_statements_for_rule_plan(...)` 中接入异常账户样本生成
- `StatementService` 继续统一补噪声、编号、分页
优点:
- 完全复用当前 Mock 主链路
- 同一个 `logId` 下规则计划、账户事实、流水样本天然一致
- 实现路径最短,后续联调稳定
缺点:
- 需要在 `FileRecord` 中增加一层最小账户事实建模
### 4.2 方案二:仅在 `StatementService` 中硬编码异常账户流水样本
优点:
- 改动最少,实现最快
缺点:
- 命中流水和账户事实分离
- 后续若需要调试命中原因或扩展账户事实,维护成本较高
### 4.3 方案三:新增独立异常账户服务模块
优点:
- 抽象边界更清晰
缺点:
- 对当前 Mock 项目来说偏重
- 超出最短路径实现要求
## 五、结论
采用方案一。
原因如下:
- 与当前 Mock 服务的规则计划机制完全一致
- 不新增接口、不增加平行数据流
- 能以最小改动实现“账户事实 + 命中流水”的稳定闭环
- 后续若主系统还需要继续扩展 Mock 规则样本,可以沿用同一套结构
## 六、总体设计
### 6.1 新增命中计划维度
在现有规则计划结构上新增:
- `abnormal_account_hit_rules`
规则池固定为:
- `SUDDEN_ACCOUNT_CLOSURE`
- `DORMANT_ACCOUNT_LARGE_ACTIVATION`
`FileService` 在为 `logId` 生成规则计划时,继续沿用现有“稳定随机且可复现”的逻辑,把异常账户规则作为平级维度一起生成并回填到 `FileRecord`
### 6.2 新增最小账户事实
`FileRecord` 新增以下字段:
- `abnormal_account_hit_rules: List[str]`
- `abnormal_accounts: List[AbnormalAccountFact]`
其中 `AbnormalAccountFact` 仅保留两条规则需要的最小字段:
- `account_no`
- `owner_id_card`
- `account_name`
- `status`
- `effective_date`
- `invalid_date`
说明:
- 本次不复制主系统 `ccdi_account_info` 全量结构
- 只保留命中 SQL 真正会用到的事实,避免过度设计
### 6.3 样本流水接入位置
现有 `StatementService` 已通过 `build_seed_statements_for_rule_plan(...)` 生成规则样本流水。
本次改造保持该入口不变,仅在其内部追加异常账户规则样本构造:
- `build_sudden_account_closure_samples(...)`
- `build_dormant_account_large_activation_samples(...)`
生成出的异常账户样本与其他规则样本一起组成 `seeded_statements`,之后继续复用现有逻辑:
1. 补足噪声流水
2. 统一分配流水编号
3. 打乱顺序
4. 分页返回
## 七、规则口径设计
### 7.1 `SUDDEN_ACCOUNT_CLOSURE`
Mock 口径必须与后端 SQL 对齐:
- 账户状态为 `2`
- `invalid_date` 非空
- 流水账号与账户事实中的 `account_no` 一致
- 所有命中样本流水时间都落在 `[invalid_date - 30天, invalid_date)` 区间内
样本策略:
- 为单个命中账户生成 2 到 3 笔流水
- 同时覆盖收入和支出,便于后端聚合 `windowTotalAmount`
- 保证存在明确的最后交易日,便于推导 `lastTxDate`
- 保证存在稳定的单笔最大金额,便于推导 `windowMaxSingleAmount`
### 7.2 `DORMANT_ACCOUNT_LARGE_ACTIVATION`
Mock 口径必须与后端 SQL 对齐:
- 账户状态为 `1`
- `effective_date` 非空
- 首笔流水日期 `>= effective_date + 6个月`
- 启用后流水满足:
- `windowTotalAmount >= 500000`
-`windowMaxSingleAmount >= 100000`
样本策略:
- 首笔流水明确落在开户满 6 个月以后
- 为避免边界漂移,直接让累计金额和单笔最大金额同时满足阈值
- 启用后生成 2 笔以上流水,保证累计口径稳定
### 7.3 未命中规则时的处理
当某个 `logId``abnormal_account_hit_rules` 中不包含某条规则时:
- 不生成该规则的账户事实
- 不生成该规则的流水样本
- 不制造“接近命中但未命中”的灰度样本
这样可以避免误命中,保证 Mock 语义清晰。
## 八、数据流设计
本次改造后的数据流如下:
1. `FileService.fetch_inner_flow(...)` 或上传链路创建 `FileRecord`
2. `FileService` 生成四类命中计划:
- `large_transaction_hit_rules`
- `phase1_hit_rules`
- `phase2_statement_hit_rules`
- `abnormal_account_hit_rules`
3. `FileService` 根据异常账户命中计划生成 `abnormal_accounts`
4. `StatementService._generate_statements(...)` 读取 `FileRecord`
5. `build_seed_statements_for_rule_plan(...)` 依据异常账户命中计划拼接对应样本流水
6. 服务层继续补噪声并返回现有结构的流水列表
结论:
- 内部多了一层异常账户事实
- 对外接口保持不变
## 九、模块与职责
### 9.1 `lsfx-mock-server/services/file_service.py`
职责调整:
- 扩展 `FileRecord`
- 生成并保存 `abnormal_account_hit_rules`
- 按命中规则生成对应 `abnormal_accounts`
- 保证同一 `logId` 下账户事实稳定可复现
### 9.2 `lsfx-mock-server/services/statement_rule_samples.py`
职责调整:
- 新增异常账户事实结构
- 新增两类异常账户样本构造器
- 在统一种子流水构造入口中接入异常账户样本
### 9.3 `lsfx-mock-server/services/statement_service.py`
职责保持不变,仅消费新增计划与样本:
- 读取 `FileRecord` 中的异常账户计划
- 调用种子样本生成器
- 继续完成补噪声、编号、缓存、分页返回
## 十、测试设计
测试分为三层:
### 10.1 计划层测试
验证点:
- `FileRecord` 能保存 `abnormal_account_hit_rules`
- 生成的异常账户计划稳定、可复现
- 命中计划与账户事实数量、规则类型一致
### 10.2 样本层测试
验证点:
- `SUDDEN_ACCOUNT_CLOSURE` 样本流水日期全部处于销户前 30 天窗口内
- `DORMANT_ACCOUNT_LARGE_ACTIVATION` 首笔流水日期晚于开户满 6 个月
- 休眠账户样本的累计金额和单笔最大金额达到后端 SQL 阈值
### 10.3 服务层测试
验证点:
- `StatementService._generate_statements(...)` 能把异常账户样本混入返回流水
- 未命中的异常账户规则不会污染其他 `logId`
- 同一个 `logId` 重复查询时缓存结果保持稳定
## 十一、风险与约束
- 本次不改协议,因此异常账户事实仅在 Mock 服务内部使用
- 由于不新增独立接口,联调时仍需通过现有流水接口间接触发后端规则命中
- 样本日期和金额必须严格贴后端 SQL 口径避免出现“Mock 看起来合理但后端不命中”的问题
## 十二、后续计划
本设计确认后,下一步仅进入实施计划编写阶段,不直接扩展其他功能。
按仓库约定,需要继续补充:
- 后端实施计划
- 前端实施计划
- 本次改动的实施记录

View File

@@ -0,0 +1,463 @@
# 项目详情风险明细异常账户人员信息设计文档
**模块**: 项目详情 - 结果总览 - 风险明细
**日期**: 2026-03-31
**作者**: Codex
**状态**: 已确认
## 一、背景
当前项目详情页 `结果总览 -> 风险明细` 已经具备以下能力:
1. `涉疑交易明细` 已接入真实分页查询与统一导出。
2. `员工负面征信信息` 已接入真实分页查询,并已纳入统一导出。
3. `异常账户人员信息` 仍停留在前端静态占位与统一导出空 sheet。
与此同时,`2026-03-31` 已完成异常账户模型接入银行流水打标主链路:
- 模型编码:`ABNORMAL_ACCOUNT`
- 规则编码:
- `SUDDEN_ACCOUNT_CLOSURE`
- `DORMANT_ACCOUNT_LARGE_ACTIVATION`
- 命中结果已写入 `ccdi_bank_statement_tag_result`
- 员工风险聚合已能承接异常账户模型命中
因此,本次需求不是新增模型能力,而是将已有的异常账户命中结果正式接入 `风险明细` 区域展示,并保证统一导出中的 `异常账户人员信息` sheet 导出真实数据。
## 二、目标
本次设计目标如下:
1.`异常账户人员信息` 区块从占位数据改为真实查询结果。
2. 页面展示字段与统一导出字段完全一致。
3. 风险明细统一导出中的第 3 个 sheet 改为真实导出异常账户人员信息。
4. 保持最短路径实现,不扩展详情弹窗、筛选器或平行链路。
## 三、范围
### 3.1 本次范围
- 新增结果总览专用异常账户人员分页查询接口
- 新增异常账户人员导出查询
- `RiskDetailSection.vue` 接入真实异常账户数据与独立分页
- `risk-details/export` 第 3 个 sheet 改为真实数据
- 补充本次设计文档与设计记录
### 3.2 不在本次范围
- 不新增异常账户详情弹窗
- 不新增异常账户区块筛选条件
- 不扩展到关系人或外部账户
- 不新增单独的异常账户导出接口
- 不改造项目分析弹窗
- 不新增兼容性补丁、兜底链路或降级方案
## 四、现状分析
### 4.1 前端现状
当前核心组件为:
- `ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue`
当前 `异常账户人员信息` 区块仍直接读取:
- `sectionData.abnormalAccountList || []`
现有列结构仍是早期占位字段:
1. `账户号`
2. `账户人姓名`
3. `开户银行`
4. `异常发生时间`
5. `状态`
6. `操作`
这意味着当前页面展示既没有真实接口,也与本次统一导出的字段口径不完全一致。
### 4.2 后端现状
当前结果总览控制器为:
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewController.java`
当前已具备:
1. `GET /ccdi/project/overview/suspicious-transactions`
2. `GET /ccdi/project/overview/employee-credit-negative`
3. `POST /ccdi/project/overview/risk-details/export`
其中统一导出由:
- `CcdiProjectOverviewServiceImpl.exportRiskDetails(...)`
- `CcdiProjectRiskDetailWorkbookExporter`
共同完成。
但当前导出器对第 3 个 sheet 仅写入表头:
1. `账号`
2. `开户人`
3. `银行`
4. `异常类型`
5. `异常发生时间`
6. `状态`
没有真实数据查询与写出逻辑。
### 4.3 已有数据基础
异常账户模型命中结果已存在于:
- `ccdi_bank_statement_tag_result`
并且当前模型设计已明确:
- `model_code = 'ABNORMAL_ACCOUNT'`
- `result_type = 'OBJECT'`
- `object_type = 'STAFF_ID_CARD'`
- `object_key = 员工身份证号`
账户主数据已存在于:
- `ccdi_account_info`
因此本次展示与导出的最短路径,是直接基于异常账户对象型命中结果与账户信息表构造结果总览专用查询,而不是从聚合结果或前端 mock 数据反推。
## 五、方案对比
### 5.1 方案 A新增结果总览专用异常账户查询链路页面与导出共用同一口径
做法:
- 新增 `GET /ccdi/project/overview/abnormal-account-people`
- 新增服务层内部导出查询方法
- 查询源直接使用 `ccdi_bank_statement_tag_result + ccdi_account_info`
- 页面展示与统一导出共用同一套字段口径
优点:
- 页面与导出完全同口径
- 不需要解析占位数据
- 不依赖聚合快照反推细节
- 与“一条命中结果一行”的确认口径天然一致
- 改动集中在结果总览域内,符合最短路径
缺点:
- 需要补充新的 VO、Mapper SQL、Excel 行对象和测试
### 5.2 方案 B复用项目分析弹窗对象型异常查询再补字段拼装
做法:
- 基于 `selectPersonAnalysisObjectRows` 再改造成风险明细列表
问题:
- 现有查询主要返回标题、摘要和 `reasonDetail`
- 不直接提供 `账号 / 银行 / 状态 / 异常发生时间`
- 需要从 `reasonDetail` 反解析字段,稳定性差
- 不适合作为统一导出数据源
### 5.3 方案 C基于员工风险聚合表反推异常账户明细
做法:
-`ccdi_project_overview_employee_result` 为主,再关联账户表补全字段
问题:
- 聚合层已经丢失“每条命中结果一行”的细粒度
- 难以稳定还原 `异常类型``异常发生时间`
- 容易导致页面与导出口径偏移
### 5.4 结论
采用 **方案 A新增结果总览专用异常账户查询链路页面与导出共用同一口径**
## 六、总体设计
### 6.1 设计原则
本次设计遵循以下原则:
1. 以已有异常账户打标结果为唯一事实来源。
2. 页面展示与导出字段保持完全一致。
3. 一条命中结果一行,不做账号合并、不做员工合并。
4. 仅识别员工本人账户,不扩展关系人或外部账户。
5. 不新增平行模块,所有改动收口在结果总览域。
### 6.2 数据流
页面查询链路:
1. `RiskDetailSection.vue` 加载当前项目的异常账户人员分页数据。
2. 前端调用 `GET /ccdi/project/overview/abnormal-account-people`
3. 控制器调用 `overviewService.getAbnormalAccountPeople(...)`
4. 服务层校验项目存在,调用 Mapper 分页 SQL。
5. Mapper 从异常账户对象命中结果与账户信息表中返回结果。
6. 前端渲染 `异常账户人员信息` 表格。
统一导出链路:
1. 用户点击 `风险明细` 卡片右上角 `导出`
2. 前端调用 `POST /ccdi/project/overview/risk-details/export`
3. 服务层查询:
- 涉疑交易全量数据
- 员工负面征信全量数据
- 异常账户人员全量数据
4. `CcdiProjectRiskDetailWorkbookExporter` 统一生成 3 个 sheet。
5. 第 3 个 sheet `异常账户人员信息` 写出真实数据。
## 七、字段与业务口径
### 7.1 页面与导出统一字段
本次 `异常账户人员信息` 页面与导出统一使用以下 6 个字段:
1. `账号`
2. `开户人`
3. `银行`
4. `异常类型`
5. `异常发生时间`
6. `状态`
不保留“操作”列,也不新增辅助列。
### 7.2 粒度口径
展示与导出粒度固定为:
- 一条异常账户命中结果一行
规则说明:
- 同一员工命中多条异常账户规则时,保留多行
- 同一账号命中多条规则时,也保留多行
- 不按员工汇总
- 不按账号合并
### 7.3 字段映射规则
#### 1. 账号
-`ccdi_account_info.account_no`
#### 2. 开户人
- 优先取 `ccdi_account_info.account_name`
- 若为空,则回退员工姓名
#### 3. 银行
-`ccdi_account_info.bank`
#### 4. 异常类型
-`ccdi_bank_statement_tag_result.rule_name`
#### 5. 异常发生时间
-`SUDDEN_ACCOUNT_CLOSURE` 取账户销户日期 `invalid_date`
-`DORMANT_ACCOUNT_LARGE_ACTIVATION` 取首次交易日期 `first_tx_date`
- 统一格式化为日期字符串
#### 6. 状态
-`ccdi_account_info.status`
- 映射文案固定为:
- `1 -> 正常`
- `2 -> 已销户`
本次不额外扩展更多状态码解释。
## 八、后端设计
### 8.1 控制器接口
`CcdiProjectOverviewController` 下新增接口:
- `GET /ccdi/project/overview/abnormal-account-people`
入参:
- `projectId`
- `pageNum`
- `pageSize`
权限:
- 沿用结果总览查询权限 `ccdi:project:query`
返回结构:
- `rows`
- `total`
### 8.2 服务层职责
`ICcdiProjectOverviewService` 与实现类中新增:
1. `getAbnormalAccountPeople(queryDTO)`
2. `exportAbnormalAccountPeople(projectId)`
服务层职责如下:
1. 校验项目存在
2. 处理分页参数
3. 查询异常账户人员分页或导出数据
4. 将查询结果映射为页面 VO 或导出 Excel 对象
5.`exportRiskDetails(...)` 中将异常账户全量数据传入工作簿导出器
### 8.3 Mapper 查询策略
查询必须满足以下约束:
1. 仅查询当前项目:
- `tr.project_id = 当前项目`
2. 仅查询异常账户模型:
- `tr.model_code = 'ABNORMAL_ACCOUNT'`
3. 仅查询对象型结果:
- `tr.bank_statement_id is null`
4. 仅查询员工本人账户:
- `account.owner_type = 'EMPLOYEE'`
- `account.owner_id = tr.object_key`
5. 每条命中结果唯一关联到一条账户记录
### 8.4 账户唯一关联规则
由于异常账户对象型结果以“员工身份证号”为主键落库,本次查询必须保证命中结果可稳定回溯到具体账户。
设计约束如下:
1. 优先依据异常账户规则 `reason_detail` 中的账号信息匹配 `ccdi_account_info.account_no`
2. 仅在账号匹配成功时返回该条结果
3. 不允许仅凭员工身份证号将同一员工下全部账户全部展开,避免误导
这意味着本次实现同时要求异常账户对象型结果具备“可唯一回溯到账号”的查询条件,不使用模糊补数方案。
### 8.5 导出收口
`POST /ccdi/project/overview/risk-details/export` 继续保持统一导出入口,不额外新增独立异常账户导出接口。
服务层导出步骤调整为:
1. 查询涉疑交易全量数据
2. 查询员工负面征信全量数据
3. 查询异常账户人员全量数据
4. 将三类数据统一传入 `CcdiProjectRiskDetailWorkbookExporter`
导出文件顺序保持不变:
1. `涉疑交易明细`
2. `员工负面征信信息`
3. `异常账户人员信息`
## 九、前端设计
### 9.1 页面位置
本次前端改动集中在:
- `ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue`
### 9.2 区块展示
`异常账户人员信息` 区块调整为真实业务表格,字段顺序固定为:
1. `账号`
2. `开户人`
3. `银行`
4. `异常类型`
5. `异常发生时间`
6. `状态`
副标题保持语义一致,可调整为:
- `展示异常账户命中人员及账户状态`
### 9.3 交互规则
- 不新增区块级导出按钮
- 不新增查看详情按钮
- 不新增行操作列
- 保持独立分页
- 保持独立 loading
- 查询失败时仅影响该区块,不影响其他两个风险明细区块
### 9.4 空态文案
空态文案统一为:
- `当前项目暂无异常账户人员信息`
## 十、测试设计
### 10.1 后端测试
新增或调整以下验证:
1. Mapper SQL 测试
- 校验异常账户分页查询与导出查询包含 `ABNORMAL_ACCOUNT`、项目过滤、对象型过滤和账户关联条件
2. Service 测试
- 校验异常账户分页查询 `rows/total` 返回正确
- 校验统一导出会将异常账户真实数据传入导出器
3. Workbook 导出测试
- 校验第 3 个 sheet 存在真实数据行
- 校验列顺序为:
- `账号`
- `开户人`
- `银行`
- `异常类型`
- `异常发生时间`
- `状态`
### 10.2 前端测试
新增或调整以下验证:
1. `RiskDetailSection.vue` 异常账户真实字段渲染测试
2. 异常账户区块独立分页测试
3. 统一导出按钮仍走 `risk-details/export` 的测试
4. 移除旧占位“操作 / 查看详情”列的静态断言
## 十一、边界与异常处理
### 11.1 空数据场景
当项目下没有异常账户命中结果时:
- 页面显示空态
- 导出 sheet 仅保留表头,不输出数据行
### 11.2 查询失败场景
页面查询失败时:
- 清空当前异常账户列表
- 提示:`加载异常账户人员信息失败`
- 不联动清空涉疑交易或员工负面征信区块
统一导出失败时:
- 沿用当前服务层异常提示:`导出风险明细失败`
### 11.3 非本次范围约束
本次明确不做以下扩展:
- 不新增详情弹窗
- 不增加筛选条件
- 不补关系人账户
- 不增加异步导出任务
- 不在导出中追加页面外字段
## 十二、后续文档规划
设计确认并完成文档复核后,继续补充两份实施计划:
1. 后端实施计划:`docs/plans/backend/`
2. 前端实施计划:`docs/plans/frontend/`
随后按实际改动沉淀实施记录。

View File

@@ -0,0 +1,494 @@
# 异常账户模型接入银行流水打标后端 Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
>
> 仓库约束:当前仓库明确禁止开启 subagent执行时统一使用 `superpowers:executing-plans`。
**Goal:** 在后端正式接入异常账户模型,新增 `ccdi_account_info`、两条对象型打标规则、最小可命中测试数据,并通过 Java 测试和 MySQL MCP SQL 校验确认命中口径正确。
**Architecture:** 复用现有 `CcdiBankTagServiceImpl -> CcdiBankTagAnalysisMapper.xml -> ccdi_bank_statement_tag_result` 主链路,不新增并行模块。两条规则统一作为 `OBJECT` 规则落到员工身份证号维度,`reason_detail` 保留账户级异常快照;测试层同时保留 Java 自动化测试与 MySQL MCP 真实 SQL 验证。
**Tech Stack:** Java 21, Spring Boot 3, MyBatis XML, MyBatis Plus, MySQL, JUnit 5, Mockito, MySQL MCP
---
## File Map
**Create:**
- `sql/migration/2026-03-31-create-ccdi-account-info-and-abnormal-account-rules.sql`
- 建表 `ccdi_account_info`,补模型与规则元数据
- `sql/migration/2026-03-31-add-abnormal-account-rule-test-data.sql`
- 插入最小命中与反样本测试数据
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiAbnormalAccountRuleSqlMetadataTest.java`
- 校验模型、规则编码、结果类型与业务口径文本
- `docs/reports/implementation/2026-03-31-abnormal-account-bank-tag-backend-implementation.md`
- 记录后端实施与验证结果
**Modify:**
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImpl.java`
- 新增两个对象型规则分发分支
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapper.java`
- 新增两个 Mapper 方法签名
- `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml`
- 新增两条对象型真实 SQL
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImplTest.java`
- 覆盖新规则分发与结果入库
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewEmployeeResultBuilderTest.java`
- 校验新增规则能进入员工风险聚合快照
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiBankTagRuleSqlMetadataTest.java`
- 补模型/规则元数据断言
**No Change Expected:**
- `ruoyi-ui/`
- 本轮前端不新增接口和页面改动
- `lsfx-mock-server/`
- 本轮不扩展 Mock 样本
## Task 1: 锁定模型、规则与落库契约
**Files:**
- Create: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiAbnormalAccountRuleSqlMetadataTest.java`
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiBankTagRuleSqlMetadataTest.java`
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImplTest.java`
- [ ] **Step 1: 先写 SQL 元数据测试**
新增 `CcdiAbnormalAccountRuleSqlMetadataTest`,至少覆盖:
```java
assertTrue(sql.contains("ABNORMAL_ACCOUNT"));
assertTrue(sql.contains("SUDDEN_ACCOUNT_CLOSURE"));
assertTrue(sql.contains("DORMANT_ACCOUNT_LARGE_ACTIVATION"));
assertTrue(sql.contains("'OBJECT'"));
```
- [ ] **Step 2: 再写 Service 分发失败测试**
`CcdiBankTagServiceImplTest` 中新增两个断言,约束新规则走对象型 Mapper
```java
verify(analysisMapper).selectSuddenAccountClosureObjects(40L);
verify(analysisMapper).selectDormantAccountLargeActivationObjects(40L);
```
- [ ] **Step 3: 运行定向测试,确认失败点正确**
Run:
```bash
mvn -pl ccdi-project -Dtest=CcdiAbnormalAccountRuleSqlMetadataTest,CcdiBankTagServiceImplTest test
```
Expected:
- FAIL提示缺少规则元数据脚本内容或缺少 Mapper / Service 分发
- [ ] **Step 4: 最小化补充规则元数据和方法签名**
按以下顺序补代码:
1. 新增规则元数据脚本文件骨架
2.`CcdiBankTagAnalysisMapper.java` 增加两个方法:
```java
List<BankTagObjectHitVO> selectSuddenAccountClosureObjects(@Param("projectId") Long projectId);
List<BankTagObjectHitVO> selectDormantAccountLargeActivationObjects(@Param("projectId") Long projectId);
```
3.`CcdiBankTagServiceImpl.java` 中新增两个 `case`
- [ ] **Step 5: 重新运行定向测试**
Run:
```bash
mvn -pl ccdi-project -Dtest=CcdiAbnormalAccountRuleSqlMetadataTest,CcdiBankTagServiceImplTest test
```
Expected:
- PASS 或仅剩 SQL 实现相关失败
- [ ] **Step 6: 提交本任务**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImpl.java \
ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapper.java \
ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiAbnormalAccountRuleSqlMetadataTest.java \
ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImplTest.java \
sql/migration/2026-03-31-create-ccdi-account-info-and-abnormal-account-rules.sql
git commit -m "补充异常账户模型规则骨架"
```
## Task 2: 落地 `ccdi_account_info` 建表与规则元数据脚本
**Files:**
- Create: `sql/migration/2026-03-31-create-ccdi-account-info-and-abnormal-account-rules.sql`
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiAbnormalAccountRuleSqlMetadataTest.java`
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiBankTagRuleSqlMetadataTest.java`
- [ ] **Step 1: 先补脚本结构测试**
为建表 SQL 增加断言,至少包含:
```java
assertTrue(sql.contains("create table if not exists `ccdi_account_info`"));
assertTrue(sql.contains("`account_no`"));
assertTrue(sql.contains("`owner_type`"));
assertTrue(sql.contains("`effective_date`"));
assertTrue(sql.contains("`invalid_date`"));
```
- [ ] **Step 2: 运行测试确认失败**
Run:
```bash
mvn -pl ccdi-project -Dtest=CcdiAbnormalAccountRuleSqlMetadataTest,CcdiBankTagRuleSqlMetadataTest test
```
Expected:
- FAIL提示脚本字段或规则元数据缺失
- [ ] **Step 3: 写最小建表与元数据脚本**
`2026-03-31-create-ccdi-account-info-and-abnormal-account-rules.sql` 中按以下顺序补内容:
1. `CREATE TABLE IF NOT EXISTS ccdi_account_info`
2. 插入模型 `ABNORMAL_ACCOUNT`
3. 插入两条规则元数据
4. 业务口径文本与设计文档保持一致
- [ ] **Step 4: 重新运行元数据测试**
Run:
```bash
mvn -pl ccdi-project -Dtest=CcdiAbnormalAccountRuleSqlMetadataTest,CcdiBankTagRuleSqlMetadataTest test
```
Expected:
- PASS
- [ ] **Step 5: 提交本任务**
```bash
git add sql/migration/2026-03-31-create-ccdi-account-info-and-abnormal-account-rules.sql \
ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiAbnormalAccountRuleSqlMetadataTest.java \
ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiBankTagRuleSqlMetadataTest.java
git commit -m "补充异常账户模型建表和规则元数据"
```
## Task 3: 先写两条规则 SQL 的失败测试
**Files:**
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImplTest.java`
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewEmployeeResultBuilderTest.java`
- Modify: `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml`
- [ ] **Step 1: 写对象型结果断言**
`CcdiBankTagServiceImplTest` 中新增两个测试,断言对象型结果内容:
```java
verify(resultMapper).insertBatch(argThat(results -> results.stream().anyMatch(item ->
"ABNORMAL_ACCOUNT".equals(item.getModelCode())
&& "SUDDEN_ACCOUNT_CLOSURE".equals(item.getRuleCode())
&& "OBJECT".equals(item.getResultType())
&& "STAFF_ID_CARD".equals(item.getObjectType())
)));
```
- [ ] **Step 2: 写聚合层断言**
`CcdiProjectOverviewEmployeeResultBuilderTest` 中补断言,确保新增规则能进入 `hitRulesJson`
```java
assertTrue(result.getHitRulesJson().contains("SUDDEN_ACCOUNT_CLOSURE"));
assertTrue(result.getHitRulesJson().contains("DORMANT_ACCOUNT_LARGE_ACTIVATION"));
```
- [ ] **Step 3: 运行测试确认失败**
Run:
```bash
mvn -pl ccdi-project -Dtest=CcdiBankTagServiceImplTest,CcdiProjectOverviewEmployeeResultBuilderTest test
```
Expected:
- FAIL提示 SQL 尚未返回结果或规则未写入正确字段
- [ ] **Step 4: 暂不修实现,先保存失败断言**
保持测试失败状态,进入下一任务补最小 SQL 实现。
## Task 4: 实现 `SUDDEN_ACCOUNT_CLOSURE` 最小闭环
**Files:**
- Modify: `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml`
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapper.java`
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImpl.java`
- [ ] **Step 1: 在 XML 中新增查询骨架**
新增 `selectSuddenAccountClosureObjects`,结果列固定为:
```sql
select
'STAFF_ID_CARD' as objectType,
staff.id_card as objectKey,
concat(...) as reasonDetail
```
- [ ] **Step 2: 按设计补完整过滤条件**
SQL 需要覆盖:
- `owner_type = 'EMPLOYEE'`
- `status = 2`
- `invalid_date is not null`
- 统计窗口 `[invalid_date - 30天, invalid_date)`
- 按员工身份证号 + 账号聚合
- [ ] **Step 3: 运行定向测试**
Run:
```bash
mvn -pl ccdi-project -Dtest=CcdiBankTagServiceImplTest test
```
Expected:
- 至少 `SUDDEN_ACCOUNT_CLOSURE` 相关断言 PASS
- [ ] **Step 4: 提交本任务**
```bash
git add ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml \
ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapper.java \
ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImpl.java
git commit -m "实现突然销户打标规则"
```
## Task 5: 实现 `DORMANT_ACCOUNT_LARGE_ACTIVATION` 最小闭环
**Files:**
- Modify: `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml`
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapper.java`
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImpl.java`
- [ ] **Step 1: 在 XML 中新增查询骨架**
新增 `selectDormantAccountLargeActivationObjects`,结果列同样返回:
```sql
'STAFF_ID_CARD' as objectType,
staff.id_card as objectKey,
concat(...) as reasonDetail
```
- [ ] **Step 2: 补完整命中条件**
SQL 需要覆盖:
- `owner_type = 'EMPLOYEE'`
- `status = 1`
- `effective_date is not null`
- `first_tx_date >= effective_date + 6个月`
- `total_amount >= 500000 or max_single_amount >= 100000`
- [ ] **Step 3: 运行定向测试**
Run:
```bash
mvn -pl ccdi-project -Dtest=CcdiBankTagServiceImplTest,CcdiProjectOverviewEmployeeResultBuilderTest test
```
Expected:
- PASS
- [ ] **Step 4: 提交本任务**
```bash
git add ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml \
ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapper.java \
ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImpl.java \
ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewEmployeeResultBuilderTest.java
git commit -m "实现休眠账户大额启用打标规则"
```
## Task 6: 补最小测试数据 SQL
**Files:**
- Create: `sql/migration/2026-03-31-add-abnormal-account-rule-test-data.sql`
- Modify: `docs/reports/implementation/2026-03-31-abnormal-account-bank-tag-backend-implementation.md`
- [ ] **Step 1: 先写测试数据注释骨架**
在 SQL 文件中先划分 4 组样本块:
- 员工 A命中 `SUDDEN_ACCOUNT_CLOSURE`
- 员工 B命中 `DORMANT_ACCOUNT_LARGE_ACTIVATION`
- 员工 C休眠不足 6 个月,不命中
- 员工 D销户前 30 天无流水,不命中
- [ ] **Step 2: 写最小测试数据**
按顺序补数据:
1. 员工基础数据
2. 项目内流水数据
3. `ccdi_account_info` 账户数据
4. 必要的清理 SQL
- [ ] **Step 3: 记录导入命令**
在实施记录中先预填导入方式:
```bash
bin/mysql_utf8_exec.sh sql/migration/2026-03-31-create-ccdi-account-info-and-abnormal-account-rules.sql
bin/mysql_utf8_exec.sh sql/migration/2026-03-31-add-abnormal-account-rule-test-data.sql
```
- [ ] **Step 4: 提交本任务**
```bash
git add sql/migration/2026-03-31-add-abnormal-account-rule-test-data.sql \
docs/reports/implementation/2026-03-31-abnormal-account-bank-tag-backend-implementation.md
git commit -m "补充异常账户规则测试数据"
```
## Task 7: 用 MySQL MCP 执行真实 SQL 校验口径
**Files:**
- Modify: `docs/reports/implementation/2026-03-31-abnormal-account-bank-tag-backend-implementation.md`
- [ ] **Step 1: 导入建表和测试数据脚本**
Run:
```bash
bin/mysql_utf8_exec.sh sql/migration/2026-03-31-create-ccdi-account-info-and-abnormal-account-rules.sql
bin/mysql_utf8_exec.sh sql/migration/2026-03-31-add-abnormal-account-rule-test-data.sql
```
Expected:
- PASS脚本执行成功且无乱码
- [ ] **Step 2: 使用 MySQL MCP 执行 `SUDDEN_ACCOUNT_CLOSURE` 对应 SQL**
要求:
- 直接执行 Mapper 中的真实查询等价 SQL
- 校验仅返回员工 A
- 校验 `reasonDetail` 中包含销户日期、最后交易日、累计金额和单笔最大金额
- [ ] **Step 3: 使用 MySQL MCP 执行 `DORMANT_ACCOUNT_LARGE_ACTIVATION` 对应 SQL**
要求:
- 直接执行 Mapper 中的真实查询等价 SQL
- 校验仅返回员工 B
- 校验员工 C 未命中
- 校验 `reasonDetail` 中包含开户日期、首次交易日期、累计金额或单笔最大金额快照
- [ ] **Step 4: 将 SQL 验证结果写入实施记录**
记录:
- 实际执行 SQL 摘要
- 命中对象
- 未命中对象
- 与业务口径的对照结论
- [ ] **Step 5: 提交本任务**
```bash
git add docs/reports/implementation/2026-03-31-abnormal-account-bank-tag-backend-implementation.md
git commit -m "补充异常账户规则SQL校验记录"
```
## Task 8: 跑完整后端验证并收尾
**Files:**
- Modify: `docs/reports/implementation/2026-03-31-abnormal-account-bank-tag-backend-implementation.md`
- [ ] **Step 1: 运行后端定向测试**
Run:
```bash
mvn -pl ccdi-project -Dtest=CcdiAbnormalAccountRuleSqlMetadataTest,CcdiBankTagRuleSqlMetadataTest,CcdiBankTagServiceImplTest,CcdiProjectOverviewEmployeeResultBuilderTest test
```
Expected:
- PASS
- [ ] **Step 2: 如需联调打标主链路,启动后端并验证后主动关闭**
Run:
```bash
mvn -pl ruoyi-admin -am package -DskipTests
cd ruoyi-admin/target && java -jar ruoyi-admin.jar
```
验证完成后关闭进程。
- [ ] **Step 3: 完善实施记录**
记录:
- 最终改动文件
- 测试结果
- MySQL MCP 校验结论
- 若启动过进程,明确已关闭
- [ ] **Step 4: 最终提交**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImpl.java \
ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapper.java \
ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml \
ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiAbnormalAccountRuleSqlMetadataTest.java \
ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiBankTagRuleSqlMetadataTest.java \
ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImplTest.java \
ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewEmployeeResultBuilderTest.java \
sql/migration/2026-03-31-create-ccdi-account-info-and-abnormal-account-rules.sql \
sql/migration/2026-03-31-add-abnormal-account-rule-test-data.sql \
docs/reports/implementation/2026-03-31-abnormal-account-bank-tag-backend-implementation.md
git commit -m "完成异常账户模型后端接入"
```
## Final Verification
- [ ] 运行:
```bash
mvn -pl ccdi-project -Dtest=CcdiAbnormalAccountRuleSqlMetadataTest,CcdiBankTagRuleSqlMetadataTest,CcdiBankTagServiceImplTest,CcdiProjectOverviewEmployeeResultBuilderTest test
```
- [ ] 使用 MySQL MCP 执行两条规则真实 SQL确认正样本命中、反样本不命中
- [ ] 确认结果写入 `ccdi_bank_statement_tag_result`
- [ ] 确认新增编码全为大写
- [ ] 如启动过后端进程,验证结束后主动关闭

View File

@@ -0,0 +1,383 @@
# LSFX Mock Server 异常账户命中流水后端 Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
>
> 仓库约束:当前仓库明确禁止开启 subagent执行时统一使用 `superpowers:executing-plans`。
**Goal:** 在现有 `lsfx-mock-server` 中补齐异常账户命中计划、最小账户事实和可命中后端 SQL 的流水样本,让同一个 `logId` 下稳定命中 `SUDDEN_ACCOUNT_CLOSURE``DORMANT_ACCOUNT_LARGE_ACTIVATION`
**Architecture:** 复用现有 `FileService -> FileRecord -> StatementService -> build_seed_statements_for_rule_plan(...)` 主链路,不新增独立接口或平行造数模块。异常账户能力拆成三层:`FileRecord` 持有命中计划与账户事实、`statement_rule_samples.py` 生成异常账户样本、`StatementService` 统一混入种子流水并继续补噪声与分页。
**Tech Stack:** Python 3, FastAPI, pytest, dataclasses, Markdown docs
---
## File Structure
- `lsfx-mock-server/services/file_service.py`: 扩展 `FileRecord`,增加异常账户规则池、命中计划和最小账户事实生成逻辑。
- `lsfx-mock-server/services/statement_rule_samples.py`: 定义异常账户事实结构,并新增两类异常账户样本生成器及统一接入点。
- `lsfx-mock-server/services/statement_service.py`: 消费 `FileRecord` 中新增的异常账户计划,让种子流水能混入异常账户命中样本。
- `lsfx-mock-server/tests/test_file_service.py`: 锁定异常账户命中计划与账户事实生成行为。
- `lsfx-mock-server/tests/test_statement_service.py`: 锁定异常账户样本日期窗口、金额阈值与服务层混入行为。
- `docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-backend-implementation.md`: 记录本次 Mock 服务后端实施结果。
- `docs/tests/records/2026-03-31-lsfx-mock-server-abnormal-account-backend-verification.md`: 记录 pytest 验证命令、结果与进程清理结论。
## Task 1: 扩展 `FileRecord` 并锁定异常账户命中计划
**Files:**
- Modify: `lsfx-mock-server/services/file_service.py`
- Modify: `lsfx-mock-server/tests/test_file_service.py`
- Reference: `docs/design/2026-03-31-lsfx-mock-server-abnormal-account-design.md`
- [ ] **Step 1: Write the failing test**
先在 `lsfx-mock-server/tests/test_file_service.py` 中新增失败用例,锁定 `fetch_inner_flow(...)` 生成的 `FileRecord` 会携带异常账户命中计划和账户事实:
```python
def test_fetch_inner_flow_should_attach_abnormal_account_rule_plan():
service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
response = service.fetch_inner_flow(
{
"groupId": 1001,
"customerNo": "customer_abnormal_account",
"dataChannelCode": "test_code",
"requestDateId": 20240101,
"dataStartDateId": 20240101,
"dataEndDateId": 20240131,
"uploadUserId": 902001,
}
)
log_id = response["data"][0]
record = service.file_records[log_id]
assert hasattr(record, "abnormal_account_hit_rules")
assert hasattr(record, "abnormal_accounts")
assert isinstance(record.abnormal_account_hit_rules, list)
assert isinstance(record.abnormal_accounts, list)
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd lsfx-mock-server
python3 -m pytest tests/test_file_service.py::test_fetch_inner_flow_should_attach_abnormal_account_rule_plan -v
```
Expected:
- `FAIL`
- 原因是 `FileRecord` 还没有异常账户相关字段
- [ ] **Step 3: Write minimal implementation**
`lsfx-mock-server/services/file_service.py` 中按最小路径补齐:
1. 新增规则池常量:
```python
ABNORMAL_ACCOUNT_RULE_CODES = [
"SUDDEN_ACCOUNT_CLOSURE",
"DORMANT_ACCOUNT_LARGE_ACTIVATION",
]
```
2. 扩展 `FileRecord` 字段:
```python
abnormal_account_hit_rules: List[str] = field(default_factory=list)
abnormal_accounts: List[dict] = field(default_factory=list)
```
3.`_build_subset_rule_hit_plan(...)``_build_all_compatible_rule_hit_plan(...)``_apply_rule_hit_plan_to_record(...)` 中纳入 `abnormal_account_hit_rules`
4. 新增最小账户事实生成方法,并在 `fetch_inner_flow(...)` / 上传链路创建记录时写入 `abnormal_accounts`
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
cd lsfx-mock-server
python3 -m pytest tests/test_file_service.py::test_fetch_inner_flow_should_attach_abnormal_account_rule_plan -v
```
Expected:
- `PASS`
- `FileRecord` 已稳定保存异常账户命中计划和账户事实列表
- [ ] **Step 5: Commit**
```bash
git add lsfx-mock-server/services/file_service.py lsfx-mock-server/tests/test_file_service.py
git commit -m "补充异常账户命中计划与账户事实"
```
## Task 2: 先写异常账户样本生成器的失败测试
**Files:**
- Modify: `lsfx-mock-server/services/statement_rule_samples.py`
- Modify: `lsfx-mock-server/tests/test_statement_service.py`
- Reference: `docs/design/2026-03-31-lsfx-mock-server-abnormal-account-design.md`
- [ ] **Step 1: Write the failing tests**
`lsfx-mock-server/tests/test_statement_service.py` 中新增两条失败测试,分别锁定两条规则的样本口径:
```python
def test_sudden_account_closure_samples_should_stay_within_30_days_before_invalid_date():
statements = build_sudden_account_closure_samples(
group_id=1000,
log_id=20001,
account_fact={
"account_no": "6222000000000001",
"owner_id_card": "320101199001010030",
"account_name": "测试员工工资卡",
"status": 2,
"effective_date": "2024-01-01",
"invalid_date": "2026-03-20",
},
le_name="测试主体",
)
assert statements
assert all("6222000000000001" == item["accountMaskNo"] for item in statements)
assert all("2026-02-18" <= item["trxDate"][:10] < "2026-03-20" for item in statements)
def test_dormant_account_large_activation_samples_should_exceed_threshold_after_6_months():
statements = build_dormant_account_large_activation_samples(
group_id=1000,
log_id=20001,
account_fact={
"account_no": "6222000000000002",
"owner_id_card": "320101199001010030",
"account_name": "测试员工工资卡",
"status": 1,
"effective_date": "2025-01-01",
"invalid_date": None,
},
le_name="测试主体",
)
assert statements
assert min(item["trxDate"][:10] for item in statements) >= "2025-07-01"
assert sum(item["drAmount"] + item["crAmount"] for item in statements) >= 500000
assert max(item["drAmount"] + item["crAmount"] for item in statements) >= 100000
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd lsfx-mock-server
python3 -m pytest tests/test_statement_service.py -k "sudden_account_closure or dormant_account_large_activation" -v
```
Expected:
- `FAIL`
- 原因是异常账户样本生成器尚不存在
- [ ] **Step 3: Write minimal implementation**
`lsfx-mock-server/services/statement_rule_samples.py` 中补齐最小实现:
1. 定义异常账户事实结构或约定字典字段
2. 新增:
```python
def build_sudden_account_closure_samples(...): ...
def build_dormant_account_large_activation_samples(...): ...
```
3. 造数要求:
- `SUDDEN_ACCOUNT_CLOSURE` 所有流水落在销户前 30 天窗口内
- `DORMANT_ACCOUNT_LARGE_ACTIVATION` 首笔流水晚于开户满 6 个月
- 休眠账户样本同时满足累计金额和单笔最大金额阈值
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
cd lsfx-mock-server
python3 -m pytest tests/test_statement_service.py -k "sudden_account_closure or dormant_account_large_activation" -v
```
Expected:
- `PASS`
- 两类样本的日期和金额口径与设计一致
- [ ] **Step 5: Commit**
```bash
git add lsfx-mock-server/services/statement_rule_samples.py lsfx-mock-server/tests/test_statement_service.py
git commit -m "补充异常账户规则样本生成器"
```
## Task 3: 将异常账户样本接入统一种子流水构造
**Files:**
- Modify: `lsfx-mock-server/services/statement_rule_samples.py`
- Modify: `lsfx-mock-server/services/statement_service.py`
- Modify: `lsfx-mock-server/tests/test_statement_service.py`
- [ ] **Step 1: Write the failing service-level test**
`lsfx-mock-server/tests/test_statement_service.py` 中新增失败测试,锁定服务层会按命中计划混入异常账户样本:
```python
def test_generate_statements_should_follow_abnormal_account_rule_plan_from_file_record():
file_service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
statement_service = StatementService(file_service=file_service)
response = file_service.fetch_inner_flow(
{
"groupId": 1001,
"customerNo": "customer_abnormal_rule_plan",
"dataChannelCode": "test_code",
"requestDateId": 20240101,
"dataStartDateId": 20240101,
"dataEndDateId": 20240131,
"uploadUserId": 902001,
}
)
log_id = response["data"][0]
record = file_service.file_records[log_id]
record.abnormal_account_hit_rules = ["SUDDEN_ACCOUNT_CLOSURE"]
record.abnormal_accounts = [
{
"account_no": "6222000000000001",
"owner_id_card": record.staff_id_card,
"account_name": "测试员工工资卡",
"status": 2,
"effective_date": "2024-01-01",
"invalid_date": "2026-03-20",
}
]
statements = statement_service._generate_statements(group_id=1001, log_id=log_id, count=80)
assert any(item["accountMaskNo"] == "6222000000000001" for item in statements)
assert any("销户" in item["userMemo"] or "异常账户" in item["userMemo"] for item in statements)
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd lsfx-mock-server
python3 -m pytest tests/test_statement_service.py::test_generate_statements_should_follow_abnormal_account_rule_plan_from_file_record -v
```
Expected:
- `FAIL`
- 原因是种子流水构造入口尚未消费异常账户命中计划
- [ ] **Step 3: Write minimal implementation**
实现顺序:
1.`build_seed_statements_for_rule_plan(...)` 中增加 `abnormal_account_hit_rules` 入参消费
2. 根据 `abnormal_accounts` 逐条匹配调用对应样本生成器
3.`StatementService._generate_statements(...)` 中把 `record.abnormal_account_hit_rules``record.abnormal_accounts` 传给样本构造入口
- [ ] **Step 4: Run targeted test to verify it passes**
Run:
```bash
cd lsfx-mock-server
python3 -m pytest tests/test_statement_service.py::test_generate_statements_should_follow_abnormal_account_rule_plan_from_file_record -v
```
Expected:
- `PASS`
- 服务层已能按 `FileRecord` 中的异常账户计划稳定混入命中样本
- [ ] **Step 5: Commit**
```bash
git add lsfx-mock-server/services/statement_rule_samples.py lsfx-mock-server/services/statement_service.py lsfx-mock-server/tests/test_statement_service.py
git commit -m "接入异常账户命中流水主链路"
```
## Task 4: 跑回归测试并补实施记录
**Files:**
- Create: `docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-backend-implementation.md`
- Create: `docs/tests/records/2026-03-31-lsfx-mock-server-abnormal-account-backend-verification.md`
- Modify: `lsfx-mock-server/README.md`
- [ ] **Step 1: Run focused backend regression**
Run:
```bash
cd lsfx-mock-server
python3 -m pytest tests/test_file_service.py tests/test_statement_service.py -v
```
Expected:
- `PASS`
- 异常账户相关新增测试和既有流水造数测试均通过
- [ ] **Step 2: Run full mock-server regression**
Run:
```bash
cd lsfx-mock-server
python3 -m pytest tests/ -v
```
Expected:
- `PASS`
- 无异常账户改造引入的回归失败
- [ ] **Step 3: Update docs**
在实施记录中至少写明:
- 新增的异常账户命中计划字段
- 两类异常账户样本生成器
- 服务层如何接入现有种子流水主链路
- 验证命令与结果
在验证记录中至少写明:
- 执行过的 pytest 命令
- 结果摘要
- 本轮未启动额外前后端进程,因此无需清理残留进程
如果 README 已描述规则计划结构,同步补充 `abnormal_account_hit_rules` 说明;否则可只补最小联调说明。
- [ ] **Step 4: Re-run docs-relevant verification if README changed**
Run:
```bash
cd lsfx-mock-server
python3 -m pytest tests/test_statement_service.py -v
```
Expected:
- `PASS`
- [ ] **Step 5: Commit**
```bash
git add lsfx-mock-server/README.md docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-backend-implementation.md docs/tests/records/2026-03-31-lsfx-mock-server-abnormal-account-backend-verification.md
git commit -m "完成异常账户Mock服务后端实施记录"
```

View File

@@ -0,0 +1,486 @@
# LSFX Mock Server 异常账户基线同步后端 Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
>
> 仓库约束:当前仓库明确禁止开启 subagent执行时统一使用 `superpowers:executing-plans`。
**Goal:**`lsfx-mock-server` 创建 `logId` 时一次性把异常账户事实同步到 `ccdi_account_info`,让同一个 `logId` 下的异常账户事实、命中流水和真实打标前提稳定闭环。
**Architecture:** 继续复用现有 `FileService -> FileRecord -> StatementService` 主链路,不新增接口,也不把写库副作用混进 `getBSByLogId`。新增一个很小的 `AbnormalAccountBaselineService` 负责按 `account_no` 幂等 upsert `ccdi_account_info`,由 `FileService` 在保存 `file_records[log_id]` 前调用;`StatementService` 仍只读取 `FileRecord` 生成异常账户流水样本。
**Tech Stack:** Python 3, FastAPI, PyMySQL, pytest, dataclasses, Markdown docs
---
## File Structure
- `lsfx-mock-server/services/abnormal_account_baseline_service.py`: 新增异常账户基线写库服务,封装数据库连接、输入校验和按账号幂等 upsert。
- `lsfx-mock-server/services/file_service.py`: 注入异常账户基线服务,并在 `fetch_inner_flow(...)` / 上传建档链路中于保存 `FileRecord` 前触发基线同步。
- `lsfx-mock-server/services/statement_service.py`: 只补链路一致性断言,不新增写库逻辑。
- `lsfx-mock-server/tests/test_abnormal_account_baseline_service.py`: 新增服务层单测,覆盖空输入、异常输入、插入和更新。
- `lsfx-mock-server/tests/test_file_service.py`: 锁定 `fetch_inner_flow(...)` 调用基线同步服务及失败回滚语义。
- `lsfx-mock-server/tests/test_statement_service.py`: 锁定异常账户样本流水与 `record.abnormal_accounts` 账号一致。
- `docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-backend-implementation.md`: 记录本次后端实施结果。
- `docs/tests/records/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-backend-verification.md`: 记录 pytest 验证命令、结果和进程清理结论。
## Task 1: 先锁定 `FileService` 的基线同步触发点
**Files:**
- Modify: `lsfx-mock-server/tests/test_file_service.py`
- Modify: `lsfx-mock-server/services/file_service.py`
- Reference: `docs/design/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-design.md`
- [ ] **Step 1: Write the failing test**
`lsfx-mock-server/tests/test_file_service.py` 中新增 fake baseline service 和两条失败测试:
```python
class FakeAbnormalAccountBaselineService:
def __init__(self, should_fail=False):
self.should_fail = should_fail
self.calls = []
def apply(self, staff_id_card, abnormal_accounts):
self.calls.append(
{
"staff_id_card": staff_id_card,
"abnormal_accounts": [dict(item) for item in abnormal_accounts],
}
)
if self.should_fail:
raise RuntimeError("baseline sync failed")
def test_fetch_inner_flow_should_sync_abnormal_account_baselines_before_caching():
baseline_service = FakeAbnormalAccountBaselineService()
service = FileService(
staff_identity_repository=FakeStaffIdentityRepository(),
abnormal_account_baseline_service=baseline_service,
)
response = service.fetch_inner_flow(
{
"groupId": 1001,
"customerNo": "customer_abnormal_baseline",
"dataChannelCode": "test_code",
"requestDateId": 20240101,
"dataStartDateId": 20240101,
"dataEndDateId": 20240131,
"uploadUserId": 902001,
}
)
log_id = response["data"][0]
record = service.file_records[log_id]
assert baseline_service.calls
assert baseline_service.calls[0]["staff_id_card"] == record.staff_id_card
assert baseline_service.calls[0]["abnormal_accounts"] == record.abnormal_accounts
def test_fetch_inner_flow_should_not_cache_log_id_when_abnormal_account_baseline_sync_fails():
baseline_service = FakeAbnormalAccountBaselineService(should_fail=True)
service = FileService(
staff_identity_repository=FakeStaffIdentityRepository(),
abnormal_account_baseline_service=baseline_service,
)
with pytest.raises(RuntimeError, match="baseline sync failed"):
service.fetch_inner_flow(
{
"groupId": 1001,
"customerNo": "customer_abnormal_baseline_fail",
"dataChannelCode": "test_code",
"requestDateId": 20240101,
"dataStartDateId": 20240101,
"dataEndDateId": 20240131,
"uploadUserId": 902001,
}
)
assert service.file_records == {}
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd lsfx-mock-server
python3 -m pytest tests/test_file_service.py -k "abnormal_account_baseline" -v
```
Expected:
- `FAIL`
- 原因是 `FileService` 还不接受 `abnormal_account_baseline_service` 注入,也没有在建档阶段触发写库
- [ ] **Step 3: Write minimal implementation**
`lsfx-mock-server/services/file_service.py` 中按最小路径补齐:
1. 新增构造参数:
```python
def __init__(..., abnormal_account_baseline_service=None):
self.abnormal_account_baseline_service = (
abnormal_account_baseline_service or AbnormalAccountBaselineService()
)
```
2. 新增内部封装:
```python
def _apply_abnormal_account_baselines(self, file_record: FileRecord) -> None:
if not file_record.abnormal_account_hit_rules:
return
if not file_record.abnormal_accounts:
raise RuntimeError("异常账户命中计划存在,但未生成账户事实")
self.abnormal_account_baseline_service.apply(
staff_id_card=file_record.staff_id_card,
abnormal_accounts=file_record.abnormal_accounts,
)
```
3.`fetch_inner_flow(...)` 和上传链路中,将:
```python
self.file_records[log_id] = file_record
```
调整为:
```python
self._apply_abnormal_account_baselines(file_record)
self.file_records[log_id] = file_record
```
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
cd lsfx-mock-server
python3 -m pytest tests/test_file_service.py -k "abnormal_account_baseline" -v
```
Expected:
- `PASS`
- 成功路径会先调用 baseline service
- 失败路径不会把半成品 `logId` 写入 `file_records`
- [ ] **Step 5: Commit**
```bash
git add lsfx-mock-server/services/file_service.py lsfx-mock-server/tests/test_file_service.py
git commit -m "接入异常账户基线同步触发点"
```
## Task 2: 实现异常账户基线写库服务
**Files:**
- Create: `lsfx-mock-server/services/abnormal_account_baseline_service.py`
- Modify: `lsfx-mock-server/tests/test_abnormal_account_baseline_service.py`
- Reference: `docs/design/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-design.md`
- [ ] **Step 1: Write the failing tests**
新建 `lsfx-mock-server/tests/test_abnormal_account_baseline_service.py`,先锁定四类行为:
```python
def test_apply_should_skip_when_abnormal_accounts_is_empty():
service = AbnormalAccountBaselineService()
fake_connection = FakeConnection()
service._connect = lambda: fake_connection
service.apply("330101199001010001", [])
assert fake_connection.executed_sql == []
def test_apply_should_raise_when_fact_owner_mismatches_staff():
service = AbnormalAccountBaselineService()
with pytest.raises(RuntimeError, match="owner_id_card"):
service.apply(
"330101199001010001",
[
{
"account_no": "6222000000000001",
"owner_id_card": "330101199001010099",
"account_name": "测试员工工资卡",
"status": 2,
"effective_date": "2024-01-01",
"invalid_date": "2026-03-20",
"rule_code": "SUDDEN_ACCOUNT_CLOSURE",
}
],
)
def test_apply_should_insert_new_account_fact_by_account_no():
...
def test_apply_should_update_existing_account_fact_by_account_no():
...
```
`FakeConnection` / `FakeCursor` 只需记录 `execute(...)` 调用和提交次数,不需要真实数据库。
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd lsfx-mock-server
python3 -m pytest tests/test_abnormal_account_baseline_service.py -v
```
Expected:
- `FAIL`
- 原因是服务文件尚不存在
- [ ] **Step 3: Write minimal implementation**
`lsfx-mock-server/services/abnormal_account_baseline_service.py` 中实现:
1. `__init__` 直接复用现有 `settings.CCDI_DB_*`
2. `_connect()` 使用 `pymysql.connect(..., charset="utf8mb4", autocommit=False)`
3. `apply(staff_id_card, abnormal_accounts)` 内部规则:
- 空列表直接返回
- 若任一 `owner_id_card``staff_id_card` 不一致,直接抛错
- 对每条 fact 执行单条 upsert
- 成功后统一 `commit()`,失败则 `rollback()`
建议 upsert 语句形态:
```sql
INSERT INTO ccdi_account_info (
account_no,
account_type,
account_name,
owner_type,
owner_id,
bank,
bank_code,
currency,
is_self_account,
trans_risk_level,
status,
effective_date,
invalid_date,
create_by,
update_by
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
account_name = VALUES(account_name),
owner_type = VALUES(owner_type),
owner_id = VALUES(owner_id),
bank = VALUES(bank),
bank_code = VALUES(bank_code),
currency = VALUES(currency),
is_self_account = VALUES(is_self_account),
trans_risk_level = VALUES(trans_risk_level),
status = VALUES(status),
effective_date = VALUES(effective_date),
invalid_date = VALUES(invalid_date),
update_by = VALUES(update_by),
update_time = NOW()
```
固定值约束:
- `account_type = 'DEBIT'`
- `owner_type = 'EMPLOYEE'`
- `bank = '兰溪农商银行'`
- `bank_code = 'LXNCSY'`
- `currency = 'CNY'`
- `is_self_account = 1`
- `trans_risk_level = 'HIGH'`
- `create_by/update_by = 'lsfx-mock-server'`
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
cd lsfx-mock-server
python3 -m pytest tests/test_abnormal_account_baseline_service.py -v
```
Expected:
- `PASS`
- 覆盖空输入、校验失败、插入和更新四类行为
- [ ] **Step 5: Commit**
```bash
git add lsfx-mock-server/services/abnormal_account_baseline_service.py lsfx-mock-server/tests/test_abnormal_account_baseline_service.py
git commit -m "新增异常账户基线写库服务"
```
## Task 3: 锁定异常账户事实与返回流水的一致性
**Files:**
- Modify: `lsfx-mock-server/tests/test_statement_service.py`
- Reference: `lsfx-mock-server/services/statement_service.py`
- Reference: `lsfx-mock-server/services/statement_rule_samples.py`
- [ ] **Step 1: Write the failing test**
`lsfx-mock-server/tests/test_statement_service.py` 中新增一条只校验一致性的用例:
```python
def test_get_bank_statement_should_only_use_abnormal_account_numbers_from_file_record():
file_service = FileService(
staff_identity_repository=FakeStaffIdentityRepository(),
abnormal_account_baseline_service=FakeAbnormalAccountBaselineService(),
)
statement_service = StatementService(file_service=file_service)
response = file_service.fetch_inner_flow(
{
"groupId": 1001,
"customerNo": "customer_abnormal_statement_consistency",
"dataChannelCode": "test_code",
"requestDateId": 20240101,
"dataStartDateId": 20240101,
"dataEndDateId": 20240131,
"uploadUserId": 902001,
}
)
log_id = response["data"][0]
record = file_service.file_records[log_id]
record.abnormal_account_hit_rules = ["SUDDEN_ACCOUNT_CLOSURE"]
record.abnormal_accounts = [
{
"account_no": "6222000000000099",
"owner_id_card": record.staff_id_card,
"account_name": "测试员工工资卡",
"status": 2,
"effective_date": "2024-01-01",
"invalid_date": "2026-03-20",
"rule_code": "SUDDEN_ACCOUNT_CLOSURE",
}
]
result = statement_service.get_bank_statement(
{"groupId": 1001, "logId": log_id, "pageNow": 1, "pageSize": 500}
)
abnormal_numbers = {
item["accountMaskNo"]
for item in result["data"]["bankStatementList"]
if "销户" in item["userMemo"] or "异常账户" in item["userMemo"]
}
assert abnormal_numbers == {"6222000000000099"}
```
- [ ] **Step 2: Run test to verify it fails when chain drifts**
Run:
```bash
cd lsfx-mock-server
python3 -m pytest tests/test_statement_service.py::test_get_bank_statement_should_only_use_abnormal_account_numbers_from_file_record -v
```
Expected:
- 若当前实现已满足,可直接 `PASS`
- 若失败,只允许修正账号回填链路,禁止引入写库逻辑到 `StatementService`
- [ ] **Step 3: Keep implementation minimal**
若失败,仅允许在 `lsfx-mock-server/services/statement_service.py` 中做最小修正:
- `_apply_primary_binding(...)` 继续只兜底空账号
- 不覆盖异常账户样本已有 `accountMaskNo`
- 不新增数据库连接或写库逻辑
- [ ] **Step 4: Run focused statement tests**
Run:
```bash
cd lsfx-mock-server
python3 -m pytest tests/test_statement_service.py -k "abnormal_account" -v
```
Expected:
- `PASS`
- 既有异常账户样本日期/金额测试与新增一致性测试同时通过
- [ ] **Step 5: Commit**
```bash
git add lsfx-mock-server/services/statement_service.py lsfx-mock-server/tests/test_statement_service.py
git commit -m "锁定异常账户流水与账户事实一致性"
```
## Task 4: 完成回归验证并补实施记录
**Files:**
- Create: `docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-backend-implementation.md`
- Create: `docs/tests/records/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-backend-verification.md`
- [ ] **Step 1: Run full targeted backend tests**
Run:
```bash
cd lsfx-mock-server
python3 -m pytest \
tests/test_abnormal_account_baseline_service.py \
tests/test_file_service.py \
tests/test_statement_service.py -k "abnormal_account or abnormal_account_baseline" -v
```
Expected:
- `PASS`
- 无新增异常账户相关失败
- [ ] **Step 2: Write implementation record**
`docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-backend-implementation.md` 中记录:
- 新增 `AbnormalAccountBaselineService`
- `FileService` 在建 `logId` 时同步异常账户基线
- 失败回滚语义
- 异常账户事实与返回流水的一致性约束
- [ ] **Step 3: Write verification record**
`docs/tests/records/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-backend-verification.md` 中记录:
- 执行过的 pytest 命令
- 关键通过点
- 本次未启动前后端长驻进程,因此无需额外杀进程
- [ ] **Step 4: Verify final diff scope**
Run:
```bash
git diff --name-only HEAD~3..HEAD
```
Expected:
- 仅包含本次异常账户基线同步相关服务、测试和文档
- [ ] **Step 5: Commit**
```bash
git add \
docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-backend-implementation.md \
docs/tests/records/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-backend-verification.md
git commit -m "记录异常账户基线同步后端实施"
```

View File

@@ -0,0 +1,523 @@
# 项目详情风险明细异常账户人员信息后端 Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
>
> 仓库约束:当前仓库明确禁止开启 subagent执行时统一使用 `superpowers:executing-plans`。
**Goal:** 为项目详情风险明细补齐“异常账户人员信息”的真实后端查询与统一导出能力,让页面展示和第 3 个 Excel sheet 共用同一套异常账户明细口径。
**Architecture:** 在结果总览域内新增异常账户分页查询接口和非分页导出查询,数据源直接使用 `ccdi_bank_statement_tag_result + ccdi_account_info`,按“一条命中结果一行”返回。统一导出继续复用 `POST /ccdi/project/overview/risk-details/export`,仅将第 3 个 sheet 从空表头改为真实数据写出,不新增平行导出接口。
**Tech Stack:** Java 21, Spring Boot 3, MyBatis Plus Page, MyBatis XML, Apache POI, JUnit 5, Mockito
---
## File Map
**Create:**
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiProjectAbnormalAccountQueryDTO.java`
- 结果总览异常账户分页查询入参,仅承载 `projectId/pageNum/pageSize`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectAbnormalAccountItemVO.java`
- 异常账户单行展示对象,字段与页面列一致
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectAbnormalAccountPageVO.java`
- 异常账户分页返回对象,统一承载 `rows/total`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/excel/CcdiProjectAbnormalAccountExcel.java`
- `异常账户人员信息` sheet 行对象
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceAbnormalAccountTest.java`
- 单独覆盖异常账户分页与导出映射
**Modify:**
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewController.java`
- 新增异常账户分页查询接口
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectOverviewService.java`
- 新增异常账户分页与导出方法定义
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImpl.java`
- 实现异常账户分页查询、导出映射,并接入统一导出流程
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapper.java`
- 新增异常账户分页与导出查询声明
- `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml`
- 新增异常账户基础 SQL、分页 SQL、导出 SQL
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectRiskDetailWorkbookExporter.java`
- 第 3 个 sheet 改为写出真实异常账户数据
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewControllerTest.java`
- 覆盖新分页接口委托行为
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewControllerContractTest.java`
- 覆盖新接口路径、注解与参数签名
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImplTest.java`
- 更新统一导出测试,断言第 3 个 sheet 已传入真实数据
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectRiskDetailWorkbookExporterTest.java`
- 更新工作簿导出断言,校验异常账户真实数据行
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapperSqlTest.java`
- 覆盖异常账户分页与导出 SQL 口径
- `docs/reports/implementation/2026-03-31-project-detail-risk-details-abnormal-account-backend-implementation.md`
- 记录后端实施细节与验证结果
## Task 1: 锁定结果总览异常账户分页接口契约
**Files:**
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiProjectAbnormalAccountQueryDTO.java`
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectAbnormalAccountPageVO.java`
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewController.java`
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectOverviewService.java`
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewControllerTest.java`
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewControllerContractTest.java`
- [ ] **Step 1: 先写控制器契约测试**
`CcdiProjectOverviewControllerContractTest` 中新增对新方法的反射断言:
```java
Method method = controllerClass.getMethod(
"getAbnormalAccountPeople",
CcdiProjectAbnormalAccountQueryDTO.class
);
GetMapping getMapping = method.getAnnotation(GetMapping.class);
assertEquals("/abnormal-account-people", getMapping.value()[0]);
```
同时断言:
- 存在 `@Operation(summary = "查询异常账户人员信息")`
- 存在 `@PreAuthorize("@ss.hasPermi('ccdi:project:query')")`
- [ ] **Step 2: 再写控制器委托单测,先让它失败**
`CcdiProjectOverviewControllerTest` 中新增测试:
```java
CcdiProjectAbnormalAccountQueryDTO queryDTO = new CcdiProjectAbnormalAccountQueryDTO();
queryDTO.setProjectId(40L);
when(overviewService.getAbnormalAccountPeople(queryDTO)).thenReturn(pageVO);
AjaxResult result = controller.getAbnormalAccountPeople(queryDTO);
verify(overviewService).getAbnormalAccountPeople(same(queryDTO));
assertSame(pageVO, result.get("data"));
```
- [ ] **Step 3: 运行控制器定向测试确认失败点正确**
Run:
```bash
mvn -pl ccdi-project -am \
-Dsurefire.failIfNoSpecifiedTests=false \
-Dtest=CcdiProjectOverviewControllerContractTest,CcdiProjectOverviewControllerTest \
test
```
Expected:
- FAIL提示缺少 `getAbnormalAccountPeople` 方法、DTO 或服务接口方法
- [ ] **Step 4: 最小化补齐控制器与接口骨架**
1. 创建 `CcdiProjectAbnormalAccountQueryDTO`
2. 创建 `CcdiProjectAbnormalAccountPageVO`
3.`ICcdiProjectOverviewService` 增加:
```java
default CcdiProjectAbnormalAccountPageVO getAbnormalAccountPeople(
CcdiProjectAbnormalAccountQueryDTO queryDTO
) {
return new CcdiProjectAbnormalAccountPageVO();
}
```
4. 在控制器中增加:
```java
@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);
}
```
- [ ] **Step 5: 重新运行控制器测试**
Run:
```bash
mvn -pl ccdi-project -am \
-Dsurefire.failIfNoSpecifiedTests=false \
-Dtest=CcdiProjectOverviewControllerContractTest,CcdiProjectOverviewControllerTest \
test
```
Expected:
- PASS
- [ ] **Step 6: 提交本任务**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiProjectAbnormalAccountQueryDTO.java \
ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectAbnormalAccountPageVO.java \
ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewController.java \
ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectOverviewService.java \
ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewControllerContractTest.java \
ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewControllerTest.java
git commit -m "补充异常账户人员查询接口契约"
```
## Task 2: 补齐异常账户分页与导出 SQL 口径
**Files:**
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectAbnormalAccountItemVO.java`
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/excel/CcdiProjectAbnormalAccountExcel.java`
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapper.java`
- Modify: `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml`
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapperSqlTest.java`
- [ ] **Step 1: 先写 Mapper SQL 测试**
`CcdiProjectOverviewMapperSqlTest` 中新增两个 select 断言:
```java
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(abnormalExportSql.contains("order by abnormal_time desc"), abnormalExportSql);
```
同时补充对状态映射和规则时间字段的静态断言:
- `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'`
- [ ] **Step 2: 运行 SQL 测试确认失败**
Run:
```bash
mvn -pl ccdi-project -am \
-Dsurefire.failIfNoSpecifiedTests=false \
-Dtest=CcdiProjectOverviewMapperSqlTest \
test
```
Expected:
- FAIL提示缺少 `selectAbnormalAccountPage``selectAbnormalAccountList`
- [ ] **Step 3: 设计基础 SQL 片段,再补 Mapper 方法签名**
`CcdiProjectOverviewMapper.java` 中新增:
```java
Page<CcdiProjectAbnormalAccountItemVO> selectAbnormalAccountPage(
Page<CcdiProjectAbnormalAccountItemVO> page,
@Param("query") CcdiProjectAbnormalAccountQueryDTO query
);
List<CcdiProjectAbnormalAccountItemVO> selectAbnormalAccountList(@Param("projectId") Long projectId);
```
在 XML 中先抽出基础 SQL 片段:
- `abnormalAccountBaseSql`
- 统一负责:
- 项目过滤
- `ABNORMAL_ACCOUNT` 模型过滤
- 对象型结果过滤
- `owner_type = 'EMPLOYEE'`
- 账号唯一关联
- `账号 / 开户人 / 银行 / 异常类型 / 异常发生时间 / 状态` 映射
- [ ] **Step 4: 实现分页 SQL 与导出 SQL**
分页 SQL
- 使用 `#{query.projectId}`
-`abnormal_time desc, account.account_no asc, tr.rule_code asc`
导出 SQL
- 使用 `#{projectId}`
- 与分页 SQL 保持同一列集合与同一排序规则
账号唯一关联要求:
- 优先通过 `tr.reason_detail` 中包含的账号匹配 `account.account_no`
- 没有账号匹配条件时不要把员工名下全部账户笛卡尔展开
- [ ] **Step 5: 重新运行 SQL 测试**
Run:
```bash
mvn -pl ccdi-project -am \
-Dsurefire.failIfNoSpecifiedTests=false \
-Dtest=CcdiProjectOverviewMapperSqlTest \
test
```
Expected:
- PASS
- [ ] **Step 6: 提交本任务**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectAbnormalAccountItemVO.java \
ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/excel/CcdiProjectAbnormalAccountExcel.java \
ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapper.java \
ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml \
ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapperSqlTest.java
git commit -m "补充异常账户人员查询SQL"
```
## Task 3: 完成服务层分页映射与项目校验
**Files:**
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectOverviewService.java`
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImpl.java`
- Create: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceAbnormalAccountTest.java`
- [ ] **Step 1: 先写服务层失败测试**
`CcdiProjectOverviewServiceAbnormalAccountTest` 中新增 4 个测试:
1. 分页查询返回 `rows/total`
2. 分页查询默认页码为 `1`、分页大小为 `5`
3. 导出查询返回 `List<CcdiProjectAbnormalAccountExcel>`
4. 项目不存在时,分页与导出都抛 `ServiceException`
核心断言示例:
```java
CcdiProjectAbnormalAccountPageVO result = service.getAbnormalAccountPeople(queryDTO);
assertEquals(1, result.getRows().size());
assertEquals("突然销户", result.getRows().getFirst().getAbnormalType());
verify(overviewMapper).selectAbnormalAccountPage(any(Page.class), any(CcdiProjectAbnormalAccountQueryDTO.class));
```
- [ ] **Step 2: 跑服务层测试确认失败**
Run:
```bash
mvn -pl ccdi-project -am \
-Dsurefire.failIfNoSpecifiedTests=false \
-Dtest=CcdiProjectOverviewServiceAbnormalAccountTest \
test
```
Expected:
- FAIL提示缺少服务方法、Mapper 调用或 Excel 映射
- [ ] **Step 3: 实现最小服务层逻辑**
`ICcdiProjectOverviewService` 中新增:
```java
default List<CcdiProjectAbnormalAccountExcel> exportAbnormalAccountPeople(Long projectId) {
return List.of();
}
```
`CcdiProjectOverviewServiceImpl` 中实现:
1. `getAbnormalAccountPeople(queryDTO)`
2. `exportAbnormalAccountPeople(projectId)`
3. `buildAbnormalAccountExcelRow(...)`
实现要求:
-`ensureProjectExists(...)`
- 分页默认值沿用现有结果总览风格
- 页面 VO 和 Excel 行对象字段完全同构
- [ ] **Step 4: 重新运行服务层测试**
Run:
```bash
mvn -pl ccdi-project -am \
-Dsurefire.failIfNoSpecifiedTests=false \
-Dtest=CcdiProjectOverviewServiceAbnormalAccountTest \
test
```
Expected:
- PASS
- [ ] **Step 5: 提交本任务**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectOverviewService.java \
ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImpl.java \
ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceAbnormalAccountTest.java
git commit -m "补充异常账户人员服务映射"
```
## Task 4: 将异常账户真实数据接入统一导出工作簿
**Files:**
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImpl.java`
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectRiskDetailWorkbookExporter.java`
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImplTest.java`
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectRiskDetailWorkbookExporterTest.java`
- [ ] **Step 1: 先改统一导出测试,让它要求第 3 个 sheet 有真实数据**
`CcdiProjectRiskDetailWorkbookExporterTest` 中把原先“只有表头”改成:
```java
CcdiProjectAbnormalAccountExcel abnormalRow = new CcdiProjectAbnormalAccountExcel();
abnormalRow.setAccountNo("6222000000000001");
abnormalRow.setAccountName("李四");
abnormalRow.setBankName("中国农业银行");
abnormalRow.setAbnormalType("突然销户");
abnormalRow.setAbnormalTime("2026-03-20");
abnormalRow.setStatus("已销户");
```
断言:
- sheet 名仍为 `异常账户人员信息`
- 第 1 行写出真实数据
- 列顺序依次为:
- `账号`
- `开户人`
- `银行`
- `异常类型`
- `异常发生时间`
- `状态`
- [ ] **Step 2: 再改服务层统一导出测试**
`CcdiProjectOverviewServiceImplTest.shouldExportRiskDetailsWorkbook` 中增加异常账户 stub
```java
when(overviewMapper.selectAbnormalAccountList(40L)).thenReturn(List.of(abnormalItem));
```
并把 `verify(workbookExporter).export(...)` 扩展为包含第 3 个参数列表断言。
- [ ] **Step 3: 运行导出相关测试确认失败**
Run:
```bash
mvn -pl ccdi-project -am \
-Dsurefire.failIfNoSpecifiedTests=false \
-Dtest=CcdiProjectOverviewServiceImplTest,CcdiProjectRiskDetailWorkbookExporterTest \
test
```
Expected:
- FAIL提示导出器方法签名或第 3 个 sheet 断言不匹配
- [ ] **Step 4: 最小化修改导出器与服务**
1.`CcdiProjectRiskDetailWorkbookExporter.export(...)` 方法签名扩为接收异常账户列表
2. `writeAbnormalAccountSheet(...)` 从“只写表头”改成“表头 + 数据行”
3. `CcdiProjectOverviewServiceImpl.exportRiskDetails(...)` 中查询 `exportAbnormalAccountPeople(projectId)`
4. 调用导出器时一并传入异常账户列表
- [ ] **Step 5: 重新运行导出相关测试**
Run:
```bash
mvn -pl ccdi-project -am \
-Dsurefire.failIfNoSpecifiedTests=false \
-Dtest=CcdiProjectOverviewServiceImplTest,CcdiProjectRiskDetailWorkbookExporterTest \
test
```
Expected:
- PASS
- [ ] **Step 6: 提交本任务**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImpl.java \
ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectRiskDetailWorkbookExporter.java \
ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImplTest.java \
ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectRiskDetailWorkbookExporterTest.java
git commit -m "补充风险明细异常账户统一导出"
```
## Task 5: 记录实施结果并做最终回归
**Files:**
- Modify: `docs/reports/implementation/2026-03-31-project-detail-risk-details-abnormal-account-backend-implementation.md`
- [ ] **Step 1: 运行后端最终回归测试**
Run:
```bash
mvn -pl ccdi-project -am \
-Dsurefire.failIfNoSpecifiedTests=false \
-Dtest=CcdiProjectOverviewControllerContractTest,CcdiProjectOverviewControllerTest,CcdiProjectOverviewMapperSqlTest,CcdiProjectOverviewServiceAbnormalAccountTest,CcdiProjectOverviewServiceImplTest,CcdiProjectRiskDetailWorkbookExporterTest \
test
```
Expected:
- PASS
- [ ] **Step 2: 如需手工联调,启动后端并验证后立即关闭**
Run:
```bash
mvn -pl ruoyi-admin -am package -DskipTests
cd ruoyi-admin/target && java -jar ruoyi-admin.jar
```
至少验证:
1. `GET /ccdi/project/overview/abnormal-account-people` 可返回 `rows/total`
2. `POST /ccdi/project/overview/risk-details/export` 第 3 个 sheet 含真实异常账户数据
验证结束后必须关闭 `java -jar ruoyi-admin.jar` 进程。
- [ ] **Step 3: 编写后端实施记录**
在实施记录中写清:
- 新增接口路径
- 新增 DTO/VO/Excel 对象
- Mapper SQL 口径
- 统一导出第 3 个 sheet 的真实化改动
- 自动化测试命令与结果
- 如有手工联调,记录启动与关闭进程情况
- [ ] **Step 4: 提交本任务**
```bash
git add docs/reports/implementation/2026-03-31-project-detail-risk-details-abnormal-account-backend-implementation.md
git commit -m "记录异常账户人员信息后端实施"
```
## Final Verification
- [ ] `GET /ccdi/project/overview/abnormal-account-people` 返回字段完整:`accountNo/accountName/bankName/abnormalType/abnormalTime/status`
- [ ] 页面查询与导出查询都只取 `ABNORMAL_ACCOUNT` 对象型结果
- [ ] 第 3 个 sheet 不再是空白模板
- [ ] 同一账号命中多条规则时保留多行
- [ ] 如启动了后端进程,验证结束后已手动关闭

View File

@@ -0,0 +1,167 @@
# 异常账户模型接入银行流水打标前端 Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
>
> 仓库约束:当前仓库明确禁止开启 subagent执行时统一使用 `superpowers:executing-plans`。
**Goal:** 基于后端新增异常账户模型完成前端影响面核查,确认本轮无需新增页面、接口或交互改动,并把验证结论沉淀为前端实施计划与实施记录。
**Architecture:** 前端保持零代码改动策略,继续消费现有项目结果总览对象聚合结果,不提前扩展“异常账户人员信息”占位区块。本计划聚焦影响面核查、联调验证和文档沉淀,确保执行时不会误把需求扩展成前端功能改造。
**Tech Stack:** Vue 2, Element UI, RuoYi 前端, 项目详情风险总览现有页面
---
## File Map
**Create:**
- `docs/reports/implementation/2026-03-31-abnormal-account-bank-tag-frontend-implementation.md`
- 记录前端零改动结论、联调范围和验证结果
**Modify:**
- `docs/plans/frontend/2026-03-31-abnormal-account-bank-tag-frontend-implementation-plan.md`
- 当前实施计划文档本身
**No Change Expected:**
- `ruoyi-ui/src/views/ccdiProject/components/detail/RiskModelSection.vue`
- 现有风险模型列表继续展示后端返回的模型与规则
- `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisSidebar.vue`
- 继续展示后端聚合后的核心异常标签
- `ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue`
- 本轮不开发异常账户独立详情链路
- `ruoyi-ui/src/api/ccdi/`
- 本轮不新增 API 封装
## Task 1: 先锁定“前端不改代码”的回归边界
**Files:**
- Create: `docs/reports/implementation/2026-03-31-abnormal-account-bank-tag-frontend-implementation.md`
- [ ] **Step 1: 记录现有页面承接点**
在实施记录中先写明本轮只核查以下页面承接能力:
1. 风险模型列表是否直接消费后端返回的模型/规则
2. 结果总览员工聚合是否直接消费后端对象型结果
3. 风险详情中的“异常账户人员信息”是否仍为占位区域
- [ ] **Step 2: 用代码检索确认前端当前承接方式**
Run:
```bash
rg -n "异常账户人员信息|异常标签|风险模型|hitRules|modelCode" ruoyi-ui/src/views/ccdiProject -S
```
Expected:
- 能定位风险模型、总览和风险详情组件
- 没有现成的异常账户独立查询 API
- [ ] **Step 3: 把零改动边界写入实施记录**
记录结论:
- 前端当前通过既有后端聚合接口承接模型和规则展示
- 本轮不需要新增字段、按钮、弹窗或路由
- [ ] **Step 4: 提交本任务**
```bash
git add docs/reports/implementation/2026-03-31-abnormal-account-bank-tag-frontend-implementation.md
git commit -m "补充异常账户模型前端影响分析"
```
## Task 2: 联调确认现有页面可承接新增模型
**Files:**
- Modify: `docs/reports/implementation/2026-03-31-abnormal-account-bank-tag-frontend-implementation.md`
- [ ] **Step 1: 如需本地验证,启动前端**
Run:
```bash
cd ruoyi-ui
npm run dev
```
Expected:
- 前端正常启动
- [ ] **Step 2: 联调核查 3 个页面点**
至少验证以下内容:
1. 风险模型区域可展示新增模型 `异常账户`
2. 员工结果总览可看到由后端聚合出的新增命中规则
3. 风险详情“异常账户人员信息”区域仍保持原状,不因本轮后端接入报错
- [ ] **Step 3: 记录联调结论**
在实施记录中写明:
- 是否需要前端改代码
- 若无需改动,说明原因是现有页面直接消费后端聚合结果
- 若启动了 `npm run dev`,验证结束后已关闭进程
- [ ] **Step 4: 提交本任务**
```bash
git add docs/reports/implementation/2026-03-31-abnormal-account-bank-tag-frontend-implementation.md
git commit -m "补充异常账户模型前端联调结论"
```
## Task 3: 做前端构建回归并收尾
**Files:**
- Modify: `docs/reports/implementation/2026-03-31-abnormal-account-bank-tag-frontend-implementation.md`
- [ ] **Step 1: 运行前端构建回归**
Run:
```bash
cd ruoyi-ui
npm run build:prod
```
Expected:
- PASS
- [ ] **Step 2: 完善实施记录**
记录:
- 本轮前端零代码改动
- 构建结果
- 联调承接点
- 若启动过 `npm run dev`,已主动关闭进程
- [ ] **Step 3: 最终提交**
```bash
git add docs/reports/implementation/2026-03-31-abnormal-account-bank-tag-frontend-implementation.md
git commit -m "完成异常账户模型前端实施记录"
```
## Final Verification
- [ ] 运行:
```bash
cd ruoyi-ui
npm run build:prod
```
- [ ] 确认本轮前端无源码改动需求
- [ ] 确认风险模型、结果总览、风险详情三处承接点已核查
- [ ] 如启动过 `npm run dev`,验证结束后主动关闭前端进程

View File

@@ -0,0 +1,128 @@
# LSFX Mock Server 异常账户基线同步前端 Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
>
> 仓库约束:当前仓库明确禁止开启 subagent执行时统一使用 `superpowers:executing-plans`。
**Goal:** 在不修改 `ruoyi-ui` 源码的前提下,明确本次 `lsfx-mock-server` 异常账户基线同步对前端的影响边界,沉淀“零代码改动”实施计划与核验记录。
**Architecture:** 本次需求只增强 Mock 服务内部的异常账户事实落库能力,不修改对外银行流水接口协议,也不新增前端入参、页面或调试入口。前端计划采用“影响面检索 + 协议不变确认 + 文档留痕”的最短路径;若核查发现必须适配新字段或新交互,应停止执行并回到设计阶段,而不是在本计划中扩展 UI。
**Tech Stack:** Vue 2, rg, git diff, Markdown docs
---
## File Structure
- `ruoyi-ui/src/api/`: 只用于检索是否存在直接依赖 `lsfx-mock-server` 异常账户内部事实的新接口封装,不预期修改。
- `ruoyi-ui/src/views/ccdiProject/`: 只用于确认现有页面是否直接依赖 Mock 内部账户事实,不预期修改。
- `docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-frontend-implementation.md`: 记录前端零代码改动结论。
- `docs/tests/records/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-frontend-verification.md`: 记录检索命令、查验范围和判断依据。
## Task 1: 核验前端是否需要承接本次 Mock 基线同步
**Files:**
- Reference: `ruoyi-ui/src/api/`
- Reference: `ruoyi-ui/src/views/ccdiProject/`
- Reference: `docs/design/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-design.md`
- [ ] **Step 1: Check existing frontend touchpoints**
先确认本次需求是否触达以下任一前端边界:
- 前端是否直接调用 `lsfx-mock-server` 并依赖异常账户内部事实
- 前端是否需要新增字段才能继续消费 `/watson/api/project/getBSByLogId`
- 前端是否存在专门围绕 Mock 联调的页面、按钮或测试入口需要跟进
若三项都不存在,则本轮前端默认保持零代码改动。
- [ ] **Step 2: Verify with search commands**
Run:
```bash
cd ruoyi-ui
rg -n "lsfx|mock|异常账户|getBSByLogId|bankStatement|account_info" src
```
Expected:
- 不存在必须新增前端适配的直接依赖
- 不应因为 Mock 内部写库增强而顺手增加演示页、调试页或临时开关
- [ ] **Step 3: Confirm contract stability**
对照设计文档确认以下三点全部成立:
- `/watson/api/project/getBSByLogId` 返回结构不变
- 本次只新增 Mock 内部异常账户基线写库,不新增前端入参
- 风险页面仍只消费后端聚合结果,不直接读取 `ccdi_account_info`
若任一点不成立,停止执行并回到设计阶段。
- [ ] **Step 4: Record the no-op boundary**
在后续实施记录中明确写明:
- 本次需求不涉及 `ruoyi-ui` 源码修改
- 前端不会为了“方便联调”新增占位页面、按钮或 mock 参数
- [ ] **Step 5: Commit**
```bash
git add \
docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-frontend-implementation.md \
docs/tests/records/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-frontend-verification.md
git commit -m "记录异常账户基线同步前端零改动结论"
```
## Task 2: 沉淀前端核验记录并确认源码未被误改
**Files:**
- Create: `docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-frontend-implementation.md`
- Create: `docs/tests/records/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-frontend-verification.md`
- [ ] **Step 1: Write implementation record**
`docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-frontend-implementation.md` 中记录:
- 需求主体是 `lsfx-mock-server` 后端基线同步
- 前端不直接消费 Mock 新增的内部写库行为
- 因此本轮不修改 `ruoyi-ui` 源码
- [ ] **Step 2: Write verification record**
`docs/tests/records/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-frontend-verification.md` 中记录:
- 执行过的 `rg` / `git diff` 命令
- 核验目录范围
- “无需前端改动”的判断依据
- [ ] **Step 3: Verify frontend diff scope**
Run:
```bash
git diff --name-only -- ruoyi-ui
```
Expected:
- 无与本次需求相关的新前端改动
- 若存在既有无关改动,只记录“本计划未新增前端源码变更”,不顺手处理他人改动
- [ ] **Step 4: Confirm no frontend build is required**
在验证记录中明确写明:
- 因为 `ruoyi-ui` 无本次需求相关源码改动,本次不执行 `npm run build:prod`
- 若后续出现真实前端接入需求,再单独补构建与联调验证
- [ ] **Step 5: Commit**
```bash
git add \
docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-frontend-implementation.md \
docs/tests/records/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-frontend-verification.md
git commit -m "补充异常账户基线同步前端核验记录"
```

View File

@@ -0,0 +1,124 @@
# LSFX Mock Server 异常账户命中流水前端 Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
>
> 仓库约束:当前仓库明确禁止开启 subagent执行时统一使用 `superpowers:executing-plans`。
**Goal:** 在不新增 `ruoyi-ui` 页面、接口或交互的前提下,明确本次 `lsfx-mock-server` 异常账户命中流水建设对前端的影响边界,并沉淀零代码改动结论与核验记录。
**Architecture:** 本次需求主体是 `lsfx-mock-server` 内部造数能力增强,不修改主系统前端消费协议。前端计划采用“零源码改动 + 承接边界核查 + 文档沉淀”的最短路径;若核查发现必须新增前端字段或联调适配,应停止执行并回到设计阶段,而不是在本计划中临时扩展 UI。
**Tech Stack:** Vue 2, rg, Markdown docs
---
## File Structure
- `ruoyi-ui/src/api/`: 只用于检索是否存在依赖 Mock 异常账户造数的新接口封装,不预期修改。
- `ruoyi-ui/src/views/ccdiProject/`: 只用于确认现有风险页面是否直接消费后端聚合结果,不预期修改。
- `docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-frontend-implementation.md`: 记录本次前端零代码改动的结论。
- `docs/tests/records/2026-03-31-lsfx-mock-server-abnormal-account-frontend-verification.md`: 记录核验命令、目录范围和判断依据。
## Task 1: 核验当前前端无需新增异常账户 Mock 适配代码
**Files:**
- Reference: `ruoyi-ui/src/api/`
- Reference: `ruoyi-ui/src/views/ccdiProject/`
- Reference: `docs/design/2026-03-31-lsfx-mock-server-abnormal-account-design.md`
- [ ] **Step 1: Check the existing frontend touchpoints**
确认当前前端是否存在以下任一需要同步改造的触点:
- 直接调用 `lsfx-mock-server` 的页面或接口封装
- 依赖异常账户 Mock 返回新字段的前端展示逻辑
- 需要为 Mock 联调新增独立上传页、测试页或调试按钮的场景
若不存在,则本轮前端默认保持零代码改动。
- [ ] **Step 2: Verify with search commands**
Run:
```bash
cd ruoyi-ui
rg -n "lsfx|mock|异常账户|getBSByLogId|bankStatement" src
```
Expected:
- 若仅命中既有业务页面或无直接 Mock 依赖,说明本轮无需新增前端代码
- 不应因为 Mock 服务增强而顺手新增演示页或调试页
- [ ] **Step 3: Confirm no contract adaptation is needed**
对照设计文档确认:
- Mock 返回的银行流水接口结构没有变化
- 本次仅增强内部造数,不新增前端必填参数
- 风险页面继续消费后端聚合结果,不直接依赖 Mock 内部账户事实
若上述任一不成立,则停止执行并回到设计阶段。
- [ ] **Step 4: Record the no-op conclusion**
在后续实施记录中明确写明:
- 本次需求不涉及 `ruoyi-ui` 源码改动
- 不为了“方便联调”临时增加前端占位页面或按钮
- [ ] **Step 5: Commit**
```bash
git add docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-frontend-implementation.md docs/tests/records/2026-03-31-lsfx-mock-server-abnormal-account-frontend-verification.md
git commit -m "记录异常账户Mock前端零改动结论"
```
## Task 2: 沉淀前端核验记录并确保源码无变更
**Files:**
- Create: `docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-frontend-implementation.md`
- Create: `docs/tests/records/2026-03-31-lsfx-mock-server-abnormal-account-frontend-verification.md`
- [ ] **Step 1: Write implementation record**
`docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-frontend-implementation.md` 中记录:
- 需求主体是 `lsfx-mock-server` 后端造数
- 前端当前不直接消费 Mock 内部新增的异常账户事实
- 因此本轮不修改 `ruoyi-ui` 任何源码
- [ ] **Step 2: Write verification record**
`docs/tests/records/2026-03-31-lsfx-mock-server-abnormal-account-frontend-verification.md` 中记录:
- 执行过的 `rg` 命令
- 查验目录范围
- “无需前端改动”的判断依据
- [ ] **Step 3: Verify frontend diff stays empty**
Run:
```bash
git diff --name-only -- ruoyi-ui
```
Expected:
- 无输出
- 证明本次前端计划执行保持零源码改动
- [ ] **Step 4: Confirm no frontend build is required**
在验证记录中明确写明:
- 因为 `ruoyi-ui` 无源码改动,本次不执行 `npm run build:prod`
- 若后续新增真实前端接入点,再补构建与联调验证
- [ ] **Step 5: Commit**
```bash
git add docs/reports/implementation/2026-03-31-lsfx-mock-server-abnormal-account-frontend-implementation.md docs/tests/records/2026-03-31-lsfx-mock-server-abnormal-account-frontend-verification.md
git commit -m "补充异常账户Mock前端核验记录"
```

View File

@@ -0,0 +1,350 @@
# 项目详情风险明细异常账户人员信息前端 Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
>
> 仓库约束:当前仓库明确禁止开启 subagent执行时统一使用 `superpowers:executing-plans`。
**Goal:**`RiskDetailSection.vue` 中的“异常账户人员信息”从占位表格改为真实接口驱动的列表,并与统一导出的 6 个字段保持一致。
**Architecture:** 前端保持最小改动,继续复用 `RiskDetailSection.vue` 内现有的独立分区加载模式,为异常账户区块补一套与员工负面征信相同风格的 `loading/pageNum/pageSize/total/list` 状态。数据请求通过 `@/api/ccdi/projectOverview` 新增一个轻量 GET 方法,不改 `PreliminaryCheck.vue` 的页面编排逻辑。
**Tech Stack:** Vue 2, Element UI, RuoYi request 封装, Node 静态单测, Vue CLI 构建
---
## File Map
**Create:**
- `ruoyi-ui/tests/unit/risk-detail-abnormal-account-layout.test.js`
- 覆盖异常账户区块的真实列结构和空态文案
- `ruoyi-ui/tests/unit/risk-detail-abnormal-account-pagination.test.js`
- 覆盖异常账户独立分页状态与加载方法
**Modify:**
- `ruoyi-ui/src/api/ccdi/projectOverview.js`
- 新增异常账户分页查询 API 封装
- `ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue`
- 接入真实异常账户列表、独立分页、独立 loading、移除旧占位操作列
- `ruoyi-ui/src/views/ccdiProject/components/detail/preliminaryCheck.mock.js`
- 对齐 mock 中异常账户字段名,避免静态预览口径落后于真实页面
- `ruoyi-ui/tests/unit/preliminary-check-model-and-detail.test.js`
- 更新旧断言,移除对“查看详情”的占位依赖
- `docs/reports/implementation/2026-03-31-project-detail-risk-details-abnormal-account-frontend-implementation.md`
- 记录前端实施与验证结果
**No Change Expected:**
- `ruoyi-ui/src/views/ccdiProject/components/detail/PreliminaryCheck.vue`
- 仍只负责把 `projectId` 传给 `RiskDetailSection`
## Task 1: 锁定异常账户区块的静态结构与测试期望
**Files:**
- Create: `ruoyi-ui/tests/unit/risk-detail-abnormal-account-layout.test.js`
- Modify: `ruoyi-ui/tests/unit/preliminary-check-model-and-detail.test.js`
- [ ] **Step 1: 先写新的静态布局测试**
创建 `risk-detail-abnormal-account-layout.test.js`,直接读取 `RiskDetailSection.vue` 源码并断言以下 token
```js
[
"异常账户人员信息",
"账号",
"开户人",
"银行",
"异常类型",
"异常发生时间",
"状态",
"当前项目暂无异常账户人员信息",
]
```
- [ ] **Step 2: 更新旧测试,先让它失败**
`preliminary-check-model-and-detail.test.js` 中把这一段:
```js
["风险明细", "涉疑交易明细", "异常账户人员信息", "查看详情"]
```
改成断言新的异常账户列名,不再要求旧占位的“查看详情”。
- [ ] **Step 3: 运行静态单测确认失败**
Run:
```bash
cd ruoyi-ui
node tests/unit/risk-detail-abnormal-account-layout.test.js
node tests/unit/preliminary-check-model-and-detail.test.js
```
Expected:
- FAIL提示 `RiskDetailSection.vue` 仍包含旧列结构或缺少新字段
- [ ] **Step 4: 提交本任务**
本任务先不提交,等待真实模板改完后与 Task 2 一起提交。
## Task 2: 接入异常账户真实 API 与独立分页状态
**Files:**
- Modify: `ruoyi-ui/src/api/ccdi/projectOverview.js`
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue`
- Create: `ruoyi-ui/tests/unit/risk-detail-abnormal-account-pagination.test.js`
- [ ] **Step 1: 先写分页/加载相关静态测试**
创建 `risk-detail-abnormal-account-pagination.test.js`,断言源码已包含:
- `getOverviewAbnormalAccountPeople`
- `abnormalAccountLoading`
- `abnormalAccountPageNum`
- `abnormalAccountPageSize`
- `abnormalAccountTotal`
- `abnormalAccountList`
- `handleAbnormalAccountPageChange`
- `loadAbnormalAccountPeople`
- [ ] **Step 2: 跑静态测试确认失败**
Run:
```bash
cd ruoyi-ui
node tests/unit/risk-detail-abnormal-account-pagination.test.js
```
Expected:
- FAIL提示 API 方法或分页状态尚未接入
- [ ] **Step 3: 在 API 文件中增加轻量请求封装**
`projectOverview.js` 中新增:
```js
export function getOverviewAbnormalAccountPeople(params) {
return request({
url: "/ccdi/project/overview/abnormal-account-people",
method: "get",
params: {
projectId: params.projectId,
pageNum: params.pageNum,
pageSize: params.pageSize
}
})
}
```
- [ ] **Step 4: 在 `RiskDetailSection.vue` 中补齐独立状态与加载函数**
按员工负面征信区块的模式补充:
1. `data()` 中新增:
- `abnormalAccountLoading`
- `abnormalAccountPageNum`
- `abnormalAccountPageSize`
- `abnormalAccountTotal`
- `abnormalAccountList`
2. `watch.sectionData` 中初始化:
- `projectId`
- 默认页码 1
- 页大小 5
- 初始列表为空
3. 新增:
- `loadAbnormalAccountPeople()`
- `handleAbnormalAccountPageChange(pageInfo)`
加载规则:
-`projectId` 时直接清空列表并结束
- 请求失败时仅清空异常账户区块并提示 `加载异常账户人员信息失败`
- 不影响涉疑交易和员工负面征信区块
- [ ] **Step 5: 重新运行静态测试**
Run:
```bash
cd ruoyi-ui
node tests/unit/risk-detail-abnormal-account-pagination.test.js
```
Expected:
- PASS
- [ ] **Step 6: 提交本任务**
```bash
git add ruoyi-ui/src/api/ccdi/projectOverview.js \
ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue \
ruoyi-ui/tests/unit/risk-detail-abnormal-account-pagination.test.js
git commit -m "补充异常账户人员前端查询状态"
```
## Task 3: 把异常账户表格从占位列改成真实 6 列
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue`
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/preliminaryCheck.mock.js`
- Create: `ruoyi-ui/tests/unit/risk-detail-abnormal-account-layout.test.js`
- Modify: `ruoyi-ui/tests/unit/preliminary-check-model-and-detail.test.js`
- [ ] **Step 1: 修改异常账户表格模板**
把旧占位表格:
- `账户号`
- `账户人姓名`
- `开户银行`
- `异常发生时间`
- `状态`
- `操作`
改为真实 6 列:
- `账号`
- `开户人`
- `银行`
- `异常类型`
- `异常发生时间`
- `状态`
并删除“操作 / 查看详情”列。
- [ ] **Step 2: 补独立空态与分页组件**
将异常账户区块从纯 `el-table` 升级为与员工负面征信一致的结构:
- `v-loading="abnormalAccountLoading"`
- `:data="abnormalAccountList"`
- `<el-empty description="当前项目暂无异常账户人员信息" />`
- 独立 `pagination`
- [ ] **Step 3: 对齐 mock 字段命名**
`preliminaryCheck.mock.js` 中把异常账户 mock 数据对齐成:
- `accountNo`
- `accountName`
- `bankName`
- `abnormalType`
- `abnormalTime`
- `status`
说明:
- 这里不要求 mock 走真实接口
- 只是避免静态数据结构继续停留在旧字段名
- [ ] **Step 4: 运行静态布局测试**
Run:
```bash
cd ruoyi-ui
node tests/unit/risk-detail-abnormal-account-layout.test.js
node tests/unit/preliminary-check-model-and-detail.test.js
```
Expected:
- PASS
- [ ] **Step 5: 提交本任务**
```bash
git add ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue \
ruoyi-ui/src/views/ccdiProject/components/detail/preliminaryCheck.mock.js \
ruoyi-ui/tests/unit/risk-detail-abnormal-account-layout.test.js \
ruoyi-ui/tests/unit/preliminary-check-model-and-detail.test.js
git commit -m "调整异常账户人员信息前端展示列"
```
## Task 4: 做前端联调与构建回归
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue`
- Modify: `docs/reports/implementation/2026-03-31-project-detail-risk-details-abnormal-account-frontend-implementation.md`
- [ ] **Step 1: 本地执行静态单测**
Run:
```bash
cd ruoyi-ui
node tests/unit/risk-detail-abnormal-account-layout.test.js
node tests/unit/risk-detail-abnormal-account-pagination.test.js
node tests/unit/preliminary-check-model-and-detail.test.js
node tests/unit/risk-detail-employee-credit-negative-layout.test.js
```
Expected:
- PASS
- [ ] **Step 2: 跑前端生产构建**
Run:
```bash
cd ruoyi-ui
npm run build:prod
```
Expected:
- PASS
- [ ] **Step 3: 如需手工联调,启动前端并在验证后关闭**
Run:
```bash
cd ruoyi-ui
npm run dev
```
至少验证:
1. `异常账户人员信息` 区块只显示 6 个目标字段
2. 翻页只刷新异常账户区块
3. 统一导出按钮仍请求 `ccdi/project/overview/risk-details/export`
4. 异常账户查询失败时只影响当前区块
验证结束后必须关闭 `npm run dev` 进程。
- [ ] **Step 4: 编写前端实施记录**
在实施记录中写清:
- 新增 API 方法
- `RiskDetailSection.vue` 的状态与模板调整
- mock 字段对齐
- 静态单测与构建结果
- 如有手工联调,记录启动与关闭前端进程情况
- [ ] **Step 5: 提交本任务**
```bash
git add docs/reports/implementation/2026-03-31-project-detail-risk-details-abnormal-account-frontend-implementation.md
git commit -m "记录异常账户人员信息前端实施"
```
## Final Verification
- [ ] 页面与导出统一使用 `账号 / 开户人 / 银行 / 异常类型 / 异常发生时间 / 状态`
- [ ] 页面已移除旧占位“查看详情”列
- [ ] 异常账户分页状态与员工负面征信分页状态互不干扰
- [ ] 构建命令 `npm run build:prod` 通过
- [ ] 如启动了前端进程,验证结束后已手动关闭

View File

@@ -0,0 +1,175 @@
# 异常账户模型接入银行流水打标后端实施记录
**日期**: 2026-03-31
**类型**: 后端实施记录
**范围**: 银行流水打标 - 异常账户模型
## 1. 已完成实施内容
### 1.1 规则与元数据
- 新增异常账户模型迁移脚本:`sql/migration/2026-03-31-create-ccdi-account-info-and-abnormal-account-rules.sql`
- 新增模型编码:`ABNORMAL_ACCOUNT`
- 新增规则编码:
- `SUDDEN_ACCOUNT_CLOSURE`
- `DORMANT_ACCOUNT_LARGE_ACTIVATION`
- 两条规则均按 `OBJECT` 结果写入现有结果表 `ccdi_bank_statement_tag_result`
### 1.2 服务与 SQL
- `CcdiBankTagServiceImpl` 已补充两条对象型规则分发
- `CcdiBankTagAnalysisMapper` 已补充两条 Mapper 方法签名
- `CcdiBankTagAnalysisMapper.xml` 已补充:
- `selectSuddenAccountClosureObjects`
- `selectDormantAccountLargeActivationObjects`
### 1.3 自动化测试
- 已新增 SQL 元数据测试:
- `CcdiAbnormalAccountRuleSqlMetadataTest`
- 已补充服务分发与对象结果断言:
- `CcdiBankTagServiceImplTest`
- 已补充员工聚合承接断言:
- `CcdiProjectOverviewEmployeeResultBuilderTest`
## 2. 测试数据准备
### 2.1 样本设计
- 员工 A命中 `SUDDEN_ACCOUNT_CLOSURE`
- 员工 B命中 `DORMANT_ACCOUNT_LARGE_ACTIVATION`
- 员工 C休眠不足 6 个月,不命中
- 员工 D销户前 30 天无流水,不命中
### 2.2 导入脚本
```bash
bin/mysql_utf8_exec.sh sql/migration/2026-03-31-create-ccdi-account-info-and-abnormal-account-rules.sql
bin/mysql_utf8_exec.sh sql/migration/2026-03-31-add-abnormal-account-rule-test-data.sql
```
### 2.3 导入结果
- 已使用 `bin/mysql_utf8_exec.sh` 成功执行两份 SQL 脚本
- 远端业务库已写入:
- 项目:`90331 / 异常账户规则测试项目`
- 员工A、B、C、D 四个最小样本
- 账户4 个员工本人账户
- 项目流水7 笔
## 3. 过程说明
- 本轮实现保持最短路径,未新增平行结果表或独立查询链路
- 异常账户结果仍复用既有项目打标主链路与员工风险聚合
- 为保证 `ccdi-project` 模块测试可执行,补充了缺失的 `easyexcel` 依赖声明
- `mvn` 定向测试统一使用 `-am`,确保 `ccdi-lsfx` 依赖以当前源码参与 reactor 构建,避免使用陈旧本地产物
## 4. SQL 校验结果
### 4.1 环境说明
- 项目导入脚本读取的数据库配置为:`jdbc:mysql://116.62.17.81:3307/ccdi`
- 当前 MySQL MCP 会话实际连接为:`ccdi@ca446c6169d2:3306`
- 由于 MySQL MCP 与项目配置数据库不是同一实例,直接在 MCP 中查询不到刚导入的样本数据
- 因此本次“真实 SQL 命中校验”实际使用项目配置对应库的只读 `mysql` 查询执行 Mapper 等价 SQLMySQL MCP 仅用于确认环境差异,而未直接承载最终命中校验
### 4.2 `SUDDEN_ACCOUNT_CLOSURE`
- 执行 SQL 摘要:
- 关联 `ccdi_account_info``ccdi_base_staff` 与项目内 `ccdi_bank_statement`
- 过滤 `owner_type = 'EMPLOYEE'``status = 2``invalid_date is not null`
- 统计窗口为 `[invalid_date - 30天, invalid_date)`
- 命中结果:
- 员工 A `330101199001010001`
- `reasonDetail` 快照:
- `账户6222000000000001于2026-03-20销户销户前30天内最后交易日2026-03-18累计交易金额180000.00元单笔最大金额70000.00元`
- 反样本校验:
- 员工 D `330101199001010004` 命中数为 `0`
### 4.3 `DORMANT_ACCOUNT_LARGE_ACTIVATION`
- 执行 SQL 摘要:
- 关联 `ccdi_account_info``ccdi_base_staff` 与项目内 `ccdi_bank_statement`
- 过滤 `owner_type = 'EMPLOYEE'``status = 1``effective_date is not null`
- 要求 `first_tx_date >= effective_date + 6个月`
- 要求 `windowTotalAmount >= 500000``windowMaxSingleAmount >= 100000`
- 命中结果:
- 员工 B `330101199001010002`
- `reasonDetail` 快照:
- `账户6222000000000002开户于2025-01-01首次交易日期2025-08-01沉睡时长7个月启用后累计交易金额550000.00元单笔最大金额300000.00元`
- 反样本校验:
- 员工 C `330101199001010003` 命中数为 `0`
### 4.4 口径结论
- 两条规则均只命中预期正样本:
- `SUDDEN_ACCOUNT_CLOSURE` 仅命中员工 A
- `DORMANT_ACCOUNT_LARGE_ACTIVATION` 仅命中员工 B
- 反样本满足预期:
- 员工 C 因沉睡期不足 6 个月未命中
- 员工 D 因销户前 30 天无流水未命中
## 5. 最终验证汇总
### 5.1 Java 定向测试
- 执行命令:
```bash
mvn -pl ccdi-project -am -Dsurefire.failIfNoSpecifiedTests=false \
-Dtest=CcdiAbnormalAccountRuleSqlMetadataTest,CcdiBankTagRuleSqlMetadataTest,CcdiBankTagServiceImplTest,CcdiProjectOverviewEmployeeResultBuilderTest \
test
```
- 执行结果:
- `Tests run: 25, Failures: 0, Errors: 0, Skipped: 0`
- `BUILD SUCCESS`
### 5.2 端到端链路验证
- 已重新执行后端打包:
```bash
mvn -pl ruoyi-admin -am package -DskipTests
```
- 已启动 `ruoyi-admin/target/ruoyi-admin.jar`,并使用测试登录接口获取 token
- 已调用手工重建接口:
```http
POST /ccdi/project/tags/rebuild
{
"projectId": 90331,
"modelCode": "ABNORMAL_ACCOUNT"
}
```
- 后端执行日志确认:
- 异常账户模型规则数为 `2`
- 实际命中数为 `2`
- 员工风险聚合已刷新
- 结果表校验确认:
- `SUDDEN_ACCOUNT_CLOSURE` 写入员工 `330101199001010001`
- `DORMANT_ACCOUNT_LARGE_ACTIVATION` 写入员工 `330101199001010002`
- 员工总览聚合表校验确认:
- 员工 A 聚合命中 `ABNORMAL_ACCOUNT / SUDDEN_ACCOUNT_CLOSURE`
- 员工 B 聚合命中 `ABNORMAL_ACCOUNT / DORMANT_ACCOUNT_LARGE_ACTIVATION`
### 5.3 进程关闭
- 端到端验证完成后,已主动关闭本轮启动的后端 `java -jar ruoyi-admin.jar` 进程
- 关闭日志可见 Quartz 调度器与 Druid 数据源正常释放,无残留后端进程
## 6. 最终改动文件清单
- `ccdi-project/pom.xml`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapper.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImpl.java`
- `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml`
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiAbnormalAccountRuleSqlMetadataTest.java`
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiBankTagRuleSqlMetadataTest.java`
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImplTest.java`
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewEmployeeResultBuilderTest.java`
- `sql/migration/2026-03-31-create-ccdi-account-info-and-abnormal-account-rules.sql`
- `sql/migration/2026-03-31-add-abnormal-account-rule-test-data.sql`
- `docs/reports/implementation/2026-03-31-abnormal-account-bank-tag-backend-implementation.md`

View File

@@ -0,0 +1,38 @@
# 异常账户模型接入银行流水打标设计记录
**日期**: 2026-03-31
**类型**: 设计记录
**范围**: 银行流水打标 - 异常账户模型
## 1. 本次变更内容
新增正式设计文档:
- `docs/design/2026-03-31-abnormal-account-bank-tag-design.md`
设计结论如下:
- 新增独立模型 `ABNORMAL_ACCOUNT`
- 新增两条对象型规则:
- `SUDDEN_ACCOUNT_CLOSURE`
- `DORMANT_ACCOUNT_LARGE_ACTIVATION`
- 新增账户信息表 `ccdi_account_info`
- 规则结果继续落到 `ccdi_bank_statement_tag_result`
- 通过测试数据 SQL、Java 自动化测试和 MySQL MCP 真实 SQL 验证共同确认命中口径
## 2. 设计约束
- 不开发异常账户独立查询、分页或详情链路
- 不改前端展示逻辑
- 不扩展到关系人或外部账户
- 不增加动态规则引擎或兼容性补丁方案
- 不改造 `lsfx-mock-server`
## 3. 后续文档规划
待用户确认设计文档后,继续补充:
- 后端实施计划
- 前端实施计划
- 后端实施记录
- 前端实施记录

View File

@@ -0,0 +1,72 @@
# 异常账户模型接入银行流水打标前端实施记录
**日期**: 2026-03-31
**类型**: 前端实施记录
**范围**: 银行流水打标 - 异常账户模型
## 1. 前端承接点核查
### 1.1 核查命令
```bash
rg -n "异常账户人员信息|异常标签|风险模型|hitRules|modelCode" ruoyi-ui/src/views/ccdiProject -S
```
### 1.2 核查结论
- 风险模型区域由 `RiskModelSection.vue` 直接消费后端返回的 `cardList` 与命中标签列表
- 风险总览人员区域由 `PreliminaryCheck.vue` 统一加载项目总览接口,再传递给 `RiskPeopleSection.vue`
- 风险详情中的“异常账户人员信息”区域仍由 `RiskDetailSection.vue` 渲染 `sectionData.abnormalAccountList || []`
- `createOverviewLoadedData` 当前固定把 `abnormalAccountList` 置为空数组,说明本轮前端仍处于占位承接状态
## 2. 零代码改动边界
- 本轮前端不新增页面、按钮、弹窗、路由或独立 API 封装
- 前端当前已具备通用模型卡片展示和对象型命中标签展示能力
- 异常账户模型接入后,只要后端项目总览接口返回新增模型与规则,现有页面即可承接
- “异常账户人员信息”区域本轮仍保持占位,不提前扩展详情链路
## 3. 接口联调验证
### 3.1 风险模型区域
- 调用接口:`GET /ccdi/project/overview/risk-models/cards?projectId=90331`
- 验证结果:
- 返回模型 `ABNORMAL_ACCOUNT`
- `modelName = 异常账户`
- `warningCount = 2`
- `peopleCount = 2`
### 3.2 风险总览人员区域
- 调用接口:`GET /ccdi/project/overview/risk-people?projectId=90331&pageNum=1&pageSize=10`
- 验证结果:
- 员工 A `330101199001010001` 返回命中标签 `SUDDEN_ACCOUNT_CLOSURE / 突然销户`
- 员工 B `330101199001010002` 返回命中标签 `DORMANT_ACCOUNT_LARGE_ACTIVATION / 休眠账户大额启用`
- 说明现有总览人员列表已能直接展示异常账户模型命中规则
### 3.3 风险详情占位区域
- `RiskDetailSection.vue` 仍以 `sectionData.abnormalAccountList || []` 渲染“异常账户人员信息”
- `preliminaryCheck.mock.js``createOverviewLoadedData` 仍固定返回 `abnormalAccountList: []`
- 本轮接口联调未发现该占位区域因异常账户模型接入而报错
## 4. 构建回归结果
- 执行命令:
```bash
cd ruoyi-ui
npm run build:prod
```
- 执行结果:
- 构建成功,`Build complete`
- 仅存在仓库既有的前端产物体积告警,无新增编译错误
## 5. 实施结论
- 本轮前端保持零代码改动
- 无需新增前端接口或页面,原因是现有页面已直接消费后端聚合结果
- 本轮未启动 `npm run dev`,因此不存在需额外关闭的前端本地进程
- 前端实施产出仅新增本实施记录文档,用于沉淀影响面核查、接口联调和构建验证结论

View File

@@ -0,0 +1,22 @@
# 异常账户模型接入银行流水打标计划记录
**日期**: 2026-03-31
**类型**: 计划记录
**范围**: 银行流水打标 - 异常账户模型
## 1. 本次变更内容
基于设计文档 [2026-03-31-abnormal-account-bank-tag-design.md](/Users/wkc/Desktop/ccdi/ccdi/docs/design/2026-03-31-abnormal-account-bank-tag-design.md),新增两份实施计划文档:
- [2026-03-31-abnormal-account-bank-tag-backend-implementation-plan.md](/Users/wkc/Desktop/ccdi/ccdi/docs/plans/backend/2026-03-31-abnormal-account-bank-tag-backend-implementation-plan.md)
- [2026-03-31-abnormal-account-bank-tag-frontend-implementation-plan.md](/Users/wkc/Desktop/ccdi/ccdi/docs/plans/frontend/2026-03-31-abnormal-account-bank-tag-frontend-implementation-plan.md)
## 2. 计划结论
- 后端按最小闭环接入现有对象型打标主链路
- 前端本轮默认零代码改动,仅做承接能力核查与实施记录
- 测试阶段除 Java 自动化测试外,必须使用 MySQL MCP 执行真实 SQL 校验规则口径
## 3. 后续动作
待用户确认计划文档后,按后端计划和前端计划分别执行实施与验证。

View File

@@ -0,0 +1,23 @@
# LSFX Mock Server 默认数据库地址调整实施记录
**日期**: 2026-03-31
**范围**: `lsfx-mock-server` 配置
## 1. 调整内容
-`lsfx-mock-server/config/settings.py` 中显式固定默认数据库地址:
- `CCDI_DB_HOST = 116.62.17.81`
- `CCDI_DB_PORT = 3307`
- 保持数据库名、用户名、密码继续沿用主工程 `application-dev.yml` 中的默认值读取逻辑
## 2. 调整原因
此前 `lsfx-mock-server` 的数据库 host/port 默认值隐式跟随 `ruoyi-admin` 的开发配置。虽然当前主工程配置本身也是 `116.62.17.81:3307`,但这种依赖关系不够直接。
本次改动后,`lsfx-mock-server` 会在自身配置层明确默认连接到 `116.62.17.81:3307`,避免后续主工程开发配置变化时影响 Mock 服务默认库选择。
## 3. 验证范围
- `lsfx-mock-server/tests/test_settings_sync.py`
- 校验默认 host/port 固定为 `116.62.17.81:3307`
- 校验数据库名、用户名、密码仍沿用主工程开发配置默认值

View File

@@ -0,0 +1,85 @@
# LSFX Mock Server 异常账户后端实施记录
## 1. 实施范围
本次改动仅覆盖 `lsfx-mock-server` 后端 Mock 造数主链路,目标是在不新增接口的前提下,为异常账户规则补齐稳定命中能力。
涉及规则:
- `SUDDEN_ACCOUNT_CLOSURE`
- `DORMANT_ACCOUNT_LARGE_ACTIVATION`
## 2. 主要改动
### 2.1 FileRecord 新增异常账户计划与事实
`lsfx-mock-server/services/file_service.py` 中扩展了 `FileRecord`
- 新增 `abnormal_account_hit_rules`
- 新增 `abnormal_accounts`
同时把异常账户规则池并入现有规则命中计划生成逻辑:
- `subset` 模式下按 `logId` 稳定随机命中异常账户规则
- `all` 模式下自动纳入全部异常账户规则
- 在上传链路与 `fetch_inner_flow(...)` 中同步生成最小异常账户事实
最小账户事实字段包括:
- `account_no`
- `owner_id_card`
- `account_name`
- `status`
- `effective_date`
- `invalid_date`
### 2.2 新增两类异常账户样本生成器
`lsfx-mock-server/services/statement_rule_samples.py` 中新增:
- `build_sudden_account_closure_samples(...)`
- `build_dormant_account_large_activation_samples(...)`
口径落实如下:
- `SUDDEN_ACCOUNT_CLOSURE` 的样本流水全部落在销户日前 30 天窗口内
- `DORMANT_ACCOUNT_LARGE_ACTIVATION` 的首笔流水晚于开户满 6 个月
- 休眠激活样本同时满足累计金额阈值与单笔最大金额阈值
### 2.3 接入现有种子流水主链路
未新增平行入口,直接复用现有:
- `FileService -> FileRecord`
- `StatementService._generate_statements(...)`
- `build_seed_statements_for_rule_plan(...)`
接入方式:
- 在统一种子流水构造入口增加 `abnormal_account_hit_rules` 分支
- 根据 `abnormal_accounts` 为每条异常账户规则选择匹配账户事实
- 生成的异常账户样本继续与既有规则样本一起补噪声、编号、打乱和分页
## 3. 测试补充
新增并通过的关键测试包括:
- `test_fetch_inner_flow_should_attach_abnormal_account_rule_plan`
- `test_sudden_account_closure_samples_should_stay_within_30_days_before_invalid_date`
- `test_dormant_account_large_activation_samples_should_exceed_threshold_after_6_months`
- `test_generate_statements_should_follow_abnormal_account_rule_plan_from_file_record`
## 4. 联动修正
`all` 模式安全噪声测试中,原有用例只清空了旧规则维度,未同步清空新增的 `abnormal_account_hit_rules`。本次已将该测试夹具补齐,保证它继续只验证“月固定收入 + 安全噪声”的原始语义。
在合并到 `dev` 后的运行态验证中,又发现 `getBSByLogId` 返回前统一回填主绑定时,会把异常账户样本原本正确的 `accountMaskNo` 覆盖成主账号,导致 HTTP 实际返回数据无法体现异常账户事实。对此补充了以下修正:
- 新增回归用例 `test_get_bank_statement_should_preserve_abnormal_account_mask_no`
-`StatementService._apply_primary_binding(...)` 调整为只兜底缺失账号,不覆盖已有的异常账户样本账号
修正后,接口返回中的异常账户流水可以保留各自独立的账号,与异常账户事实保持一致。
## 5. 结果
异常账户命中计划、最小账户事实、样本生成器和服务层主链路均已落地,现有 Mock 服务可以为同一个 `logId` 稳定提供异常账户命中流水样本。

View File

@@ -0,0 +1,41 @@
# LSFX Mock Server 异常账户基线审计字段纠正实施记录
**日期**: 2026-03-31
**范围**: `lsfx-mock-server` 异常账户基线同步链路
## 1. 问题说明
在前一轮排查中,基于 MCP 表结构结果将 `ccdi_account_info` 的审计列误判为 `created_by``updated_by`,并据此调整了异常账户基线 upsert SQL。
随后使用 `mysql` 直连 `116.62.17.81:3307/ccdi` 执行:
- `SHOW COLUMNS FROM ccdi_account_info LIKE 'create_by';`
- `SHOW COLUMNS FROM ccdi_account_info LIKE 'update_by';`
- `SHOW COLUMNS FROM ccdi_account_info;`
确认真实表结构使用的是 `create_by``update_by`
## 2. 本次纠正内容
- 修正 `lsfx-mock-server/services/abnormal_account_baseline_service.py`
- upsert 字段改回 `create_by``update_by`
- 更新分支改回 `update_by = VALUES(update_by)`
- 修正 `sql/migration/2026-03-31-create-ccdi-account-info-and-abnormal-account-rules.sql`
- `ccdi_account_info` 建表字段改回 `create_by``update_by`
- 规则初始化 SQL 的审计字段改回 `create_by` / `update_by`
- 修正 `sql/migration/2026-03-31-add-abnormal-account-rule-test-data.sql`
- `ccdi_account_info` 测试数据插入字段改回 `create_by``update_by`
- 修正 `docs/design/2026-03-31-lsfx-mock-server-abnormal-account-baseline-sync-design.md`
- 将设计文档中的账户表审计字段名改回真实库定义
## 3. 测试调整
- 更新 `lsfx-mock-server/tests/test_abnormal_account_baseline_service.py`
- 锁定 insert SQL 必须包含 `create_by``update_by`
- 锁定 upsert update 分支必须写 `update_by = VALUES(update_by)`
## 4. 结果
- 异常账户基线同步 SQL 已与 `116.62.17.81:3307/ccdi` 的真实表结构重新对齐
- 运行时不会再向不存在的 `created_by``updated_by` 字段写值
- 服务代码、migration、测试数据脚本与设计文档已恢复一致

View File

@@ -0,0 +1,54 @@
# LSFX Mock Server 异常账户基线同步后端实施记录
**日期**: 2026-03-31
**范围**: `lsfx-mock-server` 异常账户基线同步后端
## 1. 本次实施内容
本次后端完成以下改动:
- 新增 `AbnormalAccountBaselineService`
- 复用 `settings.CCDI_DB_*` 连接真实数据库
-`account_no` 为唯一键向 `ccdi_account_info` 执行幂等 upsert
- 固定写入最小命中字段:`DEBIT``EMPLOYEE``兰溪农商银行``LXNCSY``CNY``HIGH`
- 调整 `FileService`
- 新增 `abnormal_account_baseline_service` 注入点
-`fetch_inner_flow(...)` 和上传建档链路中,先同步异常账户基线,再写入 `file_records`
- 当存在异常账户命中计划但未生成 `abnormal_accounts` 时直接抛错
- 锁定 `StatementService` 链路一致性
- 继续保持只读 `FileRecord` 生成异常账户样本流水
- 通过新增测试确认不会用主账号覆盖异常账户样本自身的 `accountMaskNo`
## 2. 关键实现语义
- 基线同步触发点固定在建 `logId` 阶段,不放到 `getBSByLogId`
- 异常账户事实为空时直接跳过,不做无意义写库
- 任一 `owner_id_card` 与当前 `staff_id_card` 不一致时,立即失败
- 数据库写入失败时执行回滚,并且本次 `logId` 不进入 `file_records`
- 同一个 `logId` 下:
- `record.abnormal_accounts`
- 返回的异常账户样本流水
- `ccdi_account_info` 中的最小账户事实
保持账号级一致
## 3. 测试补充
本次新增或扩展了以下测试:
- `tests/test_file_service.py`
- 校验 `fetch_inner_flow(...)` 会在缓存前调用异常账户基线同步
- 校验同步失败时不会留下半成品 `logId`
- `tests/test_abnormal_account_baseline_service.py`
- 校验空输入跳过
- 校验证件号不一致直接失败
- 校验按账号插入
- 校验按账号更新
- `tests/test_statement_service.py`
- 校验异常账户样本流水仅使用 `record.abnormal_accounts` 中的账号
## 4. 实施结果
- `FileService -> AbnormalAccountBaselineService -> StatementService` 的职责边界保持清晰
- 异常账户基线写库与内存建档顺序已固定为“先同步、后缓存”
- 异常账户样本流水与账户事实的一致性已通过测试锁定
- 本轮未扩展接口协议,也未新增补丁式降级链路

View File

@@ -0,0 +1,36 @@
# LSFX Mock Server 异常账户命中流水设计记录
**日期**: 2026-03-31
**类型**: 设计记录
**范围**: `lsfx-mock-server` 异常账户命中流水
## 1. 本次变更内容
新增正式设计文档:
- `docs/design/2026-03-31-lsfx-mock-server-abnormal-account-design.md`
设计结论如下:
- 在现有 `rule_hit_plan` 体系中新增 `abnormal_account_hit_rules`
-`FileRecord` 中新增异常账户事实 `abnormal_accounts`
- 通过 `statement_rule_samples.py` 新增两类异常账户命中样本:
- `SUDDEN_ACCOUNT_CLOSURE`
- `DORMANT_ACCOUNT_LARGE_ACTIVATION`
- 保持现有流水接口协议不变,只在 Mock 服务内部补齐“账户事实 + 命中流水”闭环
## 2. 设计约束
- 不新增异常账户独立接口
- 不修改现有 `/watson/api/project/getBSByLogId` 返回结构
- 不把异常账户事实直接暴露给前端
- 不模拟 `ccdi_account_info` 全字段,只保留规则计算所需最小字段
- 不开启 subagent本次设计文档采用本地人工复核替代 spec subagent review
## 3. 后续文档规划
待用户确认设计文档后,继续补充:
- 后端实施计划
- 前端实施计划
- Mock 服务实施记录

View File

@@ -0,0 +1,23 @@
# LSFX Mock Server 异常账户命中流水计划记录
**日期**: 2026-03-31
**类型**: 计划记录
**范围**: `lsfx-mock-server` 异常账户命中流水
## 1. 本次变更内容
基于设计文档 [2026-03-31-lsfx-mock-server-abnormal-account-design.md](/Users/wkc/Desktop/ccdi/ccdi/docs/design/2026-03-31-lsfx-mock-server-abnormal-account-design.md),新增两份实施计划文档:
- [2026-03-31-lsfx-mock-server-abnormal-account-backend-implementation-plan.md](/Users/wkc/Desktop/ccdi/ccdi/docs/plans/backend/2026-03-31-lsfx-mock-server-abnormal-account-backend-implementation-plan.md)
- [2026-03-31-lsfx-mock-server-abnormal-account-frontend-implementation-plan.md](/Users/wkc/Desktop/ccdi/ccdi/docs/plans/frontend/2026-03-31-lsfx-mock-server-abnormal-account-frontend-implementation-plan.md)
## 2. 计划结论
- 后端按最短路径扩展现有 `rule_hit_plan``FileRecord` 和种子流水生成链路
- 异常账户规则仅在 Mock 内部补齐最小账户事实,不新增外部接口
- 前端本轮默认零代码改动,仅做承接边界核查与记录沉淀
- 测试以 `pytest` 定向回归和全量回归为主,不启动额外前后端进程
## 3. 后续动作
待用户确认计划文档后,按后端计划和前端计划分别执行实施与验证。

View File

@@ -0,0 +1,34 @@
# NAS 部署脚本 LSFX Mock 数据库地址调整实施记录
**日期**: 2026-03-31
**范围**: NAS 部署脚本、部署配置
## 1. 本次调整
- 新增 `deploy/render_nas_env.py`
- 基于根目录 `.env.example` 渲染 NAS 部署专用 `.env`
- 固定输出:
- `CCDI_DB_HOST=192.168.0.111`
- `CCDI_DB_PORT=40628`
- 调整 `deploy/deploy-to-nas.sh`
- 在组装部署目录阶段生成 `${STAGE_ROOT}/.env`
- 调整 `deploy/deploy.ps1`
- 与 Shell 部署入口保持一致,在组装部署目录阶段生成 `${stageRoot}\\.env`
## 2. 调整目的
确保 NAS 部署后的 `lsfx-mock-server` 读取部署包中的 `.env`,从而连接:
- Host: `192.168.0.111`
- Port: `40628`
同时保持本地 `docker-compose.yml` 默认值不变,不影响本地开发和手工启动。
## 3. 验证范围
- `tests/deploy/test_render_nas_env.py`
- 校验渲染后的 `.env` 包含 `CCDI_DB_HOST=192.168.0.111`
- 校验渲染后的 `.env` 包含 `CCDI_DB_PORT=40628`
- `tests/deploy/test_deploy_to_nas.py`
- 校验 `deploy-to-nas.sh` 已接入 `render_nas_env.py`
- 校验部署目录会生成 `${STAGE_ROOT}/.env`

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`

View File

@@ -0,0 +1,42 @@
# 风险明细异常账户人员信息设计记录
**日期**: 2026-03-31
**类型**: 设计记录
**范围**: 项目详情 - 结果总览 - 风险明细
## 1. 本次变更内容
新增正式设计文档:
- `docs/design/2026-03-31-project-detail-risk-details-abnormal-account-design.md`
本次设计结论如下:
- `异常账户人员信息` 从前端占位数据改为真实查询链路
- 页面展示与统一导出统一使用 6 个字段:
- `账号`
- `开户人`
- `银行`
- `异常类型`
- `异常发生时间`
- `状态`
- 展示与导出均按“一条命中结果一行”处理,不做账号合并或员工合并
- 统一导出继续复用 `POST /ccdi/project/overview/risk-details/export`
- 第 3 个 sheet `异常账户人员信息` 改为真实数据导出
## 2. 设计约束
- 不新增异常账户详情弹窗
- 不新增筛选器或区块级导出按钮
- 不扩展到关系人或外部账户
- 不新增平行导出链路
- 不增加兼容性补丁或兜底方案
## 3. 后续文档规划
待用户审核设计文档后,继续补充:
- 后端实施计划
- 前端实施计划
- 后端实施记录
- 前端实施记录

View File

@@ -0,0 +1,75 @@
# 项目详情风险明细异常账户人员信息前端实施记录
**日期**: 2026-03-31
**范围**: 项目详情 - 结果总览 - 风险明细 - 异常账户人员信息前端
## 1. 本次实施内容
-`ruoyi-ui/src/api/ccdi/projectOverview.js` 新增 `getOverviewAbnormalAccountPeople`,对接 `GET /ccdi/project/overview/abnormal-account-people`
-`ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue` 为异常账户区块补充独立状态:
- `abnormalAccountLoading`
- `abnormalAccountPageNum`
- `abnormalAccountPageSize`
- `abnormalAccountTotal`
- `abnormalAccountList`
-`RiskDetailSection.vue` 新增 `loadAbnormalAccountPeople``handleAbnormalAccountPageChange`,使异常账户区块具备独立分页刷新能力
- 将异常账户人员信息区块从占位表格替换为真实 6 列:
- `账号`
- `开户人`
- `银行`
- `异常类型`
- `异常发生时间`
- `状态`
- 移除旧占位列 `操作 / 查看详情`
- 为异常账户区块补充独立空态文案 `当前项目暂无异常账户人员信息`
-`ruoyi-ui/src/views/ccdiProject/components/detail/preliminaryCheck.mock.js` 中对齐异常账户 mock 字段:
- `accountNo`
- `accountName`
- `bankName`
- `abnormalType`
- `abnormalTime`
- `status`
- 新增并更新静态单测,覆盖异常账户区块的列结构、空态文案、独立分页状态与旧占位断言移除
## 2. 影响范围
- `ruoyi-ui/src/api/ccdi/projectOverview.js`
- `ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/preliminaryCheck.mock.js`
- `ruoyi-ui/tests/unit/risk-detail-abnormal-account-layout.test.js`
- `ruoyi-ui/tests/unit/risk-detail-abnormal-account-pagination.test.js`
- `ruoyi-ui/tests/unit/preliminary-check-model-and-detail.test.js`
## 3. 验证结果
执行静态单测:
```bash
cd ruoyi-ui
node tests/unit/risk-detail-abnormal-account-layout.test.js
node tests/unit/risk-detail-abnormal-account-pagination.test.js
node tests/unit/preliminary-check-model-and-detail.test.js
node tests/unit/risk-detail-employee-credit-negative-layout.test.js
```
执行结果:
- 全部通过
执行生产构建:
```bash
cd ruoyi-ui
npm run build:prod
```
执行结果:
- 构建成功
- 仅存在仓库原有的体积告警,没有新增编译错误
## 4. 手工联调说明
- 本轮未启动 `npm run dev` 做浏览器手工联调
- 因未启动前端开发服务,本轮不存在额外前端进程需要关闭
- 真实接口翻页、区块级失败提示与统一导出联动,待结合后端接口联调时继续确认

View File

@@ -0,0 +1,31 @@
# 风险明细异常账户人员信息实施计划记录
**日期**: 2026-03-31
**类型**: 实施计划记录
**范围**: 项目详情 - 结果总览 - 风险明细
## 1. 本次新增计划文档
- `docs/plans/backend/2026-03-31-project-detail-risk-details-abnormal-account-backend-implementation-plan.md`
- `docs/plans/frontend/2026-03-31-project-detail-risk-details-abnormal-account-frontend-implementation-plan.md`
## 2. 计划拆分原则
- 后端计划只覆盖异常账户结果总览查询接口、Mapper SQL、统一导出第 3 个 sheet 真实化、后端自动化验证
- 前端计划只覆盖 `RiskDetailSection.vue` 的异常账户真实加载、独立分页、列结构调整、前端静态单测与构建验证
- 页面展示与统一导出统一采用 `账号 / 开户人 / 银行 / 异常类型 / 异常发生时间 / 状态` 六个字段
- 展示与导出均按“一条命中结果一行”执行,不做账号合并或员工合并
## 3. 执行约束
- 前端开发直接在当前分支执行,不使用 git worktree
- 仓库要求不开启 subagent后续执行阶段统一使用当前会话串行推进
- 若验证时启动前后端进程,结束后必须主动关闭
- Git 提交前必须检查暂存区,仅纳入本次任务相关文件
## 4. 后续交付物
执行实施计划时需补齐以下记录:
- `docs/reports/implementation/2026-03-31-project-detail-risk-details-abnormal-account-backend-implementation.md`
- `docs/reports/implementation/2026-03-31-project-detail-risk-details-abnormal-account-frontend-implementation.md`

View File

@@ -0,0 +1,72 @@
# LSFX Mock Server 异常账户后端验证记录
## 1. 验证命令
按实施过程实际执行了以下命令:
```bash
cd lsfx-mock-server
python3 -m pytest tests/test_file_service.py::test_fetch_inner_flow_should_attach_abnormal_account_rule_plan -v
python3 -m pytest tests/test_statement_service.py -k "sudden_account_closure or dormant_account_large_activation" -v
python3 -m pytest tests/test_statement_service.py::test_generate_statements_should_follow_abnormal_account_rule_plan_from_file_record -v
python3 -m pytest tests/test_file_service.py tests/test_statement_service.py -v
python3 -m pytest tests/ -v
```
README 补充后按计划追加执行:
```bash
cd lsfx-mock-server
python3 -m pytest tests/test_statement_service.py -v
```
合并到 `dev` 后补充执行:
```bash
python3 -m pytest lsfx-mock-server/tests/test_statement_service.py::test_get_bank_statement_should_preserve_abnormal_account_mask_no -v
python3 -m pytest lsfx-mock-server/tests/test_file_service.py lsfx-mock-server/tests/test_statement_service.py -v
python3 main.py --rule-hit-mode all
```
启动服务后,使用标准库 `urllib` 调用了以下两个接口做运行态核验:
```text
POST /watson/api/project/getJZFileOrZjrcuFile
POST /watson/api/project/getBSByLogId
```
## 2. 验证结果摘要
- `tests/test_file_service.py::test_fetch_inner_flow_should_attach_abnormal_account_rule_plan`:通过
- `tests/test_statement_service.py -k "sudden_account_closure or dormant_account_large_activation"`:通过
- `tests/test_statement_service.py::test_generate_statements_should_follow_abnormal_account_rule_plan_from_file_record`:通过
- `python3 -m pytest tests/test_file_service.py tests/test_statement_service.py -v``43 passed`
- `python3 -m pytest tests/ -v``84 passed`
- `python3 -m pytest tests/test_statement_service.py -v``26 passed`
- `python3 -m pytest lsfx-mock-server/tests/test_statement_service.py::test_get_bank_statement_should_preserve_abnormal_account_mask_no -v`:通过
- `python3 -m pytest lsfx-mock-server/tests/test_file_service.py lsfx-mock-server/tests/test_statement_service.py -v``44 passed`
运行态 HTTP 验证结果:
- `logId=16724`
- 返回流水总数:`200`
- `SUDDEN_ACCOUNT_CLOSURE` 命中样本:`3`
- `DORMANT_ACCOUNT_LARGE_ACTIVATION` 命中样本:`3`
- 销户规则账号:`6222006485425901`
日期:`2026-02-18``2026-03-07``2026-03-18`
结论:全部落在销户日前 30 天窗口内
- 休眠激活规则账号:`6222004693652802`
日期:`2025-07-01``2025-07-10``2025-07-19`
累计金额:`560000.0`
单笔最大金额:`260000.0`
结论:满足开户满 6 个月后激活、累计金额阈值和单笔最大金额阈值
## 3. 过程说明
- 回归期间发现 `all` 模式安全噪声测试未同步清空新增的异常账户规则维度,导致异常账户样本被计入噪声断言
- 已通过补齐测试夹具方式修正,随后重新执行聚焦回归和全量回归,结果均通过
- 合并到 `dev` 后的 HTTP 验证又发现 `getBSByLogId` 返回前会覆盖异常账户样本账号,已通过新增回归用例与最小实现修正
## 4. 进程清理
本轮验证过程中临时启动了 `lsfx-mock-server``python3 main.py --rule-hit-mode all` 进程,验证完成后已主动停止,无残留端口占用。

View File

@@ -0,0 +1,35 @@
# LSFX Mock Server 异常账户基线同步后端验证记录
## 1. 验证命令
本次按实施过程实际执行了以下命令:
```bash
cd lsfx-mock-server
python3 -m pytest tests/test_file_service.py -k "abnormal_account_baseline" -v
python3 -m pytest tests/test_abnormal_account_baseline_service.py -v
python3 -m pytest tests/test_statement_service.py::test_get_bank_statement_should_only_use_abnormal_account_numbers_from_file_record -v
python3 -m pytest tests/test_statement_service.py -k "abnormal_account" -v
python3 -m pytest tests/test_abnormal_account_baseline_service.py tests/test_file_service.py tests/test_statement_service.py -k "abnormal_account or abnormal_account_baseline" -v
```
## 2. 验证结果摘要
- `tests/test_file_service.py -k "abnormal_account_baseline" -v``2 passed`
- `tests/test_abnormal_account_baseline_service.py -v``4 passed`
- `tests/test_statement_service.py::test_get_bank_statement_should_only_use_abnormal_account_numbers_from_file_record -v`:通过
- `tests/test_statement_service.py -k "abnormal_account" -v``3 passed`
- 聚合回归:
- `python3 -m pytest tests/test_abnormal_account_baseline_service.py tests/test_file_service.py tests/test_statement_service.py -k "abnormal_account or abnormal_account_baseline" -v`
- 结果:`10 passed, 41 deselected`
## 3. 关键通过点
- `FileService` 已在缓存 `logId` 前触发异常账户基线同步
- 基线同步失败时不会把半成品 `logId` 写入内存缓存
- `AbnormalAccountBaselineService` 已覆盖空输入、校验失败、插入、更新四类行为
- `StatementService` 返回的异常账户样本流水账号与 `record.abnormal_accounts` 保持一致
## 4. 进程清理
本轮验证仅执行了 `pytest` 命令,未启动前后端或 Mock 服务长驻进程,因此无需额外清理进程。

View File

@@ -37,6 +37,12 @@ python dev.py --reload --rule-hit-mode all
- `subset`:默认模式,按 `logId` 稳定随机命中部分规则
- `all`:全部兼容规则命中模式,会命中当前可共存的全部规则
补充说明:
- `fetch_inner_flow` 与上传链路会在内部生成 `abnormal_account_hit_rules`
- 当前异常账户规则样本包含 `SUDDEN_ACCOUNT_CLOSURE``DORMANT_ACCOUNT_LARGE_ACTIVATION`
- `/watson/api/project/getBSByLogId` 会沿用现有种子流水主链路,自动混入与异常账户事实匹配的命中流水样本
### 3. 访问 API 文档
- **Swagger UI**: http://localhost:8000/docs

View File

@@ -26,6 +26,8 @@ def _load_ruoyi_mysql_defaults() -> dict:
MYSQL_DEFAULTS = _load_ruoyi_mysql_defaults()
LSFX_DEFAULT_CCDI_DB_HOST = "116.62.17.81"
LSFX_DEFAULT_CCDI_DB_PORT = 3307
class Settings(BaseSettings):
@@ -50,8 +52,8 @@ class Settings(BaseSettings):
INITIAL_LOG_ID: int = 10000
# 员工库只读配置
CCDI_DB_HOST: str = MYSQL_DEFAULTS.get("host", "")
CCDI_DB_PORT: int = int(MYSQL_DEFAULTS.get("port", 3306))
CCDI_DB_HOST: str = LSFX_DEFAULT_CCDI_DB_HOST
CCDI_DB_PORT: int = LSFX_DEFAULT_CCDI_DB_PORT
CCDI_DB_NAME: str = MYSQL_DEFAULTS.get("database", "")
CCDI_DB_USERNAME: str = MYSQL_DEFAULTS.get("username", "")
CCDI_DB_PASSWORD: str = MYSQL_DEFAULTS.get("password", "")

View File

@@ -0,0 +1,114 @@
from typing import List
from config.settings import settings
class AbnormalAccountBaselineService:
"""异常账户基线写库服务。"""
UPSERT_SQL = """
INSERT INTO ccdi_account_info (
account_no,
account_type,
account_name,
owner_type,
owner_id,
bank,
bank_code,
currency,
is_self_account,
trans_risk_level,
status,
effective_date,
invalid_date,
create_by,
update_by
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
account_name = VALUES(account_name),
owner_type = VALUES(owner_type),
owner_id = VALUES(owner_id),
bank = VALUES(bank),
bank_code = VALUES(bank_code),
currency = VALUES(currency),
is_self_account = VALUES(is_self_account),
trans_risk_level = VALUES(trans_risk_level),
status = VALUES(status),
effective_date = VALUES(effective_date),
invalid_date = VALUES(invalid_date),
update_by = VALUES(update_by),
update_time = NOW()
"""
def __init__(self):
self.db_config = {
"host": settings.CCDI_DB_HOST,
"port": settings.CCDI_DB_PORT,
"database": settings.CCDI_DB_NAME,
"username": settings.CCDI_DB_USERNAME,
"password": settings.CCDI_DB_PASSWORD,
"connect_timeout_seconds": settings.CCDI_DB_CONNECT_TIMEOUT_SECONDS,
}
def _connect(self):
try:
import pymysql
except ImportError as exc:
raise RuntimeError("缺少 PyMySQL 依赖,无法写入异常账户基线") from exc
return pymysql.connect(
host=settings.CCDI_DB_HOST,
port=settings.CCDI_DB_PORT,
user=settings.CCDI_DB_USERNAME,
password=settings.CCDI_DB_PASSWORD,
database=settings.CCDI_DB_NAME,
charset="utf8mb4",
connect_timeout=settings.CCDI_DB_CONNECT_TIMEOUT_SECONDS,
autocommit=False,
)
def _validate_fact_owner(self, staff_id_card: str, abnormal_accounts: List[dict]) -> None:
for account_fact in abnormal_accounts:
owner_id_card = account_fact.get("owner_id_card")
if owner_id_card != staff_id_card:
raise RuntimeError(
f"异常账户 owner_id_card 与 staff_id_card 不一致: {owner_id_card}"
)
def _build_upsert_params(self, account_fact: dict) -> tuple:
return (
account_fact["account_no"],
"DEBIT",
account_fact["account_name"],
"EMPLOYEE",
account_fact["owner_id_card"],
"兰溪农商银行",
"LXNCSY",
"CNY",
1,
"HIGH",
account_fact["status"],
account_fact["effective_date"],
account_fact.get("invalid_date"),
"lsfx-mock-server",
"lsfx-mock-server",
)
def apply(self, staff_id_card: str, abnormal_accounts: List[dict]) -> None:
if not abnormal_accounts:
return
self._validate_fact_owner(staff_id_card, abnormal_accounts)
connection = self._connect()
try:
with connection.cursor() as cursor:
for account_fact in abnormal_accounts:
cursor.execute(self.UPSERT_SQL, self._build_upsert_params(account_fact))
connection.commit()
except Exception:
connection.rollback()
raise
finally:
connection.close()

View File

@@ -1,6 +1,7 @@
from fastapi import BackgroundTasks, UploadFile
from utils.response_builder import ResponseBuilder
from config.settings import settings
from services.abnormal_account_baseline_service import AbnormalAccountBaselineService
from services.phase2_baseline_service import Phase2BaselineService
from services.staff_identity_repository import StaffIdentityRepository
from typing import Dict, List, Union
@@ -48,6 +49,11 @@ PHASE2_BASELINE_RULE_CODES = [
"SUPPLIER_CONCENTRATION",
]
ABNORMAL_ACCOUNT_RULE_CODES = [
"SUDDEN_ACCOUNT_CLOSURE",
"DORMANT_ACCOUNT_LARGE_ACTIVATION",
]
MONTHLY_FIXED_INCOME_ISOLATED_LARGE_TRANSACTION_RULE_CODES = {
"SINGLE_LARGE_INCOME",
"CUMULATIVE_INCOME",
@@ -127,7 +133,8 @@ class FileRecord:
phase1_hit_rules: List[str] = field(default_factory=list)
phase2_statement_hit_rules: List[str] = field(default_factory=list)
phase2_baseline_hit_rules: List[str] = field(default_factory=list)
abnormal_account_hit_rules: List[str] = field(default_factory=list)
abnormal_accounts: List[dict] = field(default_factory=list)
class FileService:
"""文件上传和解析服务"""
@@ -136,11 +143,19 @@ class FileService:
LOG_ID_MIN = settings.INITIAL_LOG_ID
LOG_ID_MAX = 99999
def __init__(self, staff_identity_repository=None, phase2_baseline_service=None):
def __init__(
self,
staff_identity_repository=None,
phase2_baseline_service=None,
abnormal_account_baseline_service=None,
):
self.file_records: Dict[int, FileRecord] = {} # logId -> FileRecord
self.log_counter = settings.INITIAL_LOG_ID
self.staff_identity_repository = staff_identity_repository or StaffIdentityRepository()
self.phase2_baseline_service = phase2_baseline_service or Phase2BaselineService()
self.abnormal_account_baseline_service = (
abnormal_account_baseline_service or AbnormalAccountBaselineService()
)
def get_file_record(self, log_id: int) -> FileRecord:
"""按 logId 获取已存在的文件记录。"""
@@ -213,6 +228,9 @@ class FileService:
"phase2_baseline_hit_rules": self._pick_rule_subset(
rng, PHASE2_BASELINE_RULE_CODES, 2, 4
),
"abnormal_account_hit_rules": self._pick_rule_subset(
rng, ABNORMAL_ACCOUNT_RULE_CODES, 1, len(ABNORMAL_ACCOUNT_RULE_CODES)
),
}
def _build_all_compatible_rule_hit_plan(self) -> dict:
@@ -222,6 +240,7 @@ class FileService:
"phase1_hit_rules": list(PHASE1_RULE_CODES),
"phase2_statement_hit_rules": list(PHASE2_STATEMENT_RULE_CODES),
"phase2_baseline_hit_rules": list(PHASE2_BASELINE_RULE_CODES),
"abnormal_account_hit_rules": list(ABNORMAL_ACCOUNT_RULE_CODES),
}
def _build_monthly_fixed_income_isolated_rule_hit_plan(self) -> dict:
@@ -284,6 +303,52 @@ class FileService:
file_record.phase2_baseline_hit_rules = list(
rule_hit_plan.get("phase2_baseline_hit_rules", [])
)
file_record.abnormal_account_hit_rules = list(
rule_hit_plan.get("abnormal_account_hit_rules", [])
)
file_record.abnormal_accounts = self._build_abnormal_accounts(
log_id=file_record.log_id,
staff_id_card=file_record.staff_id_card,
abnormal_account_hit_rules=file_record.abnormal_account_hit_rules,
)
def _build_abnormal_accounts(
self,
*,
log_id: int,
staff_id_card: str,
abnormal_account_hit_rules: List[str],
) -> List[dict]:
"""按命中规则生成最小异常账户事实。"""
if not abnormal_account_hit_rules:
return []
rng = random.Random(f"abnormal-account:{log_id}")
accounts = []
for index, rule_code in enumerate(abnormal_account_hit_rules, start=1):
account_no = f"622200{rng.randint(10**9, 10**10 - 1)}"
account_fact = {
"account_no": account_no,
"owner_id_card": staff_id_card,
"account_name": "测试员工工资卡",
"status": 1,
"effective_date": "2025-01-01",
"invalid_date": None,
"rule_code": rule_code,
}
if rule_code == "SUDDEN_ACCOUNT_CLOSURE":
account_fact["status"] = 2
account_fact["effective_date"] = "2024-01-01"
account_fact["invalid_date"] = "2026-03-20"
elif rule_code == "DORMANT_ACCOUNT_LARGE_ACTIVATION":
account_fact["status"] = 1
account_fact["effective_date"] = "2025-01-01"
account_fact["invalid_date"] = None
account_fact["account_no"] = f"{account_no[:-2]}{index:02d}"
accounts.append(account_fact)
return accounts
def _rebalance_all_mode_group_rule_plans(self, group_id: int) -> None:
"""同项目存在多文件时,隔离月固定收入样本,避免被其他正向流入规则污染。"""
@@ -332,6 +397,8 @@ class FileService:
phase1_hit_rules: List[str] = None,
phase2_statement_hit_rules: List[str] = None,
phase2_baseline_hit_rules: List[str] = None,
abnormal_account_hit_rules: List[str] = None,
abnormal_accounts: List[dict] = None,
parsing: bool = True,
status: int = -5,
) -> FileRecord:
@@ -366,6 +433,8 @@ class FileService:
phase1_hit_rules=list(phase1_hit_rules or []),
phase2_statement_hit_rules=list(phase2_statement_hit_rules or []),
phase2_baseline_hit_rules=list(phase2_baseline_hit_rules or []),
abnormal_account_hit_rules=list(abnormal_account_hit_rules or []),
abnormal_accounts=[dict(account) for account in (abnormal_accounts or [])],
parsing=parsing,
status=status,
)
@@ -391,6 +460,17 @@ class FileService:
baseline_rule_codes=baseline_rule_codes,
)
def _apply_abnormal_account_baselines(self, file_record: FileRecord) -> None:
"""按当前记录命中的异常账户规则幂等补齐账户事实。"""
if not file_record.abnormal_account_hit_rules:
return
if not file_record.abnormal_accounts:
raise RuntimeError("异常账户命中计划存在,但未生成账户事实")
self.abnormal_account_baseline_service.apply(
staff_id_card=file_record.staff_id_card,
abnormal_accounts=file_record.abnormal_accounts,
)
async def upload_file(
self, group_id: int, file: UploadFile, background_tasks: BackgroundTasks
) -> Dict:
@@ -444,9 +524,15 @@ class FileService:
phase1_hit_rules=rule_hit_plan.get("phase1_hit_rules", []),
phase2_statement_hit_rules=rule_hit_plan.get("phase2_statement_hit_rules", []),
phase2_baseline_hit_rules=rule_hit_plan.get("phase2_baseline_hit_rules", []),
abnormal_account_hit_rules=rule_hit_plan.get("abnormal_account_hit_rules", []),
abnormal_accounts=self._build_abnormal_accounts(
log_id=log_id,
staff_id_card=identity_scope["staff_id_card"],
abnormal_account_hit_rules=rule_hit_plan.get("abnormal_account_hit_rules", []),
),
)
# 存储记录
self._apply_abnormal_account_baselines(file_record)
self.file_records[log_id] = file_record
self._rebalance_all_mode_group_rule_plans(group_id)
self._apply_phase2_baselines(file_record)
@@ -775,9 +861,16 @@ class FileService:
phase1_hit_rules=rule_hit_plan.get("phase1_hit_rules", []),
phase2_statement_hit_rules=rule_hit_plan.get("phase2_statement_hit_rules", []),
phase2_baseline_hit_rules=rule_hit_plan.get("phase2_baseline_hit_rules", []),
abnormal_account_hit_rules=rule_hit_plan.get("abnormal_account_hit_rules", []),
abnormal_accounts=self._build_abnormal_accounts(
log_id=log_id,
staff_id_card=identity_scope["staff_id_card"],
abnormal_account_hit_rules=rule_hit_plan.get("abnormal_account_hit_rules", []),
),
parsing=False,
)
self._apply_abnormal_account_baselines(file_record)
self.file_records[log_id] = file_record
self._rebalance_all_mode_group_rule_plans(group_id)
self._apply_phase2_baselines(file_record)

View File

@@ -811,6 +811,117 @@ def build_salary_unused_samples(group_id: int, log_id: int, **kwargs) -> List[Di
]
def build_sudden_account_closure_samples(
group_id: int,
log_id: int,
*,
account_fact: Dict,
le_name: str = "模型测试主体",
) -> List[Dict]:
invalid_date = datetime.strptime(account_fact["invalid_date"], "%Y-%m-%d")
owner_id_card = account_fact["owner_id_card"]
account_no = account_fact["account_no"]
account_name = account_fact["account_name"]
return [
_build_statement(
group_id,
log_id,
trx_datetime=invalid_date - timedelta(days=30, hours=-1),
cret_no=owner_id_card,
customer_name="杭州临时往来款账户",
user_memo=f"{account_name}销户前资金回笼",
cash_type="对私转账",
cr_amount=88000.0,
le_name=le_name,
account_mask_no=account_no,
customer_account_mask_no="6222024666610001",
),
_build_statement(
group_id,
log_id,
trx_datetime=invalid_date - timedelta(days=12, hours=2),
cret_no=owner_id_card,
customer_name="杭州消费支付商户",
user_memo=f"{account_name}销户前集中支出",
cash_type="快捷支付",
dr_amount=62000.0,
le_name=le_name,
account_mask_no=account_no,
customer_account_mask_no="6222024666610002",
),
_build_statement(
group_id,
log_id,
trx_datetime=invalid_date - timedelta(days=1, hours=3),
cret_no=owner_id_card,
customer_name="浙江异常账户清理专户",
user_memo=f"{account_name}异常账户销户前转出",
cash_type="对私转账",
dr_amount=126000.0,
le_name=le_name,
account_mask_no=account_no,
customer_account_mask_no="6222024666610003",
),
]
def build_dormant_account_large_activation_samples(
group_id: int,
log_id: int,
*,
account_fact: Dict,
le_name: str = "模型测试主体",
) -> List[Dict]:
effective_date = datetime.strptime(account_fact["effective_date"], "%Y-%m-%d")
activation_start = datetime(effective_date.year, effective_date.month, effective_date.day) + timedelta(days=181)
owner_id_card = account_fact["owner_id_card"]
account_no = account_fact["account_no"]
account_name = account_fact["account_name"]
return [
_build_statement(
group_id,
log_id,
trx_datetime=activation_start,
cret_no=owner_id_card,
customer_name="浙江存量资产回收账户",
user_memo=f"{account_name}休眠后异常账户激活入账",
cash_type="对公转账",
cr_amount=180000.0,
le_name=le_name,
account_mask_no=account_no,
customer_account_mask_no="6222024666620001",
),
_build_statement(
group_id,
log_id,
trx_datetime=activation_start + timedelta(days=9, hours=2),
cret_no=owner_id_card,
customer_name="浙江大额往来备付金专户",
user_memo=f"{account_name}休眠激活后大额转入",
cash_type="对公转账",
cr_amount=260000.0,
le_name=le_name,
account_mask_no=account_no,
customer_account_mask_no="6222024666620002",
),
_build_statement(
group_id,
log_id,
trx_datetime=activation_start + timedelta(days=18, hours=1),
cret_no=owner_id_card,
customer_name="杭州临时资金调拨账户",
user_memo=f"{account_name}休眠账户异常账户激活转出",
cash_type="对私转账",
dr_amount=120000.0,
le_name=le_name,
account_mask_no=account_no,
customer_account_mask_no="6222024666620003",
),
]
LARGE_TRANSACTION_BUILDERS = {
"HOUSE_OR_CAR_EXPENSE": build_house_or_car_samples,
"TAX_EXPENSE": build_tax_samples,
@@ -842,6 +953,39 @@ PHASE2_STATEMENT_RULE_BUILDERS = {
"SALARY_UNUSED": build_salary_unused_samples,
}
ABNORMAL_ACCOUNT_RULE_BUILDERS = {
"SUDDEN_ACCOUNT_CLOSURE": build_sudden_account_closure_samples,
"DORMANT_ACCOUNT_LARGE_ACTIVATION": build_dormant_account_large_activation_samples,
}
def _resolve_abnormal_account_fact(rule_code: str, abnormal_accounts: List[Dict]) -> Optional[Dict]:
for account_fact in abnormal_accounts:
if account_fact.get("rule_code") == rule_code:
return account_fact
if rule_code == "SUDDEN_ACCOUNT_CLOSURE":
return next(
(
account_fact
for account_fact in abnormal_accounts
if account_fact.get("status") == 2 and account_fact.get("invalid_date")
),
None,
)
if rule_code == "DORMANT_ACCOUNT_LARGE_ACTIVATION":
return next(
(
account_fact
for account_fact in abnormal_accounts
if account_fact.get("status") == 1 and account_fact.get("effective_date")
),
None,
)
return None
def build_seed_statements_for_rule_plan(
group_id: int,
@@ -850,21 +994,36 @@ def build_seed_statements_for_rule_plan(
**kwargs,
) -> List[Dict]:
statements: List[Dict] = []
abnormal_accounts = list(kwargs.get("abnormal_accounts") or [])
common_kwargs = {key: value for key, value in kwargs.items() if key != "abnormal_accounts"}
for rule_code in rule_plan.get("large_transaction_hit_rules", []):
builder = LARGE_TRANSACTION_BUILDERS.get(rule_code)
if builder is not None:
statements.extend(builder(group_id, log_id, **kwargs))
statements.extend(builder(group_id, log_id, **common_kwargs))
for rule_code in rule_plan.get("phase1_hit_rules", []):
builder = PHASE1_RULE_BUILDERS.get(rule_code)
if builder is not None:
statements.extend(builder(group_id, log_id, **kwargs))
statements.extend(builder(group_id, log_id, **common_kwargs))
for rule_code in rule_plan.get("phase2_statement_hit_rules", []):
builder = PHASE2_STATEMENT_RULE_BUILDERS.get(rule_code)
if builder is not None:
statements.extend(builder(group_id, log_id, **kwargs))
statements.extend(builder(group_id, log_id, **common_kwargs))
for rule_code in rule_plan.get("abnormal_account_hit_rules", []):
builder = ABNORMAL_ACCOUNT_RULE_BUILDERS.get(rule_code)
account_fact = _resolve_abnormal_account_fact(rule_code, abnormal_accounts)
if builder is not None and account_fact is not None:
statements.extend(
builder(
group_id,
log_id,
account_fact=account_fact,
le_name=common_kwargs.get("primary_enterprise_name", "模型测试主体"),
)
)
return statements

View File

@@ -166,6 +166,9 @@ class StatementService:
"phase2_statement_hit_rules": (
list(record.phase2_statement_hit_rules) if record is not None else []
),
"abnormal_account_hit_rules": (
list(record.abnormal_account_hit_rules) if record is not None else []
),
}
if record is not None and record.staff_id_card:
allowed_identity_cards = tuple([record.staff_id_card, *record.family_id_cards])
@@ -180,6 +183,7 @@ class StatementService:
primary_account_no=primary_account_no,
staff_id_card=record.staff_id_card if record is not None else None,
family_id_cards=record.family_id_cards if record is not None else None,
abnormal_accounts=record.abnormal_accounts if record is not None else None,
)
safe_all_mode_noise = settings.RULE_HIT_MODE == "all" and record is not None
@@ -212,7 +216,7 @@ class StatementService:
"""将解析出的主绑定统一回填到已有流水记录。"""
for statement in statements:
statement["leName"] = primary_enterprise_name
statement["accountMaskNo"] = primary_account_no
statement["accountMaskNo"] = statement.get("accountMaskNo") or primary_account_no
def get_bank_statement(self, request: Union[Dict, object]) -> Dict:
"""获取银行流水列表。"""

View File

@@ -0,0 +1,154 @@
import pytest
from services.abnormal_account_baseline_service import AbnormalAccountBaselineService
class FakeCursor:
def __init__(self, connection):
self.connection = connection
def execute(self, sql, params=None):
self.connection.executed_sql.append(
{
"sql": sql,
"params": params,
}
)
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
class FakeConnection:
def __init__(self):
self.executed_sql = []
self.commit_count = 0
self.rollback_count = 0
def cursor(self):
return FakeCursor(self)
def commit(self):
self.commit_count += 1
def rollback(self):
self.rollback_count += 1
def close(self):
return None
def test_apply_should_skip_when_abnormal_accounts_is_empty():
service = AbnormalAccountBaselineService()
fake_connection = FakeConnection()
service._connect = lambda: fake_connection
service.apply("330101199001010001", [])
assert fake_connection.executed_sql == []
assert fake_connection.commit_count == 0
assert fake_connection.rollback_count == 0
def test_apply_should_raise_when_fact_owner_mismatches_staff():
service = AbnormalAccountBaselineService()
with pytest.raises(RuntimeError, match="owner_id_card"):
service.apply(
"330101199001010001",
[
{
"account_no": "6222000000000001",
"owner_id_card": "330101199001010099",
"account_name": "测试员工工资卡",
"status": 2,
"effective_date": "2024-01-01",
"invalid_date": "2026-03-20",
"rule_code": "SUDDEN_ACCOUNT_CLOSURE",
}
],
)
def test_apply_should_insert_new_account_fact_by_account_no():
service = AbnormalAccountBaselineService()
fake_connection = FakeConnection()
service._connect = lambda: fake_connection
service.apply(
"330101199001010001",
[
{
"account_no": "6222000000000001",
"owner_id_card": "330101199001010001",
"account_name": "测试员工工资卡",
"status": 2,
"effective_date": "2024-01-01",
"invalid_date": "2026-03-20",
"rule_code": "SUDDEN_ACCOUNT_CLOSURE",
}
],
)
assert len(fake_connection.executed_sql) == 1
executed = fake_connection.executed_sql[0]
assert "INSERT INTO ccdi_account_info" in executed["sql"]
assert "create_by" in executed["sql"]
assert "update_by" in executed["sql"]
assert "created_by" not in executed["sql"]
assert "updated_by" not in executed["sql"]
assert executed["params"] == (
"6222000000000001",
"DEBIT",
"测试员工工资卡",
"EMPLOYEE",
"330101199001010001",
"兰溪农商银行",
"LXNCSY",
"CNY",
1,
"HIGH",
2,
"2024-01-01",
"2026-03-20",
"lsfx-mock-server",
"lsfx-mock-server",
)
assert fake_connection.commit_count == 1
assert fake_connection.rollback_count == 0
def test_apply_should_update_existing_account_fact_by_account_no():
service = AbnormalAccountBaselineService()
fake_connection = FakeConnection()
service._connect = lambda: fake_connection
service.apply(
"330101199001010001",
[
{
"account_no": "6222000000000001",
"owner_id_card": "330101199001010001",
"account_name": "测试员工结算卡",
"status": 1,
"effective_date": "2025-01-01",
"invalid_date": None,
"rule_code": "DORMANT_ACCOUNT_LARGE_ACTIVATION",
}
],
)
assert len(fake_connection.executed_sql) == 1
executed = fake_connection.executed_sql[0]
assert "ON DUPLICATE KEY UPDATE" in executed["sql"]
assert "update_by = VALUES(update_by)" in executed["sql"]
assert executed["params"][0] == "6222000000000001"
assert executed["params"][2] == "测试员工结算卡"
assert executed["params"][10] == 1
assert executed["params"][11] == "2025-01-01"
assert executed["params"][12] is None
assert fake_connection.commit_count == 1
assert fake_connection.rollback_count == 0

View File

@@ -5,6 +5,7 @@ FileService 单一主绑定语义测试
import asyncio
import io
import pytest
from fastapi import BackgroundTasks
from fastapi.datastructures import UploadFile
@@ -27,6 +28,22 @@ class FakeStaffIdentityRepository:
}
class FakeAbnormalAccountBaselineService:
def __init__(self, should_fail=False):
self.should_fail = should_fail
self.calls = []
def apply(self, staff_id_card, abnormal_accounts):
self.calls.append(
{
"staff_id_card": staff_id_card,
"abnormal_accounts": [dict(item) for item in abnormal_accounts],
}
)
if self.should_fail:
raise RuntimeError("baseline sync failed")
def test_upload_file_primary_binding_response(monkeypatch):
"""同一 logId 的主绑定必须稳定且只保留一组主体/账号信息。"""
service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
@@ -163,6 +180,79 @@ def test_fetch_inner_flow_persists_primary_binding_record(monkeypatch):
assert record.total_records == 200
def test_fetch_inner_flow_should_attach_abnormal_account_rule_plan():
service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
response = service.fetch_inner_flow(
{
"groupId": 1001,
"customerNo": "customer_abnormal_account",
"dataChannelCode": "test_code",
"requestDateId": 20240101,
"dataStartDateId": 20240101,
"dataEndDateId": 20240131,
"uploadUserId": 902001,
}
)
log_id = response["data"][0]
record = service.file_records[log_id]
assert hasattr(record, "abnormal_account_hit_rules")
assert hasattr(record, "abnormal_accounts")
assert isinstance(record.abnormal_account_hit_rules, list)
assert isinstance(record.abnormal_accounts, list)
def test_fetch_inner_flow_should_sync_abnormal_account_baselines_before_caching():
baseline_service = FakeAbnormalAccountBaselineService()
service = FileService(
staff_identity_repository=FakeStaffIdentityRepository(),
abnormal_account_baseline_service=baseline_service,
)
response = service.fetch_inner_flow(
{
"groupId": 1001,
"customerNo": "customer_abnormal_baseline",
"dataChannelCode": "test_code",
"requestDateId": 20240101,
"dataStartDateId": 20240101,
"dataEndDateId": 20240131,
"uploadUserId": 902001,
}
)
log_id = response["data"][0]
record = service.file_records[log_id]
assert baseline_service.calls
assert baseline_service.calls[0]["staff_id_card"] == record.staff_id_card
assert baseline_service.calls[0]["abnormal_accounts"] == record.abnormal_accounts
def test_fetch_inner_flow_should_not_cache_log_id_when_abnormal_account_baseline_sync_fails():
baseline_service = FakeAbnormalAccountBaselineService(should_fail=True)
service = FileService(
staff_identity_repository=FakeStaffIdentityRepository(),
abnormal_account_baseline_service=baseline_service,
)
with pytest.raises(RuntimeError, match="baseline sync failed"):
service.fetch_inner_flow(
{
"groupId": 1001,
"customerNo": "customer_abnormal_baseline_fail",
"dataChannelCode": "test_code",
"requestDateId": 20240101,
"dataStartDateId": 20240101,
"dataEndDateId": 20240131,
"uploadUserId": 902001,
}
)
assert service.file_records == {}
def test_generate_log_id_should_retry_when_random_value_conflicts(monkeypatch):
"""随机 logId 命中已存在记录时必须重试并返回未占用值。"""
service = FileService(staff_identity_repository=FakeStaffIdentityRepository())

View File

@@ -15,11 +15,14 @@ def test_ruoyi_mysql_defaults_should_follow_application_dev_config():
assert _load_ruoyi_mysql_defaults()["port"] == match.group("port")
def test_settings_should_use_ruoyi_mysql_defaults():
def test_settings_should_default_to_lsfx_target_mysql_host_and_port():
assert settings.CCDI_DB_HOST == "116.62.17.81"
assert settings.CCDI_DB_PORT == 3307
def test_settings_should_still_use_ruoyi_mysql_defaults_for_db_name_and_credentials():
defaults = _load_ruoyi_mysql_defaults()
assert settings.CCDI_DB_HOST == defaults["host"]
assert settings.CCDI_DB_PORT == int(defaults["port"])
assert settings.CCDI_DB_NAME == defaults["database"]
assert settings.CCDI_DB_USERNAME == defaults["username"]
assert settings.CCDI_DB_PASSWORD == defaults["password"]

View File

@@ -4,6 +4,7 @@ StatementService 主绑定注入测试
from collections import Counter, defaultdict
import services.statement_rule_samples as statement_rule_samples
from services.file_service import FileService
from services.statement_service import StatementService
from services.statement_rule_samples import (
@@ -27,6 +28,11 @@ class FakeStaffIdentityRepository:
}
class FakeAbnormalAccountBaselineService:
def apply(self, staff_id_card, abnormal_accounts):
return None
def test_generate_statements_should_include_seeded_samples_before_noise_when_rule_plan_exists():
"""存在规则命中计划时,生成流水必须先混入被选中的命中样本。"""
file_service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
@@ -234,6 +240,187 @@ def test_generate_statements_should_follow_rule_hit_plan_from_file_record():
assert not any("购汇" in item["userMemo"] for item in statements)
def test_sudden_account_closure_samples_should_stay_within_30_days_before_invalid_date():
statements = statement_rule_samples.build_sudden_account_closure_samples(
group_id=1000,
log_id=20001,
account_fact={
"account_no": "6222000000000001",
"owner_id_card": "320101199001010030",
"account_name": "测试员工工资卡",
"status": 2,
"effective_date": "2024-01-01",
"invalid_date": "2026-03-20",
},
le_name="测试主体",
)
assert statements
assert all("6222000000000001" == item["accountMaskNo"] for item in statements)
assert all("2026-02-18" <= item["trxDate"][:10] < "2026-03-20" for item in statements)
def test_dormant_account_large_activation_samples_should_exceed_threshold_after_6_months():
statements = statement_rule_samples.build_dormant_account_large_activation_samples(
group_id=1000,
log_id=20001,
account_fact={
"account_no": "6222000000000002",
"owner_id_card": "320101199001010030",
"account_name": "测试员工工资卡",
"status": 1,
"effective_date": "2025-01-01",
"invalid_date": None,
},
le_name="测试主体",
)
assert statements
assert min(item["trxDate"][:10] for item in statements) >= "2025-07-01"
assert sum(item["drAmount"] + item["crAmount"] for item in statements) >= 500000
assert max(item["drAmount"] + item["crAmount"] for item in statements) >= 100000
def test_generate_statements_should_follow_abnormal_account_rule_plan_from_file_record():
file_service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
statement_service = StatementService(file_service=file_service)
response = file_service.fetch_inner_flow(
{
"groupId": 1001,
"customerNo": "customer_abnormal_rule_plan",
"dataChannelCode": "test_code",
"requestDateId": 20240101,
"dataStartDateId": 20240101,
"dataEndDateId": 20240131,
"uploadUserId": 902001,
}
)
log_id = response["data"][0]
record = file_service.file_records[log_id]
record.abnormal_account_hit_rules = ["SUDDEN_ACCOUNT_CLOSURE"]
record.abnormal_accounts = [
{
"account_no": "6222000000000001",
"owner_id_card": record.staff_id_card,
"account_name": "测试员工工资卡",
"status": 2,
"effective_date": "2024-01-01",
"invalid_date": "2026-03-20",
}
]
statements = statement_service._generate_statements(group_id=1001, log_id=log_id, count=80)
assert any(item["accountMaskNo"] == "6222000000000001" for item in statements)
assert any("销户" in item["userMemo"] or "异常账户" in item["userMemo"] for item in statements)
def test_get_bank_statement_should_preserve_abnormal_account_mask_no():
file_service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
statement_service = StatementService(file_service=file_service)
response = file_service.fetch_inner_flow(
{
"groupId": 1001,
"customerNo": "customer_abnormal_api",
"dataChannelCode": "test_code",
"requestDateId": 20240101,
"dataStartDateId": 20240101,
"dataEndDateId": 20240131,
"uploadUserId": 902001,
}
)
log_id = response["data"][0]
record = file_service.file_records[log_id]
record.abnormal_account_hit_rules = [
"SUDDEN_ACCOUNT_CLOSURE",
"DORMANT_ACCOUNT_LARGE_ACTIVATION",
]
record.abnormal_accounts = [
{
"account_no": "6222000000000001",
"owner_id_card": record.staff_id_card,
"account_name": "测试员工工资卡",
"status": 2,
"effective_date": "2024-01-01",
"invalid_date": "2026-03-20",
"rule_code": "SUDDEN_ACCOUNT_CLOSURE",
},
{
"account_no": "6222000000000002",
"owner_id_card": record.staff_id_card,
"account_name": "测试员工工资卡",
"status": 1,
"effective_date": "2025-01-01",
"invalid_date": None,
"rule_code": "DORMANT_ACCOUNT_LARGE_ACTIVATION",
},
]
response = statement_service.get_bank_statement(
{
"groupId": 1001,
"logId": log_id,
"pageNow": 1,
"pageSize": 500,
}
)
statements = response["data"]["bankStatementList"]
abnormal_statements = [
item for item in statements if "销户" in item["userMemo"] or "激活" in item["userMemo"]
]
assert abnormal_statements
assert any(item["accountMaskNo"] == "6222000000000001" for item in abnormal_statements)
assert any(item["accountMaskNo"] == "6222000000000002" for item in abnormal_statements)
def test_get_bank_statement_should_only_use_abnormal_account_numbers_from_file_record():
file_service = FileService(
staff_identity_repository=FakeStaffIdentityRepository(),
abnormal_account_baseline_service=FakeAbnormalAccountBaselineService(),
)
statement_service = StatementService(file_service=file_service)
response = file_service.fetch_inner_flow(
{
"groupId": 1001,
"customerNo": "customer_abnormal_statement_consistency",
"dataChannelCode": "test_code",
"requestDateId": 20240101,
"dataStartDateId": 20240101,
"dataEndDateId": 20240131,
"uploadUserId": 902001,
}
)
log_id = response["data"][0]
record = file_service.file_records[log_id]
record.abnormal_account_hit_rules = ["SUDDEN_ACCOUNT_CLOSURE"]
record.abnormal_accounts = [
{
"account_no": "6222000000000099",
"owner_id_card": record.staff_id_card,
"account_name": "测试员工工资卡",
"status": 2,
"effective_date": "2024-01-01",
"invalid_date": "2026-03-20",
"rule_code": "SUDDEN_ACCOUNT_CLOSURE",
}
]
result = statement_service.get_bank_statement(
{"groupId": 1001, "logId": log_id, "pageNow": 1, "pageSize": 500}
)
abnormal_numbers = {
item["accountMaskNo"]
for item in result["data"]["bankStatementList"]
if "销户" in item["userMemo"] or "异常账户" in item["userMemo"]
}
assert abnormal_numbers == {"6222000000000099"}
def test_generate_statements_should_stay_within_single_employee_scope_per_log_id():
"""同一 logId 的流水只能落在 FileRecord 绑定的员工及亲属身份证内。"""
file_service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
@@ -572,6 +759,8 @@ def test_generate_statements_should_keep_all_mode_noise_as_safe_debits(monkeypat
"MONTHLY_FIXED_INCOME",
"FIXED_COUNTERPARTY_TRANSFER",
]
record.abnormal_account_hit_rules = []
record.abnormal_accounts = []
statements = statement_service._generate_statements(group_id=1001, log_id=log_id, count=30)
noise_statements = [

View File

@@ -79,3 +79,15 @@ export function getOverviewEmployeeCreditNegative(params) {
}
})
}
export function getOverviewAbnormalAccountPeople(params) {
return request({
url: '/ccdi/project/overview/abnormal-account-people',
method: 'get',
params: {
projectId: params.projectId,
pageNum: params.pageNum,
pageSize: params.pageSize
}
})
}

View File

@@ -188,18 +188,31 @@
</div>
</div>
<el-table :data="sectionData.abnormalAccountList || []" class="detail-table">
<el-table-column prop="accountNo" label="账户号" min-width="160" />
<el-table-column prop="accountName" label="账户人姓名" min-width="120" />
<el-table-column prop="bankName" label="开户银行" min-width="180" />
<el-table-column prop="lastTradeDate" label="异常发生时间" min-width="140" />
<el-table-column prop="handler" label="状态" min-width="100" />
<el-table-column label="操作" width="100" align="right">
<template slot-scope="scope">
<el-button type="text" size="mini">{{ scope.row.actionLabel || "查看详情" }}</el-button>
</template>
</el-table-column>
<el-table
v-loading="abnormalAccountLoading"
:data="abnormalAccountList"
class="result-table"
>
<template slot="empty">
<el-empty :image-size="96" description="当前项目暂无异常账户人员信息" />
</template>
<el-table-column prop="accountNo" label="账号" min-width="160" />
<el-table-column prop="accountName" label="开户人" min-width="120" />
<el-table-column prop="bankName" label="银行" min-width="180" />
<el-table-column prop="abnormalType" label="异常类型" min-width="160" />
<el-table-column prop="abnormalTime" label="异常发生时间" min-width="160" />
<el-table-column prop="status" label="状态" min-width="120" />
</el-table>
<pagination
v-show="abnormalAccountTotal > 0"
:total="abnormalAccountTotal"
:page.sync="abnormalAccountPageNum"
:limit.sync="abnormalAccountPageSize"
:page-sizes="[5]"
layout="total, prev, pager, next, jumper"
@pagination="handleAbnormalAccountPageChange"
/>
</div>
</div>
@@ -314,6 +327,7 @@
<script>
import {
getOverviewAbnormalAccountPeople,
getOverviewEmployeeCreditNegative,
getOverviewSuspiciousTransactions,
} from "@/api/ccdi/projectOverview";
@@ -391,6 +405,11 @@ export default {
employeeCreditNegativePageSize: 5,
employeeCreditNegativeTotal: 0,
employeeCreditNegativeList: [],
abnormalAccountLoading: false,
abnormalAccountPageNum: 1,
abnormalAccountPageSize: 5,
abnormalAccountTotal: 0,
abnormalAccountList: [],
projectId: null,
statementDetailCache: {},
};
@@ -417,6 +436,10 @@ export default {
this.employeeCreditNegativePageNum = 1;
this.employeeCreditNegativePageSize = 5;
this.employeeCreditNegativeTotal = Number(value && value.employeeCreditNegativeTotal) || 0;
this.abnormalAccountPageNum = 1;
this.abnormalAccountPageSize = 5;
this.abnormalAccountTotal = 0;
this.abnormalAccountList = [];
const rows = Array.isArray(value && value.suspiciousTransactionList)
? value.suspiciousTransactionList
: [];
@@ -424,6 +447,7 @@ export default {
? value.employeeCreditNegativeList
: [];
this.hydrateSuspiciousRows(rows);
this.loadAbnormalAccountPeople();
},
},
},
@@ -451,6 +475,15 @@ export default {
}
await this.loadEmployeeCreditNegative();
},
async handleAbnormalAccountPageChange(pageInfo) {
if (typeof pageInfo === "number") {
this.abnormalAccountPageNum = pageInfo;
} else {
this.abnormalAccountPageNum = pageInfo.page;
this.abnormalAccountPageSize = 5;
}
await this.loadAbnormalAccountPeople();
},
async loadSuspiciousTransactions() {
if (!this.projectId) {
this.suspiciousTransactionList = [];
@@ -505,6 +538,33 @@ export default {
this.employeeCreditNegativeLoading = false;
}
},
async loadAbnormalAccountPeople() {
if (!this.projectId) {
this.abnormalAccountList = [];
this.abnormalAccountTotal = 0;
this.abnormalAccountLoading = false;
return;
}
this.abnormalAccountLoading = true;
try {
const response = await getOverviewAbnormalAccountPeople({
projectId: this.projectId,
pageNum: this.abnormalAccountPageNum,
pageSize: this.abnormalAccountPageSize,
});
const data = (response && response.data) || {};
this.abnormalAccountList = Array.isArray(data.rows) ? data.rows : [];
this.abnormalAccountTotal = Number(data.total) || 0;
} catch (error) {
this.abnormalAccountList = [];
this.abnormalAccountTotal = 0;
this.$message.error("加载异常账户人员信息失败");
console.error("加载异常账户人员信息失败", error);
} finally {
this.abnormalAccountLoading = false;
}
},
async hydrateSuspiciousRows(rows) {
const safeRows = Array.isArray(rows) ? rows : [];
if (!safeRows.length) {

View File

@@ -168,17 +168,17 @@ export const mockOverviewData = {
accountNo: "62209****1234",
accountName: "李四",
bankName: "中国农业银行",
lastTradeDate: "2024-01-15",
handler: "正常",
actionLabel: "查看详情",
abnormalType: "异常转账",
abnormalTime: "2024-01-15",
status: "已核查",
},
{
accountNo: "62209****5678",
accountName: "王五",
bankName: "中国工商银行",
lastTradeDate: "2024-01-10",
handler: "正常",
actionLabel: "查看详情",
abnormalType: "频繁交易",
abnormalTime: "2024-01-10",
status: "待核查",
},
],
},

View File

@@ -33,7 +33,17 @@ const preliminaryCheck = fs.readFileSync(
["部门", "请选择部门", "查询", "重置", "selectedModelText"].forEach((token) =>
assert(model.includes(token), token)
);
["风险明细", "涉疑交易明细", "异常账户人员信息", "查看详情"].forEach((token) =>
[
"风险明细",
"涉疑交易明细",
"异常账户人员信息",
"账号",
"开户人",
"银行",
"异常类型",
"异常发生时间",
"状态",
].forEach((token) =>
assert(detail.includes(token), token)
);
["getOverviewSuspiciousTransactions", "riskDetails"].forEach((token) =>

View File

@@ -0,0 +1,22 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const source = fs.readFileSync(
path.resolve(
__dirname,
"../../src/views/ccdiProject/components/detail/RiskDetailSection.vue"
),
"utf8"
);
[
"异常账户人员信息",
"账号",
"开户人",
"银行",
"异常类型",
"异常发生时间",
"状态",
"当前项目暂无异常账户人员信息",
].forEach((token) => assert(source.includes(token), token));

View File

@@ -0,0 +1,22 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const source = fs.readFileSync(
path.resolve(
__dirname,
"../../src/views/ccdiProject/components/detail/RiskDetailSection.vue"
),
"utf8"
);
[
"getOverviewAbnormalAccountPeople",
"abnormalAccountLoading",
"abnormalAccountPageNum",
"abnormalAccountPageSize",
"abnormalAccountTotal",
"abnormalAccountList",
"handleAbnormalAccountPageChange",
"loadAbnormalAccountPeople",
].forEach((token) => assert(source.includes(token), token));

View File

@@ -0,0 +1,126 @@
START TRANSACTION;
-- 清理本次异常账户规则验证样本
DELETE FROM ccdi_bank_statement_tag_result
WHERE project_id = 90331
AND model_code = 'ABNORMAL_ACCOUNT';
DELETE FROM ccdi_bank_statement
WHERE project_id = 90331;
DELETE FROM ccdi_account_info
WHERE account_no IN (
'6222000000000001',
'6222000000000002',
'6222000000000003',
'6222000000000004'
);
DELETE FROM ccdi_base_staff
WHERE staff_id IN (9033101, 9033102, 9033103, 9033104);
DELETE FROM ccdi_project
WHERE project_id = 90331;
INSERT INTO ccdi_project (
project_id, project_name, description, config_type, status, is_archived,
target_count, high_risk_count, medium_risk_count, low_risk_count,
del_flag, create_by, create_time, update_by, update_time, remark
) VALUES (
90331, '异常账户规则测试项目', '用于验证异常账户模型两条规则的最小样本项目',
'default', '0', 0,
4, 0, 0, 0,
'0', 'system', NOW(), 'system', NOW(), 'abnormal-account-rule-test'
);
INSERT INTO ccdi_base_staff (
staff_id, name, dept_id, id_card, phone, annual_income, hire_date,
status, create_by, create_time, update_by, update_time
) VALUES
(9033101, '测试员工A', 90331, '330101199001010001', '13800000001', 180000.00, '2020-01-01', '0', 'system', NOW(), 'system', NOW()),
(9033102, '测试员工B', 90331, '330101199001010002', '13800000002', 180000.00, '2020-01-01', '0', 'system', NOW(), 'system', NOW()),
(9033103, '测试员工C', 90331, '330101199001010003', '13800000003', 180000.00, '2020-01-01', '0', 'system', NOW(), 'system', NOW()),
(9033104, '测试员工D', 90331, '330101199001010004', '13800000004', 180000.00, '2020-01-01', '0', 'system', NOW(), 'system', NOW());
INSERT INTO ccdi_account_info (
account_no, account_type, account_name, owner_type, owner_id, bank, bank_code, currency,
is_self_account, monthly_avg_trans_count, monthly_avg_trans_amount, trans_freq_type,
dr_max_single_amount, cr_max_single_amount, dr_max_daily_amount, cr_max_daily_amount,
trans_risk_level, status, effective_date, invalid_date,
create_by, create_time, update_by, update_time
) VALUES
('6222000000000001', 'DEBIT', '测试员工A工资卡', 'EMPLOYEE', '330101199001010001', '兰溪农商银行', 'LXNCSY', 'CNY',
1, 3.00, 120000.00, 'LOW', 80000.00, 70000.00, 90000.00, 70000.00,
'HIGH', 2, '2024-01-01', '2026-03-20', 'system', NOW(), 'system', NOW()),
('6222000000000002', 'DEBIT', '测试员工B工资卡', 'EMPLOYEE', '330101199001010002', '兰溪农商银行', 'LXNCSY', 'CNY',
1, 2.00, 275000.00, 'LOW', 250000.00, 300000.00, 250000.00, 300000.00,
'HIGH', 1, '2025-01-01', NULL, 'system', NOW(), 'system', NOW()),
('6222000000000003', 'DEBIT', '测试员工C工资卡', 'EMPLOYEE', '330101199001010003', '兰溪农商银行', 'LXNCSY', 'CNY',
1, 1.00, 150000.00, 'LOW', 120000.00, 120000.00, 120000.00, 120000.00,
'MEDIUM', 1, '2025-05-01', NULL, 'system', NOW(), 'system', NOW()),
('6222000000000004', 'DEBIT', '测试员工D工资卡', 'EMPLOYEE', '330101199001010004', '兰溪农商银行', 'LXNCSY', 'CNY',
1, 1.00, 20000.00, 'LOW', 20000.00, 20000.00, 20000.00, 20000.00,
'LOW', 2, '2024-06-01', '2026-03-20', 'system', NOW(), 'system', NOW());
-- 员工 A命中 SUDDEN_ACCOUNT_CLOSURE
INSERT INTO ccdi_bank_statement (
project_id, LE_ID, ACCOUNT_ID, LE_ACCOUNT_NAME, LE_ACCOUNT_NO, ACCOUNTING_DATE_ID,
ACCOUNTING_DATE, TRX_DATE, CURRENCY, AMOUNT_DR, AMOUNT_CR, AMOUNT_BALANCE,
CASH_TYPE, CUSTOMER_LE_ID, CUSTOMER_ACCOUNT_NAME, CUSTOMER_ACCOUNT_NO, customer_bank,
customer_reference, USER_MEMO, BANK_COMMENTS, BANK_TRX_NUMBER, BANK, TRX_FLAG, TRX_TYPE,
EXCEPTION_TYPE, internal_flag, batch_id, batch_sequence, CREATE_DATE, created_by,
meta_json, no_balance, begin_balance, end_balance, group_id, override_bs_id, payment_method, cret_no
) VALUES
(90331, 0, 90331011, '测试员工A', '6222000000000001', 20260305, '2026-03-05', '2026-03-05', 'CNY',
0.00, 70000.00, 180000.00, '转账', -1, '交易对手A1', NULL, '兰溪农商银行', NULL,
'异常账户测试A-1', NULL, 'A0001', 'LANXI', '0', 0, '', 0, 1, 1, NOW(), 1, NULL, 0, 0, 0, 90331, 0, NULL, '330101199001010001'),
(90331, 0, 90331011, '测试员工A', '6222000000000001', 20260310, '2026-03-10', '2026-03-10', 'CNY',
50000.00, 0.00, 130000.00, '转账', -1, '交易对手A2', NULL, '兰溪农商银行', NULL,
'异常账户测试A-2', NULL, 'A0002', 'LANXI', '0', 0, '', 0, 1, 2, NOW(), 1, NULL, 0, 0, 0, 90331, 0, NULL, '330101199001010001'),
(90331, 0, 90331011, '测试员工A', '6222000000000001', 20260318, '2026-03-18', '2026-03-18', 'CNY',
0.00, 60000.00, 190000.00, '转账', -1, '交易对手A3', NULL, '兰溪农商银行', NULL,
'异常账户测试A-3', NULL, 'A0003', 'LANXI', '0', 0, '', 0, 1, 3, NOW(), 1, NULL, 0, 0, 0, 90331, 0, NULL, '330101199001010001');
-- 员工 B命中 DORMANT_ACCOUNT_LARGE_ACTIVATION
INSERT INTO ccdi_bank_statement (
project_id, LE_ID, ACCOUNT_ID, LE_ACCOUNT_NAME, LE_ACCOUNT_NO, ACCOUNTING_DATE_ID,
ACCOUNTING_DATE, TRX_DATE, CURRENCY, AMOUNT_DR, AMOUNT_CR, AMOUNT_BALANCE,
CASH_TYPE, CUSTOMER_LE_ID, CUSTOMER_ACCOUNT_NAME, CUSTOMER_ACCOUNT_NO, customer_bank,
customer_reference, USER_MEMO, BANK_COMMENTS, BANK_TRX_NUMBER, BANK, TRX_FLAG, TRX_TYPE,
EXCEPTION_TYPE, internal_flag, batch_id, batch_sequence, CREATE_DATE, created_by,
meta_json, no_balance, begin_balance, end_balance, group_id, override_bs_id, payment_method, cret_no
) VALUES
(90331, 0, 90331022, '测试员工B', '6222000000000002', 20250801, '2025-08-01', '2025-08-01', 'CNY',
0.00, 300000.00, 300000.00, '转账', -1, '交易对手B1', NULL, '兰溪农商银行', NULL,
'异常账户测试B-1', NULL, 'B0001', 'LANXI', '0', 0, '', 0, 1, 4, NOW(), 1, NULL, 0, 0, 0, 90331, 0, NULL, '330101199001010002'),
(90331, 0, 90331022, '测试员工B', '6222000000000002', 20250820, '2025-08-20', '2025-08-20', 'CNY',
250000.00, 0.00, 50000.00, '转账', -1, '交易对手B2', NULL, '兰溪农商银行', NULL,
'异常账户测试B-2', NULL, 'B0002', 'LANXI', '0', 0, '', 0, 1, 5, NOW(), 1, NULL, 0, 0, 0, 90331, 0, NULL, '330101199001010002');
-- 员工 C休眠不足 6 个月,不命中
INSERT INTO ccdi_bank_statement (
project_id, LE_ID, ACCOUNT_ID, LE_ACCOUNT_NAME, LE_ACCOUNT_NO, ACCOUNTING_DATE_ID,
ACCOUNTING_DATE, TRX_DATE, CURRENCY, AMOUNT_DR, AMOUNT_CR, AMOUNT_BALANCE,
CASH_TYPE, CUSTOMER_LE_ID, CUSTOMER_ACCOUNT_NAME, CUSTOMER_ACCOUNT_NO, customer_bank,
customer_reference, USER_MEMO, BANK_COMMENTS, BANK_TRX_NUMBER, BANK, TRX_FLAG, TRX_TYPE,
EXCEPTION_TYPE, internal_flag, batch_id, batch_sequence, CREATE_DATE, created_by,
meta_json, no_balance, begin_balance, end_balance, group_id, override_bs_id, payment_method, cret_no
) VALUES
(90331, 0, 90331033, '测试员工C', '6222000000000003', 20250801, '2025-08-01', '2025-08-01', 'CNY',
0.00, 120000.00, 120000.00, '转账', -1, '交易对手C1', NULL, '兰溪农商银行', NULL,
'异常账户测试C-1', NULL, 'C0001', 'LANXI', '0', 0, '', 0, 1, 6, NOW(), 1, NULL, 0, 0, 0, 90331, 0, NULL, '330101199001010003');
-- 员工 D销户前 30 天无流水,不命中
INSERT INTO ccdi_bank_statement (
project_id, LE_ID, ACCOUNT_ID, LE_ACCOUNT_NAME, LE_ACCOUNT_NO, ACCOUNTING_DATE_ID,
ACCOUNTING_DATE, TRX_DATE, CURRENCY, AMOUNT_DR, AMOUNT_CR, AMOUNT_BALANCE,
CASH_TYPE, CUSTOMER_LE_ID, CUSTOMER_ACCOUNT_NAME, CUSTOMER_ACCOUNT_NO, customer_bank,
customer_reference, USER_MEMO, BANK_COMMENTS, BANK_TRX_NUMBER, BANK, TRX_FLAG, TRX_TYPE,
EXCEPTION_TYPE, internal_flag, batch_id, batch_sequence, CREATE_DATE, created_by,
meta_json, no_balance, begin_balance, end_balance, group_id, override_bs_id, payment_method, cret_no
) VALUES
(90331, 0, 90331044, '测试员工D', '6222000000000004', 20260115, '2026-01-15', '2026-01-15', 'CNY',
0.00, 20000.00, 20000.00, '转账', -1, '交易对手D1', NULL, '兰溪农商银行', NULL,
'异常账户测试D-1', NULL, 'D0001', 'LANXI', '0', 0, '', 0, 1, 7, NOW(), 1, NULL, 0, 0, 0, 90331, 0, NULL, '330101199001010004');
COMMIT;

View File

@@ -0,0 +1,68 @@
START TRANSACTION;
CREATE TABLE IF NOT EXISTS `ccdi_account_info` (
`account_id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`account_no` VARCHAR(240) NOT NULL COMMENT '账号',
`account_type` VARCHAR(64) DEFAULT NULL COMMENT '账户类型',
`account_name` VARCHAR(128) DEFAULT NULL COMMENT '账户名称',
`owner_type` VARCHAR(32) NOT NULL COMMENT '归属人类型',
`owner_id` VARCHAR(64) NOT NULL COMMENT '归属人标识',
`bank` VARCHAR(128) DEFAULT NULL COMMENT '开户行',
`bank_code` VARCHAR(64) DEFAULT NULL COMMENT '开户行编码',
`currency` VARCHAR(32) DEFAULT NULL COMMENT '币种',
`is_self_account` TINYINT DEFAULT 1 COMMENT '是否本人账户',
`monthly_avg_trans_count` DECIMAL(18, 2) DEFAULT NULL COMMENT '月均交易笔数',
`monthly_avg_trans_amount` DECIMAL(18, 2) DEFAULT NULL COMMENT '月均交易金额',
`trans_freq_type` VARCHAR(32) DEFAULT NULL COMMENT '交易频率类型',
`dr_max_single_amount` DECIMAL(18, 2) DEFAULT NULL COMMENT '最大单笔支出金额',
`cr_max_single_amount` DECIMAL(18, 2) DEFAULT NULL COMMENT '最大单笔收入金额',
`dr_max_daily_amount` DECIMAL(18, 2) DEFAULT NULL COMMENT '最大单日支出金额',
`cr_max_daily_amount` DECIMAL(18, 2) DEFAULT NULL COMMENT '最大单日收入金额',
`trans_risk_level` VARCHAR(32) DEFAULT NULL COMMENT '交易风险等级',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '账户状态1正常2已销户',
`effective_date` DATE DEFAULT NULL COMMENT '开户日期',
`invalid_date` DATE DEFAULT NULL COMMENT '销户日期',
`create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建者',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` VARCHAR(64) DEFAULT NULL COMMENT '更新者',
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`account_id`),
KEY `idx_ccdi_account_info_owner` (`owner_type`, `owner_id`),
KEY `idx_ccdi_account_info_account_no` (`account_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='员工账户信息表';
INSERT INTO ccdi_bank_tag_rule (
model_code,
model_name,
rule_code,
rule_name,
indicator_code,
result_type,
risk_level,
business_caliber,
enabled,
sort_order,
create_by,
remark
) VALUES
('ABNORMAL_ACCOUNT', '异常账户', 'SUDDEN_ACCOUNT_CLOSURE', '突然销户', NULL, 'OBJECT', 'HIGH',
'员工本人账户已销户且销户日前30天内仍存在交易记录。', 1, 10, 'system',
'真实规则识别员工本人账户销户前30天内仍有交易的员工对象'),
('ABNORMAL_ACCOUNT', '异常账户', 'DORMANT_ACCOUNT_LARGE_ACTIVATION', '休眠账户大额启用', NULL, 'OBJECT', 'HIGH',
'员工本人账户开户后长期未使用,首次启用后出现大额资金流动。', 1, 20, 'system',
'真实规则:识别长期休眠后首次启用即出现大额资金流动的员工对象')
ON DUPLICATE KEY UPDATE
model_code = VALUES(model_code),
model_name = VALUES(model_name),
rule_name = VALUES(rule_name),
indicator_code = VALUES(indicator_code),
result_type = VALUES(result_type),
risk_level = VALUES(risk_level),
business_caliber = VALUES(business_caliber),
enabled = VALUES(enabled),
sort_order = VALUES(sort_order),
update_by = 'system',
update_time = NOW(),
remark = VALUES(remark);
COMMIT;

View File

@@ -43,3 +43,10 @@ def test_sh_dry_run_accepts_override_arguments():
assert "Port: 2222" in result.stdout
assert "Username: deploy-user" in result.stdout
assert "RemoteRoot: /volume2/custom/app" in result.stdout
def test_sh_script_should_render_nas_env_into_stage_package():
script_text = SCRIPT_PATH.read_text(encoding="utf-8")
assert 'render_nas_env.py' in script_text
assert '"${STAGE_ROOT}/.env"' in script_text

View File

@@ -0,0 +1,37 @@
from pathlib import Path
import subprocess
import tempfile
REPO_ROOT = Path(__file__).resolve().parents[2]
SCRIPT_PATH = REPO_ROOT / "deploy" / "render_nas_env.py"
ENV_TEMPLATE = REPO_ROOT / ".env.example"
def test_render_nas_env_should_generate_lsfx_mock_db_override_file():
with tempfile.TemporaryDirectory() as tmp_dir:
output_path = Path(tmp_dir) / ".env"
result = subprocess.run(
[
"python3",
str(SCRIPT_PATH),
"--template",
str(ENV_TEMPLATE),
"--output",
str(output_path),
],
cwd=REPO_ROOT,
capture_output=True,
text=True,
)
assert result.returncode == 0
assert output_path.exists()
env_text = output_path.read_text(encoding="utf-8")
assert "CCDI_DB_HOST=192.168.0.111" in env_text
assert "CCDI_DB_PORT=40628" in env_text
assert "CCDI_DB_NAME=ccdi" in env_text