Compare commits

...

36 Commits

Author SHA1 Message Date
wkc
f06ae4a9bf 补充结果总览风险接口设计与实施计划 2026-03-19 12:12:48 +08:00
wkc
97bd3de299 调整风险仪表盘指标文案 2026-03-19 11:12:39 +08:00
wkc
8f3108d1cd 移除风险仪表盘操作按钮 2026-03-19 11:09:14 +08:00
wkc
42847ffdba 调整结果总览页面样式与文案 2026-03-19 11:02:16 +08:00
wkc
a508977472 完成结果总览页面前端实现 2026-03-19 10:39:24 +08:00
wkc
e4706fb7e8 实现结果总览模型与明细区块 2026-03-19 10:37:53 +08:00
wkc
75dbb76e0c 实现结果总览前两块静态页面 2026-03-19 10:36:45 +08:00
wkc
01ba288581 搭建结果总览页面骨架 2026-03-19 10:35:40 +08:00
wkc
4f945a6ed3 新增结果总览页面前后端实施计划 2026-03-19 10:32:55 +08:00
wkc
dbaab75116 新增结果总览页面前端设计文档 2026-03-19 10:30:12 +08:00
wkc
144897237b 修复流水异常标签展示与导出 2026-03-19 10:20:58 +08:00
wkc
e058cec78e 补充参数保存触发重打标前端实施记录 2026-03-19 09:14:28 +08:00
wkc
98b62efec7 合并参数保存触发重打标后端改动 2026-03-19 09:12:14 +08:00
wkc
d03427bde4 补充参数保存触发重打标后端实施记录 2026-03-19 09:06:26 +08:00
wkc
f5dcbbf821 实现参数保存后自动触发项目重打标 2026-03-19 09:06:21 +08:00
wkc
d922682d5a 新增流水异常标签前后端实施计划 2026-03-19 09:04:06 +08:00
wkc
a70fcb42c7 修正异常标签展示设计文档保存路径 2026-03-18 17:40:59 +08:00
wkc
0233e203b7 参数保存后异步触发项目流水重打标 2026-03-18 17:18:39 +08:00
wkc
acf5249caf 补充项目状态变更日志 2026-03-18 17:03:23 +08:00
wkc
cc09936556 收敛银行流水打标批量入库SQL日志 2026-03-18 16:49:04 +08:00
wkc
25a2a487dc fix: 统一mock流水可识别身份证来源 2026-03-18 16:39:09 +08:00
wkc
ddd8cc5dc8 Merge branch 'codex/lsfx-logid-primary-binding' into dev 2026-03-18 15:57:22 +08:00
wkc
c0ce5ca7f9 实现项目打标状态联动并执行前后端适配 2026-03-18 15:55:55 +08:00
wkc
ba2df2b395 补充Mock主体账号绑定实施记录 2026-03-18 15:55:41 +08:00
wkc
5195617a70 让Mock流水查询复用logId主体账号绑定 2026-03-18 15:54:11 +08:00
wkc
0a85c098e8 统一Mock上传状态主体账号绑定优先级 2026-03-18 15:50:28 +08:00
wkc
6fb728709e 让拉取本行信息链路复用Mock主体账号绑定 2026-03-18 15:01:58 +08:00
wkc
0120d097be 收敛Mock文件记录主体账号绑定模型 2026-03-18 14:51:09 +08:00
wkc
e9394939c9 新增项目打标状态联动实施计划 2026-03-18 14:43:26 +08:00
wkc
883b370e4b 调整项目打标状态设计文档目录 2026-03-18 14:37:58 +08:00
wkc
a10021a881 补充LSFX Mock主体账号绑定实施计划 2026-03-18 14:37:15 +08:00
wkc
c9c1676602 新增项目打标状态联动设计文档 2026-03-18 14:35:16 +08:00
wkc
28b0749e51 补充LSFX Mock主体账号绑定设计文档 2026-03-18 14:33:12 +08:00
wkc
7624a75dee 补充LSFX Mock大额交易实施计划 2026-03-18 14:14:34 +08:00
wkc
2db9ee7860 补充LSFX Mock大额交易样本设计文档 2026-03-18 14:10:20 +08:00
wkc
b07b725057 完成银行流水打标规则大写编码与后端落地 2026-03-18 13:44:15 +08:00
131 changed files with 12190 additions and 214 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -28,6 +28,7 @@
- 测试结束后,自动关闭测试过程中启动的前后端进程
- 遇到 MCP 数据库操作时,使用项目配置文件中的数据库连接信息
- 执行包含中文内容的 MySQL SQL 脚本时,禁止直接手写 `mysql -e` 或普通重定向执行;必须优先使用 `bin/mysql_utf8_exec.sh <sql-file>`,确保会话字符集为 `utf8mb4`,避免写入乱码
- 银行流水打标相关规则与参数编码需要统一使用全大写;新增或修改 `rule_code``indicator_code``param_code` 时,禁止混用大小写风格
---

View File

@@ -5,6 +5,7 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;
/**
* 流水标签线程池配置
@@ -12,6 +13,23 @@ import java.util.concurrent.Executor;
@Configuration
public class BankTagThreadPoolConfig {
/**
* 项目级重打标异步调度线程池
*
* @return 线程池执行器
*/
@Bean("tagRebuildExecutor")
public Executor tagRebuildExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("bank-tag-rebuild-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
executor.initialize();
return executor;
}
/**
* 规则级并行执行线程池
*

View File

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

View File

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

View File

@@ -11,6 +11,9 @@ public enum TriggerType {
/** 自动拉取本行信息 */
AUTO_PULL_BANK_INFO,
/** 自动参数变更 */
AUTO_PARAM_CHANGE,
/** 手动触发 */
MANUAL
}

View File

@@ -41,6 +41,10 @@ public class CcdiBankStatementExcel {
@Excel(name = "交易类型")
private String cashType;
/** 异常标签 */
@Excel(name = "异常标签")
private String hitTags;
/** 交易金额 */
@Excel(name = "交易金额")
private BigDecimal displayAmount;

View File

@@ -3,7 +3,9 @@ package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
/**
* 流水明细详情VO
@@ -96,4 +98,7 @@ public class CcdiBankStatementDetailVO {
/** 原始文件上传时间 */
private Date uploadTime;
/** 命中异常标签 */
private List<CcdiBankStatementHitTagVO> hitTags = new ArrayList<>();
}

View File

@@ -0,0 +1,25 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
/**
* 流水命中异常标签VO
*/
@Data
public class CcdiBankStatementHitTagVO {
/** 规则编码 */
private String ruleCode;
/** 规则名称 */
private String ruleName;
/** 风险等级 */
private String riskLevel;
/** 命中原因 */
private String reasonDetail;
/** 流水ID */
private Long bankStatementId;
}

View File

@@ -3,6 +3,8 @@ package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
/**
* 流水明细列表VO
@@ -38,4 +40,7 @@ public class CcdiBankStatementListVO {
/** 页面展示金额 */
private BigDecimal displayAmount;
/** 命中异常标签 */
private List<CcdiBankStatementHitTagVO> hitTags = new ArrayList<>();
}

View File

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

View File

@@ -89,4 +89,204 @@ public interface CcdiBankTagAnalysisMapper {
*/
List<BankTagStatementHitVO> selectLargeTransferStatements(@Param("projectId") Long projectId,
@Param("threshold") BigDecimal threshold);
/**
* 与客户之间非正常资金往来
*
* @param projectId 项目ID
* @return 流水命中结果
*/
List<BankTagStatementHitVO> selectAbnormalCustomerTransactionStatements(@Param("projectId") Long projectId);
/**
* 低收入亲属大额交易
*
* @param projectId 项目ID
* @return 对象命中结果
*/
List<BankTagObjectHitVO> selectLowIncomeRelativeLargeTransactionObjects(@Param("projectId") Long projectId);
/**
* 疑似赌博交易
*
* @param projectId 项目ID
* @return 对象命中结果
*/
List<BankTagObjectHitVO> selectMultiPartyGamblingTransferObjects(@Param("projectId") Long projectId);
/**
* 疑似敏感交易
*
* @param projectId 项目ID
* @return 流水命中结果
*/
List<BankTagStatementHitVO> selectGamblingSensitiveKeywordStatements(@Param("projectId") Long projectId);
/**
* 特殊金额交易
*
* @param projectId 项目ID
* @return 流水命中结果
*/
List<BankTagStatementHitVO> selectSpecialAmountTransactionStatements(@Param("projectId") Long projectId);
/**
* 月度固定收入疑似兼职
*
* @param projectId 项目ID
* @return 对象命中结果
*/
List<BankTagObjectHitVO> selectMonthlyFixedIncomeObjects(@Param("projectId") Long projectId);
/**
* 固定交易对手转入疑似兼职
*
* @param projectId 项目ID
* @return 对象命中结果
*/
List<BankTagObjectHitVO> selectFixedCounterpartyTransferObjects(@Param("projectId") Long projectId);
/**
* 摘要收入疑似兼职
*
* @param projectId 项目ID
* @return 流水命中结果
*/
List<BankTagStatementHitVO> selectSuspiciousIncomeKeywordStatements(@Param("projectId") Long projectId);
/**
* 购房交易与房产登记不匹配
*
* @param projectId 项目ID
* @return 流水命中结果
*/
List<BankTagStatementHitVO> selectHouseRegistrationMismatchStatements(@Param("projectId") Long projectId);
/**
* 物业缴费与房产登记不匹配
*
* @param projectId 项目ID
* @return 流水命中结果
*/
List<BankTagStatementHitVO> selectPropertyFeeRegistrationMismatchStatements(@Param("projectId") Long projectId);
/**
* 大额纳税与资产登记不匹配
*
* @param projectId 项目ID
* @return 流水命中结果
*/
List<BankTagStatementHitVO> selectTaxAssetRegistrationMismatchStatements(@Param("projectId") Long projectId);
/**
* 收入资产不符
*
* @param projectId 项目ID
* @return 流水命中结果
*/
List<BankTagStatementHitVO> selectIncomeAssetMismatchStatements(@Param("projectId") Long projectId);
/**
* 单笔购汇金额超限
*
* @param projectId 项目ID
* @return 流水命中结果
*/
List<BankTagStatementHitVO> selectForexBuyAmtStatements(@Param("projectId") Long projectId);
/**
* 单笔结汇金额超限
*
* @param projectId 项目ID
* @return 流水命中结果
*/
List<BankTagStatementHitVO> selectForexSellAmtStatements(@Param("projectId") Long projectId);
/**
* 单笔跨境汇款金额超限
*
* @param projectId 项目ID
* @return 流水命中结果
*/
List<BankTagStatementHitVO> selectCrossBorderAmtStatements(@Param("projectId") Long projectId);
/**
* 可疑付息
*
* @param projectId 项目ID
* @return 对象命中结果
*/
List<BankTagObjectHitVO> selectInterestPaymentByOthersObjects(@Param("projectId") Long projectId);
/**
* 单笔采购金额超过10万元
*
* @param projectId 项目ID
* @return 流水命中结果
*/
List<BankTagStatementHitVO> selectLargePurchaseTransactionStatements(@Param("projectId") Long projectId);
/**
* 供应商集中采购
*
* @param projectId 项目ID
* @return 对象命中结果
*/
List<BankTagObjectHitVO> selectSupplierConcentrationObjects(@Param("projectId") Long projectId);
/**
* 可疑银证大额转账
*
* @param projectId 项目ID
* @return 流水命中结果
*/
List<BankTagStatementHitVO> selectStockTfrLargeStatements(@Param("projectId") Long projectId);
/**
* 微信支付宝频繁提现
*
* @param projectId 项目ID
* @return 对象命中结果
*/
List<BankTagObjectHitVO> selectWithdrawCntObjects(@Param("projectId") Long projectId);
/**
* 微信支付宝提现超额
*
* @param projectId 项目ID
* @return 对象命中结果
*/
List<BankTagObjectHitVO> selectWithdrawAmtObjects(@Param("projectId") Long projectId);
/**
* 工资快速转出
*
* @param projectId 项目ID
* @return 对象命中结果
*/
List<BankTagObjectHitVO> selectSalaryQuickTransferObjects(@Param("projectId") Long projectId);
/**
* 工资无使用记录
*
* @param projectId 项目ID
* @return 对象命中结果
*/
List<BankTagObjectHitVO> selectSalaryUnusedObjects(@Param("projectId") Long projectId);
/**
* 大额炒股
*
* @param projectId 项目ID
* @return 流水命中结果
*/
List<BankTagStatementHitVO> selectLargeStockTradingStatements(@Param("projectId") Long projectId);
/**
* 疑似代理他人账户
*
* @param projectId 项目ID
* @return 对象命中结果
*/
List<BankTagObjectHitVO> selectProxyAccountOperationObjects(@Param("projectId") Long projectId);
}

View File

@@ -2,6 +2,7 @@ package com.ruoyi.ccdi.project.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.ccdi.project.domain.entity.CcdiBankTagResult;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementHitTagVO;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@@ -20,6 +21,18 @@ public interface CcdiBankTagResultMapper extends BaseMapper<CcdiBankTagResult> {
*/
int deleteByProjectAndModel(@Param("projectId") Long projectId, @Param("modelCode") String modelCode);
/**
* 按项目和流水ID批量查询命中的异常标签
*
* @param projectId 项目ID
* @param bankStatementIds 流水ID列表
* @return 命中的异常标签列表
*/
List<CcdiBankStatementHitTagVO> selectStatementTagsByProjectAndStatementIds(
@Param("projectId") Long projectId,
@Param("bankStatementIds") List<Long> bankStatementIds
);
/**
* 批量插入结果
*

View File

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

View File

@@ -27,7 +27,7 @@ public class BankTagRuleConfigResolver {
private static final Map<String, Set<String>> RULE_PARAM_MAPPING = Map.of(
"SINGLE_LARGE_INCOME", Set.of("SINGLE_TRANSACTION_AMOUNT"),
"CUMULATIVE_INCOME", Set.of("CUMULATIVE_TRANSACTION_AMOUNT"),
"ANNUAL_TURNOVER", Set.of("annual_turnover"),
"ANNUAL_TURNOVER", Set.of("ANNUAL_TURNOVER"),
"LARGE_CASH_DEPOSIT", Set.of("LARGE_CASH_DEPOSIT"),
"FREQUENT_CASH_DEPOSIT", Set.of("LARGE_CASH_DEPOSIT", "FREQUENT_CASH_DEPOSIT"),
"LARGE_TRANSFER", Set.of("FREQUENT_TRANSFER")

View File

@@ -5,16 +5,21 @@ import com.ruoyi.ccdi.project.domain.dto.CcdiBankStatementQueryDTO;
import com.ruoyi.ccdi.project.domain.excel.CcdiBankStatementExcel;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementDetailVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementFilterOptionsVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementHitTagVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO;
import com.ruoyi.ccdi.project.mapper.CcdiBankStatementMapper;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagResultMapper;
import com.ruoyi.ccdi.project.service.ICcdiBankStatementService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.Collections;
import java.util.Map;
import java.util.stream.Collectors;
/**
@@ -31,6 +36,9 @@ public class CcdiBankStatementServiceImpl implements ICcdiBankStatementService {
@Resource
private CcdiBankStatementMapper bankStatementMapper;
@Resource
private CcdiBankTagResultMapper bankTagResultMapper;
@Override
public CcdiBankStatementFilterOptionsVO getFilterOptions(Long projectId) {
CcdiBankStatementFilterOptionsVO options = bankStatementMapper.selectFilterOptions(projectId);
@@ -42,7 +50,9 @@ public class CcdiBankStatementServiceImpl implements ICcdiBankStatementService {
CcdiBankStatementQueryDTO queryDTO) {
CcdiBankStatementQueryDTO normalizedQuery = queryDTO == null ? new CcdiBankStatementQueryDTO() : queryDTO;
normalizeQuery(normalizedQuery);
return bankStatementMapper.selectStatementPage(page, normalizedQuery);
Page<CcdiBankStatementListVO> result = bankStatementMapper.selectStatementPage(page, normalizedQuery);
attachHitTags(result == null ? Collections.emptyList() : result.getRecords(), normalizedQuery.getProjectId());
return result;
}
@Override
@@ -53,12 +63,58 @@ public class CcdiBankStatementServiceImpl implements ICcdiBankStatementService {
if (rows == null || rows.isEmpty()) {
return Collections.emptyList();
}
attachHitTags(rows, normalizedQuery.getProjectId());
return rows.stream().map(this::toExcel).collect(Collectors.toList());
}
@Override
public CcdiBankStatementDetailVO getStatementDetail(Long bankStatementId) {
return bankStatementMapper.selectStatementDetailById(bankStatementId);
CcdiBankStatementDetailVO detail = bankStatementMapper.selectStatementDetailById(bankStatementId);
if (detail == null || detail.getProjectId() == null || detail.getBankStatementId() == null) {
return detail;
}
Map<Long, List<CcdiBankStatementHitTagVO>> hitTagMap = loadHitTagMap(
detail.getProjectId(),
List.of(detail.getBankStatementId())
);
detail.setHitTags(new ArrayList<>(hitTagMap.getOrDefault(detail.getBankStatementId(), Collections.emptyList())));
return detail;
}
private void attachHitTags(List<CcdiBankStatementListVO> rows, Long projectId) {
if (rows == null || rows.isEmpty() || projectId == null) {
return;
}
List<Long> bankStatementIds = rows.stream()
.map(CcdiBankStatementListVO::getBankStatementId)
.filter(item -> item != null)
.distinct()
.collect(Collectors.toList());
if (bankStatementIds.isEmpty()) {
return;
}
Map<Long, List<CcdiBankStatementHitTagVO>> hitTagMap = loadHitTagMap(projectId, bankStatementIds);
rows.forEach(row -> row.setHitTags(new ArrayList<>(
hitTagMap.getOrDefault(row.getBankStatementId(), Collections.emptyList())
)));
}
private Map<Long, List<CcdiBankStatementHitTagVO>> loadHitTagMap(Long projectId, List<Long> bankStatementIds) {
if (projectId == null || bankStatementIds == null || bankStatementIds.isEmpty()) {
return Collections.emptyMap();
}
List<CcdiBankStatementHitTagVO> hitTags =
bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(projectId, bankStatementIds);
if (hitTags == null || hitTags.isEmpty()) {
return Collections.emptyMap();
}
return hitTags.stream()
.filter(item -> item.getBankStatementId() != null)
.collect(Collectors.groupingBy(
CcdiBankStatementHitTagVO::getBankStatementId,
LinkedHashMap::new,
Collectors.toList()
));
}
private void normalizeQuery(CcdiBankStatementQueryDTO queryDTO) {
@@ -138,7 +194,19 @@ public class CcdiBankStatementServiceImpl implements ICcdiBankStatementService {
excel.setCustomerAccountNo(row.getCustomerAccountNo());
excel.setUserMemo(row.getUserMemo());
excel.setCashType(row.getCashType());
excel.setHitTags(formatHitTags(row.getHitTags()));
excel.setDisplayAmount(row.getDisplayAmount());
return excel;
}
private String formatHitTags(List<CcdiBankStatementHitTagVO> hitTags) {
if (hitTags == null || hitTags.isEmpty()) {
return "";
}
return hitTags.stream()
.map(CcdiBankStatementHitTagVO::getRuleName)
.filter(item -> item != null && !item.isBlank())
.distinct()
.collect(Collectors.joining(""));
}
}

View File

@@ -1,5 +1,6 @@
package com.ruoyi.ccdi.project.service.impl;
import com.ruoyi.ccdi.project.constants.CcdiProjectStatusConstants;
import com.ruoyi.ccdi.project.domain.dto.CcdiBankTagRebuildDTO;
import com.ruoyi.ccdi.project.domain.entity.CcdiBankTagResult;
import com.ruoyi.ccdi.project.domain.entity.CcdiBankTagRule;
@@ -13,6 +14,7 @@ import com.ruoyi.ccdi.project.mapper.CcdiBankTagResultMapper;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagRuleMapper;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagTaskMapper;
import com.ruoyi.ccdi.project.service.ICcdiBankTagService;
import com.ruoyi.ccdi.project.service.ICcdiProjectService;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
@@ -54,6 +56,9 @@ public class CcdiBankTagServiceImpl implements ICcdiBankTagService {
@Resource
private BankTagRuleConfigResolver configResolver;
@Resource
private ICcdiProjectService projectService;
@Resource
@Qualifier("tagRuleExecutor")
private Executor tagRuleExecutor;
@@ -88,6 +93,7 @@ public class CcdiBankTagServiceImpl implements ICcdiBankTagService {
*/
public Long rebuildProject(Long projectId, String modelCode, String operator, TriggerType triggerType) {
long taskStartTime = System.currentTimeMillis();
projectService.updateProjectStatus(projectId, CcdiProjectStatusConstants.TAGGING, operator);
CcdiBankTagTask task = buildRunningTask(projectId, modelCode, operator, triggerType);
taskMapper.insertTask(task);
log.info("【流水标签】任务创建成功: taskId={}, projectId={}, modelCode={}, triggerType={}, operator={}",
@@ -128,6 +134,7 @@ public class CcdiBankTagServiceImpl implements ICcdiBankTagService {
task.setUpdateBy(operator);
task.setUpdateTime(new Date());
taskMapper.updateTask(task);
projectService.updateProjectStatus(projectId, CcdiProjectStatusConstants.COMPLETED, operator);
log.info("【流水标签】任务执行成功: taskId={}, projectId={}, modelCode={}, triggerType={}, ruleCount={}, hitCount={}, costMs={}",
task.getId(), projectId, modelCode, triggerType, rules.size(), allResults.size(),
System.currentTimeMillis() - taskStartTime);
@@ -140,6 +147,7 @@ public class CcdiBankTagServiceImpl implements ICcdiBankTagService {
task.setUpdateBy(operator);
task.setUpdateTime(new Date());
taskMapper.updateTask(task);
projectService.updateProjectStatus(projectId, CcdiProjectStatusConstants.PROCESSING, operator);
log.error("【流水标签】任务执行失败: taskId={}, projectId={}, modelCode={}, triggerType={}, error={}",
task.getId(), projectId, modelCode, triggerType, ex.getMessage(), ex);
throw ex;
@@ -211,6 +219,20 @@ public class CcdiBankTagServiceImpl implements ICcdiBankTagService {
case "LARGE_TRANSFER" -> analysisMapper.selectLargeTransferStatements(
projectId, toBigDecimal(config.getThresholdValue("FREQUENT_TRANSFER"))
);
case "ABNORMAL_CUSTOMER_TRANSACTION" -> analysisMapper.selectAbnormalCustomerTransactionStatements(projectId);
case "GAMBLING_SENSITIVE_KEYWORD" -> analysisMapper.selectGamblingSensitiveKeywordStatements(projectId);
case "SPECIAL_AMOUNT_TRANSACTION" -> analysisMapper.selectSpecialAmountTransactionStatements(projectId);
case "SUSPICIOUS_INCOME_KEYWORD" -> analysisMapper.selectSuspiciousIncomeKeywordStatements(projectId);
case "HOUSE_REGISTRATION_MISMATCH" -> analysisMapper.selectHouseRegistrationMismatchStatements(projectId);
case "PROPERTY_FEE_REGISTRATION_MISMATCH" -> analysisMapper.selectPropertyFeeRegistrationMismatchStatements(projectId);
case "TAX_ASSET_REGISTRATION_MISMATCH" -> analysisMapper.selectTaxAssetRegistrationMismatchStatements(projectId);
case "INCOME_ASSET_MISMATCH" -> analysisMapper.selectIncomeAssetMismatchStatements(projectId);
case "FOREX_BUY_AMT" -> analysisMapper.selectForexBuyAmtStatements(projectId);
case "FOREX_SELL_AMT" -> analysisMapper.selectForexSellAmtStatements(projectId);
case "CROSS_BORDER_AMT" -> analysisMapper.selectCrossBorderAmtStatements(projectId);
case "LARGE_PURCHASE_TRANSACTION" -> analysisMapper.selectLargePurchaseTransactionStatements(projectId);
case "STOCK_TFR_LARGE" -> analysisMapper.selectStockTfrLargeStatements(projectId);
case "LARGE_STOCK_TRADING" -> analysisMapper.selectLargeStockTradingStatements(projectId);
default -> List.of();
};
}
@@ -223,13 +245,24 @@ public class CcdiBankTagServiceImpl implements ICcdiBankTagService {
projectId, toBigDecimal(config.getThresholdValue("CUMULATIVE_TRANSACTION_AMOUNT"))
);
case "ANNUAL_TURNOVER" -> analysisMapper.selectAnnualTurnoverObjects(
projectId, toBigDecimal(config.getThresholdValue("annual_turnover"))
projectId, toBigDecimal(config.getThresholdValue("ANNUAL_TURNOVER"))
);
case "FREQUENT_CASH_DEPOSIT" -> analysisMapper.selectFrequentCashDepositObjects(
projectId,
toBigDecimal(config.getThresholdValue("LARGE_CASH_DEPOSIT")),
toInteger(config.getThresholdValue("FREQUENT_CASH_DEPOSIT"))
);
case "LOW_INCOME_RELATIVE_LARGE_TRANSACTION" -> analysisMapper.selectLowIncomeRelativeLargeTransactionObjects(projectId);
case "MULTI_PARTY_GAMBLING_TRANSFER" -> analysisMapper.selectMultiPartyGamblingTransferObjects(projectId);
case "MONTHLY_FIXED_INCOME" -> analysisMapper.selectMonthlyFixedIncomeObjects(projectId);
case "FIXED_COUNTERPARTY_TRANSFER" -> analysisMapper.selectFixedCounterpartyTransferObjects(projectId);
case "INTEREST_PAYMENT_BY_OTHERS" -> analysisMapper.selectInterestPaymentByOthersObjects(projectId);
case "SUPPLIER_CONCENTRATION" -> analysisMapper.selectSupplierConcentrationObjects(projectId);
case "WITHDRAW_CNT" -> analysisMapper.selectWithdrawCntObjects(projectId);
case "WITHDRAW_AMT" -> analysisMapper.selectWithdrawAmtObjects(projectId);
case "SALARY_QUICK_TRANSFER" -> analysisMapper.selectSalaryQuickTransferObjects(projectId);
case "SALARY_UNUSED" -> analysisMapper.selectSalaryUnusedObjects(projectId);
case "PROXY_ACCOUNT_OPERATION" -> analysisMapper.selectProxyAccountOperationObjects(projectId);
default -> List.of();
};
}

View File

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

View File

@@ -16,7 +16,10 @@ import com.ruoyi.ccdi.project.domain.vo.ModelParamAllVO;
import com.ruoyi.ccdi.project.domain.vo.ModelGroupVO;
import com.ruoyi.ccdi.project.mapper.CcdiModelParamMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.ccdi.project.domain.enums.TriggerType;
import com.ruoyi.ccdi.project.service.ICcdiBankTagService;
import com.ruoyi.ccdi.project.service.ICcdiModelParamService;
import com.ruoyi.ccdi.project.service.ICcdiProjectService;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -46,6 +49,12 @@ public class CcdiModelParamServiceImpl implements ICcdiModelParamService {
@Resource
private CcdiProjectMapper projectMapper;
@Resource
private ICcdiProjectService projectService;
@Resource
private ICcdiBankTagService bankTagService;
@Override
public List<ModelListVO> selectModelList(Long projectId) {
log.info("selectModelList 被调用projectId={}", projectId);
@@ -102,10 +111,12 @@ public class CcdiModelParamServiceImpl implements ICcdiModelParamService {
Long projectId = saveDTO.getProjectId();
if (projectId > 0) {
projectService.ensureProjectWritable(projectId, "当前项目正在进行银行流水打标,暂不允许修改参数");
switchToCustomConfigIfNeeded(getRequiredProject(projectId));
}
String username = SecurityUtils.getUsername();
int updatedCount = 0;
for (ModelParamSaveDTO.ParamValueItem item : saveDTO.getParams()) {
int updated = modelParamMapper.updateParamValue(
projectId,
@@ -116,9 +127,12 @@ public class CcdiModelParamServiceImpl implements ICcdiModelParamService {
);
if (updated == 0) {
log.warn("参数不存在或未更新paramCode={}", item.getParamCode());
continue;
}
updatedCount += updated;
}
submitAutoRebuildIfNeeded(projectId, updatedCount);
} catch (ServiceException e) {
// 业务异常,直接抛出
throw e;
@@ -178,6 +192,7 @@ public class CcdiModelParamServiceImpl implements ICcdiModelParamService {
Long projectId = saveAllDTO.getProjectId();
if (projectId > 0) {
projectService.ensureProjectWritable(projectId, "当前项目正在进行银行流水打标,暂不允许修改参数");
switchToCustomConfigIfNeeded(getRequiredProject(projectId));
}
@@ -207,6 +222,7 @@ public class CcdiModelParamServiceImpl implements ICcdiModelParamService {
if (!updateList.isEmpty()) {
modelParamMapper.batchUpdateParamValues(updateList);
log.info("批量更新参数成功, count={}", updateList.size());
submitAutoRebuildIfNeeded(projectId, updateList.size());
}
} catch (ServiceException e) {
throw e;
@@ -284,4 +300,13 @@ public class CcdiModelParamServiceImpl implements ICcdiModelParamService {
target.setUpdateBy(username);
return target;
}
private void submitAutoRebuildIfNeeded(Long projectId, int updatedCount) {
if (projectId == null || projectId <= 0 || updatedCount <= 0) {
return;
}
bankTagService.submitAutoRebuild(projectId, TriggerType.AUTO_PARAM_CHANGE);
log.info("项目参数保存成功,已触发流水自动重打标: projectId={}, updatedCount={}", projectId, updatedCount);
}
}

View File

@@ -2,6 +2,7 @@ package com.ruoyi.ccdi.project.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.constants.CcdiProjectStatusConstants;
import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSaveDTO;
@@ -14,15 +15,21 @@ import com.ruoyi.lsfx.client.LsfxAnalysisClient;
import com.ruoyi.lsfx.domain.request.GetTokenRequest;
import com.ruoyi.lsfx.domain.response.GetTokenResponse;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.Date;
import java.util.Objects;
/**
* 项目Service实现类
*
* @author ruoyi
*/
@Slf4j
@Service
public class CcdiProjectServiceImpl implements ICcdiProjectService {
@@ -43,7 +50,7 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
BeanUtils.copyProperties(dto, project);
// 3. 设置默认值和流水分析平台ID
project.setStatus("0"); // 进行中
project.setStatus(CcdiProjectStatusConstants.PROCESSING);
project.setIsArchived(0); // 未归档
project.setTargetCount(0);
project.setHighRiskCount(0);
@@ -53,6 +60,9 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
// 4. 保存到数据库
projectMapper.insert(project);
log.info("【项目】项目状态初始化: projectId={}, projectName={}, newStatus={}, newStatusLabel={}, operator={}",
project.getProjectId(), project.getProjectName(), project.getStatus(), resolveStatusLabel(project.getStatus()),
resolveOperator(project.getCreateBy()));
// 5. 返回VO
CcdiProjectVO vo = new CcdiProjectVO();
@@ -115,27 +125,90 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
// 统计进行中项目状态0
Long status0Count = projectMapper.selectCount(
new LambdaQueryWrapper<CcdiProject>()
.eq(CcdiProject::getStatus, "0")
.eq(CcdiProject::getStatus, CcdiProjectStatusConstants.PROCESSING)
);
vo.setStatus0(status0Count);
// 统计已完成项目状态1
Long status1Count = projectMapper.selectCount(
new LambdaQueryWrapper<CcdiProject>()
.eq(CcdiProject::getStatus, "1")
.eq(CcdiProject::getStatus, CcdiProjectStatusConstants.COMPLETED)
);
vo.setStatus1(status1Count);
// 统计已归档项目状态2
Long status2Count = projectMapper.selectCount(
new LambdaQueryWrapper<CcdiProject>()
.eq(CcdiProject::getStatus, "2")
.eq(CcdiProject::getStatus, CcdiProjectStatusConstants.ARCHIVED)
);
vo.setStatus2(status2Count);
Long status3Count = projectMapper.selectCount(
new LambdaQueryWrapper<CcdiProject>()
.eq(CcdiProject::getStatus, CcdiProjectStatusConstants.TAGGING)
);
vo.setStatus3(status3Count);
return vo;
}
@Override
public void updateProjectStatus(Long projectId, String status, String operator) {
CcdiProject project = getRequiredProject(projectId);
String oldStatus = project.getStatus();
if (CcdiProjectStatusConstants.ARCHIVED.equals(project.getStatus())
&& !CcdiProjectStatusConstants.ARCHIVED.equals(status)) {
throw new ServiceException("已归档项目不允许重新进入打标流程");
}
project.setStatus(status);
project.setUpdateBy(operator);
project.setUpdateTime(new Date());
projectMapper.updateById(project);
if (!Objects.equals(oldStatus, status)) {
log.info("【项目】项目状态变更: projectId={}, projectName={}, oldStatus={}, oldStatusLabel={}, newStatus={}, newStatusLabel={}, operator={}",
project.getProjectId(), project.getProjectName(), oldStatus, resolveStatusLabel(oldStatus),
status, resolveStatusLabel(status), resolveOperator(operator));
}
}
@Override
public void ensureProjectCanStartTagging(Long projectId) {
CcdiProject project = getRequiredProject(projectId);
if (CcdiProjectStatusConstants.ARCHIVED.equals(project.getStatus())) {
throw new ServiceException("已归档项目不允许重新进入打标流程");
}
}
@Override
public void ensureProjectWritable(Long projectId, String message) {
CcdiProject project = getRequiredProject(projectId);
if (CcdiProjectStatusConstants.TAGGING.equals(project.getStatus())) {
throw new ServiceException(message);
}
}
private CcdiProject getRequiredProject(Long projectId) {
CcdiProject project = projectMapper.selectById(projectId);
if (project == null) {
throw new ServiceException("项目不存在");
}
return project;
}
private String resolveStatusLabel(String status) {
return switch (status) {
case CcdiProjectStatusConstants.PROCESSING -> "进行中";
case CcdiProjectStatusConstants.COMPLETED -> "已完成";
case CcdiProjectStatusConstants.ARCHIVED -> "已归档";
case CcdiProjectStatusConstants.TAGGING -> "打标中";
default -> "未知";
};
}
private String resolveOperator(String operator) {
return StringUtils.hasText(operator) ? operator : "system";
}
/**
* 调用流水分析平台获取projectId
*

View File

@@ -3,12 +3,15 @@ package com.ruoyi.ccdi.project.service.impl;
import com.ruoyi.ccdi.project.domain.entity.CcdiBankTagTask;
import com.ruoyi.ccdi.project.domain.enums.TriggerType;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagTaskMapper;
import com.ruoyi.ccdi.project.service.ICcdiProjectService;
import com.ruoyi.common.exception.ServiceException;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
import jakarta.annotation.Resource;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executor;
import lombok.extern.slf4j.Slf4j;
/**
@@ -19,6 +22,7 @@ import lombok.extern.slf4j.Slf4j;
public class ProjectBankTagRebuildCoordinator {
private final ConcurrentHashMap<Long, Boolean> runningProjects = new ConcurrentHashMap<>();
private final ConcurrentHashMap<Long, Boolean> pendingAutoRerunProjects = new ConcurrentHashMap<>();
@Resource
private CcdiBankTagTaskMapper taskMapper;
@@ -27,6 +31,13 @@ public class ProjectBankTagRebuildCoordinator {
@Resource
private CcdiBankTagServiceImpl bankTagService;
@Resource
private ICcdiProjectService projectService;
@Resource
@Qualifier("tagRebuildExecutor")
private Executor tagRebuildExecutor;
/**
* 提交手动重算
*
@@ -43,6 +54,7 @@ public class ProjectBankTagRebuildCoordinator {
throw new ServiceException("当前项目标签正在重算中,请稍后再试");
}
projectService.ensureProjectCanStartTagging(projectId);
executeWithLock(projectId, () -> bankTagService.rebuildProject(projectId, modelCode, operator, TriggerType.MANUAL));
}
@@ -57,17 +69,24 @@ public class ProjectBankTagRebuildCoordinator {
if (runningTask != null || runningProjects.containsKey(projectId)) {
log.warn("【流水标签】项目正在重算,已标记完成后补跑: projectId={}, runningTaskId={}, triggerType={}",
projectId, runningTask != null ? runningTask.getId() : null, triggerType);
markNeedRerun(runningTask);
markNeedRerun(projectId, runningTask);
return;
}
executeWithLock(projectId, () -> {
boolean needRerun;
do {
Long taskId = bankTagService.rebuildProject(projectId, null, "system", triggerType);
needRerun = taskId != null && consumeNeedRerun(taskId);
} while (needRerun);
});
if (runningProjects.putIfAbsent(projectId, Boolean.TRUE) != null) {
log.warn("【流水标签】项目自动重算已在排队,已标记完成后补跑: projectId={}, triggerType={}",
projectId, triggerType);
markNeedRerun(projectId, runningTask);
return;
}
try {
tagRebuildExecutor.execute(() -> executeAutoRebuild(projectId, triggerType));
log.info("【流水标签】自动重算任务已异步提交: projectId={}, triggerType={}", projectId, triggerType);
} catch (RuntimeException ex) {
runningProjects.remove(projectId);
throw ex;
}
}
private void executeWithLock(Long projectId, Runnable action) {
@@ -88,24 +107,46 @@ public class ProjectBankTagRebuildCoordinator {
return runningProjects.containsKey(projectId) || taskMapper.selectRunningTaskByProjectId(projectId) != null;
}
private void markNeedRerun(CcdiBankTagTask runningTask) {
private void executeAutoRebuild(Long projectId, TriggerType triggerType) {
try {
projectService.ensureProjectCanStartTagging(projectId);
boolean needRerun;
do {
Long taskId = bankTagService.rebuildProject(projectId, null, "system", triggerType);
needRerun = taskId != null && consumeNeedRerun(projectId, taskId);
} while (needRerun);
} catch (Exception ex) {
log.error("【流水标签】自动重算执行失败: projectId={}, triggerType={}, error={}",
projectId, triggerType, ex.getMessage(), ex);
} finally {
runningProjects.remove(projectId);
log.info("【流水标签】自动重算任务执行结束: projectId={}, triggerType={}", projectId, triggerType);
}
}
private void markNeedRerun(Long projectId, CcdiBankTagTask runningTask) {
if (runningTask == null) {
pendingAutoRerunProjects.put(projectId, Boolean.TRUE);
return;
}
runningTask.setNeedRerun(1);
taskMapper.updateTask(runningTask);
}
private boolean consumeNeedRerun(Long taskId) {
private boolean consumeNeedRerun(Long projectId, Long taskId) {
CcdiBankTagTask finishedTask = taskMapper.selectById(taskId);
if (finishedTask == null || finishedTask.getNeedRerun() == null || finishedTask.getNeedRerun() == 0) {
boolean taskNeedRerun = finishedTask != null && finishedTask.getNeedRerun() != null && finishedTask.getNeedRerun() == 1;
boolean pendingNeedRerun = pendingAutoRerunProjects.remove(projectId) != null;
if (!taskNeedRerun && !pendingNeedRerun) {
return false;
}
CcdiBankTagTask update = new CcdiBankTagTask();
update.setId(taskId);
update.setNeedRerun(0);
taskMapper.updateTask(update);
if (taskNeedRerun) {
CcdiBankTagTask update = new CcdiBankTagTask();
update.setId(taskId);
update.setNeedRerun(0);
taskMapper.updateTask(update);
}
return true;
}
}

View File

@@ -363,4 +363,243 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
)
</select>
<select id="selectAbnormalCustomerTransactionStatements" resultMap="BankTagStatementHitResultMap">
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
'占位SQL待补充真实规则' AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
</select>
<select id="selectLowIncomeRelativeLargeTransactionObjects" resultMap="BankTagObjectHitResultMap">
select
'STAFF_ID_CARD' AS objectType,
'' AS objectKey,
'占位SQL待补充真实规则' AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
</select>
<select id="selectMultiPartyGamblingTransferObjects" resultMap="BankTagObjectHitResultMap">
select
'STAFF_ID_CARD' AS objectType,
'' AS objectKey,
'占位SQL待补充真实规则' AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
</select>
<select id="selectGamblingSensitiveKeywordStatements" resultMap="BankTagStatementHitResultMap">
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
'占位SQL待补充真实规则' AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
</select>
<select id="selectSpecialAmountTransactionStatements" resultMap="BankTagStatementHitResultMap">
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
'占位SQL待补充真实规则' AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
</select>
<select id="selectMonthlyFixedIncomeObjects" resultMap="BankTagObjectHitResultMap">
select
'STAFF_ID_CARD' AS objectType,
'' AS objectKey,
'占位SQL待补充真实规则' AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
</select>
<select id="selectFixedCounterpartyTransferObjects" resultMap="BankTagObjectHitResultMap">
select
'STAFF_ID_CARD' AS objectType,
'' AS objectKey,
'占位SQL待补充真实规则' AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
</select>
<select id="selectSuspiciousIncomeKeywordStatements" resultMap="BankTagStatementHitResultMap">
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
'占位SQL待补充真实规则' AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
</select>
<select id="selectHouseRegistrationMismatchStatements" resultMap="BankTagStatementHitResultMap">
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
'占位SQL待补充真实规则' AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
</select>
<select id="selectPropertyFeeRegistrationMismatchStatements" resultMap="BankTagStatementHitResultMap">
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
'占位SQL待补充真实规则' AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
</select>
<select id="selectTaxAssetRegistrationMismatchStatements" resultMap="BankTagStatementHitResultMap">
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
'占位SQL待补充真实规则' AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
</select>
<select id="selectIncomeAssetMismatchStatements" resultMap="BankTagStatementHitResultMap">
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
'占位SQL待补充真实规则' AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
</select>
<select id="selectForexBuyAmtStatements" resultMap="BankTagStatementHitResultMap">
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
'占位SQL待补充真实规则' AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
</select>
<select id="selectForexSellAmtStatements" resultMap="BankTagStatementHitResultMap">
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
'占位SQL待补充真实规则' AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
</select>
<select id="selectCrossBorderAmtStatements" resultMap="BankTagStatementHitResultMap">
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
'占位SQL待补充真实规则' AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
</select>
<select id="selectInterestPaymentByOthersObjects" resultMap="BankTagObjectHitResultMap">
select
'STAFF_ID_CARD' AS objectType,
'' AS objectKey,
'占位SQL待补充真实规则' AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
</select>
<select id="selectLargePurchaseTransactionStatements" resultMap="BankTagStatementHitResultMap">
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
'占位SQL待补充真实规则' AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
</select>
<select id="selectSupplierConcentrationObjects" resultMap="BankTagObjectHitResultMap">
select
'STAFF_ID_CARD' AS objectType,
'' AS objectKey,
'占位SQL待补充真实规则' AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
</select>
<select id="selectStockTfrLargeStatements" resultMap="BankTagStatementHitResultMap">
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
'占位SQL待补充真实规则' AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
</select>
<select id="selectWithdrawCntObjects" resultMap="BankTagObjectHitResultMap">
select
'STAFF_ID_CARD' AS objectType,
'' AS objectKey,
'占位SQL待补充真实规则' AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
</select>
<select id="selectWithdrawAmtObjects" resultMap="BankTagObjectHitResultMap">
select
'STAFF_ID_CARD' AS objectType,
'' AS objectKey,
'占位SQL待补充真实规则' AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
</select>
<select id="selectSalaryQuickTransferObjects" resultMap="BankTagObjectHitResultMap">
select
'STAFF_ID_CARD' AS objectType,
'' AS objectKey,
'占位SQL待补充真实规则' AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
</select>
<select id="selectSalaryUnusedObjects" resultMap="BankTagObjectHitResultMap">
select
'STAFF_ID_CARD' AS objectType,
'' AS objectKey,
'占位SQL待补充真实规则' AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
</select>
<select id="selectLargeStockTradingStatements" resultMap="BankTagStatementHitResultMap">
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
'占位SQL待补充真实规则' AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
</select>
<select id="selectProxyAccountOperationObjects" resultMap="BankTagObjectHitResultMap">
select
'STAFF_ID_CARD' AS objectType,
'' AS objectKey,
'占位SQL待补充真实规则' AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
</select>
</mapper>

View File

@@ -29,6 +29,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="remark" column="remark"/>
</resultMap>
<resultMap id="CcdiBankStatementHitTagVOResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementHitTagVO">
<result property="ruleCode" column="rule_code"/>
<result property="ruleName" column="rule_name"/>
<result property="riskLevel" column="risk_level"/>
<result property="reasonDetail" column="reason_detail"/>
<result property="bankStatementId" column="bank_statement_id"/>
</resultMap>
<delete id="deleteByProjectAndModel">
delete from ccdi_bank_statement_tag_result
where project_id = #{projectId}
@@ -37,6 +45,23 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</if>
</delete>
<select id="selectStatementTagsByProjectAndStatementIds" resultMap="CcdiBankStatementHitTagVOResultMap">
select
rule_code,
rule_name,
risk_level,
reason_detail,
bank_statement_id
from ccdi_bank_statement_tag_result
where project_id = #{projectId}
and bank_statement_id is not null
and bank_statement_id IN
<foreach collection="bankStatementIds" item="item" open="(" separator="," close=")">
#{item}
</foreach>
order by bank_statement_id asc, id asc
</select>
<insert id="insertBatch" parameterType="java.util.List">
insert into ccdi_bank_statement_tag_result (
project_id, model_code, model_name, rule_code, rule_name, indicator_code,

View File

@@ -0,0 +1,31 @@
package com.ruoyi.ccdi.project.domain.vo;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
import static org.junit.jupiter.api.Assertions.assertNotNull;
class CcdiBankStatementHitTagsContractTest {
@Test
void listVo_shouldExposeHitTagsField() throws Exception {
Field field = CcdiBankStatementListVO.class.getDeclaredField("hitTags");
assertNotNull(field, "流水列表VO应返回命中异常标签");
}
@Test
void detailVo_shouldExposeHitTagsField() throws Exception {
Field field = CcdiBankStatementDetailVO.class.getDeclaredField("hitTags");
assertNotNull(field, "流水详情VO应返回命中异常标签");
}
@Test
void excelVo_shouldExposeHitTagsField() throws Exception {
Field field = com.ruoyi.ccdi.project.domain.excel.CcdiBankStatementExcel.class.getDeclaredField("hitTags");
assertNotNull(field, "流水导出对象应返回异常标签文本列");
}
}

View File

@@ -7,12 +7,41 @@ import javax.xml.parsers.DocumentBuilderFactory;
import java.io.StringReader;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CcdiBankTagAnalysisMapperXmlTest {
private static final String RESOURCE = "mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml";
private static final List<String> PLACEHOLDER_SELECT_IDS = List.of(
"selectAbnormalCustomerTransactionStatements",
"selectLowIncomeRelativeLargeTransactionObjects",
"selectMultiPartyGamblingTransferObjects",
"selectGamblingSensitiveKeywordStatements",
"selectSpecialAmountTransactionStatements",
"selectMonthlyFixedIncomeObjects",
"selectFixedCounterpartyTransferObjects",
"selectSuspiciousIncomeKeywordStatements",
"selectHouseRegistrationMismatchStatements",
"selectPropertyFeeRegistrationMismatchStatements",
"selectTaxAssetRegistrationMismatchStatements",
"selectIncomeAssetMismatchStatements",
"selectForexBuyAmtStatements",
"selectForexSellAmtStatements",
"selectCrossBorderAmtStatements",
"selectInterestPaymentByOthersObjects",
"selectLargePurchaseTransactionStatements",
"selectSupplierConcentrationObjects",
"selectStockTfrLargeStatements",
"selectWithdrawCntObjects",
"selectWithdrawAmtObjects",
"selectSalaryQuickTransferObjects",
"selectSalaryUnusedObjects",
"selectLargeStockTradingStatements",
"selectProxyAccountOperationObjects"
);
@Test
void statementRuleSql_shouldSelectGroupIdAndLogId() throws Exception {
@@ -42,6 +71,21 @@ class CcdiBankTagAnalysisMapperXmlTest {
assertTrue(xml.contains("selectLargeTransferStatements"));
}
@Test
void allPlaceholderRules_shouldExistInAnalysisMapperXml() throws Exception {
String xml = readXml(RESOURCE);
for (String selectId : PLACEHOLDER_SELECT_IDS) {
assertTrue(xml.contains(selectId), () -> "缺少占位规则 SQL: " + selectId);
}
}
@Test
void placeholderRules_shouldUseEmptyResultSqlTemplate() throws Exception {
String xml = readXml(RESOURCE);
assertTrue(xml.contains("占位SQL待补充真实规则"));
assertEquals(25, countMatches(xml, "where 1 = 0"));
}
@Test
void analysisMapperXml_shouldBeWellFormed() throws Exception {
String xml = readXml(RESOURCE);
@@ -56,4 +100,14 @@ class CcdiBankTagAnalysisMapperXmlTest {
return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
}
}
private int countMatches(String text, String target) {
int count = 0;
int index = 0;
while ((index = text.indexOf(target, index)) >= 0) {
count++;
index += target.length();
}
return count;
}
}

View File

@@ -2,9 +2,12 @@ package com.ruoyi.ccdi.project.mapper;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CcdiBankTagResultMapperXmlTest {
@@ -20,4 +23,25 @@ class CcdiBankTagResultMapperXmlTest {
assertTrue(xml.contains("model_code = #{modelCode}"));
}
}
@Test
void mapper_shouldExposeStatementTagQueryForBankStatementDetails() {
Method method = Arrays.stream(CcdiBankTagResultMapper.class.getDeclaredMethods())
.filter(item -> "selectStatementTagsByProjectAndStatementIds".equals(item.getName()))
.findFirst()
.orElse(null);
assertNotNull(method, "应提供按项目和流水ID批量查询异常标签的方法");
}
@Test
void xml_shouldDefineStatementTagQueryForBankStatementDetails() throws Exception {
try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(RESOURCE)) {
String xml = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
assertTrue(xml.contains("selectStatementTagsByProjectAndStatementIds"), xml);
assertTrue(xml.contains("bank_statement_id IN"), xml);
assertTrue(xml.contains("project_id = #{projectId}"), xml);
}
}
}

View File

@@ -90,6 +90,46 @@ class BankTagRuleConfigResolverTest {
}
}
@Test
void resolve_shouldReturnEmptyThresholdsForPlaceholderRulesWithoutIndicatorCode() {
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
project.setConfigType("default");
when(projectMapper.selectById(40L)).thenReturn(project);
when(modelParamMapper.selectByProjectAndModel(0L, "ABNORMAL_TRANSACTION")).thenReturn(List.of(
buildParam("IGNORED_PARAM", "999")
));
CcdiBankTagRule ruleMeta = new CcdiBankTagRule();
ruleMeta.setModelCode("ABNORMAL_TRANSACTION");
ruleMeta.setRuleCode("ABNORMAL_CUSTOMER_TRANSACTION");
ruleMeta.setIndicatorCode(null);
BankTagRuleExecutionConfig config = resolver.resolve(40L, ruleMeta);
assertTrue(config.getThresholdValues().isEmpty());
}
@Test
void resolve_shouldReadUppercaseAnnualTurnoverParamCode() {
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
project.setConfigType("default");
when(projectMapper.selectById(40L)).thenReturn(project);
when(modelParamMapper.selectByProjectAndModel(0L, "LARGE_TRANSACTION")).thenReturn(List.of(
buildParam("ANNUAL_TURNOVER", "8888")
));
CcdiBankTagRule ruleMeta = new CcdiBankTagRule();
ruleMeta.setModelCode("LARGE_TRANSACTION");
ruleMeta.setRuleCode("ANNUAL_TURNOVER");
ruleMeta.setIndicatorCode("ANNUAL_TURNOVER");
BankTagRuleExecutionConfig config = resolver.resolve(40L, ruleMeta);
assertEquals("8888", config.getThresholdValue("ANNUAL_TURNOVER"));
}
private CcdiModelParam buildParam(String paramCode, String paramValue) {
CcdiModelParam param = new CcdiModelParam();
param.setProjectId(0L);

View File

@@ -5,9 +5,11 @@ import com.ruoyi.ccdi.project.domain.dto.CcdiBankStatementQueryDTO;
import com.ruoyi.ccdi.project.domain.excel.CcdiBankStatementExcel;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementDetailVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementFilterOptionsVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementHitTagVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementOptionVO;
import com.ruoyi.ccdi.project.mapper.CcdiBankStatementMapper;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagResultMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
@@ -36,6 +38,9 @@ class CcdiBankStatementServiceImplTest {
@Mock
private CcdiBankStatementMapper bankStatementMapper;
@Mock
private CcdiBankTagResultMapper bankTagResultMapper;
@Test
void getFilterOptions_shouldReturnProjectWideOptions() {
CcdiBankStatementFilterOptionsVO options = new CcdiBankStatementFilterOptionsVO();
@@ -54,6 +59,7 @@ class CcdiBankStatementServiceImplTest {
queryDTO.setProjectId(100L);
queryDTO.setOrderBy("amount");
queryDTO.setOrderDirection("desc");
page.setRecords(List.of(new CcdiBankStatementListVO()));
doReturn(page).when(bankStatementMapper).selectStatementPage(eq(page), same(queryDTO));
service.selectStatementPage(page, queryDTO);
@@ -111,14 +117,66 @@ class CcdiBankStatementServiceImplTest {
assertEquals("6222", result.get(0).getLeAccountNo());
}
@Test
void selectStatementListForExport_shouldIncludeHitTagNames() {
CcdiBankStatementQueryDTO queryDTO = new CcdiBankStatementQueryDTO();
queryDTO.setProjectId(43L);
CcdiBankStatementListVO row = new CcdiBankStatementListVO();
row.setBankStatementId(51274L);
row.setLeAccountNo("6222");
when(bankStatementMapper.selectStatementListForExport(same(queryDTO))).thenReturn(List.of(row));
CcdiBankStatementHitTagVO hitTag = new CcdiBankStatementHitTagVO();
hitTag.setBankStatementId(51274L);
hitTag.setRuleName("大额存现交易");
when(bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(43L, List.of(51274L)))
.thenReturn(List.of(hitTag));
List<CcdiBankStatementExcel> result = service.selectStatementListForExport(queryDTO);
assertEquals(1, result.size());
assertEquals("大额存现交易", result.get(0).getHitTags());
}
@Test
void selectStatementPage_shouldAttachHitTags() {
Page<CcdiBankStatementListVO> page = new Page<>(1, 10);
CcdiBankStatementQueryDTO queryDTO = new CcdiBankStatementQueryDTO();
queryDTO.setProjectId(43L);
CcdiBankStatementListVO row = new CcdiBankStatementListVO();
row.setBankStatementId(51274L);
page.setRecords(List.of(row));
doReturn(page).when(bankStatementMapper).selectStatementPage(eq(page), same(queryDTO));
CcdiBankStatementHitTagVO hitTag = new CcdiBankStatementHitTagVO();
hitTag.setBankStatementId(51274L);
hitTag.setRuleCode("LARGE_CASH_DEPOSIT");
hitTag.setRuleName("大额存现交易");
when(bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(43L, List.of(51274L)))
.thenReturn(List.of(hitTag));
Page<CcdiBankStatementListVO> result = service.selectStatementPage(page, queryDTO);
assertEquals(1, result.getRecords().get(0).getHitTags().size());
assertEquals("LARGE_CASH_DEPOSIT", result.getRecords().get(0).getHitTags().get(0).getRuleCode());
}
@Test
void getStatementDetail_shouldDelegateToMapper() {
CcdiBankStatementDetailVO detailVO = new CcdiBankStatementDetailVO();
detailVO.setBankStatementId(200L);
detailVO.setProjectId(43L);
when(bankStatementMapper.selectStatementDetailById(200L)).thenReturn(detailVO);
CcdiBankStatementHitTagVO hitTag = new CcdiBankStatementHitTagVO();
hitTag.setBankStatementId(200L);
hitTag.setRuleName("大额存现交易");
when(bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(43L, List.of(200L)))
.thenReturn(List.of(hitTag));
CcdiBankStatementDetailVO result = service.getStatementDetail(200L);
assertSame(detailVO, result);
assertEquals(1, result.getHitTags().size());
assertEquals("大额存现交易", result.getHitTags().get(0).getRuleName());
}
}

View File

@@ -12,6 +12,7 @@ import com.ruoyi.ccdi.project.mapper.CcdiBankTagAnalysisMapper;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagResultMapper;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagRuleMapper;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagTaskMapper;
import com.ruoyi.ccdi.project.service.ICcdiProjectService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InOrder;
@@ -28,8 +29,10 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -54,6 +57,19 @@ class CcdiBankTagServiceImplTest {
@Mock
private BankTagRuleConfigResolver configResolver;
@Mock
private ICcdiProjectService projectService;
@Mock
private ProjectBankTagRebuildCoordinator coordinator;
@Test
void submitAutoRebuild_shouldKeepAutoParamChangeTriggerType() {
service.submitAutoRebuild(40L, TriggerType.AUTO_PARAM_CHANGE);
verify(coordinator).submitAuto(40L, TriggerType.AUTO_PARAM_CHANGE);
}
@Test
void rebuildProject_shouldDeleteOldResultsBeforeSubmittingRuleTasks() {
ReflectionTestUtils.setField(service, "tagRuleExecutor", (Executor) Runnable::run);
@@ -175,4 +191,97 @@ class CcdiBankTagServiceImplTest {
logger.detachAppender(logAppender);
}
}
@Test
void rebuildProject_shouldDispatchPlaceholderStatementRuleAndFinishWithoutResults() {
ReflectionTestUtils.setField(service, "tagRuleExecutor", (Executor) Runnable::run);
CcdiBankTagRule rule = buildRule("ABNORMAL_TRANSACTION", "异常交易",
"ABNORMAL_CUSTOMER_TRANSACTION", "与客户之间非正常资金往来", "STATEMENT");
BankTagRuleExecutionConfig config = buildConfig(40L, rule);
when(ruleMapper.selectEnabledRules("ABNORMAL_TRANSACTION")).thenReturn(List.of(rule));
when(configResolver.resolve(40L, rule)).thenReturn(config);
when(analysisMapper.selectAbnormalCustomerTransactionStatements(40L)).thenReturn(List.of());
service.rebuildProject(40L, "ABNORMAL_TRANSACTION", "admin", TriggerType.MANUAL);
verify(resultMapper).deleteByProjectAndModel(40L, "ABNORMAL_TRANSACTION");
verify(analysisMapper).selectAbnormalCustomerTransactionStatements(40L);
verify(resultMapper, never()).insertBatch(anyList());
verify(taskMapper).updateTask(argThat(task -> "SUCCESS".equals(task.getStatus()) && task.getHitCount() == 0));
}
@Test
void rebuildProject_shouldDispatchPlaceholderObjectRuleAndFinishWithoutResults() {
ReflectionTestUtils.setField(service, "tagRuleExecutor", (Executor) Runnable::run);
CcdiBankTagRule rule = buildRule("SUSPICIOUS_PURCHASE", "可疑采购",
"SUPPLIER_CONCENTRATION", "可疑采购", "OBJECT");
BankTagRuleExecutionConfig config = buildConfig(40L, rule);
when(ruleMapper.selectEnabledRules("SUSPICIOUS_PURCHASE")).thenReturn(List.of(rule));
when(configResolver.resolve(40L, rule)).thenReturn(config);
when(analysisMapper.selectSupplierConcentrationObjects(40L)).thenReturn(List.of());
service.rebuildProject(40L, "SUSPICIOUS_PURCHASE", "admin", TriggerType.MANUAL);
verify(analysisMapper).selectSupplierConcentrationObjects(40L);
verify(resultMapper).deleteByProjectAndModel(40L, "SUSPICIOUS_PURCHASE");
verify(resultMapper, never()).insertBatch(anyList());
verify(taskMapper).updateTask(argThat(task -> "SUCCESS".equals(task.getStatus()) && task.getFailedRuleCount() == 0));
}
@Test
void shouldMarkProjectTaggingBeforeExecutingAndCompletedAfterSuccess() {
ReflectionTestUtils.setField(service, "tagRuleExecutor", (Executor) Runnable::run);
CcdiBankTagRule rule = buildRule("LARGE_TRANSACTION", "大额交易",
"HOUSE_OR_CAR_EXPENSE", "房车消费支出交易", "STATEMENT");
BankTagRuleExecutionConfig config = buildConfig(40L, rule);
when(ruleMapper.selectEnabledRules(null)).thenReturn(List.of(rule));
when(configResolver.resolve(40L, rule)).thenReturn(config);
when(analysisMapper.selectHouseOrCarExpenseStatements(40L)).thenReturn(List.of());
service.rebuildProject(40L, null, "tester", TriggerType.MANUAL);
InOrder inOrder = inOrder(projectService, taskMapper);
inOrder.verify(projectService).updateProjectStatus(40L, "3", "tester");
inOrder.verify(taskMapper).updateTask(argThat(task -> "SUCCESS".equals(task.getStatus())));
inOrder.verify(projectService).updateProjectStatus(40L, "1", "tester");
}
@Test
void shouldRollbackProjectStatusToProcessingWhenRebuildFails() {
ReflectionTestUtils.setField(service, "tagRuleExecutor", (Executor) Runnable::run);
CcdiBankTagRule rule = buildRule("LARGE_TRANSACTION", "大额交易",
"HOUSE_OR_CAR_EXPENSE", "房车消费支出交易", "STATEMENT");
when(ruleMapper.selectEnabledRules(null)).thenReturn(List.of(rule));
when(configResolver.resolve(40L, rule)).thenThrow(new RuntimeException("threshold missing"));
assertThrows(RuntimeException.class,
() -> service.rebuildProject(40L, null, "tester", TriggerType.MANUAL));
verify(projectService).updateProjectStatus(40L, "0", "tester");
}
private CcdiBankTagRule buildRule(String modelCode, String modelName, String ruleCode, String ruleName, String resultType) {
CcdiBankTagRule rule = new CcdiBankTagRule();
rule.setModelCode(modelCode);
rule.setModelName(modelName);
rule.setRuleCode(ruleCode);
rule.setRuleName(ruleName);
rule.setResultType(resultType);
return rule;
}
private BankTagRuleExecutionConfig buildConfig(Long projectId, CcdiBankTagRule rule) {
BankTagRuleExecutionConfig config = new BankTagRuleExecutionConfig();
config.setProjectId(projectId);
config.setRuleMeta(rule);
return config;
}
}

View File

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

View File

@@ -2,10 +2,17 @@ package com.ruoyi.ccdi.project.service.impl;
import com.ruoyi.ccdi.project.domain.CcdiModelParam;
import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.enums.TriggerType;
import com.ruoyi.ccdi.project.domain.dto.ModelParamGroupDTO;
import com.ruoyi.ccdi.project.domain.dto.ModelParamSaveDTO;
import com.ruoyi.ccdi.project.domain.dto.ModelParamSaveAllDTO;
import com.ruoyi.ccdi.project.domain.dto.ParamValueItem;
import com.ruoyi.ccdi.project.domain.vo.ModelParamAllVO;
import com.ruoyi.ccdi.project.mapper.CcdiModelParamMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.ccdi.project.service.ICcdiBankTagService;
import com.ruoyi.ccdi.project.service.ICcdiProjectService;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -18,10 +25,14 @@ import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -37,6 +48,12 @@ class CcdiModelParamServiceImplTest {
@Mock
private CcdiProjectMapper projectMapper;
@Mock
private ICcdiProjectService projectService;
@Mock
private ICcdiBankTagService bankTagService;
@Test
void selectAllParams_shouldReadSystemDefaultsForDefaultProject() {
CcdiProject project = new CcdiProject();
@@ -65,7 +82,7 @@ class CcdiModelParamServiceImplTest {
when(projectMapper.selectById(123L)).thenReturn(project);
when(modelParamMapper.selectByProjectId(0L)).thenReturn(List.of(
buildParam(1L, 0L, "LARGE_TRANSACTION", "大额交易模型", "SINGLE_TRANSACTION_AMOUNT", "1111"),
buildParam(2L, 0L, "SUSPICIOUS_GAMBLING", "疑似赌博交易模型", "multi_party_amt_min", "500")
buildParam(2L, 0L, "SUSPICIOUS_GAMBLING", "疑似赌博交易模型", "MULTI_PARTY_AMT_MIN", "500")
));
when(modelParamMapper.insertBatch(anyList())).thenReturn(2);
when(modelParamMapper.updateParamValue(123L, "LARGE_TRANSACTION", "SINGLE_TRANSACTION_AMOUNT", "2222", "admin"))
@@ -95,6 +112,118 @@ class CcdiModelParamServiceImplTest {
verify(modelParamMapper).updateParamValue(123L, "LARGE_TRANSACTION", "SINGLE_TRANSACTION_AMOUNT", "2222", "admin");
}
@Test
void shouldRejectSaveAllParamsWhenProjectIsTagging() {
org.mockito.Mockito.doThrow(new ServiceException("当前项目正在进行银行流水打标,暂不允许修改参数"))
.when(projectService).ensureProjectWritable(40L, "当前项目正在进行银行流水打标,暂不允许修改参数");
assertThrows(ServiceException.class, () -> service.saveAllParams(buildSaveAllDto()));
}
@Test
void shouldRejectSaveParamsWhenProjectIsTagging() {
ModelParamSaveDTO saveDTO = new ModelParamSaveDTO();
saveDTO.setProjectId(40L);
saveDTO.setModelCode("LARGE_TRANSACTION");
ModelParamSaveDTO.ParamValueItem item = new ModelParamSaveDTO.ParamValueItem();
item.setParamCode("SINGLE_TRANSACTION_AMOUNT");
item.setParamValue("2000");
saveDTO.setParams(List.of(item));
org.mockito.Mockito.doThrow(new ServiceException("当前项目正在进行银行流水打标,暂不允许修改参数"))
.when(projectService).ensureProjectWritable(40L, "当前项目正在进行银行流水打标,暂不允许修改参数");
assertThrows(ServiceException.class, () -> service.saveParams(saveDTO));
}
@Test
void saveAllParams_shouldSubmitAutoRebuildAfterProjectParamsUpdated() {
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
project.setConfigType("custom");
when(projectMapper.selectById(40L)).thenReturn(project);
when(modelParamMapper.selectOne(any())).thenReturn(
buildParam(1L, 40L, "LARGE_TRANSACTION", "大额交易模型", "SINGLE_TRANSACTION_AMOUNT", "1000")
);
try (MockedStatic<SecurityUtils> mocked = mockStatic(SecurityUtils.class)) {
mocked.when(SecurityUtils::getUsername).thenReturn("admin");
service.saveAllParams(buildSaveAllDto());
}
verify(bankTagService).submitAutoRebuild(40L, TriggerType.AUTO_PARAM_CHANGE);
}
@Test
void saveParams_shouldSubmitAutoRebuildAfterProjectParamsUpdated() {
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
project.setConfigType("custom");
when(projectMapper.selectById(40L)).thenReturn(project);
when(modelParamMapper.updateParamValue(40L, "LARGE_TRANSACTION", "SINGLE_TRANSACTION_AMOUNT", "2000", "admin"))
.thenReturn(1);
ModelParamSaveDTO saveDTO = new ModelParamSaveDTO();
saveDTO.setProjectId(40L);
saveDTO.setModelCode("LARGE_TRANSACTION");
ModelParamSaveDTO.ParamValueItem item = new ModelParamSaveDTO.ParamValueItem();
item.setParamCode("SINGLE_TRANSACTION_AMOUNT");
item.setParamValue("2000");
saveDTO.setParams(List.of(item));
try (MockedStatic<SecurityUtils> mocked = mockStatic(SecurityUtils.class)) {
mocked.when(SecurityUtils::getUsername).thenReturn("admin");
service.saveParams(saveDTO);
}
verify(bankTagService).submitAutoRebuild(40L, TriggerType.AUTO_PARAM_CHANGE);
}
@Test
void saveParams_shouldNotSubmitAutoRebuildWhenNoProjectParamUpdated() {
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
project.setConfigType("custom");
when(projectMapper.selectById(40L)).thenReturn(project);
when(modelParamMapper.updateParamValue(40L, "LARGE_TRANSACTION", "SINGLE_TRANSACTION_AMOUNT", "2000", "admin"))
.thenReturn(0);
ModelParamSaveDTO saveDTO = new ModelParamSaveDTO();
saveDTO.setProjectId(40L);
saveDTO.setModelCode("LARGE_TRANSACTION");
ModelParamSaveDTO.ParamValueItem item = new ModelParamSaveDTO.ParamValueItem();
item.setParamCode("SINGLE_TRANSACTION_AMOUNT");
item.setParamValue("2000");
saveDTO.setParams(List.of(item));
try (MockedStatic<SecurityUtils> mocked = mockStatic(SecurityUtils.class)) {
mocked.when(SecurityUtils::getUsername).thenReturn("admin");
service.saveParams(saveDTO);
}
verify(bankTagService, never()).submitAutoRebuild(anyLong(), any());
}
@Test
void saveAllParams_shouldNotSubmitAutoRebuildForGlobalDefaults() {
ModelParamSaveAllDTO saveAllDTO = buildSaveAllDto();
saveAllDTO.setProjectId(0L);
when(modelParamMapper.selectOne(any())).thenReturn(
buildParam(1L, 0L, "LARGE_TRANSACTION", "大额交易模型", "SINGLE_TRANSACTION_AMOUNT", "1000")
);
try (MockedStatic<SecurityUtils> mocked = mockStatic(SecurityUtils.class)) {
mocked.when(SecurityUtils::getUsername).thenReturn("admin");
service.saveAllParams(saveAllDTO);
}
verify(bankTagService, never()).submitAutoRebuild(any(), any());
}
private CcdiModelParam buildParam(
Long id,
Long projectId,
@@ -114,4 +243,19 @@ class CcdiModelParamServiceImplTest {
param.setSortOrder(1);
return param;
}
private ModelParamSaveAllDTO buildSaveAllDto() {
ParamValueItem item = new ParamValueItem();
item.setParamCode("SINGLE_TRANSACTION_AMOUNT");
item.setParamValue("2000");
ModelParamGroupDTO group = new ModelParamGroupDTO();
group.setModelCode("LARGE_TRANSACTION");
group.setParams(List.of(item));
ModelParamSaveAllDTO dto = new ModelParamSaveAllDTO();
dto.setProjectId(40L);
dto.setModels(List.of(group));
return dto;
}
}

View File

@@ -0,0 +1,164 @@
package com.ruoyi.ccdi.project.service.impl;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSaveDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectStatusCountsVO;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.lsfx.client.LsfxAnalysisClient;
import com.ruoyi.lsfx.domain.response.GetTokenResponse;
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 org.slf4j.LoggerFactory;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CcdiProjectServiceImplTest {
@InjectMocks
private CcdiProjectServiceImpl service;
@Mock
private CcdiProjectMapper projectMapper;
@Mock
private LsfxAnalysisClient lsfxAnalysisClient;
@Test
void shouldCountTaggingProjectsSeparately() {
when(projectMapper.selectCount(any())).thenReturn(10L, 3L, 4L, 2L, 1L);
CcdiProjectStatusCountsVO counts = service.getStatusCounts();
assertEquals(1L, counts.getStatus3());
}
@Test
void shouldRejectUpdatingArchivedProjectToTagging() {
CcdiProject archived = new CcdiProject();
archived.setProjectId(99L);
archived.setStatus("2");
when(projectMapper.selectById(99L)).thenReturn(archived);
assertThrows(ServiceException.class,
() -> service.updateProjectStatus(99L, "3", "system"));
}
@Test
void shouldRejectWritingWhenProjectIsTagging() {
CcdiProject tagging = new CcdiProject();
tagging.setProjectId(40L);
tagging.setStatus("3");
when(projectMapper.selectById(40L)).thenReturn(tagging);
assertThrows(ServiceException.class,
() -> service.ensureProjectWritable(40L, "当前项目正在进行银行流水打标,暂不允许修改参数"));
}
@Test
void shouldLogProjectInitialStatusWhenProjectIsCreated() {
CcdiProjectSaveDTO dto = new CcdiProjectSaveDTO();
dto.setProjectName("专案A");
dto.setDescription("测试项目");
dto.setConfigType("default");
when(lsfxAnalysisClient.getToken(any())).thenReturn(buildTokenResponse(2001));
doAnswer(invocation -> {
CcdiProject project = invocation.getArgument(0);
project.setProjectId(88L);
return 1;
}).when(projectMapper).insert(any(CcdiProject.class));
Logger logger = (Logger) LoggerFactory.getLogger(CcdiProjectServiceImpl.class);
ListAppender<ILoggingEvent> logAppender = new ListAppender<>();
logAppender.start();
logger.addAppender(logAppender);
try {
service.createProject(dto);
assertTrue(logAppender.list.stream().map(ILoggingEvent::getFormattedMessage)
.anyMatch(message -> message.contains("项目状态初始化")
&& message.contains("projectId=88")
&& message.contains("projectName=专案A")
&& message.contains("newStatus=0")));
} finally {
logger.detachAppender(logAppender);
}
}
@Test
void shouldLogProjectStatusTransitionWhenStatusChanges() {
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
project.setProjectName("专案B");
project.setStatus("0");
when(projectMapper.selectById(40L)).thenReturn(project);
Logger logger = (Logger) LoggerFactory.getLogger(CcdiProjectServiceImpl.class);
ListAppender<ILoggingEvent> logAppender = new ListAppender<>();
logAppender.start();
logger.addAppender(logAppender);
try {
service.updateProjectStatus(40L, "3", "tester");
assertTrue(logAppender.list.stream().map(ILoggingEvent::getFormattedMessage)
.anyMatch(message -> message.contains("项目状态变更")
&& message.contains("projectId=40")
&& message.contains("projectName=专案B")
&& message.contains("oldStatus=0")
&& message.contains("newStatus=3")
&& message.contains("operator=tester")));
} finally {
logger.detachAppender(logAppender);
}
}
@Test
void shouldNotLogProjectStatusTransitionWhenStatusDoesNotChange() {
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
project.setProjectName("专案C");
project.setStatus("3");
when(projectMapper.selectById(40L)).thenReturn(project);
Logger logger = (Logger) LoggerFactory.getLogger(CcdiProjectServiceImpl.class);
ListAppender<ILoggingEvent> logAppender = new ListAppender<>();
logAppender.start();
logger.addAppender(logAppender);
try {
service.updateProjectStatus(40L, "3", "tester");
assertFalse(logAppender.list.stream().map(ILoggingEvent::getFormattedMessage)
.anyMatch(message -> message.contains("项目状态变更")
&& message.contains("projectId=40")));
} finally {
logger.detachAppender(logAppender);
}
}
private GetTokenResponse buildTokenResponse(Integer projectId) {
GetTokenResponse response = new GetTokenResponse();
response.setCode("200");
GetTokenResponse.TokenData data = new GetTokenResponse.TokenData();
data.setProjectId(projectId);
response.setData(data);
return response;
}
}

View File

@@ -6,6 +6,7 @@ import ch.qos.logback.core.read.ListAppender;
import com.ruoyi.ccdi.project.domain.entity.CcdiBankTagTask;
import com.ruoyi.ccdi.project.domain.enums.TriggerType;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagTaskMapper;
import com.ruoyi.ccdi.project.service.ICcdiProjectService;
import com.ruoyi.common.exception.ServiceException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -14,6 +15,8 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.slf4j.LoggerFactory;
import java.util.concurrent.Executor;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
@@ -33,6 +36,12 @@ class ProjectBankTagRebuildCoordinatorTest {
@Mock
private CcdiBankTagServiceImpl bankTagService;
@Mock
private ICcdiProjectService projectService;
@Mock
private Executor tagRebuildExecutor;
@Test
void submitManualRebuild_shouldRejectWhenProjectAlreadyRunning() {
CcdiBankTagTask runningTask = new CcdiBankTagTask();
@@ -109,4 +118,21 @@ class ProjectBankTagRebuildCoordinatorTest {
logger.detachAppender(logAppender);
}
}
@Test
void shouldRejectSubmittingRebuildForArchivedProject() {
org.mockito.Mockito.doThrow(new ServiceException("已归档项目不允许重新进入打标流程"))
.when(projectService).ensureProjectCanStartTagging(40L);
assertThrows(ServiceException.class,
() -> coordinator.submitManual(40L, null, "tester"));
}
@Test
void submitAuto_shouldDispatchRebuildAsynchronously() {
coordinator.submitAuto(40L, TriggerType.AUTO_PARAM_CHANGE);
verify(tagRebuildExecutor).execute(any(Runnable.class));
verify(bankTagService, never()).rebuildProject(40L, null, "system", TriggerType.AUTO_PARAM_CHANGE);
}
}

View File

@@ -22,6 +22,15 @@ class CcdiModelParamSqlDefaultsTest {
assertQuarterlyStableIncomeRangeConfig(updateSql);
}
@Test
void defaultSql_shouldUseUppercaseParamCodesForBankTagModels() throws IOException {
String initSql = readProjectFile("sql", "ccdi_model_param.sql");
String updateSql = readProjectFile("sql", "2026-03-16-update-ccdi-model-param-defaults.sql");
assertUppercaseBankTagParamCodes(initSql);
assertUppercaseBankTagParamCodes(updateSql);
}
private void assertQuarterlyStableIncomeRangeConfig(String sqlContent) {
assertAll(
() -> assertTrue(sqlContent.contains("FIXED_COUNTERPARTY_TRANSFER_MIN"),
@@ -41,6 +50,35 @@ class CcdiModelParamSqlDefaultsTest {
);
}
private void assertUppercaseBankTagParamCodes(String sqlContent) {
assertAll(
() -> assertTrue(sqlContent.contains("ANNUAL_TURNOVER"),
"应包含大写参数编码 ANNUAL_TURNOVER"),
() -> assertTrue(sqlContent.contains("STOCK_TFR_LARGE"),
"应包含大写参数编码 STOCK_TFR_LARGE"),
() -> assertTrue(sqlContent.contains("WITHDRAW_CNT"),
"应包含大写参数编码 WITHDRAW_CNT"),
() -> assertTrue(sqlContent.contains("WITHDRAW_AMT"),
"应包含大写参数编码 WITHDRAW_AMT"),
() -> assertTrue(sqlContent.contains("MULTI_PARTY_AMT_MIN"),
"应包含大写参数编码 MULTI_PARTY_AMT_MIN"),
() -> assertTrue(sqlContent.contains("MULTI_PARTY_AMT_MAX"),
"应包含大写参数编码 MULTI_PARTY_AMT_MAX"),
() -> assertFalse(sqlContent.contains("'annual_turnover'"),
"不应继续保留小写参数编码 annual_turnover"),
() -> assertFalse(sqlContent.contains("'stock_tfr_large'"),
"不应继续保留小写参数编码 stock_tfr_large"),
() -> assertFalse(sqlContent.contains("'withdraw_cnt'"),
"不应继续保留小写参数编码 withdraw_cnt"),
() -> assertFalse(sqlContent.contains("'withdraw_amt'"),
"不应继续保留小写参数编码 withdraw_amt"),
() -> assertFalse(sqlContent.contains("'multi_party_amt_min'"),
"不应继续保留小写参数编码 multi_party_amt_min"),
() -> assertFalse(sqlContent.contains("'multi_party_amt_max'"),
"不应继续保留小写参数编码 multi_party_amt_max")
);
}
private String readProjectFile(String... parts) throws IOException {
Path path = Path.of("..", parts);
return Files.readString(path, StandardCharsets.UTF_8);

View File

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

View File

@@ -0,0 +1,335 @@
# 流水明细异常标签展示设计
## 背景
当前项目流水标签能力已经会把规则命中结果写入 `ccdi_bank_statement_tag_result`,结果中保留了:
- `rule_name`
- `risk_level`
- `reason_detail`
- `result_type`
- `bank_statement_id`
但现有“流水明细查询”页面和导出能力仍只读取 `ccdi_bank_statement`,列表、详情、导出文件都看不到当前流水命中的异常标签,用户无法直接在查询结果中判断一条流水为何被命中。
本次需求要求在“流水明细查询”页面的流水列表、流水详情和导出文件中,展示当前流水直接命中的异常标签信息。
## 目标
- 在流水列表中展示当前流水命中的异常标签名称。
- 在流水详情中展示当前流水命中的异常标签名称、风险等级、命中原因摘要。
- 在导出文件中追加“异常标签”“命中原因摘要”两列。
- 页面、详情、导出的标签口径保持一致。
## 范围
### In Scope
- `ruoyi-ui/src/views/ccdiProject/components/detail/DetailQuery.vue`
- `ruoyi-ui/src/api/ccdiProjectBankStatement.js`
- `ccdi-project` 现有流水查询 Controller / Service / Mapper
- `ccdi_bank_statement_tag_result` 的只读查询与组装
- 流水明细导出列扩展
### Out of Scope
- 新增按异常标签筛选、排序、页签统计
- 对象级标签结果展示
- 标签重算逻辑、规则 SQL、任务调度逻辑调整
- 新增独立标签页面
## 已确认口径
- 列表页只展示标签名称。
- 详情页展示标签名称、风险等级、命中原因摘要。
- 仅展示当前流水直接命中的流水级标签。
- 不把对象级标签混入流水列表、流水详情和导出。
- 导出时需要同时导出异常标签与命中原因摘要。
## 方案对比
### 方案一:服务层二次组装标签
- 列表先按现有分页逻辑查询流水。
- 再按当前页 `bankStatementId` 批量查询标签结果,并回填到列表 VO。
- 详情继续走原有详情查询,再单独补充当前流水标签明细。
- 导出先查导出范围内流水,再批量查询标签结果并拼装导出列。
优点:
- 改动集中在现有查询服务层,最短路径实现。
- 标签查询与主分页 SQL 解耦,便于维护和测试。
- 详情可返回结构化标签数据,适合前端展示。
- 列表、详情、导出口径容易统一。
缺点:
- 列表和导出阶段会增加一次标签批量查询。
### 方案二:在 Mapper 主查询里直接聚合标签
-`CcdiBankStatementMapper.xml` 主查询中左连接标签表,并聚合标签字段。
优点:
- 接口层看起来较简单。
缺点:
- 分页 SQL、详情 SQL、导出 SQL 都会显著变复杂。
- 列表适合聚合字符串,不适合详情返回结构化标签。
- 后续维护成本高。
### 方案三:新增独立标签接口,由前端二次拉取
- 列表接口仍只查流水。
- 前端再调一个“按流水 ID 查询标签”的接口做二次组装。
优点:
- 流水主查询改动少。
缺点:
- 前端需要承担拼装逻辑。
- 导出仍需后端再实现一次同口径查询,容易分叉。
## 选型
采用方案一:服务层二次组装标签。
该方案最符合当前仓库“在既有模块上做局部扩展”的实现方式,不需要引入新的查询页面或复杂聚合 SQL也能保证详情展示与导出口径一致。
## 设计原则
- 标签展示仅来自 `ccdi_bank_statement_tag_result` 中的流水级结果。
- 列表轻量展示,详情完整展示。
- 导出结果与页面口径一致,不额外创造新的标签解释规则。
- 不为本次需求扩展筛选、排序和统计能力。
- 主功能优先,标签展示失败不能拖垮流水列表查询。
## 数据口径设计
标签查询统一限定以下条件:
- `result_type = 'STATEMENT'`
- `bank_statement_id` 命中当前流水
标签结果读取字段:
- `rule_name`
- `risk_level`
- `reason_detail`
- `bank_statement_id`
- `rule_code`
其中:
- `rule_name` 用于列表、详情、导出展示
- `risk_level` 仅用于详情展示和前端标签样式映射
- `reason_detail` 用于详情展示和导出
对象级结果即使与当前流水所属身份证、账户或对象相关,也不参与本次页面与导出口径。
## 后端设计
### 一、VO 与导出模型扩展
建议新增一个统一标签明细 VO例如
- `CcdiBankStatementHitTagVO`
- `ruleName`
- `riskLevel`
- `reasonDetail`
现有 VO 调整:
- `CcdiBankStatementListVO`
- 新增 `hitTags`
- `CcdiBankStatementDetailVO`
- 新增 `hitTags`
- `CcdiBankStatementExcel`
- 新增 `hitTagNames`
- 新增 `hitTagReasons`
列表 VO 中虽然只展示标签名称,但仍保留结构化 `hitTags`,这样可以减少后续再次改模型的成本,并让列表、详情、导出共享同一组装逻辑。
### 二、Mapper 查询设计
保留现有流水查询 Mapper 不做大改,只新增标签结果只读查询能力,建议放在 `CcdiBankTagResultMapper`
1. 按流水 ID 集合批量查询标签结果
- 入参:`List<Long> bankStatementIds`
- 条件:`result_type = 'STATEMENT'`
- 返回:标签明细列表
2. 按单个流水 ID 查询标签结果
- 入参:`Long bankStatementId`
- 条件:`result_type = 'STATEMENT'`
- 返回:标签明细列表
排序建议统一按以下顺序之一稳定输出:
- `risk_level` + `rule_code`
这样同一流水的标签顺序在列表、详情、导出中保持一致。
### 三、Service 组装设计
#### 1. 列表查询
`selectStatementPage()` 调整为两阶段:
1. 调用现有 Mapper 查询分页流水。
2. 收集当前页全部 `bankStatementId`
3. 批量查询这些流水的标签结果。
4.`bankStatementId` 分组后回填到每条 `CcdiBankStatementListVO.hitTags`
列表无标签时返回空集合,不返回 `null`,减少前端判空分支。
#### 2. 详情查询
`getStatementDetail()` 调整为:
1. 查询原有流水详情。
2. 按当前 `bankStatementId` 查询标签结果。
3. 回填到 `CcdiBankStatementDetailVO.hitTags`
#### 3. 导出查询
`selectStatementListForExport()` 调整为:
1. 查询导出范围内全部流水。
2. 批量查询这些流水的标签结果。
3. 按流水 ID 分组。
4. 组装到 `CcdiBankStatementExcel`
- `异常标签`:按标签名称拼接
- `命中原因摘要`:按相同顺序拼接
拼接分隔符建议统一使用全角分号 ``,避免在 Excel 中与金额千分位或英文逗号混淆。
### 四、Controller 设计
不新增接口,继续复用:
- `GET /ccdi/project/bank-statement/list`
- `GET /ccdi/project/bank-statement/detail/{bankStatementId}`
- `POST /ccdi/project/bank-statement/export`
这样不会影响现有菜单入口和前端 API 结构。
## 前端设计
### 一、列表展示
`DetailQuery.vue` 的表格中新增“异常标签”列,位置放在“摘要 / 交易类型”和“交易金额”之间。
展示规则:
- 有标签:逐个渲染轻量标签组件,仅显示 `ruleName`
- 无标签:显示 `-`
- 单条流水命中多个标签:同列换行或折行展示
样式规则:
- 沿用 Element UI `el-tag` 轻量视觉
- 可按 `riskLevel` 映射 `type`,但列表不展示风险等级文案
- 不新增 tooltip、展开收起、二级详情等扩展交互
### 二、详情展示
在现有详情弹窗的基础字段区下方新增“命中异常标签”模块。
展示规则:
- 无标签:显示“当前流水未命中异常标签”
- 有标签:按条展示
- 标签名称
- 风险等级
- 命中原因摘要
详情模块使用纵向结构,优先保证原因摘要可读性,不把多个原因压成单个文本段。
### 三、导出展示
导出 Excel 在现有列后追加:
- `异常标签`
- `命中原因摘要`
导出示例:
- `异常标签``房车消费支出交易;大额转账交易`
- `命中原因摘要``摘要命中购买房产首付款;转账金额 200000.00 元超过阈值`
## 错误处理
### 列表与详情
- 流水主查询成功、标签补充查询失败时,不让整个列表或详情失败。
- 列表标签列回退为空展示。
- 详情标签模块回退为无数据展示。
- 后端记录错误日志,便于排查标签结果查询异常。
### 导出
- 导出属于结果交付场景。
- 若标签结果查询或拼装失败,导出整体失败并返回错误。
- 不允许静默导出缺少标签列内容的文件,避免形成误导。
## 性能考虑
- 列表页只对当前页流水做一次批量标签查询,禁止逐条单查。
- 导出阶段对导出结果范围做一次批量标签查询,再在内存中按流水 ID 分组。
- 不把标签表聚合逻辑直接并入主分页 SQL避免影响现有分页查询稳定性。
## 测试设计
### 后端单测
- 列表查询:无标签、单标签、多标签三种场景
- 详情查询:返回结构化标签明细
- 导出查询:正确拼装“异常标签”“命中原因摘要”
- 仅返回 `STATEMENT` 标签,不混入对象级结果
### Mapper / SQL 测试
- 批量标签查询能按 `bank_statement_id` 正确分组
- 同一流水多个标签返回顺序稳定
### 前端单测
- 列表标签列正确显示多个标签与空值占位
- 详情弹窗正确显示命中异常标签模块
- 不影响现有翻页、排序、详情打开能力
### 人工验证
1. 进入项目详情页的“流水明细查询”
2. 确认列表中异常标签列展示正常
3. 打开一条命中流水详情,确认名称、风险等级、命中原因摘要可见
4. 导出当前筛选结果,确认 Excel 中新增两列且内容与页面口径一致
## 风险与边界
- 若历史标签结果中 `reason_detail` 缺失,详情和导出只能展示空摘要;本次不补历史数据修复。
- 若同一流水命中标签较多,列表列宽可能变高;本次仅做轻量折行展示,不增加复杂交互。
- 本次不新增异常标签筛选,用户仍需通过导出或详情查看具体命中原因。
## 实施拆分建议
后续进入实施计划时,默认拆为两份文档:
- 后端实施计划标签结果查询、VO 扩展、导出扩展、测试
- 前端实施计划:列表标签列、详情标签模块、单测与联调验证
## 结论
本次需求采用“现有流水查询 + 服务层批量补标签”的最短路径方案:
- 列表展示标签名称
- 详情展示标签名称、风险等级、命中原因摘要
- 导出追加异常标签与命中原因摘要
- 仅展示当前流水直接命中的流水级标签
该方案不改变现有流水查询入口、筛选项和标签计算逻辑,能在最小改动范围内补齐页面可读性与导出可交付性。

View File

@@ -151,18 +151,14 @@
1. 已有现存规则编码的,保持不变
2. Excel 提供了明确英文指标名且适合复用为规则编码的,直接使用英文指标名
3. Excel 未提供英文指标名的,使用稳定占位编码
占位编码格式:
- `<MODEL_CODE>_<两位序号>`
3. Excel 未提供英文指标名的,结合规则实际业务含义生成稳定的语义化编码
示例:
- `ABNORMAL_TRANSACTION_01`
- `SUSPICIOUS_PROPERTY_03`
- `ABNORMAL_CUSTOMER_TRANSACTION`
- `TAX_ASSET_REGISTRATION_MISMATCH`
这样可确保编码稳定、可批量生成、可回溯到 Excel 顺序,同时避免为无英文名规则主观造词
这样可确保编码稳定可读,又能直接体现规则指标语义,避免仅靠数字序号区分规则
### 自动补齐字段规则

View File

@@ -0,0 +1,302 @@
# 项目银行流水打标状态联动设计
## 背景
当前项目状态仅包含:
- `0-进行中`
- `1-已完成`
- `2-已归档`
银行流水打标已经具备手工触发和自动触发两条链路:
- 手工触发:用户在项目中发起标签重算
- 自动触发:上传流水文件或拉取本行信息成功后触发自动重算
但现状里“项目状态”和“银行流水打标任务状态”是分离的:
- 打标开始时,项目状态不会切到明确的“执行中”
- 打标期间,上传数据页仍可继续拉取本行信息、上传流水
- 打标期间,参数模型页仍可修改阈值
- 项目列表、详情页、状态统计也无法反映项目是否处于打标执行中
这会带来两个问题:
1. 用户无法判断项目当前是否正在执行银行流水打标
2. 打标运行过程中仍允许修改输入数据和参数,容易导致执行口径与结果不一致
## 目标
- 为项目增加“打标中”这一正式业务状态。
- 所有银行流水打标入口统一驱动项目状态切换。
- 打标成功后将项目状态变更为“已完成”。
- 打标失败后将项目状态回退为“进行中”。
- 当项目处于“打标中”时:
- 上传数据页禁止拉取本行信息、上传流水等会改变输入数据的操作
- 参数模型页禁止修改参数
- 项目列表、项目详情、项目状态统计统一展示“打标中”。
- 前端禁用与后端校验保持一致,避免仅靠页面控制被绕过。
## 范围
### In Scope
- `ccdi_project.status` 业务状态扩展
- 银行流水打标任务开始/结束时的项目状态切换
- 手工触发与自动触发两类打标链路
- 项目列表、详情页、状态统计的“打标中”展示
- 上传数据页与参数模型页的前端禁用
- 文件上传、拉取本行信息、参数保存的后端状态拦截
- 对应 SQL、测试与实施记录
### Out of Scope
- 打标执行线程模型调整
- 新增消息推送、WebSocket、实时进度条
- 已归档项目的业务流程重构
- 打标结果口径、规则 SQL、本次标签计算逻辑本身的调整
## 设计原则
- 项目状态是页面展示、统计、交互控制的唯一业务事实来源。
- “打标中”不是临时页面标记,而是项目正式状态。
- 所有打标触发入口必须复用同一套状态切换逻辑,不能分散在多个控制器中各自处理。
- 前端负责体验层禁用,后端负责最终业务兜底。
- 失败回退遵循用户确认规则:打标失败回退为 `0-进行中`
## 方案选择
本次采用“把打标中做成项目正式状态”的方案,不采用“从任务表动态推导打标中”。
原因:
- 当前项目列表、详情、统计、按钮显隐都已直接依赖 `ccdi_project.status`
- 将“打标中”纳入正式状态,能最小化前后端口径分叉
- 相比运行时拼装任务态,字典、统计、筛选、禁用逻辑更容易统一维护
## 状态模型设计
项目状态扩展为:
- `0-进行中`
- `1-已完成`
- `2-已归档`
- `3-打标中`
### 状态流转
#### 1. 项目创建
- 新建项目默认状态仍为 `0-进行中`
#### 2. 打标开始
当任一银行流水打标任务开始执行时,项目状态切换为 `3-打标中`
覆盖入口包括:
- 手工重算
- 批量上传流水完成后触发的自动打标
- 拉取本行信息完成后触发的自动打标
#### 3. 打标成功
- 当前轮及补跑轮次全部执行完成后,项目状态切换为 `1-已完成`
#### 4. 打标失败
- 当前轮打标执行失败时,项目状态切换回 `0-进行中`
#### 5. 补跑场景
`ProjectBankTagRebuildCoordinator` 当前已经支持 `needRerun` 机制。
设计要求:
- 当项目已经处于打标流程中且出现补跑标记时,项目状态持续保持 `3-打标中`
- 仅当最后一轮实际执行结束后,才根据最终结果落为 `1-已完成``0-进行中`
## 后端设计
## 1. 项目状态统一更新能力
建议在项目模块中补充一个明确的项目状态更新能力,供打标服务、文件上传服务、参数服务复用。
职责:
- 根据 `projectId` 获取项目并校验存在性
- 更新 `status``updateBy``updateTime`
- 对已归档项目做保护,避免被意外改写状态
不建议把状态切换逻辑散落在多个业务类里直接 `projectMapper.updateById(...)`
## 2. 打标状态切换落点
状态切换收口在 `CcdiBankTagServiceImpl.rebuildProject(...)`
建议流程:
1. 创建打标任务前或任务创建后、规则执行前,将项目状态更新为 `3-打标中`
2. 规则执行全部完成后,将项目状态更新为 `1-已完成`
3. 捕获异常时,将项目状态更新为 `0-进行中`
4. 若存在补跑循环,则状态保持 `3`,由最后一轮执行负责写最终态
这样可以保证手工和自动触发都复用同一套状态流转。
## 3. 前置拦截能力
后端需要对“打标中不可改输入”的规则做服务端校验,避免前端禁用被绕过。
### 文件上传相关
`CcdiFileUploadServiceImpl` 中,对以下入口增加项目状态校验:
- 批量上传流水
- 拉取本行信息
当项目状态为 `3-打标中` 时,直接抛出业务异常,例如:
- `当前项目正在进行银行流水打标,暂不允许上传或拉取数据`
### 参数保存相关
`CcdiModelParamServiceImpl` 中,对以下入口增加项目状态校验:
- `saveParams`
- `saveAllParams`
当项目状态为 `3-打标中` 时,直接抛出业务异常,例如:
- `当前项目正在进行银行流水打标,暂不允许修改参数`
## 4. 已归档项目保护
若已归档项目被误触发打标,不应继续推进状态切换。
建议策略:
- 已归档项目不允许进入新的打标流程
- 若入口层未提前拦住,状态更新能力也应拒绝把 `2-已归档` 改写为 `3`
这样能避免“已归档”与“打标中”之间出现非法流转。
## 前端设计
## 1. 项目详情页状态展示
`ruoyi-ui/src/views/ccdiProject/detail.vue` 需要新增 `3-打标中` 的状态展示:
- 状态文字:`打标中`
- 状态样式:与现有“进行中/已完成/已归档”区分
详情页向子组件透传的 `projectInfo.projectStatus` 保持为统一禁用依据。
## 2. 上传数据页禁用
`UploadData.vue` 在项目状态为 `3` 时进入受限态。
### 禁用范围
- `拉取本行信息`
- `上传流水`
- 所有会触发上传、拉取、重新提交输入数据的入口按钮
### 交互要求
- 按钮使用 `disabled`
- 禁用状态有明确提示文案,而不是仅变灰
- 已存在的文件列表、统计、查看类操作仍允许使用
目标是“禁止改变输入”,而不是“整个页面不可看”。
## 3. 参数模型页只读
`ParamConfig.vue` 在项目状态为 `3` 时进入只读态。
### 控制项
- 参数输入框禁用
- “保存所有修改”按钮禁用
- 页面顶部或按钮区展示提示文案:
- `项目正在进行银行流水打标,参数暂不可修改`
## 4. 项目列表与状态统计
### 项目列表
项目列表页需要新增 `3-打标中` 的显示与操作策略:
- 状态列可正常显示“打标中”
- “打标中”项目允许进入详情页查看
- 不显示仅适用于完成态的“查看结果/归档”
- 不把“打标中”误判为“进行中”或“已完成”
### 状态统计
项目首页状态统计扩展为:
- 全部
- 进行中
- 已完成
- 已归档
- 打标中
对应后端统计 VO 与前端 tabCounts 都要同步扩展。
## SQL 与字典设计
需要新增一条增量 SQL补齐状态字典与注释口径
- `ccdi_project.status` 注释补充 `3-打标中`
- `sys_dict_data``ccdi_project_status` 新增:
- `dict_label = 打标中`
- `dict_value = 3`
不直接依赖历史初始化脚本作为唯一变更承载,避免新环境与增量环境口径不一致。
## 错误处理
- 打标失败时,项目状态必须回退为 `0-进行中`
- 若状态更新失败,不应静默吞掉,需要保留日志,便于排查“任务完成但状态未切回”的问题
- 服务端拦截返回的错误文案要能让用户理解是“项目正在打标,所以暂时不可操作”
- 前端收到业务错误后直接展示后端文案,不再自行拼装另一套口径
## 测试设计
至少覆盖以下测试:
### 1. 打标状态流转测试
- 手工打标开始时置为 `3`
- 手工打标成功后置为 `1`
- 手工打标失败后回退为 `0`
- 自动打标开始时也会置为 `3`
- 存在补跑时,期间持续保持 `3`
### 2. 服务端拦截测试
- 项目状态为 `3` 时,批量上传流水被拒绝
- 项目状态为 `3` 时,拉取本行信息被拒绝
- 项目状态为 `3` 时,参数保存被拒绝
### 3. 前端页面测试/联调验证
- 项目详情页正确显示“打标中”
- 上传数据页相关按钮禁用
- 参数模型页输入框和保存按钮禁用
- 项目列表与状态统计正确显示“打标中”
## 风险与兼容性
- 新增状态 `3` 后,所有基于项目状态写死 `0/1/2` 的前后端逻辑都要排查,避免出现未知状态回退到默认分支
- 字典数据、状态统计 VO、前端颜色映射、按钮显隐条件需同步改造否则会出现展示不一致
- 若存在历史完成项目再次触发自动打标,按本设计会先进入 `3-打标中`,完成后重新回到 `1-已完成`,这是符合本次需求的
## 实施拆分建议
后续实施计划建议拆成两份:
- 后端实施计划:状态枚举/SQL/服务端状态切换/拦截/测试
- 前端实施计划:详情页禁用/列表与统计展示/交互提示/联调验证
这样能符合仓库“前后端分别产出实施计划”的协作约定。

View File

@@ -0,0 +1,357 @@
# 结果总览页面前端设计文档
**日期**: 2026-03-19
**模块**: 初核项目详情 - 结果总览
**作者**: Codex
**状态**: 已批准
## 概述
本文档用于沉淀 `结果总览` 页面的前端设计方案。设计依据为原型图 `assets/结果总览.png`,目标是在现有项目详情页壳子内,完成结果总览内容区的高保真静态页面设计。
本次设计只覆盖前端内容展示,不包含真实接口接入、后端数据结构设计,也不扩展原型图之外的业务流程。
## 设计范围
### 包含内容
- 沿用现有项目详情页外层壳子,仅替换 `结果总览` 内容区
- 高保真还原原型图中的页面结构与视觉层级
- 覆盖以下页面状态:
- 主展示态
- 空数据态
- 加载态
- 输出适合当前 Vue 2 + Element UI 项目的组件拆分方案
### 不包含内容
- 不改造项目详情页顶部标题区和页签导航
- 不接入真实接口
- 不新增原型图之外的弹窗、跳转或额外交互流程
- 不补充兜底、降级或兼容性扩展方案
## 当前上下文
当前 `结果总览` 入口组件为:
- `ruoyi-ui/src/views/ccdiProject/components/detail/PreliminaryCheck.vue`
当前文件仍为占位实现,仅显示“结果总览功能开发中...”。项目详情页通过 `detail.vue` 中的动态组件切换机制挂载该组件,因此本次设计应保持入口文件与挂载方式不变,在 `PreliminaryCheck.vue` 内完成页面编排。
## 设计原则
### 1. 高保真复刻原型
- 信息分区、模块顺序、表格结构、操作位尽量按原型图呈现
- 不主动重排信息架构
- 不将总览页改造成 tab 页或多路由页
### 2. 只覆盖内容区
- 页面仍运行在现有项目详情页中
- 标题、返回按钮、顶部页签继续沿用现有实现
- 本设计只负责 `结果总览` 区域的视觉和结构
### 3. 视觉不受现有默认样式约束
- 页面可独立定制浅灰背景、白色卡片、强调色、标签色和表格视觉
- 允许在 Element UI 基础上增加定制样式,以接近原型观感
- 但仍应优先复用现有组件能力,避免引入新 UI 依赖
### 4. 保持最短实现路径
- 页面以本地 mock 数据驱动
- 组件拆分服务于结构清晰和后续接口替换,不做额外抽象
- 所有设计均面向当前单页目标,不外扩到其他业务页面
## 页面信息架构
页面采用纵向长页面布局,自上而下分为 4 个独立分析区块:
### 1. 风险总览
页面顶部显示标题行与右侧操作位,下方展示统计卡片组。该区域用于快速传达项目风险总体情况,是用户进入页面后的第一视觉焦点。
建议保留原型中的以下展示元素:
- 页面标题
- 右侧操作按钮
- 统计卡片横排
- 关键指标数值与状态色
### 2. 风险人员
该区域分为上下两个子块:
- 风险人员总览
- 中高风险人员 TOP10
两个子块均使用卡片包裹表格,右上角保留操作位,表格行尾保留“查看详情”。上半块用于展示命中名单概况,下半块用于强调重点对象排行。
### 3. 风险模型
该区域同样分为上下两个子块:
- 模型预警次数统计
- 命中模型涉及人员列表
上半块使用模型摘要卡方式展示不同模型的命中次数、涉及人数及操作位;下半块使用筛选条 + 表格 + 分页的结构,展示命中模型下的相关人员。
### 4. 风险明细
该区域分为上下两个子块:
- 涉险交易明细
- 异常账户人员信息
两块均使用表格呈现,保留原型中金额颜色区分、标签展示及操作列结构,用于支撑用户从总览继续查看具体风险明细。
## 布局设计
### 整体布局
- 内容区使用浅灰背景,形成与顶部详情壳子的视觉分层
- 每个大区块使用独立白色卡片承载
- 区块之间保持稳定纵向间距
- 每个区块内部遵循“标题栏 / 操作区 / 主体内容区”的统一结构
### 统计卡布局
- 统计卡采用单行横排布局
- 每个卡片包含图标、指标名、数值与辅助说明
- 卡片高度统一,避免因字数不同导致顶部基线不齐
### 表格区布局
- 表格上方统一保留区块标题与操作入口
- 多个表格区之间不做复杂嵌套,保持纵向串联
- 分页控件右下对齐,贴近原型阅读习惯
### 筛选区布局
- 风险模型区的筛选条位于表格上方
- 使用紧凑型筛选布局,避免筛选器喧宾夺主
- 筛选组件与原型字段顺序保持一致
## 页面状态设计
### 主展示态
主展示态为默认呈现状态,页面所有区块均展示样例数据。
展示要求:
- 顶部统计卡显示明确数值
- 风险人员榜单显示表格数据、操作列和分页
- 风险模型区显示模型卡、筛选条、表格和分页
- 风险明细区显示交易和账户人员两块表格
- 所有按钮、筛选项、分页在视觉上完整可见
允许在静态页面阶段增加本地假交互,例如:
- 分页切换
- 筛选条件切换
- 标签激活态切换
但不延伸为真实接口调用流程。
### 空数据态
空数据态采用“按区块独立判空”的方式,而不是整页统一空态。
规则如下:
- 顶部统计卡无数据时显示 `0`
- 表格区无数据时显示统一空态占位
- 模型卡无数据时显示空卡容器或空文案
- 某一个区块为空时,不影响其他区块正常展示
该方案更符合总览页特性,也更贴合未来真实数据场景。
### 加载态
加载态采用分区块骨架屏,不使用整页单一 loading。
表现形式建议如下:
- 统计区显示若干骨架卡
- 表格区显示表头 + 多行骨架内容
- 模型卡显示矩形骨架块
- 页面外层布局保持稳定,避免内容闪动和大幅位移
## 交互边界
本次设计只定义页面展示交互,不扩展原型图外的业务逻辑。
允许保留的交互:
- 操作按钮视觉态
- 表格行 hover
- 标签高亮
- 分页切换
- 本地筛选联动
不纳入本次设计的内容:
- 真实导出流程
- 详情弹窗流程
- 权限控制
- 接口错误处理流
- 原型未体现的补充说明区或辅助引导区
## 组件拆分方案
为保持当前挂载结构稳定,同时避免单文件过大,建议采用“页面编排组件 + 区块组件 + 本地 mock 文件”的组织方式。
### 1. 页面入口组件
文件:
- `ruoyi-ui/src/views/ccdiProject/components/detail/PreliminaryCheck.vue`
职责:
- 作为结果总览主容器
- 管理主展示态、空数据态、加载态
- 组织各区块组件顺序
- 注入 mock 数据
- 控制整体布局与分区间距
### 2. 顶部总览组件
建议新增组件,例如:
- `OverviewStats.vue`
职责:
- 渲染页面标题行
- 渲染右侧操作区
- 渲染统计卡片组
- 封装统计卡的颜色、图标、数字样式
### 3. 风险人员区组件
建议新增组件,例如:
- `RiskPeopleSection.vue`
职责:
- 渲染风险人员总览表格
- 渲染中高风险人员 TOP10 表格
- 统一处理区块标题、表格操作列与分页布局
### 4. 风险模型区组件
建议新增组件,例如:
- `RiskModelSection.vue`
职责:
- 渲染模型摘要卡
- 渲染筛选区
- 渲染命中模型涉及人员表格
- 处理本地 mock 筛选态与分页态
### 5. 风险明细区组件
建议新增组件,例如:
- `RiskDetailSection.vue`
职责:
- 渲染涉险交易明细表
- 渲染异常账户人员信息表
- 统一金额颜色、标签样式、操作列表现
### 6. 本地 mock 数据文件
建议新增本地数据文件,例如:
- `preliminaryCheck.mock.js`
职责:
- 提供主展示态示例数据
- 提供空数据态示例数据
- 提供分页与筛选的前端假数据源
## 样式设计要点
### 色彩
- 页面背景:浅灰
- 卡片背景:白色
- 主强调色:延续原型中的蓝色体系
- 风险等级:通过标签色或数值色体现差异
- 金额正负值:使用不同颜色区分
### 卡片
- 卡片圆角和阴影适度增强,贴近原型
- 统计卡和区块卡在视觉层级上区分,但不出现风格割裂
### 表格
- 表头背景、行间距、操作列色值贴近原型
- 重要数值和状态信息优先通过颜色和字重建立层级
- 避免默认 Element UI 表格样式直接裸用
### 响应策略
- 优先保证桌面端呈现接近原型
- 内容宽度随当前详情页内容区域自适应
- 不在本次设计中扩展移动端重构方案
## 数据组织建议
虽然当前为静态前端页面,但为了后续实现平滑接入真实数据,建议按区块组织 mock 数据:
- `summary`: 顶部指标卡数据
- `riskPeople`: 风险人员总览与 TOP10 数据
- `riskModels`: 模型摘要卡与模型命中人员数据
- `riskDetails`: 涉险交易与异常账户人员数据
每个区块都应具备:
- `loading`
- `isEmpty`
- `list`
- `pagination`
这样在后续切换真实接口时,可以保持组件入参结构基本稳定。
## 测试关注点
本次设计阶段建议后续实现时至少验证以下内容:
1. `结果总览` 页签进入后,内容区正常替换占位页
2. 主展示态下 4 个区块顺序、标题、内容与原型一致
3. 空数据态只影响对应区块,不影响其他区块展示
4. 加载态使用骨架屏,且不会导致页面结构跳变
5. 自定义样式不会破坏当前项目详情页外层结构
## 文件落点建议
本设计对应的主要实现文件建议包括:
- `ruoyi-ui/src/views/ccdiProject/components/detail/PreliminaryCheck.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/OverviewStats.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/RiskPeopleSection.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/RiskModelSection.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/preliminaryCheck.mock.js`
## 结论
本方案采用“原型分块复刻”的最短路径设计:保留现有项目详情页壳子,仅重做 `结果总览` 内容区,按原型拆分为风险总览、风险人员、风险模型、风险明细四大区块,并覆盖主展示态、空数据态、加载态。
该设计满足以下目标:
- 高保真还原原型
- 不引入原型外流程
- 适配当前 Vue 2 + Element UI 项目结构
- 便于后续从静态展示平滑过渡到真实接口实现

View File

@@ -0,0 +1,483 @@
# 结果总览风险接口设计文档
**日期**: 2026-03-19
**模块**: 初核项目详情 - 结果总览
**作者**: Codex
**状态**: 已确认
## 一、概述
本文档用于沉淀结果总览页面中以下 3 个区块的后端设计方案:
- 风险仪表盘
- 风险人员总览
- 中高风险人员 TOP10
本次设计目标是为现有结果总览页面补齐真实后端接口,并把项目级高、中、低风险人数的统计口径收拢到流水标签打标链路中,保证项目列表、结果总览仪表盘、风险人员榜单三处口径一致。
## 二、设计范围
### 2.1 包含内容
- 新增结果总览 3 个独立后端查询接口
- 新增员工维度风险聚合查询
- 在项目流水标签打标完成后回写项目风险人数
- 输出对应后端实施计划与前端实施计划
### 2.2 不包含内容
- 不改造结果总览中的风险模型区、风险明细区
- 不新增设计需求之外的导出、弹窗、降级、补丁逻辑
- 不在查询接口中触发重新打标
- 不新增客户、中介等非员工维度榜单
## 三、当前上下文
### 3.1 前端现状
结果总览页面已经完成静态结构,相关文件如下:
- `ruoyi-ui/src/views/ccdiProject/components/detail/PreliminaryCheck.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/OverviewStats.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/RiskPeopleSection.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/preliminaryCheck.mock.js`
其中当前页面仍由本地 mock 数据驱动,三块目标区块的字段结构已经稳定:
- 风险仪表盘:`title/subtitle/stats`
- 风险人员总览:`overviewList`
- 中高风险人员 TOP10`topRiskList`
### 3.2 后端现状
当前仓库中还没有结果总览专用后端接口,但已有以下可复用数据基础:
1. `ccdi_project`
- 已有 `target_count`
- 已有 `high_risk_count`
- 已有 `medium_risk_count`
- 已有 `low_risk_count`
2. `ccdi_bank_statement_tag_result`
- 已沉淀项目维度的标签命中结果
-`project_id/model_code/rule_code/risk_level/bank_statement_id/object_type/object_key`
3. `ccdi_base_staff`
- 员工主数据,含 `name/dept_id/id_card`
4. `ccdi_staff_fmy_relation`
- 员工亲属映射,含 `person_id/relation_cert_no/status`
5. `sys_dept`
- 用于补齐所属部门名称
### 3.3 已确认业务口径
用户已确认以下口径:
1. 接口粒度
- 采用 3 个独立接口,而非 1 个汇总接口
2. 风险仪表盘口径
- `总人数 = ccdi_project.target_count`
- `高风险/中风险/低风险 = ccdi_project.high_risk_count / medium_risk_count / low_risk_count`
- `无风险人员 = 总人数 - 高风险 - 中风险 - 低风险`
3. 榜单统计对象
- 统计“员工本人 + 员工亲属”
- 若命中亲属,则归并到所属员工名下
4. 风险人员总览中的 `疑似违规数`
- 统计员工命中的去重规则数
5. 员工风险等级口径
- 命中规则数 `>= 5`:高风险
- 命中规则数 `2-4`:中风险
- 命中规则数 `= 1`:低风险
6. TOP10 排序
- 按员工风险等级优先级排序
- 同等级按命中模型数倒序
- 再按命中规则数倒序
- 最后按员工身份证号升序
7. 项目表风险人数维护方式
- 在项目流水标签打标完成后回写高、中、低风险人数
## 四、方案对比与结论
### 4.1 方案一:新增结果总览专用控制器与 3 个独立接口
优点:
- 与前端 3 个区块一一对应
- 查询职责边界清晰
- 后续风险模型区、风险明细区继续扩展时可沿用同一入口
缺点:
- 需要新增一组 DTO/VO/Mapper 查询
### 4.2 方案二:把接口直接追加到 `CcdiProjectController`
优点:
- 表面改动点更少
缺点:
- 项目管理与结果总览查询混在同一控制器
- 后续结果总览继续扩展时边界会变乱
### 4.3 方案三:提供 1 个汇总接口
优点:
- 请求数更少
缺点:
- 与已确认的“3 个独立接口”目标不符
- 单个区块口径变化会牵动整个响应结构
### 4.4 最终结论
采用方案一:新增结果总览专用控制器,提供 3 个独立接口,并新增员工维度聚合查询与项目风险人数回写逻辑。
## 五、接口设计
### 5.1 控制器落点
建议新增:
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewController.java`
接口统一挂载到:
- `/ccdi/project/overview`
### 5.2 接口清单
#### 接口一:查询风险仪表盘
- 方法:`GET`
- 路径:`/ccdi/project/overview/dashboard`
- 参数:`projectId`
- 返回:`AjaxResult.success(data)`
返回结构示例:
```json
{
"title": "风险仪表盘",
"subtitle": "风险仪表盘数据概览",
"stats": [
{ "key": "people", "label": "总人数", "value": 500 },
{ "key": "riskPeople", "label": "高风险", "value": 10 },
{ "key": "medium", "label": "中风险", "value": 20 },
{ "key": "low", "label": "低风险", "value": 38 },
{ "key": "count", "label": "无风险人员", "value": 432 }
]
}
```
#### 接口二:查询风险人员总览
- 方法:`GET`
- 路径:`/ccdi/project/overview/risk-people`
- 参数:`projectId`
- 返回:`AjaxResult.success(data)`
返回结构示例:
```json
{
"overviewList": [
{
"name": "李四",
"idNo": "330000000000000001",
"department": "信息二部",
"riskCount": 5,
"riskPoint": "大额单笔收入",
"actionLabel": "查看详情"
}
]
}
```
#### 接口三:查询中高风险人员 TOP10
- 方法:`GET`
- 路径:`/ccdi/project/overview/top-risk-people`
- 参数:`projectId`
- 返回:`AjaxResult.success(data)`
返回结构示例:
```json
{
"topRiskList": [
{
"name": "张三",
"idNo": "330000000000000002",
"department": "信贷部",
"riskLevel": "高风险",
"riskLevelType": "danger",
"modelCount": 8,
"actionLabel": "查看详情"
}
]
}
```
## 六、数据模型设计
### 6.1 新增 VO
建议新增以下 VO避免与现有 `CcdiProjectVO` 混用:
1. 仪表盘 VO
- `CcdiProjectOverviewDashboardVO`
- `CcdiProjectOverviewStatVO`
2. 风险人员总览 VO
- `CcdiProjectRiskPeopleOverviewVO`
- `CcdiProjectRiskPeopleOverviewItemVO`
3. 中高风险人员 TOP10 VO
- `CcdiProjectTopRiskPeopleVO`
- `CcdiProjectTopRiskPeopleItemVO`
4. 内部聚合中间 VO
- `CcdiProjectEmployeeRiskAggregateVO`
### 6.2 内部聚合字段
员工维度聚合中间结果至少包含:
- `staffIdCard`
- `staffName`
- `deptId`
- `deptName`
- `ruleCount`
- `modelCount`
- `topRuleCode`
- `topRuleName`
- `riskLevelCode`
- `riskLevelName`
- `riskLevelSort`
其中:
- `ruleCount = count(distinct rule_code)`
- `modelCount = count(distinct model_code)`
- `riskLevelCode` 根据 `ruleCount` 映射为 `HIGH/MEDIUM/LOW`
- `riskLevelName` 映射为 `高风险/中风险/低风险`
- `riskLevelSort` 仅供 SQL 排序使用,`HIGH=1``MEDIUM=2``LOW=3`
## 七、员工维度归并设计
### 7.1 归并目标
所有榜单都统一归并到员工维度,页面不直接展示亲属维度行。
### 7.2 归并规则
标签结果转员工归属时,按以下优先级处理:
1.`object_type = STAFF_ID_CARD``object_key` 命中员工身份证
- 直接归到该员工
2.`object_key` 为空,但该条结果关联到流水,且流水 `cret_no` 命中员工身份证
- 归到该员工
3.`object_key` 为空或为亲属证件号,且能通过 `ccdi_staff_fmy_relation.relation_cert_no` 关联到员工 `person_id`
- 归到该员工
4. 若无法归并到员工
- 本次查询中直接丢弃,不进入结果总览榜单
### 7.3 归并实现原则
- 不改造现有标签结果表结构
- 不为本次需求新增缓存表或汇总表
- 统一通过查询层完成员工归并
## 八、风险等级与项目人数回写设计
### 8.1 员工风险等级计算
按员工命中的去重规则数计算:
- `ruleCount >= 5` -> `HIGH` / `高风险`
- `ruleCount between 2 and 4` -> `MEDIUM` / `中风险`
- `ruleCount = 1` -> `LOW` / `低风险`
### 8.2 项目表风险人数统计
在员工维度聚合结果基础上统计:
- `highRiskCount = 员工风险等级为 HIGH 的人数`
- `mediumRiskCount = 员工风险等级为 MEDIUM 的人数`
- `lowRiskCount = 员工风险等级为 LOW 的人数`
### 8.3 回写时机
项目流水标签任务成功结束时执行回写,链路放在 `CcdiBankTagServiceImpl.rebuildProject(...)` 内:
1. 删除旧标签结果
2. 执行全部规则
3. 批量写入新标签结果
4. 重新按员工维度聚合高、中、低风险人数
5. 更新 `ccdi_project`
6. 更新任务状态为成功
这样可保证查询接口读到的项目风险人数与最新标签结果一致。
### 8.4 不采用的方案
不采用“查询时实时扫标签结果并顺手更新项目表”的做法,因为:
- 查询副作用过重
- 口径维护分散
- 项目列表与结果总览可能出现时间差
## 九、SQL 设计
### 9.1 Mapper 落点
建议新增:
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapper.java`
- `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml`
### 9.2 查询职责拆分
Mapper 只负责以下查询:
1. 查询项目仪表盘基础数据
2. 查询员工维度风险总览列表
3. 查询员工维度中高风险 TOP10
4. 查询项目员工风险等级分布人数
### 9.3 员工聚合 SQL 结构
建议采用“公共子查询 + 外层聚合”的方式:
1. 先构造标签结果到员工身份证的归并明细
2. 再按员工身份证聚合规则数、模型数和代表性规则
3. 最后外层补部门名称、风险等级和排序字段
建议公共子查询输出字段:
- `project_id`
- `staff_id_card`
- `rule_code`
- `rule_name`
- `model_code`
### 9.4 代表性异常点选择
`风险人员总览.riskPoint` 采用以下稳定选择策略:
1. 先按员工 + 规则维度统计命中次数
2. 按命中次数倒序
3. 再按 `rule_code` 升序
4. 取第一条 `rule_name`
这样不依赖数据库非确定性行为,也不会因为同条规则多次命中而随机波动。
### 9.5 TOP10 筛选规则
TOP10 查询仅保留:
- `ruleCount >= 2`
即仅展示中风险和高风险员工。
## 十、服务层设计
### 10.1 Service 落点
建议新增:
- `ICcdiProjectOverviewService`
- `CcdiProjectOverviewServiceImpl`
### 10.2 服务职责
服务层负责:
1. 调用 Mapper 查询项目仪表盘基础数据
2. 组装前端需要的 `title/subtitle/stats`
3. 调用员工聚合查询并映射到列表 VO
4. 在打标完成后调用项目风险人数回写逻辑
5. 处理空数据场景
### 10.3 空数据处理
1. 仪表盘
- 若项目存在但风险人数为空,按 `0` 返回
2. 风险人员总览
- 返回 `overviewList: []`
3. TOP10
- 返回 `topRiskList: []`
## 十一、控制器与权限设计
控制器沿用现有项目模块风格:
- 使用 `@RestController`
- 使用 `@Tag`
- 使用 `@Operation`
- 使用 `@PreAuthorize("@ss.hasPermi('ccdi:project:query')")`
- 返回 `AjaxResult.success(...)`
本次不新增新的权限标识,直接复用项目查询权限。
## 十二、异常处理设计
### 12.1 查询接口
- 项目不存在:返回业务异常,提示“项目不存在”
- `projectId` 为空:走参数校验失败
### 12.2 回写链路
若项目风险人数回写失败:
- 视为本次打标任务失败
- 不允许出现“标签写入成功但项目人数未更新且任务仍成功”的状态
原因是本次需求要求三处口径一致,项目人数回写属于打标成功链路的一部分,而不是可选附加动作。
## 十三、测试设计
### 13.1 后端单元/集成测试重点
1. 风险仪表盘
- 校验 `无风险人员` 计算逻辑
2. 风险人员总览
- 校验员工本人命中可正常归并
- 校验亲属命中可正常归并到员工
- 校验 `riskCount = 去重规则数`
- 校验 `riskPoint` 选择稳定
3. TOP10
- 校验仅返回中高风险
- 校验排序规则正确
4. 打标回写
- 校验任务成功后项目表风险人数被更新
- 校验回写失败时任务整体失败
### 13.2 前端联调验证重点
1. mock 替换为 3 个真实接口
2. 字段命名与当前页面结构一致
3. 空列表时页面能正确展示空表格
## 十四、风险与约束
### 14.1 已知约束
- 当前标签结果表未直接存员工归属字段,需要通过查询归并
- 部分标签结果可能来自 `bank_statement_id` 关联的流水,而非直接对象命中
### 14.2 风险控制
- 统一通过员工聚合查询输出榜单,避免前后接口各自实现一套归并逻辑
- 项目风险人数只在打标成功后回写,避免查询阶段产生副作用
- 不引入额外缓存与汇总表,保持最短实现路径
## 十五、最终结论
本方案采用“结果总览专用接口 + 标签完成后回写项目风险人数”的最短路径实现:
1. 结果总览新增 3 个独立接口
2. 风险榜单统一按员工维度聚合
3. 员工风险等级按命中规则条数区间计算
4. 项目表高、中、低风险人数在打标完成后统一回写
5. 项目列表、风险仪表盘、风险人员榜单三处口径保持一致

View File

@@ -0,0 +1,483 @@
# LSFX Mock LogId Primary Binding Backend 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.
**Goal:**`lsfx-mock-server` 为上传文件和拉取本行信息生成的每个 `logId` 绑定唯一且稳定的本方主体与本方账号,并在上传响应、上传状态和银行流水查询中保持一致。
**Architecture:**`FileService` 中统一生成并维护 `logId -> 主体账号绑定`,以 `FileRecord` 作为单一事实来源;上传文件与拉取本行信息两条链路都创建完整记录;`StatementService` 通过只读查询能力按 `logId` 取回绑定并写入每条流水的 `leName/accountMaskNo`,不再自行随机本方主体或本方账号。
**Tech Stack:** Python 3, FastAPI, pytest, httpx TestClient
## 执行结果
- 2026-03-18 已按计划完成 Task 1 至 Task 5功能实现、实施记录与最终验证均已落地。
- 代码提交顺序:
- `0120d09` `收敛Mock文件记录主体账号绑定模型`
- `6fb7287` `让拉取本行信息链路复用Mock主体账号绑定`
- `0a85c09` `统一Mock上传状态主体账号绑定优先级`
- `5195617` `让Mock流水查询复用logId主体账号绑定`
- 最终验证已通过:
- `python3 -m pytest tests/test_file_service.py -v`
- `python3 -m pytest tests/test_statement_service.py -v`
- `python3 -m pytest tests/test_api.py -v`
- `python3 -m pytest tests/integration/test_full_workflow.py -v`
- `python3 verify_implementation.py`
- 实施细节见 `docs/reports/implementation/2026-03-18-lsfx-logid-primary-binding-implementation.md`
---
### Task 1: 收敛 FileRecord 为单一主体账号绑定模型
**Files:**
- Modify: `lsfx-mock-server/services/file_service.py`
- Create: `lsfx-mock-server/tests/test_file_service.py`
- [ ] **Step 1: Write the failing test**
先新增 `test_file_service.py`,锁定 `FileRecord` 和上传响应的最小语义:一个 `logId` 只对应一个主体与一个账号。
```python
import pytest
from fastapi import BackgroundTasks, UploadFile
from io import BytesIO
from services.file_service import FileService
@pytest.mark.asyncio
async def test_upload_file_should_create_single_primary_binding():
service = FileService()
upload = UploadFile(filename="测试流水.csv", file=BytesIO(b"demo"))
response = await service.upload_file(1000, upload, BackgroundTasks())
log = response["data"]["uploadLogList"][0]
assert len(log["enterpriseNameList"]) == 1
assert len(log["accountNoList"]) == 1
assert response["data"]["accountsOfLog"][str(log["logId"])][0]["accountName"] == log["enterpriseNameList"][0]
assert response["data"]["accountsOfLog"][str(log["logId"])][0]["accountNo"] == log["accountNoList"][0]
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd lsfx-mock-server
pytest tests/test_file_service.py -k primary_binding -v
```
Expected:
- `FAIL`
- 原因是 `tests/test_file_service.py` 尚不存在,且 `FileRecord` 还没有明确的单值主体账号语义
- [ ] **Step 3: Write minimal implementation**
`file_service.py` 中增加单值字段和统一生成方法,例如:
```python
@dataclass
class FileRecord:
primary_enterprise_name: str = ""
primary_account_no: str = ""
```
```python
PRIMARY_ENTERPRISE_POOL = [
"测试主体A",
"测试主体B",
"测试主体C",
"兰溪测试主体一部",
"兰溪测试主体二部",
]
def _generate_primary_account_binding(self) -> tuple[str, str]:
enterprise_name = random.choice(PRIMARY_ENTERPRISE_POOL)
account_no = f"{random.randint(100000000000000, 999999999999999)}"
return enterprise_name, account_no
```
并在 `upload_file()` 中回填:
```python
enterprise_name, account_no = self._generate_primary_account_binding()
file_record.primary_enterprise_name = enterprise_name
file_record.primary_account_no = account_no
file_record.enterprise_name_list = [enterprise_name]
file_record.account_no_list = [account_no]
```
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
cd lsfx-mock-server
pytest tests/test_file_service.py -k primary_binding -v
```
Expected:
- `PASS`
- 说明上传接口返回已经围绕单一主体账号绑定工作
- [ ] **Step 5: Commit**
```bash
git add lsfx-mock-server/services/file_service.py lsfx-mock-server/tests/test_file_service.py
git commit -m "收敛Mock文件记录主体账号绑定模型"
```
### Task 2: 让拉取本行信息链路也创建并保存绑定记录
**Files:**
- Modify: `lsfx-mock-server/services/file_service.py`
- Modify: `lsfx-mock-server/tests/test_file_service.py`
- Modify: `lsfx-mock-server/tests/test_api.py`
- [ ] **Step 1: Write the failing test**
补两个测试,一个测服务层,一个测 API 层:
```python
def test_fetch_inner_flow_should_persist_file_record_for_returned_log_id():
service = FileService()
response = service.fetch_inner_flow({"groupId": 1000})
log_id = response["data"][0]
assert log_id in service.file_records
record = service.file_records[log_id]
assert record.primary_enterprise_name
assert record.primary_account_no
```
```python
def test_fetch_inner_flow_followed_by_upload_status_should_keep_same_binding(client, sample_inner_flow_request):
flow_response = client.post("/watson/api/project/getJZFileOrZjrcuFile", data=sample_inner_flow_request)
log_id = flow_response.json()["data"][0]
status_response = client.get(f"/watson/api/project/bs/upload?groupId=1000&logId={log_id}")
log = status_response.json()["data"]["logs"][0]
assert len(log["enterpriseNameList"]) == 1
assert len(log["accountNoList"]) == 1
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd lsfx-mock-server
pytest tests/test_file_service.py -k fetch_inner_flow -v
pytest tests/test_api.py -k "fetch_inner_flow_followed_by_upload_status" -v
```
Expected:
- `FAIL`
- 原因是 `fetch_inner_flow()` 当前只返回随机 `logId`,不会创建 `FileRecord`
- [ ] **Step 3: Write minimal implementation**
`fetch_inner_flow()` 改成复用和上传相同的记录创建逻辑:
```python
def _create_file_record(self, group_id: int, file_name: str, bank_name: str, template_name: str) -> FileRecord:
...
```
```python
def fetch_inner_flow(self, request: Union[Dict, object]) -> Dict:
group_id = request.get("groupId", 1000) if isinstance(request, dict) else request.groupId
self.log_counter += 1
log_id = self.log_counter
file_record = self._create_file_record(group_id, f"inner_flow_{log_id}.csv", "ZJRCU", "ZJRCU_T251114")
self.file_records[log_id] = file_record
return {"code": "200", "data": [log_id], "status": "200", "successResponse": True}
```
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
cd lsfx-mock-server
pytest tests/test_file_service.py -k fetch_inner_flow -v
pytest tests/test_api.py -k "fetch_inner_flow_followed_by_upload_status" -v
```
Expected:
- `PASS`
- 说明拉取本行信息返回的 `logId` 已经能在后续链路中复用同一组主体账号
- [ ] **Step 5: Commit**
```bash
git add lsfx-mock-server/services/file_service.py lsfx-mock-server/tests/test_file_service.py lsfx-mock-server/tests/test_api.py
git commit -m "让拉取本行信息链路复用Mock主体账号绑定"
```
### Task 3: 统一上传状态接口优先读取真实记录
**Files:**
- Modify: `lsfx-mock-server/services/file_service.py`
- Modify: `lsfx-mock-server/tests/test_file_service.py`
- Modify: `lsfx-mock-server/tests/test_api.py`
- [ ] **Step 1: Write the failing test**
补一组“真实记录优先级”测试,防止 `get_upload_status()` 继续走即时随机分支覆盖真实绑定:
```python
import pytest
@pytest.mark.asyncio
async def test_get_upload_status_should_prefer_existing_file_record_binding():
service = FileService()
upload = UploadFile(filename="测试流水.csv", file=BytesIO(b"demo"))
response = await service.upload_file(1000, upload, BackgroundTasks())
log_id = response["data"]["uploadLogList"][0]["logId"]
status = service.get_upload_status(1000, log_id)
log = status["data"]["logs"][0]
assert log["enterpriseNameList"] == response["data"]["uploadLogList"][0]["enterpriseNameList"]
assert log["accountNoList"] == response["data"]["uploadLogList"][0]["accountNoList"]
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd lsfx-mock-server
pytest tests/test_file_service.py -k "prefer_existing_file_record" -v
```
Expected:
- `FAIL`
- 原因是 `get_upload_status()` 当前只按 `logId` 做即时随机生成,不会优先读取 `self.file_records`
- [ ] **Step 3: Write minimal implementation**
`get_upload_status()` 改成:
```python
def get_upload_status(self, group_id: int, log_id: int = None) -> dict:
logs = []
if log_id:
if log_id in self.file_records:
logs.append(self._build_log_detail(self.file_records[log_id]))
else:
random.seed(log_id)
logs.append(self._generate_deterministic_record(log_id, group_id))
```
同时确保 `_generate_deterministic_record()` 也遵循单一绑定语义:
- `enterpriseNameList` 长度为 1
- `accountNoList` 长度为 1
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
cd lsfx-mock-server
pytest tests/test_file_service.py -v
pytest tests/test_api.py -k "upload_status or deterministic_data_generation" -v
```
Expected:
- `PASS`
- 已有上传状态接口的确定性测试仍然成立
- 对真实记录会优先返回真实绑定
- [ ] **Step 5: Commit**
```bash
git add lsfx-mock-server/services/file_service.py lsfx-mock-server/tests/test_file_service.py lsfx-mock-server/tests/test_api.py
git commit -m "统一Mock上传状态主体账号绑定优先级"
```
### Task 4: 让 StatementService 按 logId 注入本方主体和账号
**Files:**
- Modify: `lsfx-mock-server/services/statement_service.py`
- Modify: `lsfx-mock-server/services/file_service.py`
- Modify: `lsfx-mock-server/routers/api.py`
- Create: `lsfx-mock-server/tests/test_statement_service.py`
- Modify: `lsfx-mock-server/tests/integration/test_full_workflow.py`
- [ ] **Step 1: Write the failing test**
新增服务层测试,锁定同一 `logId` 的所有流水都要复用同一组 `leName/accountMaskNo`
```python
from services.file_service import FileService
from services.statement_service import StatementService
def test_bank_statement_should_reuse_file_record_primary_binding():
file_service = FileService()
log_id = file_service.fetch_inner_flow({"groupId": 1000})["data"][0]
record = file_service.file_records[log_id]
service = StatementService(file_service=file_service)
result = service.get_bank_statement({"groupId": 1000, "logId": log_id, "pageNow": 1, "pageSize": 20})
statements = result["data"]["bankStatementList"]
assert all(item["leName"] == record.primary_enterprise_name for item in statements)
assert all(item["accountMaskNo"] == record.primary_account_no for item in statements)
```
再补一个集成断言:
```python
def test_complete_workflow_should_keep_same_primary_binding_between_status_and_statement(client):
...
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd lsfx-mock-server
pytest tests/test_statement_service.py -v
pytest tests/integration/test_full_workflow.py -k primary_binding -v
```
Expected:
- `FAIL`
- 原因是 `StatementService` 目前仍写死 `leName = 张传伟` 并独立随机 `accountMaskNo`
- [ ] **Step 3: Write minimal implementation**
`StatementService` 增加对 `FileService` 的只读依赖:
```python
class StatementService:
def __init__(self, file_service=None):
self.file_service = file_service
self._cache = {}
```
```python
def _resolve_primary_binding(self, group_id: int, log_id: int) -> tuple[str, str]:
if self.file_service and log_id in self.file_service.file_records:
record = self.file_service.file_records[log_id]
return record.primary_enterprise_name, record.primary_account_no
return "测试主体A", f"{random.randint(100000000000000, 999999999999999)}"
```
`_generate_random_statement()` 中替换:
```python
enterprise_name, account_no = self._resolve_primary_binding(group_id, log_id)
...
"accountMaskNo": account_no,
...
"leName": enterprise_name,
```
并在 [`lsfx-mock-server/routers/api.py`](/Users/wkc/Desktop/ccdi/ccdi/lsfx-mock-server/routers/api.py) 中把服务实例装配改成共享同一份 `file_service`
```python
file_service = FileService()
statement_service = StatementService(file_service=file_service)
```
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
cd lsfx-mock-server
pytest tests/test_statement_service.py -v
pytest tests/integration/test_full_workflow.py -v
```
Expected:
- `PASS`
- 同一 `logId` 的分页流水在 `leName/accountMaskNo` 上保持一致
- [ ] **Step 5: Commit**
```bash
git add lsfx-mock-server/services/statement_service.py lsfx-mock-server/services/file_service.py lsfx-mock-server/tests/test_statement_service.py lsfx-mock-server/tests/integration/test_full_workflow.py
git commit -m "让Mock流水查询复用logId主体账号绑定"
```
### Task 5: 补实施记录并完成最终验证
**Files:**
- Create: `docs/reports/implementation/2026-03-18-lsfx-logid-primary-binding-implementation.md`
- Modify: `docs/plans/backend/2026-03-18-lsfx-logid-primary-binding-backend-implementation.md`
- [ ] **Step 1: Write the implementation report skeleton**
记录以下内容:
- `FileRecord` 新增的绑定字段
- 上传文件/拉取本行信息/上传状态/查流水四处的联动
- 实际执行过的 pytest 命令
- 兼容逻辑优先级说明
建议骨架:
```markdown
# LSFX Mock LogId 主体账号绑定实施记录
## 变更概述
- ...
## 验证记录
- `cd lsfx-mock-server && pytest tests/test_file_service.py -v`
- `cd lsfx-mock-server && pytest tests/test_statement_service.py -v`
- `cd lsfx-mock-server && pytest tests/test_api.py -v`
```
- [ ] **Step 2: Run final verification**
Run:
```bash
cd lsfx-mock-server
pytest tests/test_file_service.py -v
pytest tests/test_statement_service.py -v
pytest tests/test_api.py -v
pytest tests/integration/test_full_workflow.py -v
python verify_implementation.py
```
Expected:
- 所有测试通过
- 接口字段完整性检查仍然通过
- [ ] **Step 3: Stop any started services**
如果验证过程中启动了:
- `python main.py`
- `uvicorn main:app --reload --host 0.0.0.0 --port 8000`
结束时必须主动关闭进程,避免残留端口占用。
- [ ] **Step 4: Commit**
```bash
git add docs/reports/implementation/2026-03-18-lsfx-logid-primary-binding-implementation.md docs/plans/backend/2026-03-18-lsfx-logid-primary-binding-backend-implementation.md
git commit -m "补充Mock主体账号绑定实施记录"
```

View File

@@ -0,0 +1,421 @@
# LSFX Mock Large Transaction Backend 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.
**Goal:** 调整 `lsfx-mock-server` 的银行流水生成逻辑,使同一 `logId` 的分页流水稳定包含可命中后端大额交易 8 条规则的样本,同时保留随机噪声流水。
**Architecture:**`lsfx-mock-server` 内新增独立的大额交易样本生成模块,维护默认身份池、阈值常量与 5 组规则样本簇;`statement_service.py` 负责调用样本生成器、补足噪声流水、统一分配流水 ID 并缓存分页结果;测试分成样本生成测试和 API 返回测试两层,最后补一份实施记录文档。
**Tech Stack:** Python 3, FastAPI, pytest, httpx TestClient
---
### Task 1: 固化样本生成边界与文件结构
**Files:**
- Create: `lsfx-mock-server/services/statement_rule_samples.py`
- Modify: `lsfx-mock-server/services/statement_service.py`
- Test: `lsfx-mock-server/tests/test_statement_service.py`
- [ ] **Step 1: Write the failing test**
先新增一个最小单测,约束后续服务层会调用“命中样本 + 噪声流水”两段式生成,而不是继续只走单条完全随机逻辑:
```python
from services.statement_service import StatementService
def test_generate_statements_should_include_seeded_samples_before_noise():
service = StatementService()
statements = service._generate_statements(group_id=1000, log_id=20001, count=30)
assert len(statements) >= 30
assert any(item["userMemo"] == "购买房产首付款" for item in statements)
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd lsfx-mock-server
pytest tests/test_statement_service.py -k seeded_samples -v
```
Expected:
- `FAIL`
- 原因是 `tests/test_statement_service.py` 尚不存在,且 `StatementService` 还没有命中样本生成能力
- [ ] **Step 3: Write minimal implementation**
先搭文件骨架和最小接口,不一次性塞完整逻辑,但要保证首个样本断言可以通过:
```python
# lsfx-mock-server/services/statement_rule_samples.py
DEFAULT_LARGE_TRANSACTION_THRESHOLDS = {
"SINGLE_TRANSACTION_AMOUNT": 1111,
"CUMULATIVE_TRANSACTION_AMOUNT": 50000001,
"ANNUAL_TURNOVER": 50000001,
"LARGE_CASH_DEPOSIT": 2000001,
"FREQUENT_CASH_DEPOSIT": 5,
"FREQUENT_TRANSFER": 100001,
}
def build_large_transaction_seed_statements(group_id: int, log_id: int) -> list[dict]:
return [
{
"groupId": group_id,
"batchId": log_id,
"userMemo": "购买房产首付款",
"customerName": "杭州贝壳房地产经纪有限公司",
"drAmount": 680000.0,
"crAmount": 0.0,
"cashType": "对公转账",
"cretNo": "330101198801010011",
}
]
```
```python
# lsfx-mock-server/services/statement_service.py
from services.statement_rule_samples import build_large_transaction_seed_statements
def _generate_statements(self, group_id: int, log_id: int, count: int) -> List[Dict]:
statements = build_large_transaction_seed_statements(group_id, log_id)
while len(statements) < count:
statements.append(self._generate_random_statement(len(statements), group_id, log_id))
return statements
```
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
cd lsfx-mock-server
pytest tests/test_statement_service.py -k seeded_samples -v
```
Expected:
- `PASS`
- 说明样本生成器与服务层的最小整合入口已经建立
- [ ] **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 "拆分Mock大额交易样本生成骨架"
```
### Task 2: 用单元测试锁定 8 条规则样本口径
**Files:**
- Modify: `lsfx-mock-server/services/statement_rule_samples.py`
- Test: `lsfx-mock-server/tests/test_statement_service.py`
- [ ] **Step 1: Write the failing test**
`tests/test_statement_service.py` 中补一组口径测试,至少覆盖 8 条规则的关键命中特征:
```python
from services.statement_rule_samples import build_large_transaction_seed_statements
def test_large_transaction_seed_should_cover_all_eight_rules():
statements = build_large_transaction_seed_statements(group_id=1000, log_id=20001)
assert any(item["userMemo"] == "购买房产首付款" and item["drAmount"] > 0 for item in statements)
assert any("" in item["userMemo"] and item["drAmount"] > 0 for item in statements)
assert any(item["crAmount"] > 1111 for item in statements)
assert len([item for item in statements if item["cashType"] == "现金存款"]) >= 1
assert len([item for item in statements if item["customerName"] == "浙江远望贸易有限公司"]) >= 3
```
再补几个针对性断言:
- 同一身份证、同一日期,至少 6 笔 `crAmount > 2000001` 的存现样本
- 至少 1 笔 `userMemo``手机银行转账``drAmount > 100001`
- 所有“收入样本”都避开工资排除词
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd lsfx-mock-server
pytest tests/test_statement_service.py -k "eight_rules or cash_deposit or transfer" -v
```
Expected:
- `FAIL`
- 原因是样本构造函数还没有真实返回 8 条规则所需数据
- [ ] **Step 3: Write minimal implementation**
`statement_rule_samples.py` 中先把 5 组样本簇写实:
```python
IDENTITY_POOL = {
"staff_primary": {"name": "模型测试员工", "id_card": "330101198801010011", "account": "6222024000000001"},
"family_primary": {"name": "模型测试家属", "id_card": "330101199001010022", "account": "6222024000000002"},
"staff_secondary": {"name": "模型二测试员工", "id_card": "330101198802020033", "account": "6222024000000003"},
"family_secondary": {"name": "模型二测试家属", "id_card": "330101199202020044", "account": "6222024000000004"},
}
```
```python
def build_large_transaction_seed_statements(group_id: int, log_id: int) -> list[dict]:
statements = []
statements.extend(build_house_or_car_samples(group_id, log_id))
statements.extend(build_tax_samples(group_id, log_id))
statements.extend(build_income_samples(group_id, log_id))
statements.extend(build_cash_deposit_samples(group_id, log_id))
statements.extend(build_large_transfer_samples(group_id, log_id))
return statements
```
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
cd lsfx-mock-server
pytest tests/test_statement_service.py -v
```
Expected:
- `PASS`
- 测试输出能证明 8 条规则所需的关键样本全部存在
- [ ] **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 "补充Mock大额交易八条规则样本"
```
### Task 3: 重构 StatementService 合并样本与噪声流水
**Files:**
- Modify: `lsfx-mock-server/services/statement_service.py`
- Test: `lsfx-mock-server/tests/test_statement_service.py`
- [ ] **Step 1: Write the failing test**
补两个服务层测试:
```python
def test_generate_statements_should_fill_noise_up_to_requested_count():
service = StatementService()
statements = service._generate_statements(group_id=1000, log_id=20001, count=80)
assert len(statements) == 80
def test_get_bank_statement_should_keep_same_cached_result_for_same_log_id():
service = StatementService()
page1 = service.get_bank_statement({"groupId": 1000, "logId": 30001, "pageNow": 1, "pageSize": 20})
page2 = service.get_bank_statement({"groupId": 1000, "logId": 30001, "pageNow": 1, "pageSize": 20})
assert page1["data"]["bankStatementList"] == page2["data"]["bankStatementList"]
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd lsfx-mock-server
pytest tests/test_statement_service.py -k "fill_noise or cached_result" -v
```
Expected:
- `FAIL`
- 原因是当前 `_generate_statements()` 会直接按 `count` 循环生成单条随机流水,既不保底命中样本,也没有样本与噪声的装配逻辑
- [ ] **Step 3: Write minimal implementation**
`statement_service.py` 改成以下结构:
```python
def _generate_statements(self, group_id: int, log_id: int, count: int) -> List[Dict]:
seed_statements = build_large_transaction_seed_statements(group_id, log_id)
total_count = max(count, len(seed_statements))
noise_count = total_count - len(seed_statements)
statements = list(seed_statements)
for index in range(noise_count):
statements.append(self._generate_random_statement(index, group_id, log_id))
statements = self._assign_statement_ids(statements, log_id)
random.shuffle(statements)
return statements
```
同时新增一个专用方法,为样本和噪声统一补齐:
- `bankStatementId`
- `bankTrxNumber`
- `uploadSequnceNumber`
- `batchId`
- `groupId`
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
cd lsfx-mock-server
pytest tests/test_statement_service.py -v
```
Expected:
- `PASS`
- 同一 `logId` 首次生成后被缓存,后续分页请求不会漂移
- [ ] **Step 5: Commit**
```bash
git add lsfx-mock-server/services/statement_service.py lsfx-mock-server/tests/test_statement_service.py
git commit -m "重构Mock流水生成并保留分页缓存一致性"
```
### Task 4: 扩展 API 测试覆盖分页与命中特征
**Files:**
- Modify: `lsfx-mock-server/tests/test_api.py`
- Modify: `lsfx-mock-server/tests/integration/test_full_workflow.py`
- [ ] **Step 1: Write the failing test**
在 API 层增加最小回归:
```python
def test_get_bank_statement_should_return_seed_samples(client):
response = client.post(
"/watson/api/project/getBSByLogId",
data={"groupId": 1000, "logId": 40001, "pageNow": 1, "pageSize": 200}
)
assert response.status_code == 200
items = response.json()["data"]["bankStatementList"]
assert any(item["userMemo"] == "购买房产首付款" for item in items)
```
再补一个分页拼接测试,确认把前几页拼起来后可以找到:
- 税务样本
- 至少 6 笔同日存现
- 大额转账样本
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd lsfx-mock-server
pytest tests/test_api.py -k "seed_samples or bank_statement" -v
```
Expected:
- `FAIL`
- 原因是接口返回的仍是完全随机流水,无法稳定断言命中特征
- [ ] **Step 3: Write minimal implementation**
这里主要是补充测试,不引入新的 API 逻辑分支。若前一任务已完成,接口应天然满足这些断言;如仍失败,只允许回到 `statement_rule_samples.py``statement_service.py` 修正数据构造,不要在路由层堆业务判断。
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
cd lsfx-mock-server
pytest tests/test_api.py -v
pytest tests/integration/test_full_workflow.py -v
```
Expected:
- `PASS`
- 现有上传、拉取、查询等主流程回归不受影响
- [ ] **Step 5: Commit**
```bash
git add lsfx-mock-server/tests/test_api.py lsfx-mock-server/tests/integration/test_full_workflow.py
git commit -m "补充Mock流水分页与命中特征回归测试"
```
### Task 5: 补实施记录并完成最终验证
**Files:**
- Create: `docs/reports/implementation/2026-03-18-lsfx-mock-large-transaction-implementation.md`
- Modify: `docs/plans/backend/2026-03-18-lsfx-mock-large-transaction-backend-implementation.md`
- [ ] **Step 1: Write the implementation report skeleton**
记录以下内容:
- 改动文件清单
- 8 条规则对应的样本策略
- 实际跑过的 pytest 命令
- 已知约束:身份池必须存在于目标后端环境
建议骨架:
```markdown
# LSFX Mock 大额交易样本实施记录
## 变更概述
- ...
## 验证记录
- `cd lsfx-mock-server && pytest tests/test_statement_service.py -v`
- `cd lsfx-mock-server && pytest tests/test_api.py -v`
```
- [ ] **Step 2: Run final verification**
Run:
```bash
cd lsfx-mock-server
pytest tests/test_statement_service.py -v
pytest tests/test_api.py -v
pytest tests/integration/test_full_workflow.py -v
python verify_implementation.py
```
Expected:
- 所有测试通过
- `verify_implementation.py` 不报接口字段缺失
- [ ] **Step 3: Stop any started services**
如果验证过程中启动了:
- `python main.py`
- `uvicorn main:app --reload --host 0.0.0.0 --port 8000`
必须在结束时关闭对应进程,避免残留端口占用。
- [ ] **Step 4: Commit**
```bash
git add docs/reports/implementation/2026-03-18-lsfx-mock-large-transaction-implementation.md docs/plans/backend/2026-03-18-lsfx-mock-large-transaction-backend-implementation.md
git commit -m "补充Mock大额交易样本实施记录"
```

View File

@@ -0,0 +1,200 @@
# Model Param Save Trigger Rebuild Backend 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.
**Goal:** 让项目参数保存成功后由后端自动异步触发项目内流水重新打标,避免前端额外串联重打标接口。
**Architecture:** 保持 `CcdiModelParamController` 的保存接口不变,把“保存参数成功后触发重打标”收敛到 `CcdiModelParamServiceImpl`,复用现有 `ICcdiBankTagService.submitAutoRebuild` 异步提交能力,并新增专用触发类型区分来源;仅在项目级参数保存成功且实际存在更新时触发,默认配置与保存失败场景不进入重打标链路。
**Tech Stack:** Java 21, Spring Boot 3, MyBatis Plus, JUnit 5, Mockito, Maven
---
### Task 1: 补齐参数保存后触发自动重打标的失败测试
**Files:**
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiModelParamServiceImplTest.java`
- [ ] **Step 1: Write the failing test**
补充两个核心用例:
```java
@Mock
private ICcdiBankTagService bankTagService;
@Test
void saveAllParams_shouldSubmitAutoRebuildAfterProjectParamsUpdated() {
when(projectMapper.selectById(40L)).thenReturn(buildProject(40L, "custom"));
when(modelParamMapper.selectOne(any())).thenReturn(buildParam(1L, 40L, "LARGE_TRANSACTION", "大额交易模型", "SINGLE_TRANSACTION_AMOUNT", "1000"));
service.saveAllParams(buildSaveAllDto());
verify(bankTagService).submitAutoRebuild(40L, TriggerType.AUTO_PARAM_CHANGE);
}
@Test
void saveAllParams_shouldNotSubmitAutoRebuildForGlobalDefaults() {
ModelParamSaveAllDTO dto = buildSaveAllDto();
dto.setProjectId(0L);
when(modelParamMapper.selectOne(any())).thenReturn(buildParam(1L, 0L, "LARGE_TRANSACTION", "大额交易模型", "SINGLE_TRANSACTION_AMOUNT", "1000"));
service.saveAllParams(dto);
verify(bankTagService, never()).submitAutoRebuild(anyLong(), any());
}
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
mvn -pl ccdi-project -Dtest=CcdiModelParamServiceImplTest test
```
Expected:
- `FAIL`
- 原因是当前服务尚未注入 `ICcdiBankTagService`,也不会在保存成功后触发自动重打标
- [ ] **Step 3: Write minimal implementation**
最小实现包括:
1.`CcdiModelParamServiceImpl` 注入 `ICcdiBankTagService`
2.`saveAllParams` 成功批量更新后,若 `projectId > 0``updateList` 非空,则调用:
```java
bankTagService.submitAutoRebuild(projectId, TriggerType.AUTO_PARAM_CHANGE);
```
3. `saveParams` 同步补齐相同语义,保证单模型保存与批量保存行为一致
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
mvn -pl ccdi-project -Dtest=CcdiModelParamServiceImplTest test
```
Expected:
- `PASS`
- 说明参数保存成功后会自动进入异步重打标链路
- [ ] **Step 5: Commit**
```bash
git add ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiModelParamServiceImplTest.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiModelParamServiceImpl.java
git commit -m "实现参数保存后自动触发项目重打标"
```
### Task 2: 扩展重打标触发类型并保持异步链路可观测
**Files:**
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/enums/TriggerType.java`
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImpl.java`
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImplTest.java`
- [ ] **Step 1: Write the failing test**
补一个触发类型透传校验,锁定参数保存来源不会被误记成上传或拉取:
```java
@Test
void submitAutoRebuild_shouldKeepAutoParamChangeTriggerType() {
service.submitAutoRebuild(40L, TriggerType.AUTO_PARAM_CHANGE);
verify(coordinator).submitAuto(40L, TriggerType.AUTO_PARAM_CHANGE);
}
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
mvn -pl ccdi-project -Dtest=CcdiBankTagServiceImplTest test
```
Expected:
- `FAIL`
- 原因是当前 `TriggerType` 还没有 `AUTO_PARAM_CHANGE`
- [ ] **Step 3: Write minimal implementation**
`TriggerType` 新增:
```java
AUTO_PARAM_CHANGE
```
同时检查 `CcdiBankTagServiceImpl` 的日志和调用链,确保新增枚举值无需额外分支即可沿现有异步协调器执行。
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
mvn -pl ccdi-project -Dtest=CcdiBankTagServiceImplTest test
```
Expected:
- `PASS`
- [ ] **Step 5: Commit**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/enums/TriggerType.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/CcdiBankTagServiceImplTest.java
git commit -m "补充参数修改触发的自动重打标类型"
```
### Task 3: 记录服务端验证结果与边界
**Files:**
- Create: `docs/tests/records/2026-03-18-model-param-save-trigger-rebuild-backend-verification.md`
- Create: `docs/reports/implementation/2026-03-18-model-param-save-trigger-rebuild-record.md`
- [ ] **Step 1: Write verification skeleton**
先记录本次服务端验证点:
```markdown
# 参数保存触发项目流水重打标后端验证记录
## 验证范围
- 项目参数保存成功后自动异步触发重打标
- 默认参数保存不触发项目重打标
- 保存失败时不触发重打标
```
- [ ] **Step 2: Run focused tests**
Run:
```bash
mvn -pl ccdi-project -Dtest=CcdiModelParamServiceImplTest,CcdiBankTagServiceImplTest test
```
Expected:
- 相关测试全部通过
- [ ] **Step 3: Write implementation record**
把实际改动、测试命令和结果写入实施记录,覆盖:
- 新增 `AUTO_PARAM_CHANGE` 触发类型
- `saveParams/saveAllParams` 成功更新后触发异步重打标
- 不触发场景与异常场景说明
- [ ] **Step 4: Commit**
```bash
git add docs/tests/records/2026-03-18-model-param-save-trigger-rebuild-backend-verification.md docs/reports/implementation/2026-03-18-model-param-save-trigger-rebuild-record.md
git commit -m "补充参数保存触发重打标后端实施记录"
```

View File

@@ -0,0 +1,506 @@
# Project Bank Tag Status Lock Backend 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.
**Goal:** 让项目在银行流水打标开始时进入“打标中”,在成功后进入“已完成”、失败后回退为“进行中”,并由后端统一拦截打标期间的上传、拉取本行信息和参数修改操作。
**Architecture:**`ccdi_project.status` 作为唯一业务状态源,在项目模块中补充统一状态更新能力,由 `CcdiBankTagServiceImpl``ProjectBankTagRebuildCoordinator` 复用上传与参数服务在进入写操作前统一校验项目状态SQL 通过增量脚本补齐状态字典,并同步更新初始化脚本和单元测试。
**Tech Stack:** Java 21, Spring Boot 3, MyBatis Plus, JUnit 5, Mockito, Maven, MySQL SQL migration
---
### Task 1: 补齐项目状态 SQL 与字典基线
**Files:**
- Modify: `sql/ccdi_project.sql`
- Create: `sql/migration/2026-03-18-add-project-tagging-status.sql`
- Create: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiProjectStatusSqlTest.java`
- [ ] **Step 1: Write the failing test**
先新增 `CcdiProjectStatusSqlTest`,锁定初始化脚本和增量脚本都必须包含 `3-打标中`
```java
class CcdiProjectStatusSqlTest {
@Test
void shouldContainTaggingStatusInInitAndMigrationSql() throws Exception {
String initSql = Files.readString(Path.of("sql/ccdi_project.sql"));
String migrationSql = Files.readString(Path.of("sql/migration/2026-03-18-add-project-tagging-status.sql"));
assertTrue(initSql.contains("打标中"));
assertTrue(initSql.contains("'3'"));
assertTrue(migrationSql.contains("ccdi_project_status"));
assertTrue(migrationSql.contains("打标中"));
assertTrue(migrationSql.contains("'3'"));
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
mvn -pl ccdi-project -Dtest=CcdiProjectStatusSqlTest test
```
Expected:
- `FAIL`
- 原因是测试类和增量 SQL 还不存在,初始化脚本也尚未包含状态 `3`
- [ ] **Step 3: Write minimal implementation**
最小实现包括两部分:
1.`sql/ccdi_project.sql` 中把状态注释和状态字典更新为:
```sql
`status` CHAR(1) NOT NULL DEFAULT '0' COMMENT '项目状态0-进行中1-已完成2-已归档3-打标中'
```
```sql
(4, '打标中', '3', 'ccdi_project_status', '', 'warning', 'N', '0', 'admin', NOW());
```
2. 新增增量脚本 `sql/migration/2026-03-18-add-project-tagging-status.sql`,包含:
```sql
ALTER TABLE ccdi_project
MODIFY COLUMN status CHAR(1) NOT NULL DEFAULT '0' COMMENT '项目状态0-进行中1-已完成2-已归档3-打标中';
INSERT INTO sys_dict_data (...)
SELECT ...
WHERE NOT EXISTS (
SELECT 1 FROM sys_dict_data WHERE dict_type = 'ccdi_project_status' AND dict_value = '3'
);
```
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
mvn -pl ccdi-project -Dtest=CcdiProjectStatusSqlTest test
```
Expected:
- `PASS`
- 说明新环境脚本和增量脚本都已统一包含“打标中”
- [ ] **Step 5: Commit**
```bash
git add sql/ccdi_project.sql sql/migration/2026-03-18-add-project-tagging-status.sql ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiProjectStatusSqlTest.java
git commit -m "补充项目打标中状态SQL基线"
```
### Task 2: 增加项目状态统一更新能力、写保护能力与状态统计扩展
**Files:**
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/constants/CcdiProjectStatusConstants.java`
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/CcdiProject.java`
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectStatusCountsVO.java`
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectService.java`
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectServiceImpl.java`
- Create: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectServiceImplTest.java`
- [ ] **Step 1: Write the failing test**
新增 `CcdiProjectServiceImplTest`,先锁定三个核心行为:状态统计要包含 `status3`,已归档项目不能重新进入打标,打标中项目不能继续写入上传/参数数据。
```java
@ExtendWith(MockitoExtension.class)
class CcdiProjectServiceImplTest {
@InjectMocks
private CcdiProjectServiceImpl service;
@Mock
private CcdiProjectMapper projectMapper;
@Test
void shouldCountTaggingProjectsSeparately() {
when(projectMapper.selectCount(any())).thenReturn(10L, 3L, 4L, 2L, 1L);
CcdiProjectStatusCountsVO counts = service.getStatusCounts();
assertEquals(1L, counts.getStatus3());
}
@Test
void shouldRejectUpdatingArchivedProjectToTagging() {
CcdiProject archived = new CcdiProject();
archived.setProjectId(99L);
archived.setStatus("2");
when(projectMapper.selectById(99L)).thenReturn(archived);
assertThrows(ServiceException.class,
() -> service.updateProjectStatus(99L, "3", "system"));
}
@Test
void shouldRejectWritingWhenProjectIsTagging() {
CcdiProject tagging = new CcdiProject();
tagging.setProjectId(40L);
tagging.setStatus("3");
when(projectMapper.selectById(40L)).thenReturn(tagging);
assertThrows(ServiceException.class,
() -> service.ensureProjectWritable(40L, "当前项目正在进行银行流水打标,暂不允许修改参数"));
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
mvn -pl ccdi-project -Dtest=CcdiProjectServiceImplTest test
```
Expected:
- `FAIL`
- 原因是 `status3` 统计字段和统一状态更新方法尚未实现
- [ ] **Step 3: Write minimal implementation**
最小实现建议:
1. 新增状态常量类:
```java
public final class CcdiProjectStatusConstants {
public static final String PROCESSING = "0";
public static final String COMPLETED = "1";
public static final String ARCHIVED = "2";
public static final String TAGGING = "3";
}
```
2.`ICcdiProjectService` / `CcdiProjectServiceImpl` 中新增 3 个统一能力:
```java
void updateProjectStatus(Long projectId, String status, String operator);
void ensureProjectCanStartTagging(Long projectId);
void ensureProjectWritable(Long projectId, String message);
```
```java
public void updateProjectStatus(Long projectId, String status, String operator) {
CcdiProject project = getRequiredProject(projectId);
if (CcdiProjectStatusConstants.ARCHIVED.equals(project.getStatus())
&& !CcdiProjectStatusConstants.ARCHIVED.equals(status)) {
throw new ServiceException("已归档项目不允许重新进入打标流程");
}
project.setStatus(status);
project.setUpdateBy(operator);
projectMapper.updateById(project);
}
```
```java
public void ensureProjectCanStartTagging(Long projectId) {
CcdiProject project = getRequiredProject(projectId);
if (CcdiProjectStatusConstants.ARCHIVED.equals(project.getStatus())) {
throw new ServiceException("已归档项目不允许重新进入打标流程");
}
}
```
```java
public void ensureProjectWritable(Long projectId, String message) {
CcdiProject project = getRequiredProject(projectId);
if (CcdiProjectStatusConstants.TAGGING.equals(project.getStatus())) {
throw new ServiceException(message);
}
}
```
3. 扩展状态统计:
```java
private Long status3;
```
```java
vo.setStatus3(projectMapper.selectCount(
new LambdaQueryWrapper<CcdiProject>().eq(CcdiProject::getStatus, CcdiProjectStatusConstants.TAGGING)
));
```
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
mvn -pl ccdi-project -Dtest=CcdiProjectServiceImplTest test
```
Expected:
- `PASS`
- 说明统一状态更新能力和 `打标中` 统计口径已成立
- [ ] **Step 5: Commit**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/constants/CcdiProjectStatusConstants.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/CcdiProject.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectStatusCountsVO.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectService.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectServiceImpl.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectServiceImplTest.java
git commit -m "新增项目状态统一更新能力"
```
### Task 3: 将打标任务状态流转接入项目状态
**Files:**
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImpl.java`
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/ProjectBankTagRebuildCoordinator.java`
- 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/ProjectBankTagRebuildCoordinatorTest.java`
- [ ] **Step 1: Write the failing test**
先在 `CcdiBankTagServiceImplTest` 中锁定成功与失败两条状态流转,再在协调器测试里锁定已归档项目不能重入:
```java
@Test
void shouldMarkProjectTaggingBeforeExecutingAndCompletedAfterSuccess() {
...
service.rebuildProject(40L, null, "tester", TriggerType.MANUAL);
InOrder inOrder = inOrder(projectService, taskMapper);
inOrder.verify(projectService).updateProjectStatus(40L, "3", "tester");
inOrder.verify(taskMapper).updateTask(argThat(task -> "SUCCESS".equals(task.getStatus())));
inOrder.verify(projectService).updateProjectStatus(40L, "1", "tester");
}
@Test
void shouldRollbackProjectStatusToProcessingWhenRebuildFails() {
...
assertThrows(RuntimeException.class,
() -> service.rebuildProject(40L, null, "tester", TriggerType.MANUAL));
verify(projectService).updateProjectStatus(40L, "0", "tester");
}
```
```java
@Test
void shouldRejectSubmittingRebuildForArchivedProject() {
doThrow(new ServiceException("已归档项目不允许重新进入打标流程"))
.when(projectService).ensureProjectCanStartTagging(40L);
assertThrows(ServiceException.class,
() -> coordinator.submitManual(40L, null, "tester"));
}
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
mvn -pl ccdi-project -Dtest=CcdiBankTagServiceImplTest,ProjectBankTagRebuildCoordinatorTest test
```
Expected:
- `FAIL`
- 原因是打标服务还没有调用项目状态更新能力
- [ ] **Step 3: Write minimal implementation**
实现要点:
1.`CcdiBankTagServiceImpl` 注入项目服务,并在 `rebuildProject(...)` 中按顺序调用:
```java
projectService.updateProjectStatus(projectId, CcdiProjectStatusConstants.TAGGING, operator);
```
```java
projectService.updateProjectStatus(projectId, CcdiProjectStatusConstants.COMPLETED, operator);
```
```java
projectService.updateProjectStatus(projectId, CcdiProjectStatusConstants.PROCESSING, operator);
```
2.`ProjectBankTagRebuildCoordinator` 中增加前置校验,避免已归档项目进入重算:
```java
projectService.ensureProjectCanStartTagging(projectId);
```
3. 保持 `needRerun` 机制不变,但不要在补跑期间把状态提前切回非 `3`
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
mvn -pl ccdi-project -Dtest=CcdiBankTagServiceImplTest,ProjectBankTagRebuildCoordinatorTest test
```
Expected:
- `PASS`
- 说明手工与自动打标共用的重算主链路已经接入项目状态流转
- [ ] **Step 5: Commit**
```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/service/impl/ProjectBankTagRebuildCoordinator.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/ProjectBankTagRebuildCoordinatorTest.java
git commit -m "接入项目打标状态流转"
```
### Task 4: 在上传与参数保存链路中拦截“打标中”写操作
**Files:**
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java`
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiModelParamServiceImpl.java`
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java`
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiModelParamServiceImplTest.java`
- [ ] **Step 1: Write the failing test**
先补两组服务层测试:
```java
@Test
void shouldRejectPullBankInfoWhenProjectIsTagging() {
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
project.setStatus("3");
when(projectMapper.selectById(40L)).thenReturn(project);
assertThrows(ServiceException.class,
() -> service.submitPullBankInfo(40L, List.of("3301"), "2026-01-01", "2026-01-31", 1L, "tester"));
}
```
```java
@Test
void shouldRejectSaveAllParamsWhenProjectIsTagging() {
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
project.setStatus("3");
when(projectMapper.selectById(40L)).thenReturn(project);
assertThrows(ServiceException.class, () -> service.saveAllParams(buildSaveAllDto()));
}
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
mvn -pl ccdi-project -Dtest=CcdiFileUploadServiceImplTest,CcdiModelParamServiceImplTest test
```
Expected:
- `FAIL`
- 原因是当前上传和参数服务没有“打标中”拦截
- [ ] **Step 3: Write minimal implementation**
在两个服务中增加统一状态校验,优先复用项目服务:
```java
projectService.ensureProjectWritable(projectId, "当前项目正在进行银行流水打标,暂不允许上传或拉取数据");
```
```java
projectService.ensureProjectWritable(projectId, "当前项目正在进行银行流水打标,暂不允许修改参数");
```
若不额外新增专门方法,至少要抽一个私有校验函数,避免状态字符串判断散落在多个类中。
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
mvn -pl ccdi-project -Dtest=CcdiFileUploadServiceImplTest,CcdiModelParamServiceImplTest test
```
Expected:
- `PASS`
- 说明后端已经能兜底拒绝打标期间的输入变更
- [ ] **Step 5: Commit**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiModelParamServiceImpl.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiModelParamServiceImplTest.java
git commit -m "拦截项目打标中的上传与参数修改"
```
### Task 5: 完成回归验证、SQL 执行说明与实施记录
**Files:**
- Create: `docs/tests/records/2026-03-18-project-bank-tag-status-lock-backend-verification.md`
- Create: `docs/reports/implementation/2026-03-18-project-bank-tag-status-lock-backend-implementation.md`
- [ ] **Step 1: Prepare backend verification record**
先创建验证记录,至少包含以下检查项:
```markdown
# 项目打标状态联动后端验证记录
## 验证项
- [ ] 状态 `3-打标中` SQL 已同步
- [ ] 打标成功后状态为 `1`
- [ ] 打标失败后状态回退为 `0`
- [ ] 打标中拒绝上传/拉取本行信息
- [ ] 打标中拒绝参数保存
```
- [ ] **Step 2: Run focused backend regression**
Run:
```bash
mvn -pl ccdi-project -Dtest=CcdiProjectServiceImplTest,CcdiBankTagServiceImplTest,ProjectBankTagRebuildCoordinatorTest,CcdiFileUploadServiceImplTest,CcdiModelParamServiceImplTest,CcdiProjectStatusSqlTest test
```
Expected:
- 所有相关测试 `PASS`
如需在联调环境执行 SQL必须使用
```bash
bin/mysql_utf8_exec.sh sql/migration/2026-03-18-add-project-tagging-status.sql
```
- [ ] **Step 3: Write implementation report**
在实施报告中记录:
- 新增状态 `3-打标中`
- 打标状态流转接入的主链路
- 上传/参数保存拦截点
- 测试结果与 SQL 执行方式
- [ ] **Step 4: Commit**
```bash
git add docs/tests/records/2026-03-18-project-bank-tag-status-lock-backend-verification.md docs/reports/implementation/2026-03-18-project-bank-tag-status-lock-backend-implementation.md
git commit -m "补充项目打标状态联动后端验证记录"
```
## Backend Exit Criteria
- `ccdi_project.status``ccdi_project_status` 字典都支持 `3-打标中`
- 所有银行流水打标入口都会先把项目置为 `3`
- 打标成功后落为 `1`,失败后回退为 `0`
- 打标期间上传、拉取本行信息、参数保存都会被后端拒绝
- 后端测试、SQL 说明、实施记录都已补齐

View File

@@ -0,0 +1,84 @@
# 项目状态变更日志实施计划
> **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.
**Goal:** 为纪检初核项目的所有状态变更入口补充统一日志,确保创建项目与后续状态切换均能输出可追踪日志。
**Architecture:** 统一在 `CcdiProjectServiceImpl` 中收口项目状态日志。项目创建时记录初始状态日志,后续通过 `updateProjectStatus` 处理的状态变更统一记录“变更前/变更后/操作人”日志,并在状态未变化时避免重复输出。
**Tech Stack:** Java 21、Spring Boot 3、MyBatis Plus、SLF4J/Logback、JUnit 5、Mockito
---
### Task 1: 明确状态变更入口
**Files:**
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectServiceImpl.java`
- Check: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImpl.java`
- [ ] **Step 1: 盘点项目状态变更入口**
确认项目状态仅在项目创建默认置为“进行中”以及 `updateProjectStatus` 方法中发生持久化变更。
- [ ] **Step 2: 确认日志收口位置**
确保打标流程等调用方继续复用 `updateProjectStatus`,不在调用方分散新增重复日志。
### Task 2: 先补失败测试
**Files:**
- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectServiceImplTest.java`
- [ ] **Step 1: 为项目创建补日志测试**
编写测试验证创建项目成功后输出初始状态日志,日志内容包含 `projectId``projectName`、状态和值。
- [ ] **Step 2: 为状态切换补日志测试**
编写测试验证 `updateProjectStatus` 在状态真实变化时输出状态变更日志,并包含旧状态、新状态、操作人。
- [ ] **Step 3: 为重复状态写入补日志约束测试**
编写测试验证当目标状态与当前状态一致时,不重复输出状态变更日志。
- [ ] **Step 4: 运行单测确认先失败**
Run: `mvn -pl ccdi-project -Dtest=CcdiProjectServiceImplTest test`
Expected: 新增日志相关断言失败,证明测试覆盖到新增行为。
### Task 3: 实现统一日志
**Files:**
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectServiceImpl.java`
- [ ] **Step 1: 为服务类补日志能力**
引入 `@Slf4j` 或等效 Logger保持与模块现有日志风格一致。
- [ ] **Step 2: 在项目创建后记录初始状态**
在项目持久化成功后输出“项目状态初始化”日志。
- [ ] **Step 3: 在状态变更时记录统一日志**
`updateProjectStatus` 中记录“项目状态变更”日志,打印项目标识、项目名称、旧状态、新状态、操作人。
- [ ] **Step 4: 避免无效重复日志**
当旧状态与新状态一致时,不输出状态变更日志,但保留现有更新时间写入行为。
### Task 4: 补实施记录并验证
**Files:**
- Create: `docs/reports/implementation/2026-03-18-项目状态变更日志实施记录.md`
- [ ] **Step 1: 记录本次实施内容**
补充实施记录,说明状态日志覆盖范围、代码修改点和测试结果。
- [ ] **Step 2: 运行最终验证**
Run: `mvn -pl ccdi-project -Dtest=CcdiProjectServiceImplTest,CcdiBankTagServiceImplTest test`
Expected: 相关测试全部通过,确认状态日志不影响现有打标状态流转。

View File

@@ -0,0 +1,426 @@
# Bank Statement Hit Tags Backend 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.
**Goal:** 在现有流水明细查询后端链路中补齐“异常标签”读取与组装能力,让列表、详情、导出统一展示当前流水直接命中的流水级标签。
**Architecture:** 继续复用现有 `CcdiBankStatementController -> CcdiBankStatementServiceImpl -> CcdiBankStatementMapper` 主链路,不改分页查询入口和筛选协议;新增 `CcdiBankTagResultMapper` 的只读标签查询方法,由 Service 在列表分页、详情查询和导出映射阶段批量补齐 `hitTags` 与导出字符串。所有标签口径统一限定为 `ccdi_bank_statement_tag_result.result_type = 'STATEMENT'``bank_statement_id` 命中当前流水。
**Tech Stack:** Java 21, Spring Boot 3, MyBatis Plus, JUnit 5, Mockito, Maven
---
### Task 1: 补齐流水标签只读查询模型与 Mapper SQL
**Files:**
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiBankStatementHitTagVO.java`
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagResultMapper.java`
- Modify: `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagResultMapper.xml`
- Create: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagResultMapperXmlTest.java`
- [ ] **Step 1: Write the failing test**
先新增 `CcdiBankTagResultMapperXmlTest`,锁定新 SQL 必须只查流水级标签,并按稳定顺序输出:
```java
class CcdiBankTagResultMapperXmlTest {
@Test
void selectStatementHitTagsByIds_shouldFilterStatementResultType() throws Exception {
String xml = Files.readString(Path.of(
"ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagResultMapper.xml"
));
assertTrue(xml.contains("selectStatementHitTagsByBankStatementIds"));
assertTrue(xml.contains("result_type = 'STATEMENT'"));
assertTrue(xml.contains("bank_statement_id IN"));
}
@Test
void selectStatementHitTagsByIds_shouldKeepStableOrder() throws Exception {
String xml = Files.readString(Path.of(
"ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagResultMapper.xml"
));
assertTrue(xml.contains("ORDER BY"));
assertTrue(xml.contains("rule_code"));
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
mvn -pl ccdi-project -Dtest=CcdiBankTagResultMapperXmlTest test
```
Expected:
- `FAIL`
- 原因是标签明细 VO、Mapper 方法和 XML 查询都还不存在
- [ ] **Step 3: Write minimal implementation**
最小实现包含 4 个点:
1. 新增标签明细 VO
```java
@Data
public class CcdiBankStatementHitTagVO {
private Long bankStatementId;
private String ruleName;
private String riskLevel;
private String reasonDetail;
private String ruleCode;
}
```
2.`CcdiBankTagResultMapper` 中新增:
```java
List<CcdiBankStatementHitTagVO> selectStatementHitTagsByBankStatementIds(@Param("bankStatementIds") List<Long> bankStatementIds);
List<CcdiBankStatementHitTagVO> selectStatementHitTagsByBankStatementId(@Param("bankStatementId") Long bankStatementId);
```
3.`CcdiBankTagResultMapper.xml` 中新增对应 `resultMap` 与查询:
```xml
<select id="selectStatementHitTagsByBankStatementIds" resultMap="CcdiBankStatementHitTagVOResultMap">
SELECT bank_statement_id, rule_name, risk_level, reason_detail, rule_code
FROM ccdi_bank_statement_tag_result
WHERE result_type = 'STATEMENT'
AND bank_statement_id IN
<foreach collection="bankStatementIds" item="item" open="(" separator="," close=")">
#{item}
</foreach>
ORDER BY bank_statement_id ASC, rule_code ASC
</select>
```
4. 单条查询直接复用相同过滤条件:
```xml
<select id="selectStatementHitTagsByBankStatementId" resultMap="CcdiBankStatementHitTagVOResultMap">
SELECT bank_statement_id, rule_name, risk_level, reason_detail, rule_code
FROM ccdi_bank_statement_tag_result
WHERE result_type = 'STATEMENT'
AND bank_statement_id = #{bankStatementId}
ORDER BY rule_code ASC
</select>
```
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
mvn -pl ccdi-project -Dtest=CcdiBankTagResultMapperXmlTest test
```
Expected:
- `PASS`
- 说明标签 Mapper 已具备只读查询能力,且不会混入对象级结果
- [ ] **Step 5: Commit**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiBankStatementHitTagVO.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagResultMapper.java ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagResultMapper.xml ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagResultMapperXmlTest.java
git commit -m "补充流水异常标签结果查询"
```
### Task 2: 在列表与详情查询中组装结构化命中标签
**Files:**
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiBankStatementListVO.java`
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiBankStatementDetailVO.java`
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankStatementServiceImpl.java`
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiBankStatementServiceImplTest.java`
- [ ] **Step 1: Write the failing test**
先在 `CcdiBankStatementServiceImplTest` 中补 2 个失败用例,锁定列表分页和详情查询都要回填 `hitTags`
```java
@Mock
private CcdiBankTagResultMapper bankTagResultMapper;
@Test
void selectStatementPage_shouldAssembleStatementHitTags() {
Page<CcdiBankStatementListVO> page = new Page<>(1, 10);
CcdiBankStatementListVO row = new CcdiBankStatementListVO();
row.setBankStatementId(8L);
page.setRecords(List.of(row));
CcdiBankStatementHitTagVO hitTag = new CcdiBankStatementHitTagVO();
hitTag.setBankStatementId(8L);
hitTag.setRuleName("大额转账交易");
when(bankStatementMapper.selectStatementPage(any(), any())).thenReturn(page);
when(bankTagResultMapper.selectStatementHitTagsByBankStatementIds(List.of(8L)))
.thenReturn(List.of(hitTag));
Page<CcdiBankStatementListVO> result = service.selectStatementPage(new Page<>(1, 10), new CcdiBankStatementQueryDTO());
assertEquals(1, result.getRecords().get(0).getHitTags().size());
assertEquals("大额转账交易", result.getRecords().get(0).getHitTags().get(0).getRuleName());
}
@Test
void getStatementDetail_shouldAttachStatementHitTags() {
CcdiBankStatementDetailVO detailVO = new CcdiBankStatementDetailVO();
detailVO.setBankStatementId(9L);
CcdiBankStatementHitTagVO hitTag = new CcdiBankStatementHitTagVO();
hitTag.setBankStatementId(9L);
hitTag.setRuleName("房车消费支出交易");
hitTag.setRiskLevel("HIGH");
hitTag.setReasonDetail("摘要命中购买房产首付款");
when(bankStatementMapper.selectStatementDetailById(9L)).thenReturn(detailVO);
when(bankTagResultMapper.selectStatementHitTagsByBankStatementId(9L)).thenReturn(List.of(hitTag));
CcdiBankStatementDetailVO result = service.getStatementDetail(9L);
assertEquals(1, result.getHitTags().size());
assertEquals("HIGH", result.getHitTags().get(0).getRiskLevel());
}
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
mvn -pl ccdi-project -Dtest=CcdiBankStatementServiceImplTest test
```
Expected:
- `FAIL`
- 原因是 `hitTags` 字段和 Service 组装逻辑尚未实现
- [ ] **Step 3: Write minimal implementation**
按最小范围补齐结构化标签:
1. `CcdiBankStatementListVO` 新增:
```java
private List<CcdiBankStatementHitTagVO> hitTags;
```
2. `CcdiBankStatementDetailVO` 新增:
```java
private List<CcdiBankStatementHitTagVO> hitTags;
```
3. `CcdiBankStatementServiceImpl` 注入 `CcdiBankTagResultMapper`
4.`selectStatementPage()` 中新增批量组装:
```java
private void fillStatementHitTags(List<CcdiBankStatementListVO> records) {
List<Long> ids = records.stream()
.map(CcdiBankStatementListVO::getBankStatementId)
.filter(Objects::nonNull)
.distinct()
.toList();
Map<Long, List<CcdiBankStatementHitTagVO>> hitTagMap = bankTagResultMapper
.selectStatementHitTagsByBankStatementIds(ids)
.stream()
.collect(Collectors.groupingBy(CcdiBankStatementHitTagVO::getBankStatementId));
records.forEach(item -> item.setHitTags(hitTagMap.getOrDefault(item.getBankStatementId(), Collections.emptyList())));
}
```
5.`getStatementDetail()` 中按单条流水补齐:
```java
detail.setHitTags(bankTagResultMapper.selectStatementHitTagsByBankStatementId(bankStatementId));
```
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
mvn -pl ccdi-project -Dtest=CcdiBankStatementServiceImplTest test
```
Expected:
- `PASS`
- 说明列表和详情都能拿到结构化标签数据
- [ ] **Step 5: Commit**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiBankStatementListVO.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiBankStatementDetailVO.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankStatementServiceImpl.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiBankStatementServiceImplTest.java
git commit -m "补充流水明细标签组装逻辑"
```
### Task 3: 扩展导出列并补齐后端交付记录
**Files:**
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/excel/CcdiBankStatementExcel.java`
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankStatementServiceImpl.java`
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiBankStatementServiceImplTest.java`
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiBankStatementControllerTest.java`
- Create: `docs/reports/implementation/2026-03-19-bank-statement-hit-tags-backend-implementation.md`
- [ ] **Step 1: Write the failing test**
先在 `CcdiBankStatementServiceImplTest` 里锁定导出内容必须包含标签名与原因摘要:
```java
@Test
void selectStatementListForExport_shouldMapHitTagsAndReasons() {
CcdiBankStatementListVO row = new CcdiBankStatementListVO();
row.setBankStatementId(10L);
row.setLeAccountNo("6222");
CcdiBankStatementHitTagVO first = new CcdiBankStatementHitTagVO();
first.setBankStatementId(10L);
first.setRuleName("房车消费支出交易");
first.setReasonDetail("摘要命中购买房产首付款");
CcdiBankStatementHitTagVO second = new CcdiBankStatementHitTagVO();
second.setBankStatementId(10L);
second.setRuleName("大额转账交易");
second.setReasonDetail("转账金额 200000.00 元超过阈值");
when(bankStatementMapper.selectStatementListForExport(any())).thenReturn(List.of(row));
when(bankTagResultMapper.selectStatementHitTagsByBankStatementIds(List.of(10L)))
.thenReturn(List.of(first, second));
List<CcdiBankStatementExcel> result = service.selectStatementListForExport(new CcdiBankStatementQueryDTO());
assertEquals("房车消费支出交易;大额转账交易", result.get(0).getHitTagNames());
assertEquals("摘要命中购买房产首付款;转账金额 200000.00 元超过阈值", result.get(0).getHitTagReasons());
}
```
同时在交付记录里先写骨架:
```markdown
# 流水明细异常标签展示后端实施记录
## 修改内容
- 标签结果只读查询
- 列表/详情组装
- 导出列扩展
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
mvn -pl ccdi-project -Dtest=CcdiBankStatementServiceImplTest,CcdiBankStatementControllerTest test
```
Expected:
- `FAIL`
- 原因是导出对象还没有标签列Service 也没有做字符串拼装
- [ ] **Step 3: Write minimal implementation**
1. `CcdiBankStatementExcel` 新增两列:
```java
@Excel(name = "异常标签")
private String hitTagNames;
@Excel(name = "命中原因摘要")
private String hitTagReasons;
```
2. `CcdiBankStatementServiceImpl.toExcel()` 改为接收当前流水标签集合并拼装:
```java
excel.setHitTagNames(tags.stream().map(CcdiBankStatementHitTagVO::getRuleName).collect(Collectors.joining("")));
excel.setHitTagReasons(tags.stream().map(CcdiBankStatementHitTagVO::getReasonDetail).filter(StringUtils::isNotBlank).collect(Collectors.joining("")));
```
3. 导出查询阶段复用 Task 2 的批量标签查询逻辑,不新增第二套口径
4. 完成 `docs/reports/implementation/2026-03-19-bank-statement-hit-tags-backend-implementation.md`,记录改动文件、测试命令和结果
- [ ] **Step 4: Run tests to verify they pass**
Run:
```bash
mvn -pl ccdi-project -Dtest=CcdiBankTagResultMapperXmlTest,CcdiBankStatementServiceImplTest,CcdiBankStatementControllerTest test
```
Expected:
- `PASS`
- 说明标签查询、列表详情组装、导出扩展都已通过回归
- [ ] **Step 5: Commit**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/excel/CcdiBankStatementExcel.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankStatementServiceImpl.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiBankStatementServiceImplTest.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiBankStatementControllerTest.java docs/reports/implementation/2026-03-19-bank-statement-hit-tags-backend-implementation.md
git commit -m "补充流水异常标签后端导出能力"
```
### Task 4: 进行接口级人工回归并确认无对象级标签串入
**Files:**
- Modify: `docs/reports/implementation/2026-03-19-bank-statement-hit-tags-backend-implementation.md`
- [ ] **Step 1: Prepare the manual verification checklist**
在实施记录中补充 4 个后端联调点:
```markdown
## 联调检查
- [ ] 列表接口返回 `hitTags`
- [ ] 详情接口返回 `hitTags`
- [ ] 导出新增两列
- [ ] 未混入 `OBJECT` 标签结果
```
- [ ] **Step 2: Run targeted backend verification**
Run:
```bash
mvn -pl ccdi-project test -Dtest=CcdiBankTagResultMapperXmlTest,CcdiBankStatementServiceImplTest,CcdiBankStatementControllerTest
```
Expected:
- `PASS`
- [ ] **Step 3: Verify response shape manually**
联调时至少检查:
1. `GET /ccdi/project/bank-statement/list` 返回的每条流水出现 `hitTags`
2. `GET /ccdi/project/bank-statement/detail/{bankStatementId}` 返回 `hitTags[*].ruleName/riskLevel/reasonDetail`
3. 导出文件末尾新增两列且顺序固定
4. 通过构造仅有对象级结果的样本,确认不会出现在列表和详情里
- [ ] **Step 4: Update the implementation record with results**
把实际命令、结果、异常点补回实施记录,至少包含:
- 运行日期
- 测试命令
- 是否通过
- 若失败,失败原因和修正方式
- [ ] **Step 5: Commit**
```bash
git add docs/reports/implementation/2026-03-19-bank-statement-hit-tags-backend-implementation.md
git commit -m "补充流水异常标签后端验证记录"
```

View File

@@ -0,0 +1,139 @@
# Results Overview Backend 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.
**Goal:** 明确结果总览页面本轮仅实现前端静态页面,不新增任何后端接口、数据库脚本或服务逻辑,并通过边界核验避免误开后端改动。
**Architecture:** 本次页面方案基于原型图和本地 mock 数据,继续使用前端 `PreliminaryCheck.vue` 直出静态内容,不依赖 `ccdi-project``ruoyi-admin` 或其他 Java 模块提供新接口。后端实施计划的重点不是编码,而是冻结边界、校验现有链路不被误改,并补充“本次无需后端实施”的记录。
**Tech Stack:** Java 21, Spring Boot 3, Maven, Markdown documentation
---
### Task 1: 核定本次需求不涉及后端开发
**Files:**
- Modify: `docs/plans/backend/2026-03-19-results-overview-backend-implementation.md`
- Test: `docs/design/2026-03-19-results-overview-page-design.md`
- [ ] **Step 1: Write the failing review checklist**
先写出边界检查清单,锁定本轮不得出现以下后端诉求:
```md
- 不新增 Controller 接口
- 不新增 Service/Mapper 查询
- 不新增 SQL 脚本
- 不修改项目详情后端返回结构
- 不为静态页面补造 mock 后端接口
```
- [ ] **Step 2: Run boundary review to verify current intent**
Run:
```bash
sed -n '1,220p' docs/design/2026-03-19-results-overview-page-design.md
```
Expected:
- 设计文档明确写明“本次设计只覆盖前端内容展示,不包含真实接口接入”
- 若读到任何接口或数据库变更要求,则视为边界失败,需要先回到设计文档修正
- [ ] **Step 3: Record the minimal backend decision**
在本计划中明确写入后端结论:
```md
## 后端结论
- 本轮不实施后端开发
- 后端仓库代码保持不变
- 若后续从静态页升级为真实数据页,需要重新发起后端设计与实施计划
```
- [ ] **Step 4: Re-run boundary review**
Run:
```bash
grep -n "不实施后端开发\\|不新增 Controller 接口\\|不新增 SQL 脚本" docs/plans/backend/2026-03-19-results-overview-backend-implementation.md
```
Expected:
- 能检索到明确边界文案
- 说明后续执行者不会误将该需求扩展成全栈改造
- [ ] **Step 5: Commit**
```bash
git add docs/plans/backend/2026-03-19-results-overview-backend-implementation.md
git commit -m "补充结果总览页面后端实施边界"
```
### Task 2: 补充“无需后端实施”的验证与交接记录
**Files:**
- Create: `docs/tests/records/2026-03-19-results-overview-backend-verification.md`
- Create: `docs/reports/implementation/2026-03-19-results-overview-backend-implementation.md`
- [ ] **Step 1: Write the failing record skeleton**
先创建验证记录模板,锁定这次核验的是“无后端改动”而不是“后端功能实现”:
```md
# 结果总览后端验证记录
## 验证范围
- 本轮是否新增后端接口
- 本轮是否新增数据库脚本
- 本轮是否修改 Java 模块代码
```
- [ ] **Step 2: Run workspace inspection**
Run:
```bash
git diff --name-only HEAD~1..HEAD
```
Expected:
- 若当前提交仅涉及设计/计划/前端文件,则满足本轮边界
- 若出现 `ruoyi-admin``ccdi-project``sql/` 等后端路径,则需要人工复核是否偏离需求
- [ ] **Step 3: Write minimal implementation record**
`docs/reports/implementation/2026-03-19-results-overview-backend-implementation.md` 中写清:
```md
# 结果总览后端实施记录
## 结论
- 本轮未实施后端代码改动
- 页面数据由前端本地 mock 驱动
- 后端仅保留后续真实数据化扩展空间
```
- [ ] **Step 4: Re-run record review**
Run:
```bash
sed -n '1,200p' docs/tests/records/2026-03-19-results-overview-backend-verification.md
sed -n '1,200p' docs/reports/implementation/2026-03-19-results-overview-backend-implementation.md
```
Expected:
- 两份文档都明确表达“本轮无后端实施”
- 后续联调人员可直接据此判断无需等待后端发布
- [ ] **Step 5: Commit**
```bash
git add docs/tests/records/2026-03-19-results-overview-backend-verification.md docs/reports/implementation/2026-03-19-results-overview-backend-implementation.md
git commit -m "补充结果总览页面后端验证记录"
```

View File

@@ -0,0 +1,487 @@
# Results Overview Risk API Backend 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.
**Goal:** 为结果总览页面实现风险仪表盘、风险人员总览、中高风险人员 TOP10 三个后端接口,并在项目流水标签打标完成后回写项目高、中、低风险人数。
**Architecture:**`ccdi-project` 模块内新增结果总览专用 Controller、Service、Mapper 与 VO查询统一基于 `ccdi_project``ccdi_bank_statement_tag_result` 聚合,不新增兼容性补丁链路。员工风险等级按命中去重规则数分级,项目表高、中、低风险人数在标签任务成功结束后同步回写,保证项目列表与结果总览口径一致。
**Tech Stack:** Java 21, Spring Boot 3, MyBatis XML, MyBatis Plus, Maven, JUnit
---
### Task 1: 定义结果总览 VO 与服务接口骨架
**Files:**
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectOverviewDashboardVO.java`
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectOverviewStatVO.java`
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectRiskPeopleOverviewVO.java`
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectRiskPeopleOverviewItemVO.java`
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectTopRiskPeopleVO.java`
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectTopRiskPeopleItemVO.java`
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectEmployeeRiskAggregateVO.java`
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectOverviewService.java`
- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/CcdiProjectOverviewServiceStructureTest.java`
- [ ] **Step 1: Write the failing test**
新增结构测试,锁定服务接口与 VO 名称:
```java
@Test
void shouldExposeOverviewServiceMethods() throws Exception {
Class<?> clazz = Class.forName("com.ruoyi.ccdi.project.service.ICcdiProjectOverviewService");
assertNotNull(clazz.getMethod("getDashboard", Long.class));
assertNotNull(clazz.getMethod("getRiskPeopleOverview", Long.class));
assertNotNull(clazz.getMethod("getTopRiskPeople", Long.class));
assertNotNull(clazz.getMethod("refreshProjectRiskCounts", Long.class, String.class));
}
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewServiceStructureTest
```
Expected:
- `FAIL`
- 原因是接口与 VO 尚未创建
- [ ] **Step 3: Write minimal implementation**
最小落地如下:
1. 为 3 个区块分别创建返回 VO。
2. 新建员工聚合中间 VO字段至少包括
- `staffIdCard`
- `staffName`
- `deptName`
- `ruleCount`
- `modelCount`
- `topRuleName`
- `riskLevelCode`
- `riskLevelName`
- `riskLevelSort`
3. 新建 `ICcdiProjectOverviewService`,声明以下方法:
```java
CcdiProjectOverviewDashboardVO getDashboard(Long projectId);
CcdiProjectRiskPeopleOverviewVO getRiskPeopleOverview(Long projectId);
CcdiProjectTopRiskPeopleVO getTopRiskPeople(Long projectId);
void refreshProjectRiskCounts(Long projectId, String operator);
```
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewServiceStructureTest
```
Expected:
- `PASS`
- [ ] **Step 5: Commit**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectOverviewDashboardVO.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectOverviewStatVO.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectRiskPeopleOverviewVO.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectRiskPeopleOverviewItemVO.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectTopRiskPeopleVO.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectTopRiskPeopleItemVO.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectEmployeeRiskAggregateVO.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectOverviewService.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/CcdiProjectOverviewServiceStructureTest.java
git commit -m "定义结果总览风险接口基础结构"
```
### Task 2: 新增结果总览 Mapper 与员工归并聚合 SQL
**Files:**
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapper.java`
- Create: `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml`
- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapperSqlTest.java`
- [ ] **Step 1: Write the failing test**
新增 SQL 结构测试,锁定 Mapper XML 中必须包含员工归并和风险等级分段逻辑:
```java
@Test
void shouldContainEmployeeRiskAggregationSql() throws Exception {
String xml = Files.readString(Path.of("ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml"));
assertTrue(xml.contains("count(distinct base.rule_code)"));
assertTrue(xml.contains("count(distinct base.model_code)"));
assertTrue(xml.contains("when agg.rule_count >= 5 then 'HIGH'"));
assertTrue(xml.contains("when agg.rule_count between 2 and 4 then 'MEDIUM'"));
}
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewMapperSqlTest
```
Expected:
- `FAIL`
- 原因是 Mapper 文件还不存在
- [ ] **Step 3: Write minimal implementation**
创建 `CcdiProjectOverviewMapper` 与 XML至少提供以下查询
1. `selectDashboardBaseByProjectId(Long projectId)`
2. `selectRiskPeopleOverviewByProjectId(Long projectId)`
3. `selectTopRiskPeopleByProjectId(Long projectId)`
4. `selectRiskCountSummaryByProjectId(Long projectId)`
SQL 设计要求:
1. 先用公共子查询把标签结果归并到员工身份证:
- 本人命中:`object_type = 'STAFF_ID_CARD'`
- 流水本人命中:`bank_statement_id -> ccdi_bank_statement.cret_no`
- 亲属命中:`relation_cert_no -> person_id`
2. 外层按员工聚合:
```sql
count(distinct base.rule_code) as rule_count,
count(distinct base.model_code) as model_count
```
3. 风险等级按规则数分段:
```sql
case
when agg.rule_count >= 5 then 'HIGH'
when agg.rule_count between 2 and 4 then 'MEDIUM'
else 'LOW'
end
```
4. `riskPoint` 使用“规则命中次数倒序 + rule_code 升序”取第一条规则名。
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewMapperSqlTest
```
Expected:
- `PASS`
- [ ] **Step 5: Commit**
```bash
git add 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 "新增结果总览员工风险聚合查询"
```
### Task 3: 实现结果总览 Service
**Files:**
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImpl.java`
- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/CcdiProjectOverviewServiceImplTest.java`
- [ ] **Step 1: Write the failing test**
为服务层编写测试,锁定仪表盘与列表组装逻辑:
```java
@Test
void shouldBuildDashboardWithNoRiskCount() {
// mock project targetCount=100 high=5 medium=10 low=15
// assert noRiskCount == 70
}
@Test
void shouldMapRiskPeopleOverviewRows() {
// mock aggregate row
// assert riskCount/topRuleName/actionLabel mapping
}
@Test
void shouldMapTopRiskPeopleRows() {
// mock aggregate row with HIGH
// assert riskLevelType == "danger"
}
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewServiceImplTest
```
Expected:
- `FAIL`
- [ ] **Step 3: Write minimal implementation**
`CcdiProjectOverviewServiceImpl` 中完成:
1. `getDashboard`
- 读项目基础数据
- 计算无风险人数,空值按 0 处理
2. `getRiskPeopleOverview`
- 调 Mapper 查询员工聚合结果
- 映射为 `overviewList`
- 固定补 `actionLabel = "查看详情"`
3. `getTopRiskPeople`
- 调 Mapper 查询 TOP10
-`HIGH/MEDIUM` 映射为 `高风险/中风险`
-`HIGH -> danger``MEDIUM -> warning`
4. 若项目不存在,抛出业务异常
如需项目存在性校验,直接复用现有项目查询能力,不新增兜底流程。
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewServiceImplTest
```
Expected:
- `PASS`
- [ ] **Step 5: Commit**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImpl.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/CcdiProjectOverviewServiceImplTest.java
git commit -m "实现结果总览风险接口服务层"
```
### Task 4: 新增结果总览 Controller 接口
**Files:**
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewController.java`
- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewControllerTest.java`
- [ ] **Step 1: Write the failing test**
为控制器编写测试,锁定 3 个 GET 接口:
```java
@Test
void shouldExposeDashboardEndpoint() {}
@Test
void shouldExposeRiskPeopleEndpoint() {}
@Test
void shouldExposeTopRiskPeopleEndpoint() {}
```
校验内容:
- 路径为 `/ccdi/project/overview/*`
- 权限为 `ccdi:project:query`
- 返回 `AjaxResult.success(...)`
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewControllerTest
```
Expected:
- `FAIL`
- [ ] **Step 3: Write minimal implementation**
控制器实现:
```java
@GetMapping("/dashboard")
public AjaxResult getDashboard(Long projectId) { ... }
@GetMapping("/risk-people")
public AjaxResult getRiskPeople(Long projectId) { ... }
@GetMapping("/top-risk-people")
public AjaxResult getTopRiskPeople(Long projectId) { ... }
```
要求:
- 添加 Swagger 注释
- 复用 `@PreAuthorize("@ss.hasPermi('ccdi:project:query')")`
- 仅接收 `projectId`
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewControllerTest
```
Expected:
- `PASS`
- [ ] **Step 5: Commit**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewController.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewControllerTest.java
git commit -m "新增结果总览风险查询接口"
```
### Task 5: 在标签任务成功后回写项目风险人数
**Files:**
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImpl.java`
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectMapper.java`
- Modify: `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectMapper.xml`
- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/CcdiBankTagServiceRiskCountRefreshTest.java`
- [ ] **Step 1: Write the failing test**
新增测试,锁定打标成功后必须刷新项目风险人数:
```java
@Test
void shouldRefreshProjectRiskCountsAfterTagRebuildSuccess() {
// mock successful rule execution
// verify overviewService.refreshProjectRiskCounts(projectId, operator)
}
```
并新增一条失败场景:
```java
@Test
void shouldFailTaskWhenRiskCountRefreshFails() {
// mock refreshProjectRiskCounts throws exception
// assert task status becomes FAILED
}
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiBankTagServiceRiskCountRefreshTest
```
Expected:
- `FAIL`
- [ ] **Step 3: Write minimal implementation**
实施要求:
1.`CcdiBankTagServiceImpl.rebuildProject(...)` 中:
- 标签结果批量写入成功后
- 任务状态改成功前
- 调用 `projectOverviewService.refreshProjectRiskCounts(projectId, operator)`
2. 在项目 Mapper 中新增更新人数方法:
```java
int updateRiskCountsByProjectId(@Param("projectId") Long projectId,
@Param("highRiskCount") Integer highRiskCount,
@Param("mediumRiskCount") Integer mediumRiskCount,
@Param("lowRiskCount") Integer lowRiskCount,
@Param("updateBy") String updateBy);
```
3. `refreshProjectRiskCounts` 内部先查员工聚合统计,再更新项目表。
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiBankTagServiceRiskCountRefreshTest
```
Expected:
- `PASS`
- [ ] **Step 5: Commit**
```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/CcdiProjectMapper.java ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectMapper.xml ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/CcdiBankTagServiceRiskCountRefreshTest.java
git commit -m "打标完成后回写项目风险人数"
```
### Task 6: 补充后端验证记录与实施记录
**Files:**
- Create: `docs/tests/records/2026-03-19-results-overview-risk-api-backend-verification.md`
- Create: `docs/reports/implementation/2026-03-19-results-overview-risk-api-backend-implementation.md`
- [ ] **Step 1: Write the failing record skeleton**
先创建验证记录模板:
```md
# 结果总览风险接口后端验证记录
## 验证范围
- 风险仪表盘接口
- 风险人员总览接口
- 中高风险人员 TOP10 接口
- 打标完成后项目风险人数回写
```
- [ ] **Step 2: Run backend verification commands**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewServiceStructureTest,CcdiProjectOverviewMapperSqlTest,CcdiProjectOverviewServiceImplTest,CcdiProjectOverviewControllerTest,CcdiBankTagServiceRiskCountRefreshTest
```
Expected:
- 相关测试全部 `PASS`
- [ ] **Step 3: Write minimal implementation record**
在实施记录中说明:
- 新增 3 个结果总览接口
- 员工风险等级按规则数分级
- 标签完成后回写项目风险人数
- 未扩展风险模型区和风险明细区接口
- [ ] **Step 4: Re-run record review**
Run:
```bash
sed -n '1,220p' docs/tests/records/2026-03-19-results-overview-risk-api-backend-verification.md
sed -n '1,220p' docs/reports/implementation/2026-03-19-results-overview-risk-api-backend-implementation.md
```
Expected:
- 两份文档都能覆盖本次后端改动和验证结果
- [ ] **Step 5: Commit**
```bash
git add docs/tests/records/2026-03-19-results-overview-risk-api-backend-verification.md docs/reports/implementation/2026-03-19-results-overview-risk-api-backend-implementation.md
git commit -m "补充结果总览风险接口后端记录"
```

View File

@@ -0,0 +1,191 @@
# LSFX Mock LogId Primary Binding Frontend 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.
**Goal:** 在不改动现有前端业务代码的前提下,验证项目详情相关页面在接入更新后的 LSFX Mock 后,能够稳定展示同一 `logId` 下统一的本方主体与本方账号。
**Architecture:** 本期前端仍以联调验证为主,不预设 Vue 代码改造;通过联调记录、构建验证和页面手工检查,确认上传链路返回的主体账号与流水明细展示保持一致;只有在联调中暴露真实展示问题时,才拆出单独前端修复需求。
**Tech Stack:** Vue 2, Element UI, Axios request wrapper, npm, Node
---
### Task 1: 建立主体账号联调记录模板
**Files:**
- Verify: `ruoyi-ui/src/views/ccdiProject/detail.vue`
- Verify: `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
- Verify: `ruoyi-ui/src/views/ccdiProject/components/detail/DetailQuery.vue`
- Verify: `ruoyi-ui/src/api/ccdiProjectUpload.js`
- Create: `docs/tests/records/2026-03-18-lsfx-logid-primary-binding-frontend-verification.md`
- [ ] **Step 1: Write the verification record skeleton**
先创建联调记录文件,明确本次验证重点是“同一 `logId` 主体账号一致性”:
```markdown
# LSFX Mock LogId 主体账号前端联调记录
## 验证范围
- 上传返回的主体与账号
- 流水明细中的本方主体与账号
- 同一 logId 跨分页一致性
## 验证结果
- [ ] 上传返回存在主体账号
- [ ] 流水明细可见统一主体账号
- [ ] 翻页后主体账号不变化
```
- [ ] **Step 2: Run path existence smoke check**
Run:
```bash
test -f ruoyi-ui/src/views/ccdiProject/detail.vue
test -f ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue
test -f ruoyi-ui/src/views/ccdiProject/components/detail/DetailQuery.vue
test -f ruoyi-ui/src/api/ccdiProjectUpload.js
```
Expected:
- 四条命令都成功
- [ ] **Step 3: Keep implementation minimal**
本任务只建立联调记录,不修改前端业务代码。
- [ ] **Step 4: Commit**
```bash
git add docs/tests/records/2026-03-18-lsfx-logid-primary-binding-frontend-verification.md
git commit -m "新增Mock主体账号绑定前端联调记录模板"
```
### Task 2: 完成前端构建与入口冒烟
**Files:**
- Modify: `docs/tests/records/2026-03-18-lsfx-logid-primary-binding-frontend-verification.md`
- [ ] **Step 1: Run frontend build smoke check**
Run:
```bash
cd ruoyi-ui
npm run build:prod
```
Expected:
- 构建成功
- 说明本次 Mock 变化不会强制前端源码同步改动
- [ ] **Step 2: Run route-level smoke check**
手工验证以下入口:
- 项目详情页可打开
- “上传数据”页签可打开
- “流水明细查询”页签可打开
- “拉取本行信息”相关交互仍可进入
- [ ] **Step 3: Update verification notes**
把构建结果和入口冒烟结果写入联调记录。
- [ ] **Step 4: Commit**
```bash
git add docs/tests/records/2026-03-18-lsfx-logid-primary-binding-frontend-verification.md
git commit -m "记录Mock主体账号绑定前端构建与入口验证"
```
### Task 3: 验证上传结果与流水明细中的主体账号一致性
**Files:**
- Modify: `docs/tests/records/2026-03-18-lsfx-logid-primary-binding-frontend-verification.md`
- Verify: `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
- Verify: `ruoyi-ui/src/views/ccdiProject/components/detail/DetailQuery.vue`
- [ ] **Step 1: Prepare the manual verification checklist**
围绕同一 `logId`,记录以下检查点:
- 上传返回中是否能看到本方主体和本方账号
- 进入流水明细后,本方主体是否与上传返回一致
- 本方账号是否与上传返回一致
- 翻页后本方主体和账号是否仍一致
- [ ] **Step 2: Run manual verification against updated Mock**
建议联调步骤:
1. 启动前端、后端、`lsfx-mock-server`
2. 在项目详情页执行上传文件或拉取本行信息
3. 记下返回的 `logId`、本方主体、本方账号
4. 打开同一 `logId` 对应的流水明细
5. 验证列表首屏和翻页后的主体账号是否一致
- [ ] **Step 3: Record actual UI results**
在联调记录中按以下格式记录:
```markdown
- `logId=xxxxx`
- 上传返回主体:`测试主体A`
- 上传返回账号:`6222...`
- 明细页主体:`测试主体A`
- 明细页账号:`6222...`
- 翻页后是否一致:是 / 否
```
- [ ] **Step 4: Commit**
```bash
git add docs/tests/records/2026-03-18-lsfx-logid-primary-binding-frontend-verification.md
git commit -m "记录Mock主体账号绑定前端联调结果"
```
### Task 4: 收敛前端结论并清理验证进程
**Files:**
- Modify: `docs/tests/records/2026-03-18-lsfx-logid-primary-binding-frontend-verification.md`
- Modify: `docs/plans/frontend/2026-03-18-lsfx-logid-primary-binding-frontend-implementation.md`
- [ ] **Step 1: Summarize whether frontend code changes are required**
在联调记录末尾输出最终结论:
- 若上传返回和流水明细中的主体账号一致,则结论为“本期前端零代码改动”
- 若存在显示缺口,则明确是接口字段问题还是页面渲染问题
- [ ] **Step 2: Stop any started services**
若本任务启动过:
- `npm run dev`
- 后端 `mvn -pl ruoyi-admin spring-boot:run`
- Mock `python main.py``uvicorn main:app --reload --host 0.0.0.0 --port 8000`
验证完成后必须关闭进程,避免残留占用端口。
- [ ] **Step 3: Keep implementation minimal**
除非联调明确暴露前端显示缺陷,否则不提前修改 `ruoyi-ui/src` 下页面代码。
- [ ] **Step 4: Commit**
```bash
git add docs/tests/records/2026-03-18-lsfx-logid-primary-binding-frontend-verification.md docs/plans/frontend/2026-03-18-lsfx-logid-primary-binding-frontend-implementation.md
git commit -m "补充Mock主体账号绑定前端联调结论"
```
## Frontend Integration Conclusion
本计划的默认结论是“先联调验证,再决定是否需要前端修复”:
- 这次主改动仍在 `lsfx-mock-server`
- 前端重点验证同一 `logId` 主体账号的一致性展示
- 若页面已能正确展示,则本期不需要前端代码改造

View File

@@ -0,0 +1,202 @@
# LSFX Mock Large Transaction Frontend 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.
**Goal:** 在不改动现有 Vue 页面业务逻辑的前提下,完成与 LSFX Mock 大额交易样本的前端联调验证,确保项目详情中的上传、拉取本行信息和流水明细查询页面能正常展示命中样本数据。
**Architecture:** 本期前端以联调验证为主,不预设页面代码改造;先锁定实际受影响的入口页面与 API再通过构建、手工回归和验证记录确认现有组件能承接新的 Mock 流水样本;若联调中发现展示问题,再将问题回流到后端 Mock 数据字段层或单独前端修复需求。
**Tech Stack:** Vue 2, Element UI, Axios request wrapper, npm, Node
---
### Task 1: 固化联调入口与验证记录模板
**Files:**
- Verify: `ruoyi-ui/src/views/ccdiProject/detail.vue`
- Verify: `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
- Verify: `ruoyi-ui/src/views/ccdiProject/components/detail/DetailQuery.vue`
- Verify: `ruoyi-ui/src/api/ccdiProjectUpload.js`
- Create: `docs/tests/records/2026-03-18-lsfx-mock-large-transaction-frontend-verification.md`
- [ ] **Step 1: Write the verification record skeleton**
先创建前端联调记录,提前写好待验证项目:
```markdown
# LSFX Mock 大额交易前端联调记录
## 验证范围
- UploadData 拉取本行信息
- DetailQuery 流水明细查询
- 关键样本展示:房产首付、税款、现金存款、手机银行转账
## 验证结果
- [ ] 页面可打开
- [ ] 列表可分页
- [ ] 摘要和对手方可见
```
- [ ] **Step 2: Run path existence smoke check**
Run:
```bash
test -f ruoyi-ui/src/views/ccdiProject/detail.vue
test -f ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue
test -f ruoyi-ui/src/views/ccdiProject/components/detail/DetailQuery.vue
test -f ruoyi-ui/src/api/ccdiProjectUpload.js
```
Expected:
- 四条命令都返回成功
- [ ] **Step 3: Keep implementation minimal**
本任务不改动 Vue 代码,只建立联调记录文件并确认入口路径真实存在。
- [ ] **Step 4: Commit**
```bash
git add docs/tests/records/2026-03-18-lsfx-mock-large-transaction-frontend-verification.md
git commit -m "新增Mock大额交易前端联调记录模板"
```
### Task 2: 完成前端静态构建与入口冒烟
**Files:**
- Modify: `docs/tests/records/2026-03-18-lsfx-mock-large-transaction-frontend-verification.md`
- [ ] **Step 1: Run frontend build smoke check**
Run:
```bash
cd ruoyi-ui
npm run build:prod
```
Expected:
- 构建成功
- 说明本次 Mock 数据增强不会强制触发前端源码改动
- [ ] **Step 2: Run route-level smoke check**
手工验证以下入口:
- 项目详情页可打开
- “上传数据”页签可打开
- “流水明细查询”页签可打开
- “拉取本行信息”弹窗可打开
若要辅助定位,可在浏览器中重点观察:
- `ruoyi-ui/src/views/ccdiProject/detail.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/DetailQuery.vue`
- [ ] **Step 3: Update verification notes**
把构建结果和入口检查结果记入联调记录文件。
- [ ] **Step 4: Commit**
```bash
git add docs/tests/records/2026-03-18-lsfx-mock-large-transaction-frontend-verification.md
git commit -m "记录Mock大额交易前端构建与入口冒烟结果"
```
### Task 3: 使用新 Mock 样本验证流水明细展示
**Files:**
- Modify: `docs/tests/records/2026-03-18-lsfx-mock-large-transaction-frontend-verification.md`
- Verify: `ruoyi-ui/src/views/ccdiProject/components/detail/DetailQuery.vue`
- Verify: `ruoyi-ui/src/api/ccdiProjectUpload.js`
- [ ] **Step 1: Prepare the manual verification checklist**
围绕新样本,准备以下查询口径:
- 搜索摘要 `首付款`
- 搜索摘要 `税款`
- 搜索摘要 `现金存款`
- 搜索摘要 `手机银行转账`
- 按金额倒序检查大额收入 / 大额支出是否展示正确
- [ ] **Step 2: Run manual verification against the updated Mock**
建议联调步骤:
1. 启动后端、前端、`lsfx-mock-server`
2. 在项目详情页执行上传或拉取本行信息
3. 打开“流水明细查询”页签
4. 通过关键词和排序验证新样本是否可见
重点观察字段:
- 交易日期
- 收入/支出金额
- 对手方名称
- 用户摘要
- [ ] **Step 3: Record the actual UI results**
把每一类样本是否能在页面上被找到写入联调记录,例如:
```markdown
- `购买房产首付款`:可见 / 不可见
- `个人所得税税款`:可见 / 不可见
- `柜面现金存款`:可见 / 不可见
- `手机银行转账`:可见 / 不可见
```
- [ ] **Step 4: Commit**
```bash
git add docs/tests/records/2026-03-18-lsfx-mock-large-transaction-frontend-verification.md
git commit -m "记录Mock大额交易流水前端明细联调结果"
```
### Task 4: 收敛前端结论并关闭验证进程
**Files:**
- Modify: `docs/tests/records/2026-03-18-lsfx-mock-large-transaction-frontend-verification.md`
- Modify: `docs/plans/frontend/2026-03-18-lsfx-mock-large-transaction-frontend-implementation.md`
- [ ] **Step 1: Summarize whether frontend code changes are required**
在联调记录末尾补最终结论:
- 若现有页面能正确展示新样本,则结论为“本期前端零代码改动”
- 若页面出现字段缺失或文案异常,明确问题是 Mock 字段问题还是页面展示问题
- [ ] **Step 2: Stop any started services**
若本任务启动过:
- `npm run dev`
- 后端 `mvn -pl ruoyi-admin spring-boot:run`
- Mock `python main.py``uvicorn main:app --reload --host 0.0.0.0 --port 8000`
必须在验证完成后主动关闭进程,避免残留端口占用。
- [ ] **Step 3: Keep implementation minimal**
除非联调明确暴露真实展示缺陷,否则不提前改动 `ruoyi-ui/src` 下业务代码。
- [ ] **Step 4: Commit**
```bash
git add docs/tests/records/2026-03-18-lsfx-mock-large-transaction-frontend-verification.md docs/plans/frontend/2026-03-18-lsfx-mock-large-transaction-frontend-implementation.md
git commit -m "补充Mock大额交易前端联调结论"
```
## Frontend Integration Conclusion
本计划默认结论是“先联调验证,再决定是否改前端代码”:
- 这次主变更在 `lsfx-mock-server`
- 前端优先验证现有页面是否已经能承接增强后的流水样本
- 只有在联调明确暴露展示问题时,才补独立前端修复任务,避免为了 Mock 数据升级而提前制造无效改动

View File

@@ -0,0 +1,132 @@
# Model Param Save Trigger Rebuild Frontend 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.
**Goal:** 在项目详情参数配置页点击“保存所有修改”时,先弹出确认提示“将进行流水重新打标”,用户确认后再提交参数保存,并在保存成功后提示已开始重打标。
**Architecture:** 前端不再直接调用重打标接口,只在 `ParamConfig.vue` 中增加提交前确认与提交后提示,实际重打标由后端保存成功后自动异步发起;页面在保存成功后继续刷新参数,并通过父级项目详情刷新拿到最新项目状态,保持最小改动范围。
**Tech Stack:** Vue 2, Element UI, Axios request wrapper, Node.js, npm
---
### Task 1: 在参数页补充确认弹窗与成功提示
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/ParamConfig.vue`
- Modify: `ruoyi-ui/src/views/ccdiProject/detail.vue`
- [ ] **Step 1: Define the failing interaction expectation**
先明确本次交互基线:
- 点击“保存所有修改”当前会直接发起保存
- 没有提醒“将进行流水重新打标”
- 保存成功后没有提示后端已异步开始重打标
- [ ] **Step 2: Reproduce current behavior manually**
手工确认当前参数页行为:
1. 修改任意参数
2. 点击“保存所有修改”
3. 观察页面直接保存成功,无确认弹窗
Expected:
- 当前行为与需求不符,可作为修改前基线
- [ ] **Step 3: Write minimal implementation**
最小实现建议:
1.`handleSaveAll` 里先弹确认框:
```js
await this.$confirm(
'保存参数后将进行项目内流水重新打标,是否继续?',
'提示',
{ type: 'warning' }
)
```
2. 用户确认后再调用 `saveAllParams`
3. 保存成功提示改为:
```js
this.$modal.msgSuccess('保存成功,已开始项目内流水重新打标')
```
4. 保存成功后在当前组件 `loadAllParams()` 之外,再向父组件发出刷新项目事件:
```js
this.$emit('refresh-project')
```
- [ ] **Step 4: Run build to verify it passes**
Run:
```bash
cd ruoyi-ui
npm run build:prod
```
Expected:
- `PASS`
- [ ] **Step 5: Commit**
```bash
git add ruoyi-ui/src/views/ccdiProject/components/detail/ParamConfig.vue ruoyi-ui/src/views/ccdiProject/detail.vue
git commit -m "补充参数保存前重打标确认提示"
```
### Task 2: 补齐前端验证记录
**Files:**
- Create: `docs/tests/records/2026-03-18-model-param-save-trigger-rebuild-frontend-verification.md`
- Create: `docs/reports/implementation/2026-03-18-model-param-save-trigger-rebuild-frontend-record.md`
- [ ] **Step 1: Write verification skeleton**
记录前端验证范围:
```markdown
# 参数保存触发项目流水重打标前端验证记录
## 验证范围
- 保存前出现确认弹窗
- 取消时不提交保存
- 确认后保存成功并提示已开始重打标
- 构建通过
```
- [ ] **Step 2: Run build**
Run:
```bash
cd ruoyi-ui
npm run build:prod
```
Expected:
- 构建成功
- [ ] **Step 3: Write implementation record**
记录本次前端只承担:
- 提交前提醒
- 提交后提示与详情刷新
- 不新增单独重打标 API 调用
- [ ] **Step 4: Commit**
```bash
git add docs/tests/records/2026-03-18-model-param-save-trigger-rebuild-frontend-verification.md docs/reports/implementation/2026-03-18-model-param-save-trigger-rebuild-frontend-record.md
git commit -m "补充参数保存触发重打标前端实施记录"
```

View File

@@ -0,0 +1,317 @@
# Project Bank Tag Status Lock Frontend 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.
**Goal:** 在项目列表与项目详情中展示“打标中”状态,并在项目打标期间禁用上传数据页的拉取/上传入口以及参数模型页的修改入口。
**Architecture:** 以前端现有的 `projectInfo.projectStatus` 为统一判断条件,扩展列表页、详情页和状态统计对状态 `3` 的展示;在 `UploadData.vue``ParamConfig.vue` 中基于同一状态进入受限/只读态;同时补一个轻量的项目详情刷新链路,确保提交上传或拉取任务后页面能够尽快拿到后端返回的最新状态。仓库当前没有现成的 Vue 单测基建,本计划以 `npm run build:prod` 加手工联调记录替代自动化前端单测。
**Tech Stack:** Vue 2, Element UI, Axios request wrapper, Node.js, npm
---
### Task 1: 扩展项目列表、详情页与状态统计对“打标中”的展示
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiProject/detail.vue`
- Modify: `ruoyi-ui/src/views/ccdiProject/index.vue`
- Modify: `ruoyi-ui/src/views/ccdiProject/components/ProjectTable.vue`
- Modify: `ruoyi-ui/src/views/ccdiProject/components/SearchBar.vue`
- Create: `docs/tests/records/2026-03-18-project-bank-tag-status-lock-frontend-verification.md`
- [ ] **Step 1: Write the verification record skeleton**
先创建联调记录,明确本次前端验证点:
```markdown
# 项目打标状态联动前端验证记录
## 验证范围
- 列表页状态与统计显示
- 详情页状态标签
- 上传数据页禁用
- 参数模型页只读
## 验证结果
- [ ] 列表页出现“打标中”
- [ ] 顶部统计出现“打标中”
- [ ] 详情页出现“打标中”
```
- [ ] **Step 2: Run baseline build check before edits**
Run:
```bash
cd ruoyi-ui
npm run build:prod
```
Expected:
- 当前前端代码可以正常构建
- 作为后续改动的基线
- [ ] **Step 3: Write minimal implementation**
按最小范围扩展前端状态映射:
1. `detail.vue` 中把状态映射补到 `3`
```js
const statusMap = {
0: "进行中",
1: "已完成",
2: "已归档",
3: "打标中"
}
```
2. `ProjectTable.vue` 中补充 `3` 的颜色和操作显隐
3. `index.vue` / `SearchBar.vue` 中把 `tabCounts` 和 tabs 扩展到 `3`
- [ ] **Step 4: Run build to verify it passes**
Run:
```bash
cd ruoyi-ui
npm run build:prod
```
Expected:
- `PASS`
- 说明新增状态展示不会破坏现有页面构建
- [ ] **Step 5: Commit**
```bash
git add ruoyi-ui/src/views/ccdiProject/detail.vue ruoyi-ui/src/views/ccdiProject/index.vue ruoyi-ui/src/views/ccdiProject/components/ProjectTable.vue ruoyi-ui/src/views/ccdiProject/components/SearchBar.vue docs/tests/records/2026-03-18-project-bank-tag-status-lock-frontend-verification.md
git commit -m "补充前端项目打标中状态展示"
```
### Task 2: 让上传数据页在“打标中”进入受限态
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
- Modify: `ruoyi-ui/src/views/ccdiProject/detail.vue`
- [ ] **Step 1: Define the failing interaction expectation**
先把联调记录里的受限行为补完整,锁定以下结果必须成立:
```markdown
- [ ] 打标中时“拉取本行信息”按钮禁用
- [ ] 打标中时“上传流水”按钮禁用
- [ ] 打标中时仍可查看上传记录列表
- [ ] 页面有明确提示文案
```
- [ ] **Step 2: Reproduce current behavior manually**
手工确认当前页面在 `projectStatus = '3'` 时仍然可以点击:
1. 打开项目详情页“上传数据”
2. 通过 mock `projectInfo.projectStatus = '3'` 或联调真实数据观察页面
3. 记录当前“拉取本行信息”和“上传流水”仍可点击的现状
Expected:
- 当前行为与需求不符,作为修改前基线
- [ ] **Step 3: Write minimal implementation**
`UploadData.vue` 中增加统一计算属性,例如:
```js
computed: {
isProjectTagging() {
return String(this.projectInfo.projectStatus) === "3";
}
}
```
再基于同一条件处理:
- 顶部 `拉取本行信息` 按钮 `:disabled="isProjectTagging"`
- 流水导入卡片 `disabled: this.isProjectTagging`
- 打开弹窗前再次短路返回,避免旧状态下已打开页面绕过按钮禁用
- 页面顶部补一句提示,例如:
```html
<div v-if="isProjectTagging" class="tagging-lock-tip">
项目正在进行银行流水打标,暂不可上传或拉取数据。
</div>
```
- [ ] **Step 4: Run build to verify it passes**
Run:
```bash
cd ruoyi-ui
npm run build:prod
```
Expected:
- `PASS`
- [ ] **Step 5: Commit**
```bash
git add ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue ruoyi-ui/src/views/ccdiProject/detail.vue docs/tests/records/2026-03-18-project-bank-tag-status-lock-frontend-verification.md
git commit -m "限制前端打标中的上传数据操作"
```
### Task 3: 让参数模型页在“打标中”进入只读态
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/ParamConfig.vue`
- [ ] **Step 1: Define the failing interaction expectation**
在联调记录中增加参数页检查项:
```markdown
- [ ] 打标中时参数输入框禁用
- [ ] 打标中时“保存所有修改”按钮禁用
- [ ] 参数页有只读提示文案
```
- [ ] **Step 2: Reproduce current editable behavior**
手工确认当前 `projectStatus = '3'` 时:
- 参数输入框仍可编辑
- 保存按钮仍可点击
Expected:
- 当前行为与需求不符
- [ ] **Step 3: Write minimal implementation**
`ParamConfig.vue` 中增加:
```js
computed: {
isProjectTagging() {
return String(this.projectInfo.projectStatus) === "3";
}
}
```
并将其接到:
- 参数输入框 `:disabled="isProjectTagging"`
- 保存按钮 `:disabled="isProjectTagging || saving"`
- 顶部/底部提示文案:
```html
<div v-if="isProjectTagging" class="readonly-tip">
项目正在进行银行流水打标,参数暂不可修改。
</div>
```
- [ ] **Step 4: Run build to verify it passes**
Run:
```bash
cd ruoyi-ui
npm run build:prod
```
Expected:
- `PASS`
- [ ] **Step 5: Commit**
```bash
git add ruoyi-ui/src/views/ccdiProject/components/detail/ParamConfig.vue docs/tests/records/2026-03-18-project-bank-tag-status-lock-frontend-verification.md
git commit -m "限制前端打标中的参数修改"
```
### Task 4: 补项目状态刷新链路并完成联调验证
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiProject/detail.vue`
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
- Modify: `docs/tests/records/2026-03-18-project-bank-tag-status-lock-frontend-verification.md`
- Create: `docs/reports/implementation/2026-03-18-project-bank-tag-status-lock-frontend-implementation.md`
- [ ] **Step 1: Write the refresh expectation**
先在验证记录里加上刷新口径:
```markdown
- [ ] 提交上传或拉取任务后,详情页能重新获取项目状态
- [ ] 文件轮询期间如后端状态切为“打标中”,页面会同步受限
```
- [ ] **Step 2: Write minimal implementation**
建议增加一个轻量的父子联动:
1. `detail.vue` 监听子组件的 `refresh-project` 事件:
```html
<component
...
@refresh-project="handleRefresh"
/>
```
2. `UploadData.vue` 在这些时机 `this.$emit("refresh-project")`
- 批量上传提交成功后
- 拉取本行信息提交成功后
- 轮询到文件状态变化后
- 手工刷新文件列表后
目标不是做实时推送,而是复用现有轮询时机把 `projectInfo` 更新到最新。
- [ ] **Step 3: Run build and manual verification**
Run:
```bash
cd ruoyi-ui
npm run build:prod
```
手工联调建议:
1. 启动后端、前端、Mock 服务
2. 打开一个项目详情页
3. 触发上传流水或拉取本行信息
4. 等待后端进入打标流程
5. 验证页面状态变为“打标中”,上传/拉取被禁用,参数页只读
6. 验证列表页和顶部统计刷新后可显示“打标中”
完成验证后,按仓库要求关闭测试过程中启动的前后端进程。
- [ ] **Step 4: Write implementation report and commit**
实施报告至少记录:
- 哪些页面补了 `3-打标中`
- 哪些按钮/输入框在打标中被禁用
- 项目状态刷新是如何接入的
- `npm run build:prod` 和手工联调结果
```bash
git add ruoyi-ui/src/views/ccdiProject/detail.vue ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue docs/tests/records/2026-03-18-project-bank-tag-status-lock-frontend-verification.md docs/reports/implementation/2026-03-18-project-bank-tag-status-lock-frontend-implementation.md
git commit -m "补充前端项目打标状态联动验证"
```
## Frontend Exit Criteria
- 列表页、详情页、状态统计都能正确展示 `3-打标中`
- 上传数据页在 `3` 状态下禁用上传/拉取入口并给出提示
- 参数模型页在 `3` 状态下进入只读态
- 提交上传或拉取任务后,详情页能重新拿到最新项目状态
- 前端构建通过,联调记录与实施报告已补齐

View File

@@ -0,0 +1,325 @@
# Bank Statement Hit Tags Frontend 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.
**Goal:** 在项目详情“流水明细查询”页面的列表和详情弹窗中展示当前流水命中的异常标签,并保持导出入口与现有筛选、分页、排序交互兼容。
**Architecture:** 继续沿用 `DetailQuery.vue` 作为唯一页面组件,不新增页面和接口;前端直接消费后端返回的 `hitTags` 结构,列表只渲染标签名称,详情弹窗渲染标签名称、风险等级和命中原因摘要。由于仓库当前前端单测基建是静态 Node 脚本,本计划以“先补 source 断言 + 再跑 build 验证”为主,不额外引入测试框架。
**Tech Stack:** Vue 2, Element UI, Axios request wrapper, Node.js, npm
---
### Task 1: 在列表表格中新增异常标签列
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/DetailQuery.vue`
- Create: `ruoyi-ui/tests/unit/detail-query-hit-tags-list.test.js`
- [ ] **Step 1: Write the failing test**
先新增静态断言脚本,锁定列表必须新增“异常标签”列并读取 `scope.row.hitTags`
```js
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const componentPath = path.resolve(
__dirname,
"../../src/views/ccdiProject/components/detail/DetailQuery.vue"
);
const source = fs.readFileSync(componentPath, "utf8");
assert(source.includes('label="异常标签"'), "列表应新增异常标签列");
assert(source.includes("scope.row.hitTags"), "异常标签列应读取当前行的 hitTags");
assert(source.includes('v-for="(tag, index) in scope.row.hitTags"'), "异常标签列应逐个渲染命中标签");
assert(source.includes("tag.ruleName"), "异常标签列应展示标签名称");
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd ruoyi-ui
node tests/unit/detail-query-hit-tags-list.test.js
```
Expected:
- `FAIL`
- 原因是当前列表还没有异常标签列和对应格式化方法
- [ ] **Step 3: Write minimal implementation**
`DetailQuery.vue` 中做最小范围改动:
1. 在“摘要 / 交易类型”和“交易金额”之间新增:
```html
<el-table-column label="异常标签" min-width="220">
<template slot-scope="scope">
<div v-if="scope.row.hitTags && scope.row.hitTags.length" class="hit-tag-list">
<el-tag
v-for="(tag, index) in scope.row.hitTags"
:key="`${scope.row.bankStatementId}-tag-${index}`"
size="mini"
:type="mapRiskLevelToTagType(tag.riskLevel)"
effect="plain"
>
{{ tag.ruleName }}
</el-tag>
</div>
<span v-else class="empty-text">-</span>
</template>
</el-table-column>
```
2.`createEmptyDetailData()``hitTags: []`,并在列表取数后统一兜底:
```js
this.list = (res.rows || []).map((item) => ({
hitTags: [],
...item,
}));
```
3. 新增方法:
```js
mapRiskLevelToTagType(riskLevel) {
const level = String(riskLevel || "").toUpperCase();
if (level === "HIGH") return "danger";
if (level === "MEDIUM") return "warning";
return "info";
}
```
4. 增加轻量样式:
```scss
.hit-tag-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.empty-text {
color: #909399;
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run:
```bash
cd ruoyi-ui
node tests/unit/detail-query-hit-tags-list.test.js
node tests/unit/detail-query-filter-layout.test.js
node tests/unit/detail-query-detail-dialog.test.js
```
Expected:
- 3 个脚本都 `PASS`
- 说明列表新增列没有破坏现有静态结构
- [ ] **Step 5: Commit**
```bash
git add ruoyi-ui/src/views/ccdiProject/components/detail/DetailQuery.vue ruoyi-ui/tests/unit/detail-query-hit-tags-list.test.js ruoyi-ui/tests/unit/detail-query-filter-layout.test.js ruoyi-ui/tests/unit/detail-query-detail-dialog.test.js
git commit -m "补充流水明细异常标签列表展示"
```
### Task 2: 在详情弹窗中新增命中异常标签模块
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/DetailQuery.vue`
- Modify: `ruoyi-ui/tests/unit/detail-query-detail-dialog.test.js`
- [ ] **Step 1: Write the failing test**
`detail-query-detail-dialog.test.js` 中追加断言,锁定详情弹窗必须出现“命中异常标签”模块:
```js
[
"命中异常标签",
"detail-hit-tag-section",
"detailData.hitTags",
"当前流水未命中异常标签",
"mapRiskLevelToTagType(tag.riskLevel)",
].forEach((token) => {
assert(source.includes(token), `详情弹窗缺少异常标签结构: ${token}`);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd ruoyi-ui
node tests/unit/detail-query-detail-dialog.test.js
```
Expected:
- `FAIL`
- 原因是详情弹窗尚未渲染命中异常标签模块
- [ ] **Step 3: Write minimal implementation**
在详情弹窗文件区下方新增独立模块:
```html
<div class="detail-hit-tag-section">
<div class="detail-section-title">命中异常标签</div>
<div v-if="detailData.hitTags && detailData.hitTags.length" class="detail-hit-tag-items">
<div
v-for="(tag, index) in detailData.hitTags"
:key="`detail-tag-${index}`"
class="detail-hit-tag-item"
>
<div class="detail-hit-tag-header">
<span class="detail-hit-tag-name">{{ formatField(tag.ruleName) }}</span>
<el-tag size="mini" :type="mapRiskLevelToTagType(tag.riskLevel)" effect="plain">
{{ formatRiskLevel(tag.riskLevel) }}
</el-tag>
</div>
<div class="detail-hit-tag-reason">{{ formatField(tag.reasonDetail) }}</div>
</div>
</div>
<div v-else class="detail-hit-tag-empty">当前流水未命中异常标签</div>
</div>
```
并新增方法:
```js
formatRiskLevel(value) {
const level = String(value || "").toUpperCase();
if (level === "HIGH") return "高风险";
if (level === "MEDIUM") return "中风险";
if (level === "LOW") return "低风险";
return "未标注";
}
```
同时补充对应样式类,保证详情里纵向展示原因摘要,不把多条命中压成一行。
- [ ] **Step 4: Run tests to verify they pass**
Run:
```bash
cd ruoyi-ui
node tests/unit/detail-query-detail-dialog.test.js
node tests/unit/detail-query-hit-tags-list.test.js
```
Expected:
- `PASS`
- 说明详情模块和列表模块结构都已具备
- [ ] **Step 5: Commit**
```bash
git add ruoyi-ui/src/views/ccdiProject/components/detail/DetailQuery.vue ruoyi-ui/tests/unit/detail-query-detail-dialog.test.js ruoyi-ui/tests/unit/detail-query-hit-tags-list.test.js
git commit -m "补充流水详情异常标签展示"
```
### Task 3: 补齐前端回归验证记录并完成构建验证
**Files:**
- Create: `docs/tests/records/2026-03-19-bank-statement-hit-tags-frontend-verification.md`
- Create: `docs/reports/implementation/2026-03-19-bank-statement-hit-tags-frontend-implementation.md`
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/DetailQuery.vue`
- [ ] **Step 1: Write the verification record skeleton**
先建立前端验证记录,锁定联调检查项:
```markdown
# 流水明细异常标签前端验证记录
## 验证范围
- 列表异常标签列
- 详情异常标签模块
- 空态展示
- 导出入口回归
## 验证结果
- [ ] 列表显示命中标签名称
- [ ] 详情显示名称、风险等级、命中原因摘要
- [ ] 无标签时显示空态
- [ ] 导出入口仍可触发下载
```
同时创建前端实施记录骨架:
```markdown
# 流水明细异常标签前端实施记录
## 修改内容
- 列表异常标签列
- 详情异常标签模块
- 静态测试与构建验证
```
- [ ] **Step 2: Run baseline build check before final polish**
Run:
```bash
cd ruoyi-ui
npm run build:prod
```
Expected:
- 当前改动后的前端代码可以正常构建
- [ ] **Step 3: Write minimal final polish**
补齐最后的回归点:
1. 确认 `handleViewDetail()` 在详情返回无 `hitTags` 时仍回填空数组
2. 确认列表、详情空态文案和样式一致
3. 若需要,给异常标签模块补最小响应式样式,确保移动端不溢出
4. 在实施记录中写明本次不改 `ruoyi-ui/src/api/ccdiProjectBankStatement.js` 的原因:
- 复用现有接口
- 仅扩展响应数据结构
- [ ] **Step 4: Run full frontend verification**
Run:
```bash
cd ruoyi-ui
node tests/unit/detail-query-filter-layout.test.js
node tests/unit/detail-query-detail-dialog.test.js
node tests/unit/detail-query-hit-tags-list.test.js
npm run build:prod
```
Expected:
- 静态脚本全部 `PASS`
- 构建 `PASS`
联调时额外检查:
1. 列表页一条命中多标签的流水是否正确折行
2. 无标签流水是否显示 `-`
3. 详情页命中原因摘要是否完整可见
4. 点击“导出流水”按钮仍能正常触发下载
- [ ] **Step 5: Commit**
```bash
git add ruoyi-ui/src/views/ccdiProject/components/detail/DetailQuery.vue docs/tests/records/2026-03-19-bank-statement-hit-tags-frontend-verification.md docs/reports/implementation/2026-03-19-bank-statement-hit-tags-frontend-implementation.md
git commit -m "补充流水异常标签前端验证记录"
```

View File

@@ -0,0 +1,384 @@
# Results Overview Frontend 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.
**Goal:** 在项目详情页 `结果总览` 页签下实现一版基于原型图的高保真静态前端页面,覆盖主展示态、空数据态和加载态。
**Architecture:** 保持 `detail.vue` 现有挂载链路不变,以 `PreliminaryCheck.vue` 作为页面编排入口,新增顶部总览、风险人员、风险模型、风险明细 4 个内容区组件,并由同目录 mock 数据文件统一驱动页面状态。测试继续沿用仓库当前前端做法,优先补静态 source 断言脚本,再跑 `npm run build:prod` 做构建回归。
**Tech Stack:** Vue 2, Element UI, SCSS, Node.js, npm
---
### Task 1: 搭建结果总览页面骨架与静态断言
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/PreliminaryCheck.vue`
- Create: `ruoyi-ui/src/views/ccdiProject/components/detail/preliminaryCheck.mock.js`
- Create: `ruoyi-ui/tests/unit/preliminary-check-layout.test.js`
- [ ] **Step 1: Write the failing test**
先新增静态断言脚本,锁定页面入口必须从占位页升级为真实页面骨架:
```js
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const componentPath = path.resolve(
__dirname,
"../../src/views/ccdiProject/components/detail/PreliminaryCheck.vue"
);
const source = fs.readFileSync(componentPath, "utf8");
[
"OverviewStats",
"RiskPeopleSection",
"RiskModelSection",
"RiskDetailSection",
"pageState",
"mockOverviewData",
].forEach((token) => {
assert(source.includes(token), `结果总览入口缺少结构: ${token}`);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd ruoyi-ui
node tests/unit/preliminary-check-layout.test.js
```
Expected:
- `FAIL`
- 原因是当前 `PreliminaryCheck.vue` 仍然只有占位文案
- [ ] **Step 3: Write minimal implementation**
做最小范围骨架改造:
1.`preliminaryCheck.mock.js` 中导出统一 mock 数据:
```js
export const mockOverviewData = {
summary: {},
riskPeople: {},
riskModels: {},
riskDetails: {},
};
```
2.`PreliminaryCheck.vue` 改为页面编排组件,新增:
```js
data() {
return {
pageState: "loaded",
mockData: mockOverviewData,
};
}
```
3. 在模板中预留 4 个区块组件挂载位:
```html
<overview-stats v-if="pageState === 'loaded'" :summary="mockData.summary" />
<risk-people-section v-if="pageState === 'loaded'" :section-data="mockData.riskPeople" />
<risk-model-section v-if="pageState === 'loaded'" :section-data="mockData.riskModels" />
<risk-detail-section v-if="pageState === 'loaded'" :section-data="mockData.riskDetails" />
```
4. 加入 `loaded / empty / loading` 三态容器骨架,但先不做完整内容。
- [ ] **Step 4: Run tests to verify they pass**
Run:
```bash
cd ruoyi-ui
node tests/unit/preliminary-check-layout.test.js
```
Expected:
- `PASS`
- 说明页面入口已具备后续拆分基础
- [ ] **Step 5: Commit**
```bash
git add ruoyi-ui/src/views/ccdiProject/components/detail/PreliminaryCheck.vue ruoyi-ui/src/views/ccdiProject/components/detail/preliminaryCheck.mock.js ruoyi-ui/tests/unit/preliminary-check-layout.test.js
git commit -m "搭建结果总览页面骨架"
```
### Task 2: 实现顶部总览区与风险人员区
**Files:**
- Create: `ruoyi-ui/src/views/ccdiProject/components/detail/OverviewStats.vue`
- Create: `ruoyi-ui/src/views/ccdiProject/components/detail/RiskPeopleSection.vue`
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/preliminaryCheck.mock.js`
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/PreliminaryCheck.vue`
- Create: `ruoyi-ui/tests/unit/preliminary-check-summary-and-people.test.js`
- [ ] **Step 1: Write the failing test**
新增静态断言脚本,锁定原型前两块结构必须出现:
```js
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const entry = fs.readFileSync(path.resolve(__dirname, "../../src/views/ccdiProject/components/detail/PreliminaryCheck.vue"), "utf8");
const stats = fs.readFileSync(path.resolve(__dirname, "../../src/views/ccdiProject/components/detail/OverviewStats.vue"), "utf8");
const people = fs.readFileSync(path.resolve(__dirname, "../../src/views/ccdiProject/components/detail/RiskPeopleSection.vue"), "utf8");
["风险总览", "overview-stats"].forEach((token) => assert(stats.includes(token), token));
["风险人员总览", "中高风险人员TOP10", "查看详情"].forEach((token) => assert(people.includes(token), token));
assert(entry.includes("risk-people-section"), "入口应挂载风险人员区");
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd ruoyi-ui
node tests/unit/preliminary-check-summary-and-people.test.js
```
Expected:
- `FAIL`
- 原因是两个区块组件还不存在或仍未包含目标结构
- [ ] **Step 3: Write minimal implementation**
按原型补齐前两块:
1. `OverviewStats.vue` 实现标题行、右侧操作位和顶部统计卡。
2. `RiskPeopleSection.vue` 实现两个白卡表格区:
- 风险人员总览
- 中高风险人员 TOP10
3. `preliminaryCheck.mock.js` 补齐:
```js
summary: {
title: "风险总览",
stats: [/* 5 个指标卡 */]
},
riskPeople: {
overviewList: [/* 风险人员总览 */],
topRiskList: [/* TOP10 */]
}
```
4.`PreliminaryCheck.vue` 中接入两个组件并传入对应 mock 数据。
5. 样式上按原型落实浅灰背景、白卡、蓝色操作位和紧凑表格头部。
- [ ] **Step 4: Run tests to verify they pass**
Run:
```bash
cd ruoyi-ui
node tests/unit/preliminary-check-layout.test.js
node tests/unit/preliminary-check-summary-and-people.test.js
```
Expected:
- 2 个脚本都 `PASS`
- 页面入口和前两块结构都已稳定
- [ ] **Step 5: Commit**
```bash
git add ruoyi-ui/src/views/ccdiProject/components/detail/PreliminaryCheck.vue ruoyi-ui/src/views/ccdiProject/components/detail/OverviewStats.vue ruoyi-ui/src/views/ccdiProject/components/detail/RiskPeopleSection.vue ruoyi-ui/src/views/ccdiProject/components/detail/preliminaryCheck.mock.js ruoyi-ui/tests/unit/preliminary-check-layout.test.js ruoyi-ui/tests/unit/preliminary-check-summary-and-people.test.js
git commit -m "实现结果总览前两块静态页面"
```
### Task 3: 实现风险模型区与风险明细区
**Files:**
- Create: `ruoyi-ui/src/views/ccdiProject/components/detail/RiskModelSection.vue`
- Create: `ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue`
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/preliminaryCheck.mock.js`
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/PreliminaryCheck.vue`
- Create: `ruoyi-ui/tests/unit/preliminary-check-model-and-detail.test.js`
- [ ] **Step 1: Write the failing test**
新增静态断言脚本,锁定原型后两块结构:
```js
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const model = fs.readFileSync(path.resolve(__dirname, "../../src/views/ccdiProject/components/detail/RiskModelSection.vue"), "utf8");
const detail = fs.readFileSync(path.resolve(__dirname, "../../src/views/ccdiProject/components/detail/RiskDetailSection.vue"), "utf8");
["模型预警次数统计", "命中模型涉及人员", "筛查模型", "预警类型"].forEach((token) => assert(model.includes(token), token));
["涉险交易明细", "异常账户人员信息", "查看详情"].forEach((token) => assert(detail.includes(token), token));
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd ruoyi-ui
node tests/unit/preliminary-check-model-and-detail.test.js
```
Expected:
- `FAIL`
- 原因是风险模型区和风险明细区组件尚未实现
- [ ] **Step 3: Write minimal implementation**
按原型补齐后两块:
1. `RiskModelSection.vue` 实现:
- 模型摘要卡列表
- 筛选条
- 命中模型涉及人员表格与分页
2. `RiskDetailSection.vue` 实现:
- 涉险交易明细表
- 异常账户人员信息表
- 金额正负值颜色区分
3. `preliminaryCheck.mock.js` 补齐:
```js
riskModels: {
cardList: [],
filterOptions: {},
peopleList: []
},
riskDetails: {
transactionList: [],
abnormalAccountList: []
}
```
4. 在入口组件中按顺序接入两个区块组件。
- [ ] **Step 4: Run tests to verify they pass**
Run:
```bash
cd ruoyi-ui
node tests/unit/preliminary-check-model-and-detail.test.js
node tests/unit/preliminary-check-summary-and-people.test.js
```
Expected:
- `PASS`
- 说明四大区块模板结构齐备,且新增区块没有破坏前两块
- [ ] **Step 5: Commit**
```bash
git add ruoyi-ui/src/views/ccdiProject/components/detail/RiskModelSection.vue ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue ruoyi-ui/src/views/ccdiProject/components/detail/preliminaryCheck.mock.js ruoyi-ui/src/views/ccdiProject/components/detail/PreliminaryCheck.vue ruoyi-ui/tests/unit/preliminary-check-model-and-detail.test.js ruoyi-ui/tests/unit/preliminary-check-summary-and-people.test.js
git commit -m "实现结果总览模型与明细区块"
```
### Task 4: 补齐三种页面状态、构建验证与实施记录
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/PreliminaryCheck.vue`
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/preliminaryCheck.mock.js`
- Create: `ruoyi-ui/tests/unit/preliminary-check-states.test.js`
- Create: `docs/tests/records/2026-03-19-results-overview-frontend-verification.md`
- Create: `docs/reports/implementation/2026-03-19-results-overview-frontend-implementation.md`
- [ ] **Step 1: Write the failing test**
新增状态断言脚本,锁定加载态与空态入口:
```js
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const source = fs.readFileSync(
path.resolve(__dirname, "../../src/views/ccdiProject/components/detail/PreliminaryCheck.vue"),
"utf8"
);
["pageState === 'loading'", "pageState === 'empty'", "el-skeleton", "el-empty"].forEach((token) => {
assert(source.includes(token), `缺少页面状态结构: ${token}`);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd ruoyi-ui
node tests/unit/preliminary-check-states.test.js
```
Expected:
- `FAIL`
- 原因是三种页面状态尚未完整落地
- [ ] **Step 3: Write minimal implementation**
补齐收尾内容:
1.`PreliminaryCheck.vue` 中实现:
- 加载态骨架屏
- 空态容器
- 主展示态切换
2.`preliminaryCheck.mock.js` 中补齐 `loaded / empty / loading` 三套数据组织。
3. 新增前端验证记录:
```md
# 结果总览前端验证记录
## 验证范围
- 结果总览主展示态
- 空数据态
- 加载态
- 页面构建
```
4. 新增实施记录,说明本次页面落地内容与验证结果。
- [ ] **Step 4: Run tests to verify they pass**
Run:
```bash
cd ruoyi-ui
node tests/unit/preliminary-check-layout.test.js
node tests/unit/preliminary-check-summary-and-people.test.js
node tests/unit/preliminary-check-model-and-detail.test.js
node tests/unit/preliminary-check-states.test.js
npm run build:prod
```
Expected:
- 4 个静态脚本全部 `PASS`
- `npm run build:prod` 成功
- 说明页面结构、状态和样式未引入构建错误
- [ ] **Step 5: Commit**
```bash
git add ruoyi-ui/src/views/ccdiProject/components/detail/PreliminaryCheck.vue ruoyi-ui/src/views/ccdiProject/components/detail/preliminaryCheck.mock.js ruoyi-ui/tests/unit/preliminary-check-layout.test.js ruoyi-ui/tests/unit/preliminary-check-summary-and-people.test.js ruoyi-ui/tests/unit/preliminary-check-model-and-detail.test.js ruoyi-ui/tests/unit/preliminary-check-states.test.js docs/tests/records/2026-03-19-results-overview-frontend-verification.md docs/reports/implementation/2026-03-19-results-overview-frontend-implementation.md
git commit -m "完成结果总览页面前端实现"
```

View File

@@ -0,0 +1,311 @@
# Results Overview Risk API Frontend 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.
**Goal:** 将结果总览页面中的风险仪表盘、风险人员总览、中高风险人员 TOP10 从本地 mock 数据切换为真实后端接口数据。
**Architecture:** 保持 `PreliminaryCheck.vue` 作为结果总览页面入口,不改造当前 4 区块布局,只在前端增加结果总览 API 封装、页面数据拉取与 3 个区块的真实数据映射。风险模型区和风险明细区继续使用现有 mock 或原状,不额外扩展接口范围。
**Tech Stack:** Vue 2, Element UI, Axios (`@/utils/request`), Node.js, npm
---
### Task 1: 新增结果总览 API 封装
**Files:**
- Create: `ruoyi-ui/src/api/ccdi/projectOverview.js`
- Test: `ruoyi-ui/tests/unit/project-overview-api.test.js`
- [ ] **Step 1: Write the failing test**
新增静态断言脚本,锁定 3 个接口方法:
```js
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const source = fs.readFileSync(
path.resolve(__dirname, "../../src/api/ccdi/projectOverview.js"),
"utf8"
);
[
"getOverviewDashboard",
"getOverviewRiskPeople",
"getOverviewTopRiskPeople",
"/ccdi/project/overview/dashboard",
"/ccdi/project/overview/risk-people",
"/ccdi/project/overview/top-risk-people",
].forEach((token) => assert(source.includes(token), token));
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd ruoyi-ui
node tests/unit/project-overview-api.test.js
```
Expected:
- `FAIL`
- [ ] **Step 3: Write minimal implementation**
创建 API 文件:
```js
import request from "@/utils/request";
export function getOverviewDashboard(projectId) {
return request({ url: "/ccdi/project/overview/dashboard", method: "get", params: { projectId } });
}
```
同理补齐另外两个接口。
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
cd ruoyi-ui
node tests/unit/project-overview-api.test.js
```
Expected:
- `PASS`
- [ ] **Step 5: Commit**
```bash
git add ruoyi-ui/src/api/ccdi/projectOverview.js ruoyi-ui/tests/unit/project-overview-api.test.js
git commit -m "新增结果总览风险接口前端封装"
```
### Task 2: 在 PreliminaryCheck 页面接入 3 个真实接口
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/PreliminaryCheck.vue`
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/preliminaryCheck.mock.js`
- Test: `ruoyi-ui/tests/unit/preliminary-check-api-integration.test.js`
- [ ] **Step 1: Write the failing test**
新增静态断言脚本,锁定页面必须引入真实 API
```js
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const source = fs.readFileSync(
path.resolve(__dirname, "../../src/views/ccdiProject/components/detail/PreliminaryCheck.vue"),
"utf8"
);
[
"getOverviewDashboard",
"getOverviewRiskPeople",
"getOverviewTopRiskPeople",
"loadOverviewData",
"Promise.all",
].forEach((token) => assert(source.includes(token), token));
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd ruoyi-ui
node tests/unit/preliminary-check-api-integration.test.js
```
Expected:
- `FAIL`
- [ ] **Step 3: Write minimal implementation**
`PreliminaryCheck.vue` 中:
1. 引入 3 个 API 方法
2. 新增 `loadOverviewData`
3. 在页面进入或 `projectId` 变化时并发拉取 3 个接口
4. 将返回结果合并到页面数据:
```js
this.realData = {
...this.mockData,
summary: dashboardData,
riskPeople: {
overviewList: riskPeopleData.overviewList || [],
topRiskList: topRiskPeopleData.topRiskList || [],
},
};
```
5. 保留现有 `loading / empty / loaded` 状态
本任务只替换这 3 块数据,不动风险模型区和风险明细区。
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
cd ruoyi-ui
node tests/unit/preliminary-check-api-integration.test.js
```
Expected:
- `PASS`
- [ ] **Step 5: Commit**
```bash
git add ruoyi-ui/src/views/ccdiProject/components/detail/PreliminaryCheck.vue ruoyi-ui/src/views/ccdiProject/components/detail/preliminaryCheck.mock.js ruoyi-ui/tests/unit/preliminary-check-api-integration.test.js
git commit -m "接入结果总览风险真实接口"
```
### Task 3: 校准风险人员区字段映射与空态
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/RiskPeopleSection.vue`
- Test: `ruoyi-ui/tests/unit/preliminary-check-risk-people-binding.test.js`
- [ ] **Step 1: Write the failing test**
新增静态断言脚本,锁定字段映射仍与当前页面结构兼容:
```js
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const source = fs.readFileSync(
path.resolve(__dirname, "../../src/views/ccdiProject/components/detail/RiskPeopleSection.vue"),
"utf8"
);
[
"sectionData.overviewList",
"sectionData.topRiskList",
"riskCount",
"riskPoint",
"modelCount",
"riskLevelType",
].forEach((token) => assert(source.includes(token), token));
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd ruoyi-ui
node tests/unit/preliminary-check-risk-people-binding.test.js
```
Expected:
- 若字段被改乱或误删则 `FAIL`
- [ ] **Step 3: Write minimal implementation**
实施要求:
1. 保持现有表格列不重排
2. 若接口返回空数组,表格自然展示 Element 空态
3. `riskLevelType` 不在组件内重新判断,直接使用后端返回值
4. `actionLabel` 缺失时仍回退为 `查看详情`
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
cd ruoyi-ui
node tests/unit/preliminary-check-risk-people-binding.test.js
```
Expected:
- `PASS`
- [ ] **Step 5: Commit**
```bash
git add ruoyi-ui/src/views/ccdiProject/components/detail/RiskPeopleSection.vue ruoyi-ui/tests/unit/preliminary-check-risk-people-binding.test.js
git commit -m "校准结果总览风险人员区字段映射"
```
### Task 4: 补充前端验证记录与实施记录
**Files:**
- Create: `docs/tests/records/2026-03-19-results-overview-risk-api-frontend-verification.md`
- Create: `docs/reports/implementation/2026-03-19-results-overview-risk-api-frontend-implementation.md`
- [ ] **Step 1: Write the failing record skeleton**
先创建验证记录模板:
```md
# 结果总览风险接口前端验证记录
## 验证范围
- API 封装
- 结果总览页面并发取数
- 风险人员区字段映射
```
- [ ] **Step 2: Run frontend verification commands**
Run:
```bash
cd ruoyi-ui
node tests/unit/project-overview-api.test.js
node tests/unit/preliminary-check-api-integration.test.js
node tests/unit/preliminary-check-risk-people-binding.test.js
npm run build:prod
```
Expected:
- 3 个脚本 `PASS`
- 构建成功
- [ ] **Step 3: Write minimal implementation record**
实施记录至少包含:
- 新增结果总览 API 封装
- 页面接入 3 个真实接口
- 风险模型区和风险明细区本轮保持不变
- [ ] **Step 4: Re-run record review**
Run:
```bash
sed -n '1,220p' docs/tests/records/2026-03-19-results-overview-risk-api-frontend-verification.md
sed -n '1,220p' docs/reports/implementation/2026-03-19-results-overview-risk-api-frontend-implementation.md
```
Expected:
- 两份文档都能覆盖本次前端接入范围和验证方法
- [ ] **Step 5: Commit**
```bash
git add docs/tests/records/2026-03-19-results-overview-risk-api-frontend-verification.md docs/reports/implementation/2026-03-19-results-overview-risk-api-frontend-implementation.md
git commit -m "补充结果总览风险接口前端记录"
```

View File

@@ -0,0 +1,29 @@
# 流水明细异常标签展示设计记录
## 变更概述
- 新增“流水明细异常标签展示”设计文档。
- 明确列表只展示当前流水直接命中的异常标签名称。
- 明确详情展示标签名称、风险等级和命中原因摘要。
- 明确导出文件追加“异常标签”“命中原因摘要”两列。
- 明确本次只使用流水级标签结果,不混入对象级标签。
## 新增文件
- `docs/design/2026-03-18-bank-statement-hit-tags-design.md`
## 设计结论
- 后端采用“现有流水查询 + 服务层批量补标签”的方式实现。
- 列表、详情、导出统一读取 `ccdi_bank_statement_tag_result``result_type = 'STATEMENT'` 的结果。
- 页面不新增异常标签筛选、排序和统计能力。
- 导出失败时不允许静默丢失标签列内容。
## 过程修正
- 设计文档已从通用技能默认目录纠正到仓库规范目录 `docs/design/`
- 后续同类设计文档一律优先以仓库 `AGENTS.md` 与现有目录结构为准,不再使用通用默认 spec 路径。
## 后续动作
- 待用户审阅 spec 后,进入前后端实施计划阶段。

View File

@@ -0,0 +1,17 @@
# 银行流水打标批量入库日志收敛实施记录
## 本次改动
- 在 [`ruoyi-admin/src/main/resources/application.yml`](/Users/wkc/Desktop/ccdi/ccdi/ruoyi-admin/src/main/resources/application.yml) 新增 `com.ruoyi.ccdi.project.mapper.CcdiBankTagResultMapper.insertBatch: info` 日志级别配置。
- 仅收敛银行流水打标结果批量入库 SQL 明细日志,不调整 [`CcdiBankTagServiceImpl`](/Users/wkc/Desktop/ccdi/ccdi/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImpl.java) 中的业务摘要日志。
## 变更原因
- 当前全局 `com.ruoyi` 日志级别为 `debug`MyBatis 会输出 Mapper SQL 明细。
- 银行流水打标结果在 [`CcdiBankTagServiceImpl`](/Users/wkc/Desktop/ccdi/ccdi/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImpl.java) 中通过 `resultMapper.insertBatch(allResults)` 批量写入,容易在日志中打印整段批量插入 SQL。
- 按既有设计文档约束,流水打标链路应保留任务级摘要日志,但不打印 SQL 明细。
## 实施结果
- 银行流水打标批量插入数据库时,不再输出 `CcdiBankTagResultMapper.insertBatch` 的 SQL 明细日志。
- 业务侧仍保留“批量写入标签结果”“任务执行成功/失败”等可观测摘要日志,便于排障与审计。

View File

@@ -0,0 +1,40 @@
# 银行流水占位规则后端实施记录
## 变更概述
-`assets/模型信息.xlsx` 补齐银行流水打标缺失的 25 条规则初始化数据。
- 为新增规则补齐 `CcdiBankTagAnalysisMapper` 方法声明与 `CcdiBankTagAnalysisMapper.xml` 占位 SQL。
-`CcdiBankTagServiceImpl` 中补齐新增规则分发,保证空结果规则也能正常执行完成。
- 为占位规则补充 XML、Service、参数解析回归测试。
## 涉及文件
- `sql/2026-03-16-bank-tagging.sql`
- `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/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImpl.java`
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapperXmlTest.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/BankTagRuleConfigResolverTest.java`
## 实施结果
- 初始化 SQL 中规则总数补齐为 33 条,覆盖 10 个模型组。
- 数字占位 `rule_code` 已按业务语义改为可读编码,例如 `ABNORMAL_CUSTOMER_TRANSACTION``SUPPLIER_CONCENTRATION``SALARY_QUICK_TRANSFER`
- 规则元数据中的 `rule_code``indicator_code` 已统一为全大写;默认参数脚本中的相关 `param_code` 也已同步切换为全大写,并同步调整了解析映射。
- 新增 25 个占位 Mapper 方法与对应 XML `select`,全部使用独立 `where 1 = 0` 空结果 SQL。
- Service 已按 `rule_code` 分发到新增占位方法,空结果不会触发批量写入,任务状态可正常收敛为 `SUCCESS`
- 参数解析器现有逻辑已可安全处理无参数映射的占位规则,因此本次未修改 `BankTagRuleConfigResolver.java`
## 验证记录
- `mvn test -pl ccdi-project -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=CcdiBankTagAnalysisMapperXmlTest`
- `mvn test -pl ccdi-project -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=CcdiBankTagServiceImplTest`
- `mvn test -pl ccdi-project -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=BankTagRuleConfigResolverTest`
- `mvn test -pl ccdi-project -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=CcdiBankTagAnalysisMapperXmlTest,BankTagRuleConfigResolverTest,CcdiBankTagServiceImplTest`
- `mvn -pl ccdi-project -am -DskipTests compile`
## 注意事项
- 计划中的 Maven 单测命令在当前多模块 Reactor 下需要补 `-Dsurefire.failIfNoSpecifiedTests=false`,否则上游无匹配测试的模块会提前失败。
- `assets/模型信息.xlsx` 中“工资无使用记录”对应业务口径原始单元格存在乱码,本次按源数据原样落库,后续若拿到修正版设计数据建议同步修正。

View File

@@ -0,0 +1,34 @@
# 银行流水打标编码大写与数据库同步实施记录
## 变更概述
- 在 [`AGENTS.md`](/Users/wkc/Desktop/ccdi/ccdi/AGENTS.md) 补充银行流水打标相关 `rule_code``indicator_code``param_code` 必须统一全大写的协作约束。
- 将默认参数脚本中遗漏的 `SUSPICIOUS_GAMBLING` 参数编码改为全大写:`MULTI_PARTY_AMT_MIN``MULTI_PARTY_AMT_MAX`
- 新增可重复执行的增量脚本 [`sql/migration/2026-03-18-sync-bank-tag-uppercase-and-rules.sql`](/Users/wkc/Desktop/ccdi/ccdi/sql/migration/2026-03-18-sync-bank-tag-uppercase-and-rules.sql),用于同步规则表、指标编码和参数编码。
## 涉及文件
- [`AGENTS.md`](/Users/wkc/Desktop/ccdi/ccdi/AGENTS.md)
- [`sql/ccdi_model_param.sql`](/Users/wkc/Desktop/ccdi/ccdi/sql/ccdi_model_param.sql)
- [`sql/2026-03-16-update-ccdi-model-param-defaults.sql`](/Users/wkc/Desktop/ccdi/ccdi/sql/2026-03-16-update-ccdi-model-param-defaults.sql)
- [`sql/migration/2026-03-18-sync-bank-tag-uppercase-and-rules.sql`](/Users/wkc/Desktop/ccdi/ccdi/sql/migration/2026-03-18-sync-bank-tag-uppercase-and-rules.sql)
- [`ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiModelParamSqlDefaultsTest.java`](/Users/wkc/Desktop/ccdi/ccdi/ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiModelParamSqlDefaultsTest.java)
- [`ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiModelParamServiceImplTest.java`](/Users/wkc/Desktop/ccdi/ccdi/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiModelParamServiceImplTest.java)
## 实施结果
- 默认参数 SQL 中银行流水打标相关 `param_code` 已全部统一为全大写,不再保留 `annual_turnover``stock_tfr_large``withdraw_cnt``withdraw_amt``multi_party_amt_min``multi_party_amt_max` 等小写残留。
- 增量脚本已执行,规则表已补齐到 33 条,数据库中的 `rule_code``indicator_code` 均为全大写或 `NULL`
- 参数表中 `LARGE_TRANSACTION``ABNORMAL_BEHAVIOR``SUSPICIOUS_GAMBLING` 模型的默认参数编码已同步为全大写。
## 执行与验证
- 执行脚本:
- `bin/mysql_utf8_exec.sh sql/migration/2026-03-18-sync-bank-tag-uppercase-and-rules.sql`
- 数据库核验:
- `ccdi_bank_tag_rule` 共 33 条规则,`rule_code`/`indicator_code` 全部满足全大写约束。
- `ccdi_model_param` 中相关参数编码核验结果为 `ANNUAL_TURNOVER``STOCK_TFR_LARGE``WITHDRAW_CNT``WITHDRAW_AMT``MULTI_PARTY_AMT_MIN``MULTI_PARTY_AMT_MAX`
## 备注
- 初始化脚本 [`sql/2026-03-16-bank-tagging.sql`](/Users/wkc/Desktop/ccdi/ccdi/sql/2026-03-16-bank-tagging.sql) 因唯一键限制不适合直接重复执行,本次通过增量迁移脚本完成数据库同步,避免重复插入失败。

View File

@@ -0,0 +1,21 @@
# LSFX Mock LogId 主体账号绑定设计记录
## 变更概述
- 新增一份关于 `logId` 主体账号绑定的设计文档。
- 明确上传文件与拉取本行信息两条链路都要生成并保存主体账号绑定。
- 明确银行流水查询必须复用 `FileRecord` 中的绑定值,而不是自行随机 `leName/accountMaskNo`
## 新增文件
- `docs/superpowers/specs/2026-03-18-lsfx-logid-primary-binding-design.md`
## 设计结论
- 一个 `logId` 只绑定一个本方主体和一个本方账号。
- `FileService` 负责生成和维护绑定。
- `StatementService` 负责按 `logId` 读取绑定并写入流水明细。
## 后续动作
- 待用户审阅 spec 后,进入实施计划阶段。

View File

@@ -0,0 +1,49 @@
# LSFX Mock LogId 主体账号绑定实施记录
## 变更概述
-`lsfx-mock-server/services/file_service.py``FileRecord` 增加 `primary_enterprise_name``primary_account_no`,统一维护 `logId -> 主体/账号` 单一主绑定。
- 上传文件、拉取本行信息两条链路都改为创建并保存完整 `FileRecord`,后续上传状态查询优先读取真实记录,未命中时才回退到 deterministic 生成。
- `lsfx-mock-server/services/statement_service.py` 改为按 `logId``FileService` 读取主绑定,并将 `leName``accountMaskNo` 统一注入分页流水结果。
- `lsfx-mock-server/routers/api.py` 改为让 `statement_service``file_service` 共享同一份 `FileService` 实例,确保上传状态接口与查流水接口使用同一组绑定数据。
- 补齐 `tests/test_file_service.py``tests/test_statement_service.py``tests/test_api.py``tests/integration/test_full_workflow.py` 回归测试,并在 `tests/conftest.py` 增加单例状态重置夹具,避免测试串扰。
## 联动结果
- 上传文件:创建 `FileRecord` 时同时生成主绑定,并同步回填 `accountsOfLog``uploadLogList`
- 拉取本行信息:返回 `logId` 前即落库 `FileRecord`,后续 `bs/upload``getpendings``getBSByLogId` 都可复用同一主绑定。
- 上传状态:优先读取真实 `FileRecord`;只有当 `logId` 没有已存记录时,才走 deterministic fallback。
- 银行流水:优先从 `FileService` 读取 `primary_enterprise_name``primary_account_no`,统一写入每条流水的 `leName``accountMaskNo`
## 优先级说明
- `FileService.get_upload_status()`:真实 `FileRecord` 优先deterministic fallback 兜底。
- `StatementService` 主绑定解析:真实 `FileRecord` 优先,服务内 fallback 仅在记录不存在时生效。
- deterministic fallback 的时间字段、主体字段、账号字段都已改为基于 `logId` 的稳定生成,保证同一 `logId` 重复查询结果一致。
## 提交记录
- `0120d09` `收敛Mock文件记录主体账号绑定模型`
- `6fb7287` `让拉取本行信息链路复用Mock主体账号绑定`
- `0a85c09` `统一Mock上传状态主体账号绑定优先级`
- `5195617` `让Mock流水查询复用logId主体账号绑定`
## 验证记录
- `cd lsfx-mock-server && python3 -m pytest tests/test_file_service.py -v`
- `cd lsfx-mock-server && python3 -m pytest tests/test_statement_service.py -v`
- `cd lsfx-mock-server && python3 -m pytest tests/test_api.py -v`
- `cd lsfx-mock-server && python3 -m pytest tests/integration/test_full_workflow.py -v`
- `cd lsfx-mock-server && python3 verify_implementation.py`
## 验证结果
- `tests/test_file_service.py`3 个用例全部通过。
- `tests/test_statement_service.py`1 个用例通过。
- `tests/test_api.py`14 个用例全部通过。
- `tests/integration/test_full_workflow.py`4 个用例全部通过。
- `verify_implementation.py`:接口字段完整性与模板文件校验全部通过。
## 进程说明
- 本次验证仅运行 pytest 与 `verify_implementation.py`,未启动 `python main.py``uvicorn`,无需额外停止服务进程。

View File

@@ -0,0 +1,17 @@
# LSFX Mock LogId 主体账号绑定实施计划产出记录
## 变更概述
- 基于已确认的主体账号绑定设计文档,新增后端实施计划与前端实施计划各一份。
- 后端计划聚焦 `FileService``StatementService` 围绕 `logId` 复用主体账号绑定。
- 前端计划聚焦现有项目详情页对同一 `logId` 主体账号一致性的联调验证,默认不做 Vue 代码改造。
## 新增文件
- `docs/plans/backend/2026-03-18-lsfx-logid-primary-binding-backend-implementation.md`
- `docs/plans/frontend/2026-03-18-lsfx-logid-primary-binding-frontend-implementation.md`
## 说明
- 计划已按仓库约定拆分为前后端两份。
- 后续按计划执行时,仍需继续补实施记录与测试记录。

View File

@@ -0,0 +1,21 @@
# LSFX Mock 大额交易样本设计记录
## 变更概述
- 新增大额交易 Mock 设计文档,明确 `lsfx-mock-server` 如何稳定生成可命中后端 8 条大额交易规则的流水样本。
- 设计中补充了一个关键前提Mock 必须复用后端可识别的员工/家属身份证号,否则仅靠金额和摘要无法命中后端规则。
## 新增文件
- `docs/superpowers/specs/2026-03-18-lsfx-large-transaction-mock-design.md`
## 设计要点
- 生成策略从“完全随机流水”调整为“规则命中样本 + 随机噪声流水”。
- 8 条规则拆为 5 组样本簇,复用样本以减少无意义的硬编码流水。
- 默认复用已有验证文档中的 4 个身份作为命中身份池。
- Mock 内置当前后端系统默认阈值,避免对主系统数据库产生耦合。
## 后续动作
- 待用户审阅 spec 后,进入实施计划阶段。

View File

@@ -0,0 +1,17 @@
# LSFX Mock 大额交易实施计划产出记录
## 变更概述
- 基于已确认的设计文档,新增后端实施计划与前端实施计划各一份。
- 后端计划聚焦 `lsfx-mock-server` 的样本生成、分页缓存、pytest 回归和实施记录。
- 前端计划聚焦现有项目详情页对增强后 Mock 流水的联调验证,默认不做 Vue 代码改动。
## 新增文件
- `docs/plans/backend/2026-03-18-lsfx-mock-large-transaction-backend-implementation.md`
- `docs/plans/frontend/2026-03-18-lsfx-mock-large-transaction-frontend-implementation.md`
## 说明
- 计划已按仓库约定拆为前后端两份。
- 后续若按计划执行并产生代码改动,需要继续补实施记录与测试记录。

View File

@@ -0,0 +1,47 @@
# Mock 流水 `cretNo` 身份池统一修复记录
## 本次调整
- 目标:让 `lsfx-mock-server` 生成的所有流水都只使用当前库可识别的身份证号。
- 范围:
- 命中样本流水
- 随机噪声流水
## 修改内容
### 1. 统一身份证池常量
- 文件:`lsfx-mock-server/services/statement_rule_samples.py`
- 新增 `IDENTITY_CARD_POOL`
- 当前统一使用以下 4 个身份证号:
- `330101198801010011`
- `330101199001010022`
- `330101198802020033`
- `330101199202020044`
### 2. 噪声流水不再使用旧固定证件号
- 文件:`lsfx-mock-server/services/statement_service.py`
- 原实现:随机噪声流水固定使用 `230902199012261247`
- 新实现:随机噪声流水改为从 `IDENTITY_CARD_POOL` 中稳定抽取
## 测试验证
- 新增断言:`tests/test_statement_service.py`
- 校验全量生成流水中的 `cretNo` 集合必须为可识别身份证子集
- 执行命令:
```bash
PYTHONPATH=. python3 -m pytest tests/test_statement_service.py -q
PYTHONPATH=. python3 -m pytest tests -q
```
- 结果:
- `8 passed`
- `32 passed`
## 结果
- 现在 Mock 返回的命中样本和随机噪声,均不会再出现旧的无效身份证号。
- 后端打标联调时,所有流水都能通过现库已有员工/家属身份池参与规则匹配。

View File

@@ -0,0 +1,32 @@
# 参数保存触发项目流水重打标前端实施记录
## 本次改动
- 复核前端参数页现有实现,确认已满足计划要求
- 补齐前端实施记录文档
- 更新前端验证记录中的最新构建结论
## 修改内容
- `ParamConfig.vue` 现状已满足以下要求:
- 点击“保存所有修改”前弹出“保存参数后将进行项目内流水重新打标,是否继续?”确认框
- 用户取消确认时直接返回,不提交保存
- 保存成功后提示“保存成功,已开始项目内流水重新打标”
- 保存成功后刷新当前参数,并通过 `refresh-project` 事件通知父页面刷新项目状态
- `detail.vue` 现状已接收 `refresh-project` 事件,并通过 `handleRefreshProject()` 重新加载项目详情
- 前端不新增单独的重打标 API 调用,仍只调用原有 `saveAllParams` 保存接口
- 本次补齐前端实施记录,并更新验证记录中的构建结果描述
## 测试与验证
```bash
cd ruoyi-ui
npm run build:prod
```
## 结果
- 前端生产构建通过
- 构建输出 `dist/`
- 构建过程中仅有既有包体积告警,无新增编译错误
- 本次未启动额外前端开发进程,无需清理测试进程

View File

@@ -0,0 +1,17 @@
# 参数保存触发项目流水重打标实施计划产出记录
## 变更概述
- 本次需求要求在项目详情“参数配置”页提交修改时先弹出提醒,确认后保存参数,并由后端自动异步执行项目内流水重新打标。
- 方案采用“前端确认 + 后端保存成功后自动触发异步重打标”,不新增前端直调重打标接口。
## 新增文件
- `docs/plans/backend/2026-03-18-model-param-save-trigger-rebuild-backend-implementation.md`
- `docs/plans/frontend/2026-03-18-model-param-save-trigger-rebuild-frontend-implementation.md`
## 说明
- 后端计划聚焦参数保存成功后的自动重打标触发、触发类型扩展和单元测试补齐。
- 前端计划聚焦参数提交确认弹窗、保存成功提示和项目详情刷新。
- 后续实施完成后,需继续补充测试记录与实施结果记录。

View File

@@ -0,0 +1,28 @@
# 参数保存触发项目流水重打标后端实施记录
## 本次改动
- 补齐项目级单模型参数保存成功后的自动重打标触发
- 保持批量参数保存与单模型保存使用一致的触发语义
- 增加触发类型透传测试与后端验证记录
## 修改内容
-`CcdiModelParamServiceImpl.saveParams()` 中统计实际更新条数,仅在 `projectId > 0` 且存在实际更新时触发 `submitAutoRebuild`
- 抽取 `submitAutoRebuildIfNeeded` 私有方法,统一 `saveParams``saveAllParams` 的触发条件和日志
-`CcdiModelParamServiceImplTest` 中新增:
- 项目级单模型保存成功后触发自动重打标
- 无实际更新时不触发自动重打标
-`CcdiBankTagServiceImplTest` 中新增 `AUTO_PARAM_CHANGE` 触发类型透传校验
- 更新后端验证记录,覆盖单模型保存、批量保存、默认参数与未更新场景
## 测试与验证
```bash
mvn -pl ccdi-project -Dtest=CcdiModelParamServiceImplTest,CcdiBankTagServiceImplTest test
```
## 结果
- 后端相关聚焦单元测试全部通过
- 本次验证未启动额外前后端运行进程,无需清理测试进程

View File

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

View File

@@ -0,0 +1,22 @@
# 项目银行流水打标状态联动设计记录
## 变更概述
- 新增“项目银行流水打标状态联动”设计文档。
- 明确项目状态扩展为 `0-进行中``1-已完成``2-已归档``3-打标中`
- 明确所有银行流水打标入口都要统一驱动项目状态切换。
- 明确打标中期间,上传数据页与参数模型页都需要受限,同时后端接口必须做兜底拦截。
## 新增文件
- `docs/design/2026-03-18-project-bank-tag-status-lock-design.md`
## 设计结论
- 打标开始前后由后端统一切换项目状态,成功置为 `已完成`,失败回退为 `进行中`
- `打标中` 作为项目正式状态参与列表展示、详情展示与状态统计。
- 前端禁用只作为体验控制,文件上传、拉取本行信息、参数保存仍要由后端按项目状态拒绝。
## 后续动作
- 待用户审阅 spec 后,进入前后端实施计划阶段。

View File

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

View File

@@ -0,0 +1,18 @@
# 项目银行流水打标状态联动实施计划产出记录
## 变更概述
- 基于已确认的“项目银行流水打标状态联动设计”文档,新增后端实施计划与前端实施计划各一份。
- 后端计划聚焦状态 `3-打标中` 的 SQL、项目状态流转主链路、以及打标期间的服务端拦截。
- 前端计划聚焦列表/详情/统计的状态展示、上传数据页禁用、参数模型页只读,以及详情页状态刷新联动。
## 新增文件
- `docs/plans/backend/2026-03-18-project-bank-tag-status-lock-backend-implementation.md`
- `docs/plans/frontend/2026-03-18-project-bank-tag-status-lock-frontend-implementation.md`
## 说明
- 两份计划均按仓库规范拆分到 `docs/plans/backend/``docs/plans/frontend/`
- 前端计划已明确说明当前仓库缺少现成 Vue 单测基建,因此以构建验证与联调记录替代自动化单测。
- 后续按计划实施时,仍需继续补测试记录和实施报告。

View File

@@ -0,0 +1,74 @@
# project_id=42 大额交易无命中修复记录
## 问题现象
- `project_id=42` 的银行流水共 4133 条。
- 项目配置为 `config_type=default`,当前大额交易默认参数已降到:
- `SINGLE_TRANSACTION_AMOUNT=100`
- `CUMULATIVE_TRANSACTION_AMOUNT=1000`
- `ANNUAL_TURNOVER=5000`
- `LARGE_CASH_DEPOSIT=5000`
- `FREQUENT_CASH_DEPOSIT=2`
- `FREQUENT_TRANSFER=100`
- 但自动打标任务 `id=11/12/13` 均为 `SUCCESS``hit_count=0`
## 根因定位
### 1. 项目 42 现有流水使用了库中不存在的身份证号
- `ccdi_bank_statement``project_id=42` 的全部 4133 条流水都使用同一个 `cret_no=230902199012261247`
- 该证件号在 `ccdi_base_staff.id_card``ccdi_staff_fmy_relation.relation_cert_no` 中均不存在。
- 当前大额交易模型多条 SQL 都依赖:
- `staff.id_card = bs.cret_no`
-`relation.relation_cert_no = bs.cret_no`
- 因此即使金额超过阈值,也会在身份关联层被整体过滤掉。
### 2. Mock 流水服务与既有设计不一致
- `lsfx-mock-server/services/statement_service.py` 原先仍以随机流水为主,并固定输出不存在于当前库的 `cretNo`
- 仓库内已有大额交易样本设计与对应测试口径,但运行链路没有稳定复用可识别身份池。
## 本次处理
### 代码修复
- 校准 `lsfx-mock-server` 的大额交易样本生成与 `StatementService` 集成。
- 保持主体名称 / 本方账号绑定逻辑不变,继续复用同一 `logId` 的主绑定。
- 补充并校准 `lsfx-mock-server/tests/test_statement_service.py` 断言,使其与现有样本口径一致。
### 数据修正
-`project_id=42` 现有流水中的旧证件号批量修正为当前库真实存在的员工身份证:
- `230902199012261247 -> 330101198802020033`
- 影响行数:`4133`
### 触发重算
- 通过后端接口 `/ccdi/project/tags/rebuild` 手动提交重算。
- 最新任务:
- `id=14`
- `trigger_type=MANUAL`
- `status=SUCCESS`
- `hit_count=2559`
## 验证结果
### Mock 测试
- 命令:
- `PYTHONPATH=. python3 -m pytest tests -q`
- 结果:
- `31 passed`
### 项目 42 命中结果
- `SINGLE_LARGE_INCOME`2029
- `LARGE_TRANSFER`521
- `ANNUAL_TURNOVER`1
- `CUMULATIVE_INCOME`1
## 结论
- 本次 `project_id=42` 无命中的直接原因,是流水中的 `cret_no` 无法关联到员工/家属身份。
- 修正为现库存在的身份证后,按当前默认系统参数可立即命中规则。
- Mock 流水层也已同步校准,后续新生成数据不再继续复用这组无效证件号。

View File

@@ -0,0 +1,26 @@
# 项目状态变更日志实施记录
## 变更背景
根据需求,为项目状态发生变更的所有后端入口补充日志,便于排查项目生命周期中的状态切换过程。
## 实施内容
1.`CcdiProjectServiceImpl` 中新增统一项目状态日志能力。
2. 项目创建成功后,记录项目初始状态日志,覆盖“默认进入进行中”场景。
3.`updateProjectStatus` 中记录项目状态变更日志输出项目ID、项目名称、变更前状态、变更后状态、操作人。
4. 当状态未发生变化时,不重复输出“项目状态变更”日志,避免无效噪音。
5.`CcdiProjectServiceImplTest` 中补充日志相关单测,覆盖创建、状态变化、状态不变化三种场景。
## 涉及文件
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectServiceImpl.java`
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectServiceImplTest.java`
- `docs/plans/backend/2026-03-18-项目状态变更日志实施计划.md`
## 验证情况
- `mvn -pl ccdi-project -Dtest=CcdiProjectServiceImplTest test`
- 结果:通过
- `mvn -pl ccdi-project -Dtest=CcdiProjectServiceImplTest,CcdiBankTagServiceImplTest test`
- 结果:通过

View File

@@ -0,0 +1,26 @@
# 流水明细异常标签后端实施记录
## 修改内容
- 流水列表/详情返回异常标签
- 导出流水返回异常标签列
- 流水标签结果批量查询能力
- 后端单元测试补充
## 实施说明
- 新增 `CcdiBankStatementHitTagVO`,统一承载流水命中的异常标签编码、名称、风险等级和命中原因。
-`CcdiBankStatementListVO``CcdiBankStatementDetailVO` 中新增 `hitTags` 字段,作为流水列表页和详情弹窗的统一返回结构。
-`CcdiBankStatementExcel` 中新增“异常标签”导出列,导出时将命中的标签名称按顺序拼接为文本。
-`CcdiBankTagResultMapper` 中新增按 `projectId + bankStatementIds` 批量查询命中标签的方法,并在 XML 中补充对应 SQL。
-`CcdiBankStatementServiceImpl` 中补充标签挂载逻辑:
- 列表查询完成后,按当前页流水 ID 批量回查标签并分组挂载到每条记录。
- 详情查询完成后,按当前流水 ID 回查标签并挂载到详情对象。
- 导出查询完成后,复用同一套标签回查逻辑,将命中标签名称写入 Excel 导出对象。
- 本次未改动流水标签判定规则,只修复“标签结果已入库但接口未返回”的数据链路缺口。
## 问题定位结论
- 数据库中 `project_id = 43`、摘要为 `ATM现金存款` 的流水记录已存在 `LARGE_CASH_DEPOSIT` 等命中结果。
- 原因是 `ccdi_bank_statement` 列表接口与详情接口此前未将 `ccdi_bank_statement_tag_result` 的命中标签组装到返回 VO 中,导致前端无法展示。
## 验证执行
- 执行 `mvn -pl ccdi-project test -Dtest=CcdiBankTagResultMapperXmlTest,CcdiBankStatementHitTagsContractTest,CcdiBankStatementServiceImplTest`,结果通过。
- 执行 `mvn -pl ccdi-project test -Dtest=CcdiBankStatementHitTagsContractTest,CcdiBankStatementServiceImplTest,CcdiBankStatementControllerTest`,结果通过。

View File

@@ -0,0 +1,22 @@
# 流水明细异常标签前端实施记录
## 修改内容
- 列表异常标签列
- 详情异常标签模块
- 静态测试与构建验证
## 实施说明
-`DetailQuery.vue` 的列表表格中新增“异常标签”列,仅展示 `hitTags` 中的标签名称,并根据风险等级映射标签颜色。
- 在详情弹窗中新增“命中异常标签”模块,展示标签名称、风险等级中文文案和命中原因摘要。
- 列表接口返回值与详情接口返回值统一对 `hitTags` 做数组归一化,避免后端返回 `null` 时影响空态展示。
- 为空标签场景补充统一空态文案,保持列表和详情展示一致。
## 验证执行
- 执行 `node tests/unit/detail-query-filter-layout.test.js`,结果通过。
- 执行 `node tests/unit/detail-query-detail-dialog.test.js`,结果通过。
- 执行 `node tests/unit/detail-query-hit-tags-list.test.js`,结果通过。
- 执行 `npm run build:prod`,结果通过,存在项目既有体积告警,但无新增编译错误。
## 接口说明
- 本次未修改 `ruoyi-ui/src/api/ccdiProjectBankStatement.js`
- 原因:继续复用现有列表与详情接口,仅消费后端扩展后的响应数据结构,不新增前端请求入口。

View File

@@ -0,0 +1,23 @@
# 流水明细异常标签实施计划记录
## 变更概述
- 基于已确认设计文档,新增后端实施计划与前端实施计划各一份。
- 后端计划聚焦标签结果只读查询、列表详情组装、导出列扩展与后端验证。
- 前端计划聚焦列表异常标签列、详情异常标签模块、静态测试与构建验证。
- 两份计划都延续现有仓库规范,未使用通用技能默认目录。
## 新增文件
- `docs/plans/backend/2026-03-19-bank-statement-hit-tags-backend-implementation.md`
- `docs/plans/frontend/2026-03-19-bank-statement-hit-tags-frontend-implementation.md`
## 计划结论
- 后端按“现有流水查询 + 服务层批量补标签”实施。
- 前端继续只改 `DetailQuery.vue`,不新增页面、不改接口路径。
- 实施阶段分别补充前后端实施记录与验证记录。
## 后续动作
- 待用户确认后,进入实际开发执行阶段。

View File

@@ -0,0 +1,19 @@
# 结果总览前端实施记录
## 实施内容
-`PreliminaryCheck.vue` 从占位页升级为结果总览页面入口
- 新增顶部总览、风险人员、风险模型、风险明细 4 个区块组件
- 新增本地 mock 数据文件,覆盖主展示态、空数据态、加载态
- 新增静态断言脚本,锁定页面分块结构和页面状态
- 按最新页面反馈将卡片改为直角、移除内容区浅灰背景,并收紧结果总览标题与统计卡之间的间距
- 对齐结果总览内容区与其他页签的顶部间距,去掉额外的 16px 顶部留白
- 按最新页面反馈为风险总览统计卡补阴影,并将“风险交易”指标文案调整为“无风险人员”
- 按最新页面反馈将页面主标题“风险总览”调整为“风险仪表盘”
- 按最新页面反馈移除风险仪表盘卡片内的“批量导出”和“切换视图”按钮
- 按最新页面反馈将风险仪表盘中的“风险人数”指标文案调整为“高风险”
## 验证情况
- 新增 4 个静态断言脚本,分别覆盖页面骨架、前两块区块、后两块区块和三种页面状态
- 所有静态断言脚本已通过
- `npm run build:prod` 已通过
- 修复了 3 个新组件中深度选择器写法与当前构建链不兼容的问题

View File

@@ -0,0 +1,35 @@
# 结果总览风险接口计划记录
## 变更概述
- 新增结果总览风险接口设计文档 1 份。
- 新增结果总览风险接口后端实施计划 1 份。
- 新增结果总览风险接口前端实施计划 1 份。
- 本次仅完成设计与计划沉淀,尚未进入代码实现阶段。
## 新增文件
- `docs/design/2026-03-19-results-overview-risk-api-design.md`
- `docs/plans/backend/2026-03-19-results-overview-risk-api-backend-implementation.md`
- `docs/plans/frontend/2026-03-19-results-overview-risk-api-frontend-implementation.md`
## 设计结论
- 结果总览采用 3 个独立接口:风险仪表盘、风险人员总览、中高风险人员 TOP10。
- 风险人员榜单统一按员工维度聚合,员工亲属命中归并到所属员工。
- 员工风险等级按命中去重规则数分级:
- 5 条及以上:高风险
- 2 到 4 条:中风险
- 1 条:低风险
- 项目表高、中、低风险人数在项目流水标签打标成功后统一回写。
## 计划结论
- 后端计划聚焦结果总览 Controller、Service、Mapper、聚合 SQL 与打标后人数回写。
- 前端计划聚焦 3 个结果总览风险接口接入,不扩展风险模型区和风险明细区。
- 后续实施阶段需分别补充前后端验证记录与实施记录。
## 说明
- 本次按仓库规范将设计文档落在 `docs/design/`,将实施计划分别落在 `docs/plans/backend/``docs/plans/frontend/`
- 由于本轮用户明确要求直接完成设计与计划,不再分段等待确认,故本记录视为本次设计收口材料。

View File

@@ -0,0 +1,277 @@
# LSFX Mock 大额交易命中样本设计
## 背景
当前 `lsfx-mock-server` 的银行流水接口会返回完全随机的流水数据,但后端银行流水打标中的大额交易模型依赖摘要关键词、收支方向、金额阈值、交易日期,以及 `cretNo` 与员工/家属身份关系的匹配。仅有随机金额和随机摘要不足以稳定命中后端规则,导致联调时即使接口返回成功,后端打标结果仍可能为空。
本设计用于调整 Mock 流水生成策略,使其在保留随机噪声数据的同时,稳定产出可命中后端大额交易 8 条规则的样本流水。
## 目标
- 更新 `lsfx-mock-server` 的银行流水生成逻辑。
- 每个 `logId` 首次生成流水时,稳定包含可命中大额交易 8 条规则的样本。
- 在命中样本之外继续混入普通随机流水,保持 Mock 数据的随机感和分页场景。
- 不修改后端打标实现,以后端现有 SQL 口径作为唯一准绳。
## 范围
### In Scope
- `lsfx-mock-server` 获取银行流水接口的生成逻辑。
- 大额交易 8 条规则所需的命中样本模板。
- 对应的 Mock 单元测试与接口测试。
- 本次改动的实施记录文档。
### Out of Scope
- 后端 `CcdiBankTagAnalysisMapper.xml` 或打标 Service 逻辑调整。
- 其他模型组规则的 Mock 命中样本。
- Mock 服务接入主系统数据库或动态读取后端参数。
## 后端规则现状
后端当前已实际实现并可命中的大额交易规则为:
1. `HOUSE_OR_CAR_EXPENSE`
2. `TAX_EXPENSE`
3. `SINGLE_LARGE_INCOME`
4. `CUMULATIVE_INCOME`
5. `ANNUAL_TURNOVER`
6. `LARGE_CASH_DEPOSIT`
7. `FREQUENT_CASH_DEPOSIT`
8. `LARGE_TRANSFER`
这些规则的关键命中条件总结如下:
- 房车消费、税务支出:依赖 `userMemo``customerName` 的关键词命中,且 `drAmount > 0`
- 单笔大额收入、累计收入、年流水、大额转账:依赖员工身份、对手方名称、收支方向、阈值及排除条件。
- 大额存现、单日多次存现:依赖 `cashType``userMemo``customerName``crAmount` 的联合条件。
- 多条规则要求 `cretNo` 能在后端库中关联到员工或家属,否则规则不会命中。
## 关键前提
Mock 必须复用一组后端环境中真实可识别的身份证号,否则仅靠金额和摘要无法满足后端 `exists staff/relation` 条件。
本次默认复用现有文档中已用于大额交易验证的身份池:
- 员工:`330101198801010011`
- 家属:`330101199001010022`
- 员工:`330101198802020033`
- 家属:`330101199202020044`
这些身份已在既有大额交易验证文档与种子 SQL 中使用,可作为 Mock 默认命中身份池。若后续目标环境不存在这些身份,需要在样本配置中同步替换。
## 设计概览
将现有“逐条完全随机生成”的策略调整为“两段式生成”:
1. 先生成固定数量的规则命中样本。
2. 再补充普通随机噪声流水。
3. 合并后统一排序/洗牌,写入当前 `logId` 缓存。
4. 分页接口继续基于缓存返回,保证同一 `logId` 多次请求结果一致。
## 组件设计
### 1. 普通随机流水生成器
保留现有随机生成能力,继续负责生成非命中噪声数据。其职责仅限于提供分页场景和背景数据,不承担命中保障。
### 2. 大额交易样本生成器
新增专用样本生成层,负责生成 8 条规则所需的流水组合。建议拆为独立模块,例如:
- `services/statement_rule_samples.py`
该模块维护:
- 默认身份池
- 默认阈值常量
- 每条规则的样本构造函数
- 公共流水字段构造辅助函数
### 3. 汇总与缓存层
`statement_service.py` 负责:
- 调用大额交易样本生成器获取命中样本
- 生成指定数量的噪声流水
- 为所有记录补齐唯一 `bankStatementId``bankTrxNumber``uploadSequnceNumber`
- 合并、打乱顺序并缓存
- 按页返回 `bankStatementList`
## 样本组织方案
为避免只为单一规则生成孤立流水8 条规则拆为 5 组样本簇:
### A. 房车消费支出样本
- 1 至 2 条支出流水
- `drAmount > 0`
- `userMemo``购买房产首付款``购车首付款` 等关键词
-`customerName` 命中 `房地产``贝壳``汽车销售``4S店` 等关键词
- `cretNo` 使用员工或家属身份证
### B. 税务支出样本
- 1 至 2 条支出流水
- `drAmount > 0`
- `userMemo``customerName` 命中 `税务``税款``税务局``国库`
- `cretNo` 使用员工或家属身份证
### C. 大额收入样本簇
使用同一员工身份证与同一非亲属对手方生成多条收入流水,复用命中:
- `SINGLE_LARGE_INCOME`
- `CUMULATIVE_INCOME`
- `ANNUAL_TURNOVER`
约束如下:
- 至少 1 条 `crAmount` 大于 `SINGLE_TRANSACTION_AMOUNT`
- 多条累计 `crAmount` 大于 `CUMULATIVE_TRANSACTION_AMOUNT`
- 再叠加大额收入/支出,使近一年累计交易额大于 `ANNUAL_TURNOVER`
- `leName != customerName`
- `customerName` 不得映射为该员工家属名称
- 避开工资排除条件,不能使用 `浙江兰溪农村商业银行股份有限公司``userMemo` 也不得含 `代发``工资``奖金``薪酬` 等词
### D. 大额存现样本簇
使用同一身份证、同一天生成 6 条现金存入流水,复用命中:
- `LARGE_CASH_DEPOSIT`
- `FREQUENT_CASH_DEPOSIT`
约束如下:
- 每条 `crAmount` 大于 `LARGE_CASH_DEPOSIT`
- 同日条数大于 `FREQUENT_CASH_DEPOSIT`
- `cashType` / `userMemo` / `customerName` 的组合需满足后端 `cashDepositPredicate`
- 推荐使用 `现金存款``ATM现金存款``自助存款现金存入``CRS存款``本行ATM存款` 等摘要
### E. 大额转账支出样本
- 1 至 2 条支出流水
- `drAmount > FREQUENT_TRANSFER`
- `userMemo``手机银行转账``对外转账`
-`cashType` / `customerName` 含转账特征
- `userMemo` 不能包含 `款`,避免被后端排除
- `leName != customerName`
## 默认阈值策略
Mock 不接主系统数据库,因此不动态读取项目参数。为保证命中稳定,样本生成器内置与当前系统默认参数一致的阈值常量:
- `SINGLE_TRANSACTION_AMOUNT = 1111`
- `CUMULATIVE_TRANSACTION_AMOUNT = 50000001`
- `ANNUAL_TURNOVER = 50000001`
- `LARGE_CASH_DEPOSIT = 2000001`
- `FREQUENT_CASH_DEPOSIT = 5`
- `FREQUENT_TRANSFER = 100001`
样本金额应显著高于阈值,避免边界值因格式化或比较方式差异导致误判。
## 数据生成细节
### 字段稳定要求
以下字段不得继续完全随机:
- `cretNo`
- `leName`
- `customerName`
- `userMemo`
- `cashType`
- `trxDate`
- `crAmount`
- `drAmount`
它们需要按规则模板构造。
### 可以继续随机的字段
以下字段可保留随机生成或在模板基础上随机化:
- `accountMaskNo`
- `customerAccountMaskNo`
- `bankTrxNumber`
- `balanceAmount`
- `bank`
- `customerBank`
- `uploadSequnceNumber`
### 日期策略
- `ANNUAL_TURNOVER` 相关样本统一放在当前日期前 12 个月内。
- `FREQUENT_CASH_DEPOSIT` 相关样本统一放在同一天但不同时间点。
- 其他样本可分布在近一年内,避免全部集中到同一时刻。
### 总量策略
- 继续沿用当前 `1200-1500` 的总条数范围。
- 先生成全部命中样本,再以 `totalCount - sampleCount` 的差值补足噪声流水。
- 若后续命中样本数量增长超过随机总量下限,应自动以命中样本数作为最小总量,避免截断命中数据。
### 排序与分页
生成完成后统一打乱顺序,再根据请求分页返回。这样:
- 前端仍能看到类似真实的随机顺序
- 同一 `logId` 由于缓存存在,重复请求时顺序稳定
## 错误处理
- 若大额交易样本构造失败,直接抛出明确异常,不静默降级为纯随机流水。
- 这样能避免接口返回正常但后端永远打不中的隐性问题。
- 不新增复杂容错或回退机制,保持 Mock 行为可预测。
## 测试设计
至少补充两类验证:
### 1. 样本生成测试
验证大额交易样本生成器输出满足关键特征:
- 存在房车消费关键词支出
- 存在税务关键词支出
- 存在单笔超阈值收入
- 存在同一对手方累计超阈值收入
- 存在近一年累计交易额超阈值样本
- 存在同日 6 笔以上现金存入
- 存在单笔存现超阈值
- 存在单笔转账支出超阈值且摘要不含 `款`
### 2. 接口返回测试
调用 `getBSByLogId`,验证:
- 返回总数正常
- `bankStatementList` 中能找到各类命中样本特征
- 同一 `logId` 多次请求返回相同数据
- 分页拼接后的全量结果仍包含全部命中样本
## 成功标准
- Mock 每次首次生成流水时都稳定包含大额交易 8 条规则所需样本。
- 同一 `logId` 多次分页请求数据一致。
- 在默认身份池存在于目标环境的前提下,后端接入 Mock 返回数据后,现有大额交易 8 条规则可以被正确命中。
- 普通随机噪声流水仍然存在,不退化为完全固定数据集。
## 风险与约束
- 默认身份池依赖后端环境已有对应员工/家属数据;若环境不一致,需要替换身份池常量。
- Mock 使用内置阈值若后端系统默认参数未来变更Mock 也需要同步更新。
- 若后端 SQL 规则继续演进,本设计中的样本关键词和排除词也要同步校准。
## 实施输出
实施阶段应至少产出:
- Mock 代码改动
- 对应测试用例
- 一份前端实施计划
- 一份后端实施计划
- 一份实施记录文档
其中前后端实施计划用于说明联调影响面:本次主要改动在 Mock 服务,但后端计划中需要明确验证路径和命中校验方式。

View File

@@ -0,0 +1,258 @@
# LSFX Mock LogId 主体账号绑定设计
## 背景
当前 `lsfx-mock-server` 的上传文件接口已经会为文件记录生成 `enterpriseNameList``accountNoList`,但银行流水查询接口 `getBSByLogId` 仍然在 `StatementService` 内独立随机或写死 `leName``accountMaskNo`。这导致同一个 `logId` 在不同接口里看到的“本方主体/本方账号”并不统一。
新增需求要求:
- 每次上传流水文件时,随机生成银行流水的本方主体和本方账号。
- 一个本方主体对应一个本方账号。
- 一个 `logId` 对应一组固定的本方主体和本方账号。
- 同样的逻辑也覆盖“拉取本行信息”链路产生的 `logId`
## 目标
- 为每个新生成的 `logId` 绑定一组随机本方主体和本方账号。
- 上传文件、上传状态、银行流水查询三类接口对同一 `logId` 返回一致的主体账号信息。
- 保持现有接口出参结构兼容,不引入前端契约变更。
## 范围
### In Scope
- `lsfx-mock-server/services/file_service.py`
- `lsfx-mock-server/services/statement_service.py`
- 上传文件链路的 `logId` 绑定生成
- 拉取本行信息链路的 `logId` 绑定生成
- 对应测试和实施记录
### Out of Scope
- 前端页面或后端 Java 工程改造
- 一个 `logId` 下支持多个本方主体/账号
-`logId` 复用同一主体账号映射
## 设计原则
- 一个 `logId` = 一个本方主体 + 一个本方账号
- 同一个 `logId` 的所有流水记录都使用同一组 `leName/accountMaskNo`
- 外部响应兼容当前数组格式,但内部语义收敛为单值绑定
- 主体账号由 `FileService` 统一生成,`StatementService` 只消费,不再自行决定
## 现状问题
### 上传文件链路
`FileService.upload_file()` 在创建 `FileRecord` 时会随机生成:
- `account_no_list`
- `enterprise_name_list`
但它们只服务于上传接口和解析状态接口。
### 拉取本行信息链路
`FileService.fetch_inner_flow()` 目前仅返回随机 `logId`,不会创建 `FileRecord`,因此后续没有主体账号上下文可以复用。
### 流水查询链路
`StatementService._generate_random_statement()` 当前独立写死或随机:
- `accountMaskNo`
- `leName`
它不知道上传或拉取本行信息时已经生成了什么主体账号。
## 方案概览
将“本方主体/本方账号”的主数据收敛到 `FileService` 维护的 `FileRecord` 中,并让所有基于 `logId` 的接口围绕同一份绑定工作:
1. `FileService` 在生成新 `logId` 时,创建并保存一组主体账号绑定。
2. `upload_file()``fetch_inner_flow()` 都走这套绑定生成逻辑。
3. `StatementService` 根据 `logId` 读取绑定值,填入流水的 `leName/accountMaskNo`
4. 对外仍然返回:
- `enterpriseNameList: [name]`
- `accountNoList: [account]`
5. 删除记录时,若 `logId` 绑定被删除,后续兼容逻辑生成的新记录也必须保持接口间自洽。
## 数据模型设计
## FileRecord 扩展
建议在现有 `FileRecord` 上明确单值语义字段:
- `primary_enterprise_name: str`
- `primary_account_no: str`
同时保留兼容字段:
- `enterprise_name_list`
- `account_no_list`
约束为:
- `enterprise_name_list == [primary_enterprise_name]`
- `account_no_list == [primary_account_no]`
这样既不破坏当前响应格式,也让内部逻辑不再把主体和账号当成可变长集合。
## 主体账号生成策略
新增统一生成方法,例如:
- `_generate_primary_account_binding()`
职责:
- 随机选取一个本方主体名称
- 随机生成一个本方账号
- 返回单一绑定对象
建议主体名称从固定 Mock 名称池中随机选择,例如:
- `测试主体A`
- `测试主体B`
- `测试主体C`
- `兰溪测试主体一部`
- `兰溪测试主体二部`
账号生成规则保持简单:
- 生成合法长度的纯数字字符串
- 作为单个 `logId` 的专属账号使用
本次不要求“同一主体跨所有 `logId` 始终映射同一账号”,只要求:
- 对单个 `logId` 而言,主体与账号是一对一且稳定的
## 接口联动设计
### 1. 上传文件接口
`upload_file()` 在创建 `FileRecord` 时:
- 生成 `logId`
- 生成主体账号绑定
- 回填:
- `primary_enterprise_name`
- `primary_account_no`
- `enterprise_name_list`
- `account_no_list`
上传响应里的:
- `accountsOfLog[*].accountName`
- `accountsOfLog[*].accountNo`
- `uploadLogList[*].enterpriseNameList`
- `uploadLogList[*].accountNoList`
都来自这组绑定。
### 2. 拉取本行信息接口
`fetch_inner_flow()` 不能再只返回裸 `logId`,需要同时:
- 生成新 `logId`
- 创建 `FileRecord`
- 生成主体账号绑定并落入 `self.file_records`
这样同一个 `logId` 后续再查上传状态或银行流水时,能拿到一致的本方主体和账号。
### 3. 上传状态接口
`check_parse_status()``get_upload_status()` 对已存在的 `FileRecord`,统一返回其中的绑定数据。
`get_upload_status()` 走兼容分支按 `logId` 现场生成记录,生成出的主体账号也必须写成单一绑定,并在该次响应内部保持自洽。
### 4. 银行流水查询接口
`StatementService` 不再自己决定:
- `leName`
- `accountMaskNo`
而是通过 `logId` 获取对应绑定:
- `leName = primary_enterprise_name`
- `accountMaskNo = primary_account_no`
同一 `logId` 下生成的所有流水记录都使用这一组值。
## 组件边界
### FileService
负责:
- 生成 `logId`
- 生成并保存主体账号绑定
- 对上传/拉取本行信息/上传状态返回统一绑定值
### StatementService
负责:
- 读取 `logId` 对应主体账号绑定
- 将绑定值注入每条流水记录
- 不再自行随机本方主体或本方账号
为避免重复造一份状态,`StatementService` 需要拿到查询绑定的方法或共享 `file_records` 访问能力,但不负责写绑定。
## 错误处理
-`logId` 没有对应绑定记录,允许走兼容生成逻辑,但必须在当前响应范围内自洽。
- 不允许同一次 `getBSByLogId` 返回中出现多个本方主体或多个本方账号。
- 不允许上传响应和流水响应对同一 `logId` 返回不同主体账号。
## 测试设计
至少补 3 类测试:
### 1. 上传文件链路测试
验证上传接口返回中:
- `accountsOfLog``accountName/accountNo`
- `uploadLogList``enterpriseNameList/accountNoList`
三者一致,且数组长度为 `1`
### 2. 拉取本行信息链路测试
验证 `fetch_inner_flow()` 返回 `logId` 后:
- `FileService` 内已存在对应 `FileRecord`
- 该记录带有主体账号绑定
- 后续查询上传状态或银行流水时能看到相同绑定
### 3. 银行流水链路测试
验证同一 `logId` 下:
- 第一页和第二页流水里的 `leName/accountMaskNo` 一致
- 重复查询结果中的本方主体账号不漂移
- 流水里的 `leName/accountMaskNo``FileRecord` 中的绑定完全一致
## 成功标准
- 上传文件和拉取本行信息产生的新 `logId` 都会绑定一组随机本方主体与账号。
- 同一 `logId` 的上传响应、上传状态、银行流水查询三处返回一致的主体账号。
- 同一 `logId` 的所有流水记录只出现一个 `leName` 和一个 `accountMaskNo`
- 对外接口结构不变,前端无需因这次改动调整解析逻辑。
## 风险与约束
- 现有 `get_upload_status()` 存在按 `logId` 即时生成确定性记录的兼容路径,这部分要特别注意与 `file_records` 中真实记录的优先级。
- 若后续一个 `logId` 需要支持多个本方账号,本次设计需要重做,因为当前明确锁定为“一对一”。
- `StatementService` 新增对 `FileService` 绑定数据的依赖后,需要避免双向循环依赖,优先通过轻量查询函数或共享只读访问注入。
## 实施输出
实施阶段应至少产出:
- Mock 服务代码改动
- 对应 pytest 测试
- 一份后端实施计划
- 一份前端实施计划
- 一份实施记录文档

View File

@@ -0,0 +1,81 @@
# 银行流水打标功能测试记录
## 测试时间
- 2026-03-18
## 测试范围
- 后端规则分发与结果写入
- 手动重算接口
- 项目级重算协调逻辑
- 批量上传完成后的自动触发逻辑
- 项目 `40` 的真实重算结果校验
## 环境信息
- 本地后端服务:`http://127.0.0.1:62318`
- 测试项目:`project_id = 40`
- 项目名称:`大额交易模型测试`
- 数据库现状:
- `ccdi_bank_statement` 中项目 `40` 现有流水 `1289`
- `ccdi_bank_tag_rule` 启用规则 `33` 条,其中 `LARGE_TRANSACTION``8`
## 单元测试验证
- 执行命令:
- `mvn test -pl ccdi-project -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=CcdiBankTagAnalysisMapperXmlTest,CcdiBankTagResultMapperXmlTest,CcdiBankTagControllerTest,CcdiBankTagServiceImplTest,ProjectBankTagRebuildCoordinatorTest,BankTagRuleConfigResolverTest,CcdiBankTagEntityMappingTest,CcdiFileUploadServiceImplTest`
- 执行结果:
- `BUILD SUCCESS`
- 共执行 `45` 个用例,`0` 失败,`0` 错误,`0` 跳过
- 覆盖点:
- `CcdiBankTagAnalysisMapper.xml` 中 8 条大额交易规则与 25 条占位规则 SQL 存在性校验
- 标签结果删除与批量写入 XML 校验
- 手动重算接口参数透传与日志校验
- 打标任务创建、规则执行、失败日志、空结果规则收敛校验
- 运行中任务拒绝手动重算、自动重算补跑标记校验
- 上传完成触发自动重算校验
## 接口回归验证
- 登录接口:
- `POST /login/test`
- 结果:成功获取测试 token
- 手动重算接口:
- `POST /ccdi/project/tags/rebuild`
- 请求体:`{"projectId":40,"modelCode":"LARGE_TRANSACTION"}`
- 返回结果:`{"msg":"标签重算任务已提交","code":200}`
## 数据库校验
- 重算前:
- 最新任务 ID`8`
- `LARGE_TRANSACTION` 结果数:`26`
- 重算后:
- 新增任务 ID`9`
- 任务状态:`SUCCESS`
- `success_rule_count = 8`
- `failed_rule_count = 0`
- `hit_count = 26`
- 运行中任务数:`0`
## 命中结果分布
- `ANNUAL_TURNOVER``1`
- `CUMULATIVE_INCOME``1`
- `FREQUENT_CASH_DEPOSIT``1`
- `HOUSE_OR_CAR_EXPENSE``2`
- `LARGE_CASH_DEPOSIT``6`
- `LARGE_TRANSFER``3`
- `SINGLE_LARGE_INCOME``10`
- `TAX_EXPENSE``2`
## 结论
- 银行流水打标主链路测试通过。
- 手动重算接口可正常提交并完成 `LARGE_TRANSACTION` 模型重算。
- 项目 `40` 本次重算后结果总数仍为 `26`8 条大额交易规则命中分布与历史预期一致,未发现结果漂移。
## 环境清理
- 本次未新启动前后端测试进程,复用了已在运行的本地后端服务,因此无需额外清理进程。

View File

@@ -0,0 +1,29 @@
# 参数保存触发项目流水重打标后端验证记录
## 验证范围
- 项目级单模型参数保存成功后自动异步触发重打标
- 项目级批量参数保存成功后自动异步触发重打标
- 全局默认参数保存不触发项目重打标
- 参数未实际更新或保存失败时不触发重打标
- 自动触发来源透传为 `AUTO_PARAM_CHANGE`
## 验证命令
```bash
mvn -pl ccdi-project -Dtest=CcdiModelParamServiceImplTest,CcdiBankTagServiceImplTest test
```
## 验证结果
- 结果:通过
- `CcdiModelParamServiceImplTest` 通过 8 个用例
- `CcdiBankTagServiceImplTest` 通过 8 个用例
- 总计 16 个用例全部通过
## 关键结论
- `saveParams``saveAllParams` 在项目级参数实际更新成功后,都会调用 `submitAutoRebuild(projectId, TriggerType.AUTO_PARAM_CHANGE)`
- `projectId=0` 的全局默认参数保存不会触发项目级重打标
- `submitAutoRebuild` 会保持 `AUTO_PARAM_CHANGE` 触发类型透传到协调器
- 当参数未实际更新时,不会误触发自动重打标

View File

@@ -0,0 +1,27 @@
# 参数保存触发项目流水重打标前端验证记录
## 验证范围
- 参数页保存前出现确认弹窗
- 取消确认时不提交保存
- 确认后保存成功并提示已开始项目内流水重新打标
- 前端生产构建通过
## 验证命令
```bash
cd ruoyi-ui
npm run build:prod
```
## 验证结果
- 结果:通过
- 构建成功产出 `dist/`
- 构建过程中出现 2 条既有的包体积告警,无新增编译错误
## 关键结论
- `ParamConfig.vue` 已在提交前增加确认弹窗
- 前端仍只调用原有 `saveAllParams` 接口,不新增单独重打标请求
- 保存成功后会刷新参数并通知父页面刷新项目状态

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
# 流水明细异常标签后端验证记录
## 验证范围
- 流水列表返回 `hitTags`
- 流水详情返回 `hitTags`
- 导出流水返回异常标签列
- 标签结果 Mapper 查询能力
- 已入库标签结果的链路核对
## 数据核对
- 2026-03-19 查询 `ccdi_bank_statement``project_id = 43` 下存在摘要为 `ATM现金存款` 的流水记录 `bank_statement_id=51274``bank_statement_id=49342`
- 2026-03-19 查询 `ccdi_bank_statement_tag_result`:上述两条流水均存在 `LARGE_CASH_DEPOSIT``SINGLE_LARGE_INCOME` 命中结果。
## 自动验证
- 2026-03-19 执行 `mvn -pl ccdi-project test -Dtest=CcdiBankTagResultMapperXmlTest,CcdiBankStatementHitTagsContractTest,CcdiBankStatementServiceImplTest`,结果:通过
- 2026-03-19 执行 `mvn -pl ccdi-project test -Dtest=CcdiBankStatementHitTagsContractTest,CcdiBankStatementServiceImplTest,CcdiBankStatementControllerTest`,结果:通过
## 验证结果
- [x] 列表查询结果可挂载命中异常标签
- [x] 详情查询结果可挂载命中异常标签
- [x] 导出对象包含“异常标签”文本列
- [x] 标签结果 Mapper 支持按项目和流水 ID 批量回查
- [x] `ATM现金存款` 对应流水的标签数据已在库内存在
## 联调建议
- 前端刷新 `project_id=43` 的流水明细页面后,重点核对 `bank_statement_id=51274``bank_statement_id=49342` 是否显示“大额存现交易”等异常标签。
- 使用相同筛选条件执行“导出流水”,核对导出文件中的“异常标签”列是否包含“大额存现交易”等标签名称。

View File

@@ -0,0 +1,22 @@
# 流水明细异常标签前端验证记录
## 验证范围
- 列表异常标签列
- 详情异常标签模块
- 空态展示
- 导出入口回归
## 自动验证
- 2026-03-19 执行 `node tests/unit/detail-query-filter-layout.test.js`,结果:通过
- 2026-03-19 执行 `node tests/unit/detail-query-detail-dialog.test.js`,结果:通过
- 2026-03-19 执行 `node tests/unit/detail-query-hit-tags-list.test.js`,结果:通过
- 2026-03-19 执行 `npm run build:prod`,结果:通过
## 验证结果
- [x] 列表显示命中标签名称
- [x] 详情显示名称、风险等级、命中原因摘要
- [x] 无标签时显示空态
- [ ] 导出入口仍可触发下载
## 联调待确认
- 点击“导出流水”按钮后是否仍能正常触发下载

View File

@@ -0,0 +1,25 @@
# 结果总览前端验证记录
## 验证范围
- 结果总览主展示态
- 空数据态
- 加载态
- 页面构建
## 验证命令
- `cd ruoyi-ui && node tests/unit/preliminary-check-layout.test.js`
- `cd ruoyi-ui && node tests/unit/preliminary-check-summary-and-people.test.js`
- `cd ruoyi-ui && node tests/unit/preliminary-check-model-and-detail.test.js`
- `cd ruoyi-ui && node tests/unit/preliminary-check-states.test.js`
- `cd ruoyi-ui && npm run build:prod`
## 验证结果
- `node tests/unit/preliminary-check-layout.test.js`:通过
- `node tests/unit/preliminary-check-summary-and-people.test.js`:通过
- `node tests/unit/preliminary-check-model-and-detail.test.js`:通过
- `node tests/unit/preliminary-check-states.test.js`:通过
- `npm run build:prod`:通过
## 备注
- 构建过程中存在既有 `asset size limit``entrypoint size limit` 警告
- 本次结果总览页面改动未引入新的编译错误

View File

@@ -13,7 +13,7 @@ router = APIRouter()
# 初始化服务实例
token_service = TokenService()
file_service = FileService()
statement_service = StatementService()
statement_service = StatementService(file_service=file_service)
def _parse_log_ids(log_ids: str) -> List[int]:

View File

@@ -21,6 +21,8 @@ class FileRecord:
parsing: bool = True # True表示正在解析
# 新增字段 - 账号和主体信息
primary_enterprise_name: str = ""
primary_account_no: str = ""
account_no_list: List[str] = field(default_factory=list)
enterprise_name_list: List[str] = field(default_factory=list)
@@ -65,6 +67,10 @@ class FileService:
self.file_records: Dict[int, FileRecord] = {} # logId -> FileRecord
self.log_counter = settings.INITIAL_LOG_ID
def get_file_record(self, log_id: int) -> FileRecord:
"""按 logId 获取已存在的文件记录。"""
return self.file_records.get(log_id)
def _infer_bank_name(self, filename: str) -> tuple:
"""根据文件名推断银行名称和模板名称"""
if "支付宝" in filename or "alipay" in filename.lower():
@@ -74,6 +80,75 @@ class FileService:
else:
return "ZJRCU", "ZJRCU_T251114"
def _generate_primary_binding(self) -> tuple:
"""生成单一稳定的本方主体/本方账号绑定。"""
primary_account_no = f"{random.randint(10000000000, 99999999999)}"
primary_enterprise_name = "测试主体"
return primary_enterprise_name, primary_account_no
def _generate_primary_binding_from_rng(self, rng: random.Random) -> tuple:
"""使用局部随机源生成单一稳定的本方主体/本方账号绑定。"""
primary_account_no = f"{rng.randint(10000000000, 99999999999)}"
primary_enterprise_name = "测试主体"
return primary_enterprise_name, primary_account_no
def _build_primary_binding_lists(
self, primary_enterprise_name: str, primary_account_no: str
) -> dict:
"""基于主绑定事实源构建列表字段。"""
return {
"accountNoList": [primary_account_no],
"enterpriseNameList": [primary_enterprise_name],
}
def _create_file_record(
self,
*,
log_id: int,
group_id: int,
file_name: str,
download_file_name: str,
bank_name: str,
template_name: str,
primary_enterprise_name: str,
primary_account_no: str,
file_size: int,
total_records: int,
trx_date_start_id: int,
trx_date_end_id: int,
le_id: int,
login_le_id: int,
parsing: bool = True,
status: int = -5,
) -> FileRecord:
"""创建文件记录并写入主绑定信息。"""
binding_lists = self._build_primary_binding_lists(
primary_enterprise_name,
primary_account_no,
)
return FileRecord(
log_id=log_id,
group_id=group_id,
file_name=file_name,
download_file_name=download_file_name,
bank_name=bank_name,
real_bank_name=bank_name,
template_name=template_name,
primary_enterprise_name=primary_enterprise_name,
primary_account_no=primary_account_no,
account_no_list=binding_lists["accountNoList"],
enterprise_name_list=binding_lists["enterpriseNameList"],
le_id=le_id,
login_le_id=login_le_id,
file_size=file_size,
total_records=total_records,
trx_date_start_id=trx_date_start_id,
trx_date_end_id=trx_date_end_id,
parsing=parsing,
status=status,
)
async def upload_file(
self, group_id: int, file: UploadFile, background_tasks: BackgroundTasks
) -> Dict:
@@ -100,29 +175,25 @@ class FileService:
trx_date_start_id = int(start_date.strftime("%Y%m%d"))
trx_date_end_id = int(end_date.strftime("%Y%m%d"))
# 生成随机账号和主体
account_no = f"{random.randint(10000000000, 99999999999)}"
enterprise_names = ["测试主体"] if random.random() > 0.3 else [""]
# 生成单一主绑定
primary_enterprise_name, primary_account_no = self._generate_primary_binding()
# 创建完整的文件记录
file_record = FileRecord(
file_record = self._create_file_record(
log_id=log_id,
group_id=group_id,
file_name=file.filename,
download_file_name=file.filename,
bank_name=bank_name,
real_bank_name=bank_name,
template_name=template_name,
account_no_list=[account_no],
enterprise_name_list=enterprise_names,
le_id=10000 + random.randint(0, 9999),
login_le_id=10000 + random.randint(0, 9999),
primary_enterprise_name=primary_enterprise_name,
primary_account_no=primary_account_no,
file_size=random.randint(10000, 100000),
total_records=random.randint(100, 300),
trx_date_start_id=trx_date_start_id,
trx_date_end_id=trx_date_end_id,
parsing=True,
status=-5
le_id=10000 + random.randint(0, 9999),
login_le_id=10000 + random.randint(0, 9999),
)
# 存储记录
@@ -143,19 +214,21 @@ class FileService:
str(file_record.log_id): [
{
"bank": file_record.bank_name,
"accountName": file_record.enterprise_name_list[0] if file_record.enterprise_name_list else "",
"accountNo": file_record.account_no_list[0] if file_record.account_no_list else "",
"accountName": file_record.primary_enterprise_name,
"accountNo": file_record.primary_account_no,
"currency": "CNY"
}
]
},
"uploadLogList": [
{
"accountNoList": file_record.account_no_list,
**self._build_primary_binding_lists(
file_record.primary_enterprise_name,
file_record.primary_account_no,
),
"bankName": file_record.bank_name,
"dataTypeInfo": file_record.data_type_info,
"downloadFileName": file_record.download_file_name,
"enterpriseNameList": file_record.enterprise_name_list,
"filePackageId": file_record.file_package_id,
"fileSize": file_record.file_size,
"fileUploadBy": file_record.file_upload_by,
@@ -197,7 +270,9 @@ class FileService:
if log_id in self.file_records:
self.file_records[log_id].parsing = False
def _generate_deterministic_record(self, log_id: int, group_id: int) -> dict:
def _generate_deterministic_record(
self, log_id: int, group_id: int, rng: random.Random
) -> dict:
"""
基于 logId 生成确定性的文件记录
@@ -215,53 +290,71 @@ class FileService:
("ZJRCU", "ZJRCU_T251114")
]
bank_name, template_name = random.choice(bank_options)
bank_name, template_name = rng.choice(bank_options)
# 生成交易日期范围
end_date = datetime.now()
start_date = end_date - timedelta(days=random.randint(90, 365))
# 生成基于种子的稳定时间范围,确保同一 logId 重复查询完全一致
base_datetime = datetime(2024, 1, 1, 8, 0, 0)
end_date = base_datetime + timedelta(days=rng.randint(180, 540))
start_date = end_date - timedelta(days=rng.randint(90, 365))
file_upload_time = (
base_datetime
+ timedelta(
days=rng.randint(0, 540),
hours=rng.randint(0, 23),
minutes=rng.randint(0, 59),
seconds=rng.randint(0, 59),
)
)
# 生成账号和主体
account_no = f"{random.randint(10000000000, 99999999999)}"
enterprise_names = ["测试主体"] if random.random() > 0.3 else [""]
primary_enterprise_name, primary_account_no = self._generate_primary_binding_from_rng(rng)
binding_lists = self._build_primary_binding_lists(
primary_enterprise_name, primary_account_no
)
return {
"accountNoList": [account_no],
**binding_lists,
"bankName": bank_name,
"dataTypeInfo": ["CSV", ","],
"downloadFileName": f"测试文件_{log_id}.csv",
"enterpriseNameList": enterprise_names,
"fileSize": random.randint(10000, 100000),
"fileSize": rng.randint(10000, 100000),
"fileUploadBy": 448,
"fileUploadByUserName": "admin@support.com",
"fileUploadTime": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"fileUploadTime": file_upload_time.strftime("%Y-%m-%d %H:%M:%S"),
"isSplit": 0,
"leId": 10000 + random.randint(0, 9999),
"leId": 10000 + rng.randint(0, 9999),
"logId": log_id,
"logMeta": "{\"lostHeader\":[],\"balanceAmount\":\"-1\"}",
"logType": "bankstatement",
"loginLeId": 10000 + random.randint(0, 9999),
"loginLeId": 10000 + rng.randint(0, 9999),
"lostHeader": [],
"realBankName": bank_name,
"rows": 0,
"source": "http",
"status": -5,
"templateName": template_name,
"totalRecords": random.randint(100, 300),
"totalRecords": rng.randint(100, 300),
"trxDateEndId": int(end_date.strftime("%Y%m%d")),
"trxDateStartId": int(start_date.strftime("%Y%m%d")),
"uploadFileName": f"测试文件_{log_id}.pdf",
"uploadStatusDesc": "data.wait.confirm.newaccount"
}
def _build_deterministic_log_detail(self, log_id: int, group_id: int) -> dict:
"""构建 deterministic 回退的单条日志详情。"""
rng = random.Random(log_id)
return self._generate_deterministic_record(log_id, group_id, rng)
def _build_log_detail(self, record: FileRecord) -> dict:
"""构建日志详情对象"""
return {
"accountNoList": record.account_no_list,
**self._build_primary_binding_lists(
record.primary_enterprise_name,
record.primary_account_no,
),
"bankName": record.bank_name,
"dataTypeInfo": record.data_type_info,
"downloadFileName": record.download_file_name,
"enterpriseNameList": record.enterprise_name_list,
"fileSize": record.file_size,
"fileUploadBy": record.file_upload_by,
"fileUploadByUserName": record.file_upload_by_user_name,
@@ -332,13 +425,13 @@ class FileService:
"""
logs = []
if log_id:
# 使用 logId 作为随机种子,确保相同 logId 返回相同数据
random.seed(log_id)
if log_id is not None:
if log_id in self.file_records:
log_detail = self._build_log_detail(self.file_records[log_id])
else:
log_detail = self._build_deterministic_log_detail(log_id, group_id)
# 生成确定性的文件记录
record = self._generate_deterministic_record(log_id, group_id)
logs.append(record)
logs.append(log_detail)
# 返回响应
return {
@@ -382,16 +475,50 @@ class FileService:
}
def fetch_inner_flow(self, request: Union[Dict, object]) -> Dict:
"""拉取行内流水(返回随机logId
"""拉取行内流水(创建并保存绑定记录
Args:
request: 拉取流水请求(保留参数以符合接口规范当前Mock实现不使用
request: 拉取流水请求(可以是字典或对象
Returns:
流水响应字典,包含随机生成的logId数组
流水响应字典,包含创建并保存的logId数组
"""
# 随机生成一个logId范围10000-99999
log_id = random.randint(10000, 99999)
# 支持 dict 或对象
if isinstance(request, dict):
group_id = request.get("groupId", 1000)
customer_no = request.get("customerNo", "")
data_start_date_id = request.get("dataStartDateId", 20240101)
data_end_date_id = request.get("dataEndDateId", 20241231)
else:
group_id = request.groupId
customer_no = request.customerNo
data_start_date_id = request.dataStartDateId
data_end_date_id = request.dataEndDateId
# 使用递增 logId确保与上传链路一致
self.log_counter += 1
log_id = self.log_counter
primary_enterprise_name, primary_account_no = self._generate_primary_binding()
file_record = self._create_file_record(
log_id=log_id,
group_id=group_id,
file_name=f"{customer_no or 'inner_flow'}_{log_id}.csv",
download_file_name=f"{customer_no or 'inner_flow'}_{log_id}.csv",
bank_name="ZJRCU",
template_name="ZJRCU_T251114",
primary_enterprise_name=primary_enterprise_name,
primary_account_no=primary_account_no,
file_size=random.randint(10000, 100000),
total_records=random.randint(100, 300),
trx_date_start_id=data_start_date_id,
trx_date_end_id=data_end_date_id,
le_id=10000 + random.randint(0, 9999),
login_le_id=10000 + random.randint(0, 9999),
parsing=False,
)
self.file_records[log_id] = file_record
# 返回成功的响应包含logId数组
return {

View File

@@ -0,0 +1,338 @@
from datetime import datetime, timedelta
from typing import Dict, List, Optional
DEFAULT_LARGE_TRANSACTION_THRESHOLDS = {
"SINGLE_TRANSACTION_AMOUNT": 1111,
"CUMULATIVE_TRANSACTION_AMOUNT": 50000001,
"ANNUAL_TURNOVER": 50000001,
"LARGE_CASH_DEPOSIT": 2000001,
"FREQUENT_CASH_DEPOSIT": 5,
"FREQUENT_TRANSFER": 100001,
}
IDENTITY_POOL = {
"staff_primary": {
"name": "模型测试员工",
"id_card": "330101198801010011",
"account": "6222024000000001",
},
"family_primary": {
"name": "模型测试家属",
"id_card": "330101199001010022",
"account": "6222024000000002",
},
"staff_secondary": {
"name": "模型二测试员工",
"id_card": "330101198802020033",
"account": "6222024000000003",
},
"family_secondary": {
"name": "模型二测试家属",
"id_card": "330101199202020044",
"account": "6222024000000004",
},
}
IDENTITY_CARD_POOL = tuple(identity["id_card"] for identity in IDENTITY_POOL.values())
REFERENCE_NOW = datetime(2026, 3, 18, 9, 0, 0)
def _format_datetime(value: datetime) -> str:
return value.strftime("%Y-%m-%d %H:%M:%S")
def _format_date(value: datetime) -> str:
return value.strftime("%Y-%m-%d")
def _build_statement(
group_id: int,
log_id: int,
*,
trx_datetime: datetime,
cret_no: str,
customer_name: str,
user_memo: str,
cash_type: str,
dr_amount: float = 0.0,
cr_amount: float = 0.0,
le_name: str = "模型测试主体",
account_mask_no: str = "6222024999999999",
customer_account_mask_no: str = "9558800000000001",
bank_comments: str = "",
customer_bank: str = "",
) -> Dict:
trans_amount = round(dr_amount if dr_amount > 0 else cr_amount, 2)
balance_amount = round(80000000 + cr_amount - dr_amount, 2)
return {
"accountId": 0,
"accountMaskNo": account_mask_no,
"accountingDate": _format_date(trx_datetime),
"accountingDateId": int(trx_datetime.strftime("%Y%m%d")),
"archivingFlag": 0,
"attachments": 0,
"balanceAmount": balance_amount,
"bank": "ZJRCU",
"bankComments": bank_comments,
"bankStatementId": 0,
"bankTrxNumber": "",
"batchId": log_id,
"cashType": cash_type,
"commentsNum": 0,
"crAmount": round(cr_amount, 2),
"createDate": _format_datetime(REFERENCE_NOW),
"createdBy": "902001",
"cretNo": cret_no,
"currency": "CNY",
"customerAccountMaskNo": customer_account_mask_no,
"customerBank": customer_bank,
"customerId": -1,
"customerName": customer_name,
"customerReference": "",
"downPaymentFlag": 0,
"drAmount": round(dr_amount, 2),
"exceptionType": "",
"groupId": group_id,
"internalFlag": 0,
"leId": 16308,
"leName": le_name,
"overrideBsId": 0,
"paymentMethod": "",
"sourceCatalogId": 0,
"split": 0,
"subBankstatementId": 0,
"toDoFlag": 0,
"transAmount": trans_amount,
"transFlag": "P" if dr_amount > 0 else "R",
"transTypeId": 0,
"transformAmount": 0,
"transformCrAmount": 0,
"transformDrAmount": 0,
"transfromBalanceAmount": 0,
"trxBalance": 0,
"trxDate": _format_datetime(trx_datetime),
"uploadSequnceNumber": 0,
"userMemo": user_memo,
}
def build_large_transaction_seed_statements(
group_id: int,
log_id: int,
primary_enterprise_name: Optional[str] = None,
primary_account_no: Optional[str] = None,
) -> List[Dict]:
le_name = primary_enterprise_name or "模型测试主体"
account_no = primary_account_no or "6222024999999999"
statements: List[Dict] = []
statements.extend([
_build_statement(
group_id,
log_id,
trx_datetime=REFERENCE_NOW - timedelta(days=9, hours=1),
cret_no=IDENTITY_POOL["staff_primary"]["id_card"],
customer_name="杭州贝壳房地产经纪有限公司",
user_memo="购买房产首付款",
cash_type="对公转账",
dr_amount=680000.0,
le_name=le_name,
account_mask_no=account_no,
customer_account_mask_no="6222024555500001",
),
_build_statement(
group_id,
log_id,
trx_datetime=REFERENCE_NOW - timedelta(days=8, hours=2),
cret_no=IDENTITY_POOL["family_primary"]["id_card"],
customer_name="兰溪星耀汽车销售服务有限公司",
user_memo="购车首付款",
cash_type="对公转账",
dr_amount=380000.0,
le_name=le_name,
account_mask_no=account_no,
customer_account_mask_no="6222024555500002",
),
_build_statement(
group_id,
log_id,
trx_datetime=REFERENCE_NOW - timedelta(days=7, hours=1),
cret_no=IDENTITY_POOL["staff_secondary"]["id_card"],
customer_name="国家金库兰溪市中心支库",
user_memo="个人所得税税款",
cash_type="税务缴款",
dr_amount=126000.0,
le_name=le_name,
account_mask_no=account_no,
customer_account_mask_no="6222024555500003",
),
_build_statement(
group_id,
log_id,
trx_datetime=REFERENCE_NOW - timedelta(days=6, hours=3),
cret_no=IDENTITY_POOL["family_secondary"]["id_card"],
customer_name="兰溪市税务局",
user_memo="房产税务缴税",
cash_type="税务缴款",
dr_amount=88000.0,
le_name=le_name,
account_mask_no=account_no,
customer_account_mask_no="6222024555500004",
),
_build_statement(
group_id,
log_id,
trx_datetime=REFERENCE_NOW - timedelta(days=5, hours=2),
cret_no=IDENTITY_POOL["staff_secondary"]["id_card"],
customer_name="浙江远望贸易有限公司",
user_memo="经营往来收入",
cash_type="对公转账",
cr_amount=18800000.0,
le_name=le_name,
account_mask_no=account_no,
customer_account_mask_no="6222024666600001",
),
_build_statement(
group_id,
log_id,
trx_datetime=REFERENCE_NOW - timedelta(days=5, hours=1),
cret_no=IDENTITY_POOL["staff_secondary"]["id_card"],
customer_name="浙江远望贸易有限公司",
user_memo="项目回款收入",
cash_type="对公转账",
cr_amount=20800000.0,
le_name=le_name,
account_mask_no=account_no,
customer_account_mask_no="6222024666600001",
),
_build_statement(
group_id,
log_id,
trx_datetime=REFERENCE_NOW - timedelta(days=4, hours=4),
cret_no=IDENTITY_POOL["staff_secondary"]["id_card"],
customer_name="浙江远望贸易有限公司",
user_memo="业务合作收入",
cash_type="对公转账",
cr_amount=20700000.0,
le_name=le_name,
account_mask_no=account_no,
customer_account_mask_no="6222024666600001",
),
_build_statement(
group_id,
log_id,
trx_datetime=datetime(2026, 3, 10, 9, 0, 0),
cret_no=IDENTITY_POOL["staff_primary"]["id_card"],
customer_name="",
user_memo="现金存款",
cash_type="现金存款",
cr_amount=3000000.0,
le_name=le_name,
account_mask_no=account_no,
),
_build_statement(
group_id,
log_id,
trx_datetime=datetime(2026, 3, 10, 9, 30, 0),
cret_no=IDENTITY_POOL["staff_primary"]["id_card"],
customer_name="",
user_memo="ATM现金存款",
cash_type="现金存款",
cr_amount=3100000.0,
le_name=le_name,
account_mask_no=account_no,
),
_build_statement(
group_id,
log_id,
trx_datetime=datetime(2026, 3, 10, 10, 0, 0),
cret_no=IDENTITY_POOL["staff_primary"]["id_card"],
customer_name="",
user_memo="自助存款现金存入",
cash_type="现金存款",
cr_amount=3200000.0,
le_name=le_name,
account_mask_no=account_no,
),
_build_statement(
group_id,
log_id,
trx_datetime=datetime(2026, 3, 10, 10, 30, 0),
cret_no=IDENTITY_POOL["staff_primary"]["id_card"],
customer_name="",
user_memo="CRS存款",
cash_type="现金存款",
cr_amount=3300000.0,
le_name=le_name,
account_mask_no=account_no,
),
_build_statement(
group_id,
log_id,
trx_datetime=datetime(2026, 3, 10, 11, 0, 0),
cret_no=IDENTITY_POOL["staff_primary"]["id_card"],
customer_name="",
user_memo="本行ATM存款",
cash_type="现金存款",
cr_amount=3400000.0,
le_name=le_name,
account_mask_no=account_no,
),
_build_statement(
group_id,
log_id,
trx_datetime=datetime(2026, 3, 10, 11, 30, 0),
cret_no=IDENTITY_POOL["staff_primary"]["id_card"],
customer_name="",
user_memo="柜面现金存款",
cash_type="现金存款",
cr_amount=3500000.0,
le_name=le_name,
account_mask_no=account_no,
),
_build_statement(
group_id,
log_id,
trx_datetime=REFERENCE_NOW - timedelta(days=3, hours=1),
cret_no=IDENTITY_POOL["staff_secondary"]["id_card"],
customer_name="异地转账平台",
user_memo="手机银行转账",
cash_type="转账支出",
dr_amount=12000000.0,
le_name=le_name,
account_mask_no=account_no,
customer_account_mask_no="6222024777700001",
),
_build_statement(
group_id,
log_id,
trx_datetime=REFERENCE_NOW - timedelta(days=3, hours=2),
cret_no=IDENTITY_POOL["staff_secondary"]["id_card"],
customer_name="跨行转账中心",
user_memo="对外转账",
cash_type="转账支出",
dr_amount=10000000.0,
le_name=le_name,
account_mask_no=account_no,
customer_account_mask_no="6222024777700002",
),
_build_statement(
group_id,
log_id,
trx_datetime=REFERENCE_NOW - timedelta(days=2, hours=5),
cret_no=IDENTITY_POOL["staff_secondary"]["id_card"],
customer_name="跨境转账服务平台",
user_memo="网银转账",
cash_type="转账支出",
dr_amount=9000000.0,
le_name=le_name,
account_mask_no=account_no,
customer_account_mask_no="6222024777700003",
),
])
return statements

View File

@@ -1,9 +1,13 @@
from utils.response_builder import ResponseBuilder
from typing import Dict, Union, List
import random
from datetime import datetime, timedelta
import uuid
from typing import Dict, List, Union
import logging
import random
import uuid
from datetime import datetime, timedelta
from services.statement_rule_samples import (
IDENTITY_CARD_POOL,
build_large_transaction_seed_statements,
)
# 配置日志
logging.basicConfig(level=logging.INFO)
@@ -13,87 +17,79 @@ logger = logging.getLogger(__name__)
class StatementService:
"""流水数据服务"""
def __init__(self):
def __init__(self, file_service=None):
# 缓存logId -> (statements_list, total_count)
self._cache: Dict[int, tuple] = {}
# 配置日志级别为 INFO
logger.info(f"StatementService initialized with empty cache")
self.file_service = file_service
logger.info("StatementService initialized with empty cache")
def _generate_random_statement(self, index: int, group_id: int, log_id: int) -> Dict:
"""生成单条随机流水记录
def _resolve_primary_binding(self, log_id: int) -> tuple:
"""优先从 FileService 读取真实主绑定,不存在时再走 deterministic fallback。"""
if self.file_service is not None:
record = self.file_service.get_file_record(log_id)
if record is not None:
return record.primary_enterprise_name, record.primary_account_no
Args:
index: 流水序号
group_id: 项目ID
log_id: 文件ID
rng = random.Random(f"binding:{log_id}")
return "张传伟", f"{rng.randint(100000000000000, 999999999999999)}"
Returns:
单条流水记录字典
"""
# 随机生成交易日期最近1年内
days_ago = random.randint(0, 365)
trx_datetime = datetime.now() - timedelta(days=days_ago)
trx_date = trx_datetime.strftime("%Y-%m-%d %H:%M:%S")
accounting_date = trx_datetime.strftime("%Y-%m-%d")
accounting_date_id = int(trx_datetime.strftime("%Y%m%d"))
def _generate_random_statement(
self,
group_id: int,
log_id: int,
primary_enterprise_name: str,
primary_account_no: str,
rng: random.Random,
) -> Dict:
"""生成单条随机噪声流水记录。"""
reference_now = datetime(2026, 3, 18, 9, 0, 0)
days_ago = rng.randint(0, 365)
trx_datetime = reference_now - timedelta(days=days_ago, minutes=rng.randint(0, 1439))
trans_amount = round(rng.uniform(10, 10000), 2)
# 生成创建日期格式YYYY-MM-DD HH:MM:SS
create_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# 随机生成交易金额
trans_amount = round(random.uniform(10, 10000), 2)
# 随机决定是收入还是支出
if random.random() > 0.5:
# 支出
if rng.random() > 0.5:
dr_amount = trans_amount
cr_amount = 0
cr_amount = 0.0
trans_flag = "P"
else:
# 收入
cr_amount = trans_amount
dr_amount = 0
dr_amount = 0.0
trans_flag = "R"
# 随机余额
balance_amount = round(random.uniform(1000, 50000), 2)
# 随机客户信息
customers = ["小店", "支付宝", "微信支付", "财付通", "美团", "京东", "淘宝", "银行转账"]
customer_name = random.choice(customers)
customer_account = str(random.randint(100000000, 999999999))
# 随机交易描述
memos = [
f"消费_{customer_name}",
f"转账_{customer_name}",
f"收款_{customer_name}",
f"支付_{customer_name}",
f"退款_{customer_name}",
]
user_memo = random.choice(memos)
customer_name = rng.choice(
["小店", "支付宝", "微信支付", "财付通", "美团", "京东", "淘宝", "银行转账"]
)
user_memo = rng.choice(
[
f"消费_{customer_name}",
f"转账_{customer_name}",
f"收款_{customer_name}",
f"支付_{customer_name}",
f"退款_{customer_name}",
]
)
return {
"accountId": 0,
"accountMaskNo": f"{random.randint(100000000000000, 999999999999999)}",
"accountingDate": accounting_date,
"accountingDateId": accounting_date_id,
"accountMaskNo": primary_account_no,
"accountingDate": trx_datetime.strftime("%Y-%m-%d"),
"accountingDateId": int(trx_datetime.strftime("%Y%m%d")),
"archivingFlag": 0,
"attachments": 0,
"balanceAmount": balance_amount,
"balanceAmount": round(rng.uniform(1000, 50000), 2),
"bank": "ZJRCU",
"bankComments": "",
"bankStatementId": 12847662 + index,
"bankTrxNumber": uuid.uuid4().hex,
"bankStatementId": 0,
"bankTrxNumber": "",
"batchId": log_id,
"cashType": "1",
"commentsNum": 0,
"crAmount": cr_amount,
"createDate": create_date,
"createDate": reference_now.strftime("%Y-%m-%d %H:%M:%S"),
"createdBy": "902001",
"cretNo": "230902199012261247",
"cretNo": rng.choice(IDENTITY_CARD_POOL),
"currency": "CNY",
"customerAccountMaskNo": customer_account,
"customerAccountMaskNo": str(rng.randint(100000000, 999999999)),
"customerBank": "",
"customerId": -1,
"customerName": customer_name,
@@ -104,7 +100,7 @@ class StatementService:
"groupId": group_id,
"internalFlag": 0,
"leId": 16308,
"leName": "张传伟",
"leName": primary_enterprise_name,
"overrideBsId": 0,
"paymentMethod": "",
"sourceCatalogId": 0,
@@ -119,39 +115,69 @@ class StatementService:
"transformDrAmount": 0,
"transfromBalanceAmount": 0,
"trxBalance": 0,
"trxDate": trx_date,
"uploadSequnceNumber": index + 1,
"userMemo": user_memo
"trxDate": trx_datetime.strftime("%Y-%m-%d %H:%M:%S"),
"uploadSequnceNumber": 0,
"userMemo": user_memo,
}
def _assign_statement_ids(self, statements: List[Dict], group_id: int, log_id: int) -> List[Dict]:
"""为样本与噪声流水统一补齐稳定的流水标识。"""
assigned: List[Dict] = []
base_id = log_id * 100000
for index, statement in enumerate(statements, start=1):
item = dict(statement)
item["groupId"] = group_id
item["batchId"] = log_id
item["bankStatementId"] = base_id + index
item["bankTrxNumber"] = uuid.uuid5(
uuid.NAMESPACE_DNS, f"lsfx-mock-{log_id}-{index}"
).hex
item["uploadSequnceNumber"] = index
item["transAmount"] = round(item.get("drAmount", 0) + item.get("crAmount", 0), 2)
assigned.append(item)
return assigned
def _generate_statements(self, group_id: int, log_id: int, count: int) -> List[Dict]:
"""生成指定数量的流水记录
"""生成指定数量的流水记录"""
primary_enterprise_name, primary_account_no = self._resolve_primary_binding(log_id)
rng = random.Random(f"statement:{log_id}")
seeded_statements = build_large_transaction_seed_statements(
group_id=group_id,
log_id=log_id,
primary_enterprise_name=primary_enterprise_name,
primary_account_no=primary_account_no,
)
Args:
group_id: 项目ID
log_id: 文件ID
count: 生成数量
total_count = max(count, len(seeded_statements))
statements = list(seeded_statements)
for _ in range(total_count - len(seeded_statements)):
statements.append(
self._generate_random_statement(
group_id,
log_id,
primary_enterprise_name,
primary_account_no,
rng,
)
)
Returns:
流水记录列表
"""
statements = []
for i in range(count):
statements.append(self._generate_random_statement(i, group_id, log_id))
statements = self._assign_statement_ids(statements, group_id, log_id)
rng.shuffle(statements)
return statements
def _apply_primary_binding(
self,
statements: List[Dict],
primary_enterprise_name: str,
primary_account_no: str,
) -> None:
"""将解析出的主绑定统一回填到已有流水记录。"""
for statement in statements:
statement["leName"] = primary_enterprise_name
statement["accountMaskNo"] = primary_account_no
def get_bank_statement(self, request: Union[Dict, object]) -> Dict:
"""获取银行流水列表
Args:
request: 获取银行流水请求(可以是字典或对象)
Returns:
银行流水响应字典
"""
# 支持 dict 或对象
"""获取银行流水列表"""
if isinstance(request, dict):
group_id = request.get("groupId", 1000)
log_id = request.get("logId", 10000)
@@ -163,19 +189,16 @@ class StatementService:
page_now = request.pageNow
page_size = request.pageSize
# 检查缓存中是否已有该logId的数据
if log_id not in self._cache:
# 随机生成总条数1200-1500之间
total_count = random.randint(1200, 1500)
# 生成所有流水记录
total_rng = random.Random(f"total:{log_id}")
total_count = total_rng.randint(1200, 1500)
all_statements = self._generate_statements(group_id, log_id, total_count)
# 存入缓存
self._cache[log_id] = (all_statements, total_count)
# 从缓存获取数据
all_statements, total_count = self._cache[log_id]
primary_enterprise_name, primary_account_no = self._resolve_primary_binding(log_id)
self._apply_primary_binding(all_statements, primary_enterprise_name, primary_account_no)
# 模拟分页
start = (page_now - 1) * page_size
end = start + page_size
page_data = all_statements[start:end]

View File

@@ -10,6 +10,18 @@ import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from main import app
from config.settings import settings
from routers.api import file_service
@pytest.fixture(autouse=True)
def reset_file_service_state():
"""避免 file_service 单例状态影响测试顺序。"""
file_service.file_records.clear()
file_service.log_counter = settings.INITIAL_LOG_ID
yield
file_service.file_records.clear()
file_service.log_counter = settings.INITIAL_LOG_ID
@pytest.fixture

View File

@@ -2,7 +2,6 @@
集成测试 - 完整的接口调用流程测试
"""
import pytest
import time
def test_complete_workflow(client):
@@ -123,3 +122,51 @@ def test_pagination(client):
if page1["data"]["totalCount"] > 1:
assert len(page1["data"]["bankStatementList"]) == 1
assert len(page2["data"]["bankStatementList"]) >= 0
def test_upload_status_and_bank_statement_share_same_primary_binding(client, monkeypatch):
"""上传状态接口与银行流水接口对同一 logId 必须使用同一组主体/账号绑定。"""
from routers.api import file_service
monkeypatch.setattr(
file_service,
"_generate_primary_binding",
lambda: ("链路主体", "6222555566667777"),
)
fetch_response = client.post(
"/watson/api/project/getJZFileOrZjrcuFile",
data={
"groupId": 1001,
"customerNo": "customer_002",
"dataChannelCode": "channel_code",
"requestDateId": 20240101,
"dataStartDateId": 20240101,
"dataEndDateId": 20240131,
"uploadUserId": 902001,
},
)
assert fetch_response.status_code == 200
log_id = fetch_response.json()["data"][0]
status_response = client.get(f"/watson/api/project/bs/upload?groupId=1001&logId={log_id}")
assert status_response.status_code == 200
status_log = status_response.json()["data"]["logs"][0]
statement_response = client.post(
"/watson/api/project/getBSByLogId",
data={
"groupId": 1001,
"logId": log_id,
"pageNow": 1,
"pageSize": 5,
},
)
assert statement_response.status_code == 200
statements = statement_response.json()["data"]["bankStatementList"]
assert status_log["enterpriseNameList"] == ["链路主体"]
assert status_log["accountNoList"] == ["6222555566667777"]
assert statements
assert all(item["leName"] == status_log["enterpriseNameList"][0] for item in statements)
assert all(item["accountMaskNo"] == status_log["accountNoList"][0] for item in statements)

View File

@@ -2,6 +2,8 @@
API 端点测试
"""
from routers.api import file_service
def test_root_endpoint(client):
"""测试根路径"""
@@ -87,6 +89,103 @@ def test_fetch_inner_flow_error_501014(client):
assert data["successResponse"] == False
def test_fetch_inner_flow_followed_by_upload_status(client):
"""拉取行内流水后,上传状态查询应命中同一条绑定记录。"""
response = client.post(
"/watson/api/project/getJZFileOrZjrcuFile",
data={
"groupId": 1001,
"customerNo": "test_customer_002",
"dataChannelCode": "test_code",
"requestDateId": 20240101,
"dataStartDateId": 20240101,
"dataEndDateId": 20240131,
"uploadUserId": 902001,
},
)
assert response.status_code == 200
log_id = response.json()["data"][0]
assert log_id in file_service.file_records
record = file_service.file_records[log_id]
upload_response = client.get(
f"/watson/api/project/bs/upload?groupId=1001&logId={log_id}"
)
assert upload_response.status_code == 200
upload_data = upload_response.json()
assert upload_data["code"] == "200"
assert upload_data["successResponse"] is True
assert len(upload_data["data"]["logs"]) == 1
log = upload_data["data"]["logs"][0]
assert log["enterpriseNameList"] == [record.primary_enterprise_name]
assert log["accountNoList"] == [record.primary_account_no]
assert log["enterpriseNameList"][0] == record.primary_enterprise_name
assert log["accountNoList"][0] == record.primary_account_no
def test_upload_file_followed_by_upload_status_reads_real_record(client, monkeypatch):
"""上传文件后,上传状态查询应优先返回真实记录而不是 deterministic 回退。"""
monkeypatch.setattr(
file_service,
"_generate_primary_binding",
lambda: ("上传主体", "6222777788889999"),
)
upload_response = client.post(
"/watson/api/project/remoteUploadSplitFile",
data={"groupId": 1001},
files={"files": ("测试文件.csv", b"mock", "text/csv")},
)
assert upload_response.status_code == 200
upload_data = upload_response.json()
upload_log = upload_data["data"]["uploadLogList"][0]
log_id = upload_log["logId"]
status_response = client.get(f"/watson/api/project/bs/upload?groupId=1001&logId={log_id}")
assert status_response.status_code == 200
status_data = status_response.json()
status_log = status_data["data"]["logs"][0]
assert status_log["enterpriseNameList"] == upload_log["enterpriseNameList"]
assert status_log["accountNoList"] == upload_log["accountNoList"]
assert status_log["enterpriseNameList"] == ["上传主体"]
assert status_log["accountNoList"] == ["6222777788889999"]
assert len(status_log["enterpriseNameList"]) == 1
assert len(status_log["accountNoList"]) == 1
def test_fetch_inner_flow_marks_pending_complete(client):
"""拉取行内流水后getpendings 应返回未解析状态。"""
response = client.post(
"/watson/api/project/getJZFileOrZjrcuFile",
data={
"groupId": 1001,
"customerNo": "test_customer_003",
"dataChannelCode": "test_code",
"requestDateId": 20240101,
"dataStartDateId": 20240101,
"dataEndDateId": 20240131,
"uploadUserId": 902001,
},
)
assert response.status_code == 200
log_id = response.json()["data"][0]
pending_response = client.post(
"/watson/api/project/upload/getpendings",
data={"groupId": 1001, "inprogressList": str(log_id)},
)
assert pending_response.status_code == 200
pending_data = pending_response.json()
assert pending_data["data"]["parsing"] is False
assert len(pending_data["data"]["pendingList"]) == 1
assert pending_data["data"]["pendingList"][0]["logId"] == log_id
def test_get_upload_status_with_log_id(client):
"""测试带 logId 参数查询返回非空 logs"""
response = client.get("/watson/api/project/bs/upload?groupId=1000&logId=13994")
@@ -150,9 +249,12 @@ def test_deterministic_data_generation(client):
assert log1["bankName"] == log2["bankName"]
assert log1["accountNoList"] == log2["accountNoList"]
assert log1["enterpriseNameList"] == log2["enterpriseNameList"]
assert len(log1["accountNoList"]) == 1
assert len(log1["enterpriseNameList"]) == 1
assert log1["status"] == log2["status"]
assert log1["logMeta"] == log2["logMeta"]
assert log1["templateName"] == log2["templateName"]
assert log1["fileUploadTime"] == log2["fileUploadTime"]
assert log1["trxDateStartId"] == log2["trxDateStartId"]
assert log1["trxDateEndId"] == log2["trxDateEndId"]

View File

@@ -0,0 +1,116 @@
"""
FileService 单一主绑定语义测试
"""
import asyncio
import io
from fastapi import BackgroundTasks
from fastapi.datastructures import UploadFile
from services.file_service import FileService
def test_upload_file_primary_binding_response(monkeypatch):
"""同一 logId 的主绑定必须稳定且只保留一组主体/账号信息。"""
service = FileService()
monkeypatch.setattr(
service,
"_generate_primary_binding",
lambda: ("测试主体A", "6222021234567890"),
)
background_tasks = BackgroundTasks()
file = UploadFile(filename="测试文件.csv", file=io.BytesIO(b"mock"))
response = asyncio.run(service.upload_file(1001, file, background_tasks))
log = response["data"]["uploadLogList"][0]
account_info = response["data"]["accountsOfLog"][str(log["logId"])][0]
record = service.file_records[log["logId"]]
assert log["enterpriseNameList"] == ["测试主体A"]
assert log["accountNoList"] == ["6222021234567890"]
assert account_info["accountName"] == "测试主体A"
assert account_info["accountNo"] == "6222021234567890"
assert record.primary_enterprise_name == "测试主体A"
assert record.primary_account_no == "6222021234567890"
assert record.enterprise_name_list == ["测试主体A"]
assert record.account_no_list == ["6222021234567890"]
def test_upload_file_then_upload_status_reads_same_record(monkeypatch):
"""上传后再查状态时,上传状态接口必须读取同一条真实记录。"""
service = FileService()
monkeypatch.setattr(
service,
"_generate_primary_binding",
lambda: ("测试主体B", "6222333344445555"),
)
background_tasks = BackgroundTasks()
file = UploadFile(filename="测试文件.csv", file=io.BytesIO(b"mock"))
upload_response = asyncio.run(service.upload_file(1001, file, background_tasks))
log = upload_response["data"]["uploadLogList"][0]
monkeypatch.setattr(
service,
"_build_deterministic_log_detail",
lambda *args, **kwargs: (_ for _ in ()).throw(
AssertionError("真实记录存在时不应走 deterministic fallback")
),
)
status_response = service.get_upload_status(1001, log["logId"])
status_log = status_response["data"]["logs"][0]
assert status_log["enterpriseNameList"] == log["enterpriseNameList"]
assert status_log["accountNoList"] == log["accountNoList"]
assert status_log["bankName"] == log["bankName"]
assert status_log["templateName"] == log["templateName"]
assert status_log["uploadFileName"] == log["uploadFileName"]
assert status_log["trxDateStartId"] == log["trxDateStartId"]
assert status_log["trxDateEndId"] == log["trxDateEndId"]
assert status_log["enterpriseNameList"] == ["测试主体B"]
assert status_log["accountNoList"] == ["6222333344445555"]
assert len(status_log["enterpriseNameList"]) == 1
assert len(status_log["accountNoList"]) == 1
def test_fetch_inner_flow_persists_primary_binding_record(monkeypatch):
"""拉取行内流水必须创建并保存绑定记录。"""
service = FileService()
monkeypatch.setattr(
service,
"_generate_primary_binding",
lambda: ("行内主体", "6210987654321098"),
)
request = {
"groupId": 1001,
"customerNo": "test_customer_001",
"dataChannelCode": "test_code",
"requestDateId": 20240101,
"dataStartDateId": 20240101,
"dataEndDateId": 20240131,
"uploadUserId": 902001,
}
response = service.fetch_inner_flow(request)
log_id = response["data"][0]
assert log_id == service.log_counter
assert log_id in service.file_records
record = service.file_records[log_id]
assert record.parsing is False
assert record.primary_enterprise_name
assert record.primary_account_no
assert record.primary_enterprise_name == "行内主体"
assert record.primary_account_no == "6210987654321098"
assert record.enterprise_name_list == ["行内主体"]
assert record.account_no_list == ["6210987654321098"]

View File

@@ -0,0 +1,229 @@
"""
StatementService 主绑定注入测试
"""
from collections import Counter, defaultdict
from services.file_service import FileService
from services.statement_service import StatementService
from services.statement_rule_samples import (
DEFAULT_LARGE_TRANSACTION_THRESHOLDS,
build_large_transaction_seed_statements,
)
def test_generate_statements_should_include_seeded_samples_before_noise():
"""生成流水时必须先混入固定命中样本,而不是纯随机噪声。"""
service = StatementService()
statements = service._generate_statements(group_id=1000, log_id=20001, count=30)
assert len(statements) >= 30
assert any(item["userMemo"] == "购买房产首付款" for item in statements)
def test_large_transaction_seed_should_cover_all_eight_rules():
"""大额交易样本生成器必须覆盖 8 条已实现规则的关键口径。"""
statements = build_large_transaction_seed_statements(group_id=1000, log_id=20001)
assert any(
item["userMemo"] == "购买房产首付款" and item["drAmount"] > 0
for item in statements
)
assert any(
"" in item["userMemo"] and item["drAmount"] > 0
for item in statements
)
assert any(
item["crAmount"] > DEFAULT_LARGE_TRANSACTION_THRESHOLDS["SINGLE_TRANSACTION_AMOUNT"]
for item in statements
)
assert sum(
1
for item in statements
if item["customerName"] == "浙江远望贸易有限公司" and item["crAmount"] > 0
) >= 3
assert sum(
1
for item in statements
if item["cashType"] == "现金存款"
and item["crAmount"] > DEFAULT_LARGE_TRANSACTION_THRESHOLDS["LARGE_CASH_DEPOSIT"]
) >= 1
assert any(
item["userMemo"] == "手机银行转账"
and item["drAmount"] > DEFAULT_LARGE_TRANSACTION_THRESHOLDS["FREQUENT_TRANSFER"]
for item in statements
)
same_day_cash_deposits = [
item for item in statements
if item["cretNo"] == "330101198801010011"
and item["trxDate"].startswith("2026-03-10")
and item["crAmount"] > DEFAULT_LARGE_TRANSACTION_THRESHOLDS["LARGE_CASH_DEPOSIT"]
]
assert len(same_day_cash_deposits) >= (
DEFAULT_LARGE_TRANSACTION_THRESHOLDS["FREQUENT_CASH_DEPOSIT"] + 1
)
def test_large_transaction_seed_income_should_avoid_salary_exclusion():
"""收入样本不得误带工资代发关键词,否则会被后端过滤。"""
statements = build_large_transaction_seed_statements(group_id=1000, log_id=20001)
income_samples = [item for item in statements if item["crAmount"] > 0]
assert income_samples
assert all(item["customerName"] != "浙江兰溪农村商业银行股份有限公司" for item in income_samples)
assert all(
keyword not in item["userMemo"]
for item in income_samples
for keyword in ("代发", "工资", "奖金", "薪酬", "薪金")
)
def test_generate_statements_should_fill_noise_up_to_requested_count():
"""样本不足请求总数时,服务层需要自动补齐噪声流水。"""
service = StatementService()
statements = service._generate_statements(group_id=1000, log_id=20001, count=80)
assert len(statements) == 80
def test_generate_statements_should_only_use_recognizable_identity_cards():
"""命中样本和随机噪声都只能使用现库可识别的身份证号。"""
service = StatementService()
statements = service._generate_statements(group_id=1000, log_id=20005, count=1600)
assert {item["cretNo"] for item in statements}.issubset(
{
"330101198801010011",
"330101199001010022",
"330101198802020033",
"330101199202020044",
}
)
def test_get_bank_statement_should_keep_same_cached_result_for_same_log_id():
"""同一 logId 首次生成后应复用缓存,避免分页结果漂移。"""
service = StatementService()
page1 = service.get_bank_statement(
{"groupId": 1000, "logId": 30001, "pageNow": 1, "pageSize": 20}
)
page2 = service.get_bank_statement(
{"groupId": 1000, "logId": 30001, "pageNow": 1, "pageSize": 20}
)
assert page1["data"]["bankStatementList"] == page2["data"]["bankStatementList"]
def test_get_bank_statement_uses_primary_binding_from_file_service(monkeypatch):
"""同一 logId 的流水记录必须复用 FileService 中的主体与账号绑定。"""
file_service = FileService()
statement_service = StatementService(file_service=file_service)
monkeypatch.setattr(
file_service,
"_generate_primary_binding",
lambda: ("绑定主体", "6222000011112222"),
)
response = file_service.fetch_inner_flow(
{
"groupId": 1001,
"customerNo": "customer_001",
"dataChannelCode": "test",
"requestDateId": 20240101,
"dataStartDateId": 20240101,
"dataEndDateId": 20240131,
"uploadUserId": 902001,
}
)
log_id = response["data"][0]
record = file_service.file_records[log_id]
statement_response = statement_service.get_bank_statement(
{
"groupId": 1001,
"logId": log_id,
"pageNow": 1,
"pageSize": 5,
}
)
statements = statement_response["data"]["bankStatementList"]
assert statements
assert all(item["leName"] == record.primary_enterprise_name for item in statements)
assert all(item["accountMaskNo"] == record.primary_account_no for item in statements)
def test_get_bank_statement_contains_large_transaction_hit_samples(monkeypatch):
"""流水 Mock 首次生成时必须稳定包含可命中大额交易规则的样本簇。"""
file_service = FileService()
statement_service = StatementService(file_service=file_service)
monkeypatch.setattr(
file_service,
"_generate_primary_binding",
lambda: ("命中主体", "6222000099998888"),
)
response = file_service.fetch_inner_flow(
{
"groupId": 1001,
"customerNo": "customer_large_transaction",
"dataChannelCode": "test",
"requestDateId": 20240101,
"dataStartDateId": 20240101,
"dataEndDateId": 20240131,
"uploadUserId": 902001,
}
)
log_id = response["data"][0]
statement_response = statement_service.get_bank_statement(
{
"groupId": 1001,
"logId": log_id,
"pageNow": 1,
"pageSize": 2000,
}
)
statements = statement_response["data"]["bankStatementList"]
assert statements
assert any(
item["cretNo"] in {
"330101198801010011",
"330101199001010022",
"330101198802020033",
"330101199202020044",
}
for item in statements
)
assert any("房产首付款" in item["userMemo"] for item in statements)
assert any("" in item["userMemo"] or "税务" in item["customerName"] for item in statements)
income_amounts = defaultdict(float)
cash_deposit_daily_counter = Counter()
has_large_transfer = False
for item in statements:
if (
item["cretNo"] == "330101198802020033"
and item["customerName"] == "浙江远望贸易有限公司"
and item["crAmount"] > 0
):
income_amounts[(item["cretNo"], item["customerName"])] += item["crAmount"]
if item["crAmount"] > 2000001 and "现金" in item["cashType"]:
cash_deposit_daily_counter[(item["cretNo"], item["trxDate"][:10])] += 1
if item["drAmount"] > 100001 and item["userMemo"] == "手机银行转账":
has_large_transfer = True
assert any(amount > 50000001 for amount in income_amounts.values())
assert any(count >= 6 for count in cash_deposit_daily_counter.values())
assert has_large_transfer

View File

@@ -21,6 +21,7 @@ logging:
com.ruoyi: debug
org.springframework: warn
"com.ruoyi.ccdi.project.mapper.CcdiBankStatementMapper.insertBatch": info
"com.ruoyi.ccdi.project.mapper.CcdiBankTagResultMapper.insertBatch": info
# 用户配置
user:

View File

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

Some files were not shown because too many files have changed in this diff Show More