Compare commits

...

76 Commits

Author SHA1 Message Date
wkc
079b412d38 修复流水详情原始文件关联与Mock随机logId 2026-03-20 16:25:22 +08:00
wkc
d7c9f0e5bf 补充新增模型打标验证执行计划 2026-03-20 15:21:08 +08:00
wkc
7cdf9212b6 补充新增模型打标验证执行计划 2026-03-20 15:13:19 +08:00
wkc
b44b133a21 调整新增模型打标验证计划文档归档路径 2026-03-20 15:06:53 +08:00
wkc
2d79b36dd9 补充新增模型打标完整验证设计 2026-03-20 15:04:10 +08:00
wkc
a405dc7df5 Merge branch 'codex/lsfx-mock-random-hit-rule-backend' into dev 2026-03-20 14:52:17 +08:00
wkc
61684b9f2e 补充Mock随机命中后端实施计划 2026-03-20 14:52:08 +08:00
wkc
62fa2b1aac 调整风险人员总览核心异常点标签展示 2026-03-20 14:52:08 +08:00
wkc
b7588309e6 收紧Mock随机命中规则设计计划范围 2026-03-20 14:52:08 +08:00
wkc
b8471af3ae 补充兰溪流水Mock随机命中规则设计 2026-03-20 14:52:08 +08:00
wkc
ad20d356af 优化结果总览模型卡片加载反馈 2026-03-20 14:52:08 +08:00
wkc
2bc4f00ce6 补充第一期流水模型后端实施记录 2026-03-20 14:52:08 +08:00
wkc
2f86472091 接通第一期对象规则真实分发 2026-03-20 14:52:08 +08:00
wkc
7d943f96cc 实现第一期流水明细规则真实SQL 2026-03-20 14:52:08 +08:00
wkc
91eb46798e 补齐第一期流水模型参数映射 2026-03-20 14:52:08 +08:00
wkc
76727b3c67 补充Mock随机命中后端实施记录 2026-03-20 14:50:52 +08:00
wkc
440fc38805 补齐Mock采购规则数据库基线 2026-03-20 14:49:29 +08:00
wkc
e97055379c 接通Mock随机命中流水生成链路 2026-03-20 14:48:02 +08:00
wkc
5d03811d49 拆分Mock规则样本构造器 2026-03-20 14:45:49 +08:00
wkc
1fd7ae7026 持久化Mock随机命中规则计划 2026-03-20 14:42:11 +08:00
wkc
477a82a4c3 补充Mock随机命中后端实施计划 2026-03-20 14:33:48 +08:00
wkc
3bf1c276e8 调整风险人员总览核心异常点标签展示 2026-03-20 14:31:22 +08:00
wkc
5a650ab05f 收紧Mock随机命中规则设计计划范围 2026-03-20 14:26:36 +08:00
wkc
7c1ee420a4 补充兰溪流水Mock随机命中规则设计 2026-03-20 14:25:11 +08:00
wkc
16dbad3194 优化结果总览模型卡片加载反馈 2026-03-20 14:19:48 +08:00
wkc
f70f228c21 合并第一期银行流水真实规则后端实现 2026-03-20 14:03:33 +08:00
wkc
538fb9c9f3 完善结果总览模型区卡片展示与联动修复 2026-03-20 14:02:18 +08:00
wkc
c95ed24d04 补充第一期流水模型后端实施记录 2026-03-20 13:31:53 +08:00
wkc
1bd24497b3 接通第一期对象规则真实分发 2026-03-20 13:28:43 +08:00
wkc
edf5869eba 实现第一期流水明细规则真实SQL 2026-03-20 13:26:41 +08:00
wkc
1c73322f94 补齐第一期流水模型参数映射 2026-03-20 13:22:26 +08:00
wkc
726265fb70 新增银行流水模型两期实施计划 2026-03-20 12:16:23 +08:00
wkc
633f085083 修复结果总览模型名称排序兼容性问题 2026-03-20 12:06:37 +08:00
wkc
a7f068b309 新增银行流水模型真实规则两阶段设计文档 2026-03-20 12:04:41 +08:00
wkc
b32210c088 修复结果总览模型分页参数绑定异常 2026-03-20 12:01:07 +08:00
wkc
e5afc1adee 补充结果总览模型区前端实施记录 2026-03-20 11:32:18 +08:00
wkc
2285ebd3f0 调整结果总览模型区筛选与列表列 2026-03-20 11:30:08 +08:00
wkc
e147d6dfee 补充结果总览模型卡片联动交互 2026-03-20 11:27:46 +08:00
wkc
37e6eef26c 接入结果总览模型卡片真实数据 2026-03-20 11:24:32 +08:00
wkc
6cdc1b4019 补充结果总览模型区前端接口封装 2026-03-20 11:22:45 +08:00
wkc
b552d7d0b7 补充结果总览模型区后端实施记录 2026-03-20 11:19:11 +08:00
wkc
164a82d883 完成结果总览模型区服务组装 2026-03-20 11:15:57 +08:00
wkc
12fa064a48 补充结果总览模型区人员分页查询 2026-03-20 11:13:29 +08:00
wkc
aae2d44c07 修正模型信息中码值不明确的SQL判断 2026-03-20 11:09:46 +08:00
wkc
5abfc3e0b7 补充结果总览模型卡片统计查询 2026-03-20 11:09:09 +08:00
wkc
c149b2ae33 定义结果总览模型区接口结构 2026-03-20 11:07:12 +08:00
wkc
345b166cb1 修正模型信息xlsx中的add_months写法 2026-03-20 11:02:21 +08:00
wkc
d29e243aaf 提交结果总览模型卡片联动筛选实施计划文档 2026-03-20 10:58:44 +08:00
wkc
190ad21bbd 修正模型信息xlsx中的派生表别名 2026-03-20 10:56:18 +08:00
wkc
ffce521772 核对模型信息xlsx中的参数占位符映射 2026-03-20 10:54:20 +08:00
wkc
a8cef20687 改写模型信息xlsx中的with语句 2026-03-20 10:49:42 +08:00
wkc
a3cef7e0aa 修正模型信息xlsx中的资产更新时间字段 2026-03-20 10:23:14 +08:00
wkc
c13aa66bbd 修正模型信息xlsx中的交易时间字段 2026-03-20 10:17:00 +08:00
wkc
40a2bf3e23 修正模型信息xlsx中project_id校验口径 2026-03-20 10:03:23 +08:00
wkc
2e295f417d 更新模型信息xlsx并补充SQL校验结论 2026-03-20 09:52:52 +08:00
wkc
c0dba89fe3 补充模型信息xlsx更新设计文档 2026-03-20 09:44:03 +08:00
wkc
11c678ab26 收口结果总览风险人员区块 2026-03-20 09:31:33 +08:00
wkc
faaf04abf4 补充结果总览风险人员区块收口实施计划 2026-03-20 09:25:17 +08:00
wkc
ecd7ab9d47 补充结果总览风险人员区块收口设计文档 2026-03-20 09:22:35 +08:00
wkc
99f96e101e 项目详情页打标状态轮询改为1秒刷新 2026-03-19 17:41:18 +08:00
wkc
f858fbdcbc 调整风险人员总览异常点与疑似违规数口径 2026-03-19 17:37:20 +08:00
wkc
c33f411c8b 更新AGENTS后端启动命令说明 2026-03-19 17:34:53 +08:00
wkc
3ba5f9d266 调整兰溪本地流水条数为200 2026-03-19 17:18:02 +08:00
wkc
948caef532 调整后端脚本为Jar启动方式 2026-03-19 17:08:07 +08:00
wkc
148535c154 修正风险仪表盘总人数员工匹配口径 2026-03-19 16:41:56 +08:00
wkc
d31b30f44f 新增后端一键重启脚本 2026-03-19 16:38:20 +08:00
wkc
33af208fe1 补充员工收入和亲属数据 2026-03-19 16:11:32 +08:00
wkc
0457c8f3a6 修复Mock流水按数据库员工及亲属绑定身份证 2026-03-19 16:07:28 +08:00
wkc
627886f711 删除上传文件后触发项目重新打标 2026-03-19 16:05:40 +08:00
wkc
199dbb1d9d 修复风险仪表盘总人数统计 2026-03-19 15:57:27 +08:00
wkc
e305902e7c 补充结果总览风险接口前端记录 2026-03-19 15:40:51 +08:00
wkc
dc36631abe 校准结果总览风险人员区字段映射 2026-03-19 15:40:46 +08:00
wkc
b848280b9f 接入结果总览风险真实接口 2026-03-19 15:40:43 +08:00
wkc
ee9f502c16 新增结果总览风险接口前端封装 2026-03-19 15:40:32 +08:00
wkc
cb8e144564 实现结果总览风险接口并完成回写联调 2026-03-19 15:23:52 +08:00
wkc
8ff6570ba8 调整lsfx mock上传流水条数范围 2026-03-19 15:23:44 +08:00
152 changed files with 12127 additions and 351 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -40,8 +40,8 @@
# 根目录编译全部 Java 模块
mvn clean compile
# 启动主应用
mvn -pl ruoyi-admin spring-boot:run
# 启动主应用Jar
cd ruoyi-admin/target && java -jar ruoyi-admin.jar
# 打包全部模块
mvn clean package

Binary file not shown.

219
bin/restart_java_backend.sh Executable file
View File

@@ -0,0 +1,219 @@
#!/bin/sh
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
ROOT_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
LOG_DIR="$ROOT_DIR/logs"
CONSOLE_LOG="$LOG_DIR/backend-console.log"
PID_FILE="$LOG_DIR/backend-java.pid"
TARGET_DIR="$ROOT_DIR/ruoyi-admin/target"
JAR_NAME="ruoyi-admin.jar"
SERVER_PORT=62318
STOP_WAIT_SECONDS=30
APP_KEYWORD="$JAR_NAME"
JAVA_OPTS="-Duser.timezone=Asia/Shanghai -Xms512m -Xmx1024m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError"
timestamp() {
date "+%Y-%m-%d %H:%M:%S"
}
log_info() {
printf '[%s] %s\n' "$(timestamp)" "$1"
}
log_error() {
printf '[%s] %s\n' "$(timestamp)" "$1" >&2
}
usage() {
cat <<'EOF'
用法: ./bin/restart_java_backend.sh [start|stop|restart|status]
默认动作:
restart 重新构建后端并重启,随后持续输出运行日志
EOF
}
ensure_command() {
if ! command -v "$1" >/dev/null 2>&1; then
log_error "缺少命令: $1"
exit 1
fi
}
collect_pids() {
all_pids=""
if [ -f "$PID_FILE" ]; then
file_pid=$(cat "$PID_FILE" 2>/dev/null || true)
if [ -n "${file_pid:-}" ] && kill -0 "$file_pid" 2>/dev/null; then
all_pids="$all_pids $file_pid"
fi
fi
port_pids=$(lsof -tiTCP:"$SERVER_PORT" -sTCP:LISTEN 2>/dev/null || true)
if [ -n "${port_pids:-}" ]; then
all_pids="$all_pids $port_pids"
fi
app_pids=$(pgrep -f "$APP_KEYWORD" 2>/dev/null || true)
if [ -n "${app_pids:-}" ]; then
all_pids="$all_pids $app_pids"
fi
unique_pids=""
for pid in $all_pids; do
case " $unique_pids " in
*" $pid "*) ;;
*)
unique_pids="$unique_pids $pid"
;;
esac
done
printf '%s\n' "$(echo "$unique_pids" | xargs 2>/dev/null || true)"
}
build_backend() {
log_info "开始构建后端: mvn -pl ruoyi-admin -am clean package -DskipTests"
(
cd "$ROOT_DIR"
mvn -pl ruoyi-admin -am clean package -DskipTests
)
}
stop_backend() {
pids=$(collect_pids)
if [ -z "${pids:-}" ]; then
log_info "未发现运行中的后端进程"
rm -f "$PID_FILE"
return 0
fi
log_info "准备停止后端进程: $pids"
for pid in $pids; do
kill -TERM "$pid" 2>/dev/null || true
done
remaining_pids="$pids"
elapsed=0
while [ -n "${remaining_pids:-}" ] && [ "$elapsed" -lt "$STOP_WAIT_SECONDS" ]; do
sleep 1
elapsed=$((elapsed + 1))
remaining_pids=""
for pid in $pids; do
if kill -0 "$pid" 2>/dev/null; then
remaining_pids="$remaining_pids $pid"
fi
done
remaining_pids=$(echo "$remaining_pids" | xargs 2>/dev/null || true)
done
if [ -n "${remaining_pids:-}" ]; then
log_info "仍有进程未退出,执行强制停止: $remaining_pids"
for pid in $remaining_pids; do
kill -KILL "$pid" 2>/dev/null || true
done
fi
rm -f "$PID_FILE"
log_info "后端停止完成"
}
start_backend() {
mkdir -p "$LOG_DIR"
touch "$CONSOLE_LOG"
printf '\n===== %s restart =====\n' "$(timestamp)" >> "$CONSOLE_LOG"
log_info "开始启动后端,控制台日志输出到: $CONSOLE_LOG"
if [ ! -f "$TARGET_DIR/$JAR_NAME" ]; then
log_error "未找到打包产物: $TARGET_DIR/$JAR_NAME"
exit 1
fi
(
cd "$TARGET_DIR"
nohup java $JAVA_OPTS -jar "$JAR_NAME" >> "$CONSOLE_LOG" 2>&1 &
echo $! > "$PID_FILE"
)
sleep 3
starter_pid=$(cat "$PID_FILE" 2>/dev/null || true)
if [ -z "${starter_pid:-}" ] || ! kill -0 "$starter_pid" 2>/dev/null; then
log_error "启动命令未保持运行,请检查日志: $CONSOLE_LOG"
exit 1
fi
log_info "启动命令已提交PID: $starter_pid"
}
status_backend() {
pids=$(collect_pids)
if [ -n "${pids:-}" ]; then
log_info "后端正在运行,进程: $pids"
else
log_info "后端未运行"
fi
}
follow_logs() {
mkdir -p "$LOG_DIR"
touch "$CONSOLE_LOG"
log_info "持续输出日志中,按 Ctrl+C 仅退出日志查看"
tail -n 200 -F "$CONSOLE_LOG"
}
start_action() {
running_pids=$(collect_pids)
if [ -n "${running_pids:-}" ]; then
log_error "检测到已有后端进程在运行: $running_pids,请先执行 stop 或 restart"
exit 1
fi
build_backend
start_backend
follow_logs
}
restart_action() {
build_backend
stop_backend
start_backend
follow_logs
}
main() {
ensure_command mvn
ensure_command lsof
ensure_command pgrep
ensure_command tail
action="${1:-restart}"
case "$action" in
start)
start_action
;;
stop)
stop_backend
;;
restart)
restart_action
;;
status)
status_backend
;;
-h|--help|help)
usage
;;
*)
usage
exit 1
;;
esac
}
main "$@"

View File

@@ -0,0 +1,85 @@
package com.ruoyi.ccdi.project.controller;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewDashboardVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelCardsVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskPeopleOverviewVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleVO;
import com.ruoyi.ccdi.project.service.ICcdiProjectOverviewService;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 结果总览控制器
*/
@RestController
@RequestMapping("/ccdi/project/overview")
@Tag(name = "项目结果总览")
public class CcdiProjectOverviewController extends BaseController {
@Resource
private ICcdiProjectOverviewService overviewService;
/**
* 查询风险仪表盘
*/
@GetMapping("/dashboard")
@Operation(summary = "查询风险仪表盘")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getDashboard(Long projectId) {
CcdiProjectOverviewDashboardVO dashboard = overviewService.getDashboard(projectId);
return AjaxResult.success(dashboard);
}
/**
* 查询风险人员总览
*/
@GetMapping("/risk-people")
@Operation(summary = "查询风险人员总览")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getRiskPeople(Long projectId) {
CcdiProjectRiskPeopleOverviewVO overview = overviewService.getRiskPeopleOverview(projectId);
return AjaxResult.success(overview);
}
/**
* 查询中高风险人员TOP10
*/
@GetMapping("/top-risk-people")
@Operation(summary = "查询中高风险人员TOP10")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getTopRiskPeople(Long projectId) {
CcdiProjectTopRiskPeopleVO topRiskPeople = overviewService.getTopRiskPeople(projectId);
return AjaxResult.success(topRiskPeople);
}
/**
* 查询风险模型卡片
*/
@GetMapping("/risk-models/cards")
@Operation(summary = "查询风险模型卡片")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getRiskModelCards(Long projectId) {
CcdiProjectRiskModelCardsVO cards = overviewService.getRiskModelCards(projectId);
return AjaxResult.success(cards);
}
/**
* 查询风险模型命中人员
*/
@GetMapping("/risk-models/people")
@Operation(summary = "查询风险模型命中人员")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getRiskModelPeople(CcdiProjectRiskModelPeopleQueryDTO queryDTO) {
CcdiProjectRiskModelPeopleVO people = overviewService.getRiskModelPeople(queryDTO);
return AjaxResult.success(people);
}
}

View File

@@ -0,0 +1,54 @@
package com.ruoyi.ccdi.project.domain.dto;
import jakarta.validation.constraints.Pattern;
import java.util.List;
import java.util.stream.Collectors;
import lombok.Data;
/**
* 风险模型命中人员查询DTO
*/
@Data
public class CcdiProjectRiskModelPeopleQueryDTO {
/** 项目ID */
private Long projectId;
/** 模型编码列表 */
private List<String> modelCodes;
/** 匹配方式 */
@Pattern(regexp = "ANY|ALL", message = "匹配方式仅支持ANY或ALL")
private String matchMode;
/** 关键字 */
private String keyword;
/** 部门ID */
private Long deptId;
/** 页码 */
private Integer pageNum;
/** 每页数量 */
private Integer pageSize;
public String getModelCodesCsv() {
if (modelCodes == null || modelCodes.isEmpty()) {
return null;
}
return modelCodes.stream()
.filter(item -> item != null && !item.isBlank())
.map(String::trim)
.distinct()
.collect(Collectors.joining(","));
}
public Integer getModelCodesCount() {
String modelCodesCsv = getModelCodesCsv();
if (modelCodesCsv == null || modelCodesCsv.isBlank()) {
return 0;
}
return modelCodesCsv.split(",").length;
}
}

View File

@@ -14,6 +14,9 @@ public enum TriggerType {
/** 自动参数变更 */
AUTO_PARAM_CHANGE,
/** 自动文件删除 */
AUTO_FILE_DELETE,
/** 手动触发 */
MANUAL
}

View File

@@ -0,0 +1,36 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
/**
* 员工风险聚合结果
*/
@Data
public class CcdiProjectEmployeeRiskAggregateVO {
private String staffIdCard;
private String staffName;
private Long deptId;
private String deptName;
private Integer ruleCount;
private Integer modelCount;
private Integer hitCount;
private String topRuleCode;
private String topRuleName;
private String riskPoint;
private String riskLevelCode;
private String riskLevelName;
private Integer riskLevelSort;
}

View File

@@ -0,0 +1,17 @@
package com.ruoyi.ccdi.project.domain.vo;
import java.util.List;
import lombok.Data;
/**
* 结果总览风险仪表盘
*/
@Data
public class CcdiProjectOverviewDashboardVO {
private String title;
private String subtitle;
private List<CcdiProjectOverviewStatVO> stats;
}

View File

@@ -0,0 +1,16 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
/**
* 结果总览统计项
*/
@Data
public class CcdiProjectOverviewStatVO {
private String key;
private String label;
private Integer value;
}

View File

@@ -0,0 +1,16 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
/**
* 风险命中标签
*/
@Data
public class CcdiProjectRiskHitTagVO {
private String ruleCode;
private String ruleName;
private String riskLevel;
}

View File

@@ -0,0 +1,18 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
/**
* 风险模型卡片
*/
@Data
public class CcdiProjectRiskModelCardVO {
private String modelCode;
private String modelName;
private Integer warningCount;
private Integer peopleCount;
}

View File

@@ -0,0 +1,13 @@
package com.ruoyi.ccdi.project.domain.vo;
import java.util.List;
import lombok.Data;
/**
* 风险模型卡片列表
*/
@Data
public class CcdiProjectRiskModelCardsVO {
private List<CcdiProjectRiskModelCardVO> cardList;
}

View File

@@ -0,0 +1,25 @@
package com.ruoyi.ccdi.project.domain.vo;
import java.util.List;
import lombok.Data;
/**
* 风险模型命中人员项
*/
@Data
public class CcdiProjectRiskModelPeopleItemVO {
private String staffName;
private String staffCode;
private String idNo;
private String department;
private List<String> modelNames;
private List<CcdiProjectRiskHitTagVO> hitTagList;
private String actionLabel;
}

View File

@@ -0,0 +1,15 @@
package com.ruoyi.ccdi.project.domain.vo;
import java.util.List;
import lombok.Data;
/**
* 风险模型命中人员分页
*/
@Data
public class CcdiProjectRiskModelPeopleVO {
private List<CcdiProjectRiskModelPeopleItemVO> rows;
private Long total;
}

View File

@@ -0,0 +1,28 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
/**
* 风险人员总览项
*/
@Data
public class CcdiProjectRiskPeopleOverviewItemVO {
private String name;
private String idNo;
private String department;
private Integer riskCount;
private String riskLevel;
private String riskLevelType;
private Integer modelCount;
private String riskPoint;
private String actionLabel;
}

View File

@@ -0,0 +1,13 @@
package com.ruoyi.ccdi.project.domain.vo;
import java.util.List;
import lombok.Data;
/**
* 风险人员总览
*/
@Data
public class CcdiProjectRiskPeopleOverviewVO {
private List<CcdiProjectRiskPeopleOverviewItemVO> overviewList;
}

View File

@@ -0,0 +1,24 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
/**
* 中高风险人员项
*/
@Data
public class CcdiProjectTopRiskPeopleItemVO {
private String name;
private String idNo;
private String department;
private String riskLevel;
private String riskLevelType;
private Integer modelCount;
private String actionLabel;
}

View File

@@ -0,0 +1,13 @@
package com.ruoyi.ccdi.project.domain.vo;
import java.util.List;
import lombok.Data;
/**
* 中高风险人员TOP10
*/
@Data
public class CcdiProjectTopRiskPeopleVO {
private List<CcdiProjectTopRiskPeopleItemVO> topRiskList;
}

View File

@@ -38,4 +38,6 @@ public interface CcdiBankStatementMapper extends BaseMapper<CcdiBankStatement> {
CcdiBankStatementDetailVO selectStatementDetailById(@Param("bankStatementId") Long bankStatementId);
CcdiBankStatementFilterOptionsVO selectFilterOptions(@Param("projectId") Long projectId);
Integer countMatchedStaffCountByProjectId(@Param("projectId") Long projectId);
}

View File

@@ -190,17 +190,21 @@ public interface CcdiBankTagAnalysisMapper {
* 单笔购汇金额超限
*
* @param projectId 项目ID
* @param threshold 单笔购汇阈值
* @return 流水命中结果
*/
List<BankTagStatementHitVO> selectForexBuyAmtStatements(@Param("projectId") Long projectId);
List<BankTagStatementHitVO> selectForexBuyAmtStatements(@Param("projectId") Long projectId,
@Param("threshold") BigDecimal threshold);
/**
* 单笔结汇金额超限
*
* @param projectId 项目ID
* @param threshold 单笔结汇阈值
* @return 流水命中结果
*/
List<BankTagStatementHitVO> selectForexSellAmtStatements(@Param("projectId") Long projectId);
List<BankTagStatementHitVO> selectForexSellAmtStatements(@Param("projectId") Long projectId,
@Param("threshold") BigDecimal threshold);
/**
* 单笔跨境汇款金额超限
@@ -238,17 +242,21 @@ public interface CcdiBankTagAnalysisMapper {
* 可疑银证大额转账
*
* @param projectId 项目ID
* @param threshold 银证转账阈值
* @return 流水命中结果
*/
List<BankTagStatementHitVO> selectStockTfrLargeStatements(@Param("projectId") Long projectId);
List<BankTagStatementHitVO> selectStockTfrLargeStatements(@Param("projectId") Long projectId,
@Param("threshold") BigDecimal threshold);
/**
* 微信支付宝频繁提现
*
* @param projectId 项目ID
* @param frequencyThreshold 提现频次阈值
* @return 对象命中结果
*/
List<BankTagObjectHitVO> selectWithdrawCntObjects(@Param("projectId") Long projectId);
List<BankTagObjectHitVO> selectWithdrawCntObjects(@Param("projectId") Long projectId,
@Param("frequencyThreshold") Integer frequencyThreshold);
/**
* 微信支付宝提现超额
@@ -278,9 +286,11 @@ public interface CcdiBankTagAnalysisMapper {
* 大额炒股
*
* @param projectId 项目ID
* @param threshold 三方资管交易阈值
* @return 流水命中结果
*/
List<BankTagStatementHitVO> selectLargeStockTradingStatements(@Param("projectId") Long projectId);
List<BankTagStatementHitVO> selectLargeStockTradingStatements(@Param("projectId") Long projectId,
@Param("threshold") BigDecimal threshold);
/**
* 疑似代理他人账户

View File

@@ -23,4 +23,20 @@ public interface CcdiProjectMapper extends BaseMapper<CcdiProject> {
* @return 分页结果
*/
Page<CcdiProjectVO> selectProjectPage(Page<CcdiProjectVO> page, @Param("queryDTO") CcdiProjectQueryDTO queryDTO);
/**
* 更新项目风险人数
*
* @param projectId 项目ID
* @param highRiskCount 高风险人数
* @param mediumRiskCount 中风险人数
* @param lowRiskCount 低风险人数
* @param updateBy 更新人
* @return 更新行数
*/
int updateRiskCountsByProjectId(@Param("projectId") Long projectId,
@Param("highRiskCount") Integer highRiskCount,
@Param("mediumRiskCount") Integer mediumRiskCount,
@Param("lowRiskCount") Integer lowRiskCount,
@Param("updateBy") String updateBy);
}

View File

@@ -0,0 +1,71 @@
package com.ruoyi.ccdi.project.mapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeRiskAggregateVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelCardVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleItemVO;
import java.util.List;
import java.util.Map;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
/**
* 结果总览Mapper
*/
@Mapper
public interface CcdiProjectOverviewMapper {
/**
* 查询仪表盘基础数据
*
* @param projectId 项目ID
* @return 项目基础数据
*/
CcdiProject selectDashboardBaseByProjectId(@Param("projectId") Long projectId);
/**
* 查询风险人员总览
*
* @param projectId 项目ID
* @return 风险人员聚合列表
*/
List<CcdiProjectEmployeeRiskAggregateVO> selectRiskPeopleOverviewByProjectId(@Param("projectId") Long projectId);
/**
* 查询中高风险TOP10
*
* @param projectId 项目ID
* @return 中高风险人员列表
*/
List<CcdiProjectEmployeeRiskAggregateVO> selectTopRiskPeopleByProjectId(@Param("projectId") Long projectId);
/**
* 查询风险模型卡片
*
* @param projectId 项目ID
* @return 风险模型卡片列表
*/
List<CcdiProjectRiskModelCardVO> selectRiskModelCardsByProjectId(@Param("projectId") Long projectId);
/**
* 分页查询风险模型命中人员
*
* @param page 分页参数
* @param query 查询条件
* @return 命中人员分页结果
*/
Page<CcdiProjectRiskModelPeopleItemVO> selectRiskModelPeoplePage(
Page<CcdiProjectRiskModelPeopleItemVO> page,
@Param("query") CcdiProjectRiskModelPeopleQueryDTO query
);
/**
* 查询项目风险人数汇总
*
* @param projectId 项目ID
* @return 风险人数汇总
*/
Map<String, Object> selectRiskCountSummaryByProjectId(@Param("projectId") Long projectId);
}

View File

@@ -0,0 +1,66 @@
package com.ruoyi.ccdi.project.service;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewDashboardVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelCardsVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskPeopleOverviewVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleVO;
/**
* 结果总览服务接口
*/
public interface ICcdiProjectOverviewService {
/**
* 查询风险仪表盘
*
* @param projectId 项目ID
* @return 风险仪表盘
*/
CcdiProjectOverviewDashboardVO getDashboard(Long projectId);
/**
* 查询风险人员总览
*
* @param projectId 项目ID
* @return 风险人员总览
*/
CcdiProjectRiskPeopleOverviewVO getRiskPeopleOverview(Long projectId);
/**
* 查询中高风险人员TOP10
*
* @param projectId 项目ID
* @return 中高风险人员TOP10
*/
CcdiProjectTopRiskPeopleVO getTopRiskPeople(Long projectId);
/**
* 查询风险模型卡片
*
* @param projectId 项目ID
* @return 风险模型卡片
*/
default CcdiProjectRiskModelCardsVO getRiskModelCards(Long projectId) {
return new CcdiProjectRiskModelCardsVO();
}
/**
* 查询风险模型命中人员
*
* @param queryDTO 查询条件
* @return 风险模型命中人员
*/
default CcdiProjectRiskModelPeopleVO getRiskModelPeople(CcdiProjectRiskModelPeopleQueryDTO queryDTO) {
return new CcdiProjectRiskModelPeopleVO();
}
/**
* 刷新项目风险人数
*
* @param projectId 项目ID
* @param operator 操作人
*/
void refreshProjectRiskCounts(Long projectId, String operator);
}

View File

@@ -24,13 +24,18 @@ import lombok.extern.slf4j.Slf4j;
@Component
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"),
"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")
private static final Map<String, Set<String>> RULE_PARAM_MAPPING = Map.ofEntries(
Map.entry("SINGLE_LARGE_INCOME", Set.of("SINGLE_TRANSACTION_AMOUNT")),
Map.entry("CUMULATIVE_INCOME", Set.of("CUMULATIVE_TRANSACTION_AMOUNT")),
Map.entry("ANNUAL_TURNOVER", Set.of("ANNUAL_TURNOVER")),
Map.entry("LARGE_CASH_DEPOSIT", Set.of("LARGE_CASH_DEPOSIT")),
Map.entry("FREQUENT_CASH_DEPOSIT", Set.of("LARGE_CASH_DEPOSIT", "FREQUENT_CASH_DEPOSIT")),
Map.entry("LARGE_TRANSFER", Set.of("FREQUENT_TRANSFER")),
Map.entry("FOREX_BUY_AMT", Set.of("SINGLE_PURCHASE_AMOUNT")),
Map.entry("FOREX_SELL_AMT", Set.of("SINGLE_SETTLEMENT_AMOUNT")),
Map.entry("WITHDRAW_CNT", Set.of("WITHDRAW_CNT")),
Map.entry("STOCK_TFR_LARGE", Set.of("STOCK_TFR_LARGE")),
Map.entry("LARGE_STOCK_TRADING", Set.of("STOCK_TFR_LARGE"))
);
@Resource

View File

@@ -14,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.ICcdiProjectOverviewService;
import com.ruoyi.ccdi.project.service.ICcdiProjectService;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Lazy;
@@ -59,6 +60,9 @@ public class CcdiBankTagServiceImpl implements ICcdiBankTagService {
@Resource
private ICcdiProjectService projectService;
@Resource
private ICcdiProjectOverviewService projectOverviewService;
@Resource
@Qualifier("tagRuleExecutor")
private Executor tagRuleExecutor;
@@ -125,6 +129,8 @@ public class CcdiBankTagServiceImpl implements ICcdiBankTagService {
resultMapper.insertBatch(allResults);
}
projectOverviewService.refreshProjectRiskCounts(projectId, operator);
task.setStatus(STATUS_SUCCESS);
task.setSuccessRuleCount(rules.size());
task.setFailedRuleCount(0);
@@ -227,12 +233,20 @@ public class CcdiBankTagServiceImpl implements ICcdiBankTagService {
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 "FOREX_BUY_AMT" -> analysisMapper.selectForexBuyAmtStatements(
projectId, toBigDecimal(config.getThresholdValue("SINGLE_PURCHASE_AMOUNT"))
);
case "FOREX_SELL_AMT" -> analysisMapper.selectForexSellAmtStatements(
projectId, toBigDecimal(config.getThresholdValue("SINGLE_SETTLEMENT_AMOUNT"))
);
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);
case "STOCK_TFR_LARGE" -> analysisMapper.selectStockTfrLargeStatements(
projectId, toBigDecimal(config.getThresholdValue("STOCK_TFR_LARGE"))
);
case "LARGE_STOCK_TRADING" -> analysisMapper.selectLargeStockTradingStatements(
projectId, toBigDecimal(config.getThresholdValue("STOCK_TFR_LARGE"))
);
default -> List.of();
};
}
@@ -258,7 +272,9 @@ public class CcdiBankTagServiceImpl implements ICcdiBankTagService {
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_CNT" -> analysisMapper.selectWithdrawCntObjects(
projectId, toInteger(config.getThresholdValue("WITHDRAW_CNT"))
);
case "WITHDRAW_AMT" -> analysisMapper.selectWithdrawAmtObjects(projectId);
case "SALARY_QUICK_TRANSFER" -> analysisMapper.selectSalaryQuickTransferObjects(projectId);
case "SALARY_UNUSED" -> analysisMapper.selectSalaryUnusedObjects(projectId);

View File

@@ -235,12 +235,18 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
}
bankStatementMapper.deleteByProjectIdAndBatchId(record.getProjectId(), record.getLogId());
refreshProjectTargetCount(record.getProjectId());
CcdiFileUploadRecord update = new CcdiFileUploadRecord();
update.setId(record.getId());
update.setFileStatus("deleted");
recordMapper.updateById(update);
return "删除成功";
int updatedRows = recordMapper.updateById(update);
if (updatedRows <= 0) {
throw new RuntimeException("更新上传记录状态失败");
}
bankTagService.submitAutoRebuild(record.getProjectId(), TriggerType.AUTO_FILE_DELETE);
return "删除成功,已开始项目重新打标";
}
@Override
@@ -853,6 +859,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
result.setTotalCount(totalCount == null ? 0 : totalCount);
if (totalCount == null || totalCount <= 0) {
log.warn("【文件上传】无流水数据需要保存: totalCount={}", totalCount);
refreshProjectTargetCount(projectId);
result.setSuccess(true);
return result;
}
@@ -922,6 +929,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
log.info("【文件上传】流水入库完成: fetchedCount={}, attemptedCount={}",
totalCount, totalAttempted);
refreshProjectTargetCount(projectId);
result.setSuccess(true);
result.setAttemptedCount(totalAttempted);
return result;
@@ -938,6 +946,18 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
bankStatementMapper.deleteByProjectIdAndBatchId(projectId, logId);
}
private void refreshProjectTargetCount(Long projectId) {
CcdiProject project = projectMapper.selectById(projectId);
if (project == null) {
log.warn("【项目】刷新目标人数时项目不存在: projectId={}", projectId);
return;
}
int targetCount = bankStatementMapper.countMatchedStaffCountByProjectId(projectId);
project.setTargetCount(targetCount);
projectMapper.updateById(project);
}
private void validateDeleteRecord(CcdiFileUploadRecord record) {
if (record == null) {
throw new RuntimeException("上传记录不存在");

View File

@@ -0,0 +1,238 @@
package com.ruoyi.ccdi.project.service.impl;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeRiskAggregateVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewDashboardVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewStatVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelCardsVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskPeopleOverviewItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskPeopleOverviewVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleVO;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectOverviewMapper;
import com.ruoyi.ccdi.project.service.ICcdiProjectOverviewService;
import com.ruoyi.common.exception.ServiceException;
import jakarta.annotation.Resource;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 结果总览服务实现
*/
@Service
public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewService {
private static final String ACTION_LABEL = "查看详情";
@Resource
private CcdiProjectOverviewMapper overviewMapper;
@Resource
private CcdiProjectMapper projectMapper;
@Override
public CcdiProjectOverviewDashboardVO getDashboard(Long projectId) {
CcdiProject project = overviewMapper.selectDashboardBaseByProjectId(projectId);
if (project == null) {
throw new ServiceException("项目不存在");
}
int targetCount = defaultZero(project.getTargetCount());
int highRiskCount = defaultZero(project.getHighRiskCount());
int mediumRiskCount = defaultZero(project.getMediumRiskCount());
int lowRiskCount = defaultZero(project.getLowRiskCount());
int noRiskCount = targetCount - highRiskCount - mediumRiskCount - lowRiskCount;
CcdiProjectOverviewDashboardVO dashboard = new CcdiProjectOverviewDashboardVO();
dashboard.setTitle("风险仪表盘");
dashboard.setSubtitle("风险仪表盘数据概览");
dashboard.setStats(List.of(
buildStat("people", "总人数", targetCount),
buildStat("riskPeople", "高风险", highRiskCount),
buildStat("medium", "中风险", mediumRiskCount),
buildStat("low", "低风险", lowRiskCount),
buildStat("count", "无风险人员", noRiskCount)
));
return dashboard;
}
@Override
public CcdiProjectRiskPeopleOverviewVO getRiskPeopleOverview(Long projectId) {
ensureProjectExists(projectId);
List<CcdiProjectRiskPeopleOverviewItemVO> overviewList = overviewMapper.selectRiskPeopleOverviewByProjectId(projectId)
.stream()
.map(this::buildRiskPeopleItem)
.toList();
CcdiProjectRiskPeopleOverviewVO overview = new CcdiProjectRiskPeopleOverviewVO();
overview.setOverviewList(overviewList);
return overview;
}
@Override
public CcdiProjectTopRiskPeopleVO getTopRiskPeople(Long projectId) {
ensureProjectExists(projectId);
List<CcdiProjectTopRiskPeopleItemVO> topRiskList = overviewMapper.selectTopRiskPeopleByProjectId(projectId)
.stream()
.map(this::buildTopRiskPeopleItem)
.toList();
CcdiProjectTopRiskPeopleVO topRiskPeople = new CcdiProjectTopRiskPeopleVO();
topRiskPeople.setTopRiskList(topRiskList);
return topRiskPeople;
}
@Override
public CcdiProjectRiskModelCardsVO getRiskModelCards(Long projectId) {
ensureProjectExists(projectId);
CcdiProjectRiskModelCardsVO cards = new CcdiProjectRiskModelCardsVO();
cards.setCardList(defaultList(overviewMapper.selectRiskModelCardsByProjectId(projectId)));
return cards;
}
@Override
public CcdiProjectRiskModelPeopleVO getRiskModelPeople(CcdiProjectRiskModelPeopleQueryDTO queryDTO) {
ensureProjectExists(queryDTO.getProjectId());
normalizeRiskModelPeopleQuery(queryDTO);
Page<CcdiProjectRiskModelPeopleItemVO> page = new Page<>(
defaultPageNum(queryDTO.getPageNum()),
defaultPageSize(queryDTO.getPageSize())
);
Page<CcdiProjectRiskModelPeopleItemVO> resultPage = overviewMapper.selectRiskModelPeoplePage(page, queryDTO);
List<CcdiProjectRiskModelPeopleItemVO> rows = defaultList(resultPage == null ? null : resultPage.getRecords())
.stream()
.peek(item -> item.setActionLabel(ACTION_LABEL))
.toList();
CcdiProjectRiskModelPeopleVO people = new CcdiProjectRiskModelPeopleVO();
people.setRows(rows);
people.setTotal(resultPage == null ? 0L : resultPage.getTotal());
return people;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void refreshProjectRiskCounts(Long projectId, String operator) {
getRequiredProject(projectId);
Map<String, Object> summary = overviewMapper.selectRiskCountSummaryByProjectId(projectId);
projectMapper.updateRiskCountsByProjectId(
projectId,
readCount(summary, "highRiskCount"),
readCount(summary, "mediumRiskCount"),
readCount(summary, "lowRiskCount"),
operator
);
}
private CcdiProjectRiskPeopleOverviewItemVO buildRiskPeopleItem(CcdiProjectEmployeeRiskAggregateVO aggregate) {
CcdiProjectRiskPeopleOverviewItemVO item = new CcdiProjectRiskPeopleOverviewItemVO();
item.setName(aggregate.getStaffName());
item.setIdNo(aggregate.getStaffIdCard());
item.setDepartment(aggregate.getDeptName());
item.setRiskCount(defaultZero(aggregate.getHitCount()));
item.setRiskLevel(resolveRiskLevelName(aggregate.getRiskLevelCode()));
item.setRiskLevelType(resolveRiskLevelType(aggregate.getRiskLevelCode()));
item.setModelCount(defaultZero(aggregate.getModelCount()));
item.setRiskPoint(aggregate.getRiskPoint());
item.setActionLabel(ACTION_LABEL);
return item;
}
private CcdiProjectTopRiskPeopleItemVO buildTopRiskPeopleItem(CcdiProjectEmployeeRiskAggregateVO aggregate) {
CcdiProjectTopRiskPeopleItemVO item = new CcdiProjectTopRiskPeopleItemVO();
item.setName(aggregate.getStaffName());
item.setIdNo(aggregate.getStaffIdCard());
item.setDepartment(aggregate.getDeptName());
item.setRiskLevel(resolveRiskLevelName(aggregate.getRiskLevelCode()));
item.setRiskLevelType(resolveRiskLevelType(aggregate.getRiskLevelCode()));
item.setModelCount(defaultZero(aggregate.getModelCount()));
item.setActionLabel(ACTION_LABEL);
return item;
}
private void ensureProjectExists(Long projectId) {
getRequiredProject(projectId);
}
private void normalizeRiskModelPeopleQuery(CcdiProjectRiskModelPeopleQueryDTO queryDTO) {
if (queryDTO.getMatchMode() == null || queryDTO.getMatchMode().isBlank()) {
queryDTO.setMatchMode("ANY");
return;
}
queryDTO.setMatchMode(queryDTO.getMatchMode().trim().toUpperCase());
}
private CcdiProjectOverviewStatVO buildStat(String key, String label, Integer value) {
CcdiProjectOverviewStatVO stat = new CcdiProjectOverviewStatVO();
stat.setKey(key);
stat.setLabel(label);
stat.setValue(value);
return stat;
}
private Integer readCount(Map<String, Object> summary, String key) {
if (summary == null) {
return 0;
}
Object value = summary.get(key);
if (value == null) {
return 0;
}
if (value instanceof Number number) {
return number.intValue();
}
throw new ServiceException("项目风险人数统计结果类型异常");
}
private Integer defaultZero(Integer value) {
return value == null ? 0 : value;
}
private long defaultPageNum(Integer pageNum) {
return pageNum == null || pageNum < 1 ? 1L : pageNum.longValue();
}
private long defaultPageSize(Integer pageSize) {
return pageSize == null || pageSize < 1 ? 10L : pageSize.longValue();
}
private <T> List<T> defaultList(List<T> value) {
return value == null ? List.of() : value;
}
private CcdiProject getRequiredProject(Long projectId) {
CcdiProject project = projectMapper.selectById(projectId);
if (project == null) {
throw new ServiceException("项目不存在");
}
return project;
}
private String resolveRiskLevelName(String riskLevelCode) {
return switch (riskLevelCode) {
case "HIGH" -> "高风险";
case "MEDIUM" -> "中风险";
default -> "低风险";
};
}
private String resolveRiskLevelType(String riskLevelCode) {
return switch (riskLevelCode) {
case "HIGH" -> "danger";
case "MEDIUM" -> "warning";
default -> "info";
};
}
}

View File

@@ -115,6 +115,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
select="selectOurAccountOptions"/>
</resultMap>
<select id="countMatchedStaffCountByProjectId" resultType="java.lang.Integer">
select count(distinct trim(bs.cret_no))
from ccdi_bank_statement bs
inner join ccdi_base_staff staff on staff.id_card = bs.cret_no
where bs.project_id = #{projectId}
and bs.cret_no is not null
and trim(bs.cret_no) != ''
</select>
<sql id="parsedTrxDateExpr">
CASE
WHEN bs.TRX_DATE IS NULL OR TRIM(bs.TRX_DATE) = '' THEN NULL
@@ -319,7 +328,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
fur.file_name AS originalFileName,
fur.upload_time AS uploadTime
FROM ccdi_bank_statement bs
LEFT JOIN ccdi_file_upload_record fur ON fur.log_id = bs.batch_id AND fur.project_id = bs.project_id
LEFT JOIN (
SELECT latest_record.project_id, latest_record.log_id, latest_record.file_name, latest_record.upload_time
FROM ccdi_file_upload_record latest_record
INNER JOIN (
SELECT project_id, log_id, MAX(id) AS max_id
FROM ccdi_file_upload_record
GROUP BY project_id, log_id
) latest_meta ON latest_meta.max_id = latest_record.id
) fur ON fur.log_id = bs.batch_id AND fur.project_id = bs.project_id
WHERE bs.bank_statement_id = #{bankStatementId}
</select>

View File

@@ -396,9 +396,19 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
'占位SQL待补充真实规则' AS reasonDetail
CONCAT(
'摘要/对手命中赌博敏感词,摘要“', IFNULL(bs.USER_MEMO, ''),
'”,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''),
'”,支出金额 ', CAST(IFNULL(bs.AMOUNT_DR, 0) AS CHAR), ' 元'
) AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
inner join ccdi_base_staff staff on staff.id_card = bs.cret_no
where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_DR, 0) > 0
and (
IFNULL(bs.USER_MEMO, '') REGEXP '游戏|抖币|体彩|福彩|彩票|赌|球|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|捕鱼|电子游艺|投注'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '游戏|抖币|体彩|福彩|彩票|赌|球|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|捕鱼|电子游艺|投注'
)
</select>
<select id="selectSpecialAmountTransactionStatements" resultMap="BankTagStatementHitResultMap">
@@ -406,9 +416,25 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
'占位SQL待补充真实规则' AS reasonDetail
CONCAT(
'与非配偶/子女交易出现特殊金额 ',
CAST(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS CHAR),
' 元,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''),
'”,关系类型“', IFNULL(relation.relation_type, '非亲属'), '”'
) AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
inner join ccdi_base_staff staff on staff.id_card = bs.cret_no
left join ccdi_staff_fmy_relation relation
on relation.person_id = staff.id_card
and relation.relation_name = bs.CUSTOMER_ACCOUNT_NAME
and relation.status = 1
where bs.project_id = #{projectId}
and IFNULL(bs.LE_ACCOUNT_NAME, '') &lt;&gt; IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')
and (IFNULL(relation.relation_type, '') = '' or relation.relation_type not in ('配偶', '子女'))
and (
IFNULL(bs.AMOUNT_DR, 0) in (520, 1314)
or IFNULL(bs.AMOUNT_CR, 0) in (520, 1314)
)
</select>
<select id="selectMonthlyFixedIncomeObjects" resultMap="BankTagObjectHitResultMap">
@@ -434,9 +460,20 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
'占位SQL待补充真实规则' AS reasonDetail
CONCAT(
'摘要命中收入关键词,摘要“', IFNULL(bs.USER_MEMO, ''),
'”,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''),
'”,流入金额 ', CAST(IFNULL(bs.AMOUNT_CR, 0) AS CHAR), ' 元'
) AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
inner join ccdi_base_staff staff on staff.id_card = bs.cret_no
where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_CR, 0) > 0
and IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') &lt;&gt; '浙江兰溪农村商业银行股份有限公司'
and (
IFNULL(bs.USER_MEMO, '') REGEXP '代发|工资|分红|红利|奖金|薪酬|薪金|补贴|薪|年终奖|年金|加班费|劳务费|劳务外包|提成|劳务派遣|绩效|酬劳|批量代付|PAYROLL|SALA|CPF|directors.*fees'
or IFNULL(bs.CASH_TYPE, '') REGEXP '代发|工资|劳务费'
)
</select>
<select id="selectHouseRegistrationMismatchStatements" resultMap="BankTagStatementHitResultMap">
@@ -484,9 +521,18 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
'占位SQL待补充真实规则' AS reasonDetail
CONCAT(
'购汇交易金额 ', CAST(IFNULL(bs.AMOUNT_DR, 0) AS CHAR),
' 元,超过阈值 ', CAST(#{threshold} AS CHAR),
' 元,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''),
'”,摘要“', IFNULL(bs.USER_MEMO, ''), '”'
) AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
inner join ccdi_base_staff staff on staff.id_card = bs.cret_no
where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_DR, 0) > #{threshold}
and IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '银行|外汇|售汇|国家外汇管理局'
and IFNULL(bs.USER_MEMO, '') REGEXP '购汇|换汇|外汇|汇率|外币|现汇|人民币兑换外币|外汇买入|购外币|购买外汇'
</select>
<select id="selectForexSellAmtStatements" resultMap="BankTagStatementHitResultMap">
@@ -494,9 +540,18 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
'占位SQL待补充真实规则' AS reasonDetail
CONCAT(
'结汇交易金额 ', CAST(IFNULL(bs.AMOUNT_CR, 0) AS CHAR),
' 元,超过阈值 ', CAST(#{threshold} AS CHAR),
' 元,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''),
'”,摘要“', IFNULL(bs.USER_MEMO, ''), '”'
) AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
inner join ccdi_base_staff staff on staff.id_card = bs.cret_no
where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_CR, 0) > #{threshold}
and IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '银行|外汇|结汇|国家外汇管理局'
and IFNULL(bs.USER_MEMO, '') REGEXP '购汇|结汇|换汇|外汇|汇率|外币|现汇|结汇水单|外币兑换人民币|结汇入账|外汇结汇'
</select>
<select id="selectCrossBorderAmtStatements" resultMap="BankTagStatementHitResultMap">
@@ -520,12 +575,36 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<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
CAST(NULL AS SIGNED) AS bankStatementId,
CAST(NULL AS SIGNED) AS groupId,
CAST(NULL AS SIGNED) AS logId,
CONCAT(
'采购事项“', IFNULL(t.subjectName, ''),
'”实际采购金额 ', CAST(IFNULL(t.actualAmount, 0) AS CHAR),
' 元,供应商“', IFNULL(t.supplierName, ''), '”'
) AS reasonDetail
from (
select distinct
pt.purchase_id AS purchaseId,
pt.subject_name AS subjectName,
pt.supplier_name AS supplierName,
pt.actual_amount AS actualAmount
from ccdi_purchase_transaction pt
inner join ccdi_base_staff staff
on CAST(staff.staff_id AS CHAR) = pt.applicant_id
where IFNULL(pt.actual_amount, 0) > 100000
union
select distinct
pt.purchase_id AS purchaseId,
pt.subject_name AS subjectName,
pt.supplier_name AS supplierName,
pt.actual_amount AS actualAmount
from ccdi_purchase_transaction pt
inner join ccdi_base_staff staff
on CAST(staff.staff_id AS CHAR) = pt.purchase_leader_id
where pt.purchase_leader_id is not null
and IFNULL(pt.actual_amount, 0) > 100000
) t
</select>
<select id="selectSupplierConcentrationObjects" resultMap="BankTagObjectHitResultMap">
@@ -542,18 +621,51 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
'占位SQL待补充真实规则' AS reasonDetail
CONCAT(
'银证转账金额 ', CAST(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS CHAR),
' 元,超过阈值 ', CAST(#{threshold} AS CHAR),
' 元,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''),
'”,摘要“', IFNULL(bs.USER_MEMO, ''), '”'
) AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
inner join ccdi_base_staff staff on staff.id_card = bs.cret_no
where bs.project_id = #{projectId}
and (
IFNULL(bs.AMOUNT_DR, 0) > #{threshold}
or IFNULL(bs.AMOUNT_CR, 0) > #{threshold}
)
and (
IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|资金存管|第三方存管'
or IFNULL(bs.USER_MEMO, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|资金存管|第三方存管|银证转账|银证|证转银|银转证'
or IFNULL(bs.CASH_TYPE, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|资金存管|第三方存管|银证转账|银证|证转银|银转证'
)
</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
t.objectKey AS objectKey,
CONCAT(
'单日微信/支付宝提现 ', CAST(t.withdrawCount AS CHAR),
' 次,超过阈值 ', CAST(#{frequencyThreshold} AS CHAR),
' 次,交易日:', t.transDate
) AS reasonDetail
from (
select
staff.id_card AS objectKey,
LEFT(TRIM(bs.TRX_DATE), 10) AS transDate,
COUNT(1) AS withdrawCount
from ccdi_bank_statement bs
inner join ccdi_base_staff staff on staff.id_card = bs.cret_no
where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_CR, 0) >= 0
and (
IFNULL(bs.USER_MEMO, '') REGEXP '财付通|微信零钱|微信|wechat|WeChat|Tenpay|支付宝|Alipay|提现'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '财付通|微信零钱|微信|wechat|WeChat|Tenpay|支付宝|Alipay|提现'
)
group by staff.id_card, LEFT(TRIM(bs.TRX_DATE), 10)
having COUNT(1) > #{frequencyThreshold}
) t
</select>
<select id="selectWithdrawAmtObjects" resultMap="BankTagObjectHitResultMap">
@@ -588,9 +700,21 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
'占位SQL待补充真实规则' AS reasonDetail
CONCAT(
'三方资管交易金额 ', CAST(IFNULL(bs.AMOUNT_DR, 0) AS CHAR),
' 元,超过阈值 ', CAST(#{threshold} AS CHAR),
' 元,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''),
'”,摘要“', IFNULL(bs.USER_MEMO, ''), '”'
) AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
inner join ccdi_base_staff staff on staff.id_card = bs.cret_no
where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_DR, 0) > #{threshold}
and (
IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|理财|资金存管|第三方存管'
or IFNULL(bs.USER_MEMO, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|理财|资金存管|第三方存管'
or IFNULL(bs.CASH_TYPE, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|理财|资金存管|第三方存管'
)
</select>
<select id="selectProxyAccountOperationObjects" resultMap="BankTagObjectHitResultMap">

View File

@@ -40,4 +40,15 @@
</where>
ORDER BY p.update_time DESC
</select>
<update id="updateRiskCountsByProjectId">
update ccdi_project
set high_risk_count = #{highRiskCount},
medium_risk_count = #{mediumRiskCount},
low_risk_count = #{lowRiskCount},
update_by = #{updateBy},
update_time = now()
where project_id = #{projectId}
and del_flag = '0'
</update>
</mapper>

View File

@@ -0,0 +1,314 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.ccdi.project.mapper.CcdiProjectOverviewMapper">
<resultMap id="EmployeeRiskAggregateResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeRiskAggregateVO">
<result property="staffIdCard" column="staff_id_card"/>
<result property="staffName" column="staff_name"/>
<result property="deptId" column="dept_id"/>
<result property="deptName" column="dept_name"/>
<result property="ruleCount" column="rule_count"/>
<result property="modelCount" column="model_count"/>
<result property="hitCount" column="hit_count"/>
<result property="topRuleCode" column="top_rule_code"/>
<result property="topRuleName" column="top_rule_name"/>
<result property="riskPoint" column="risk_point"/>
<result property="riskLevelCode" column="risk_level_code"/>
<result property="riskLevelName" column="risk_level_name"/>
<result property="riskLevelSort" column="risk_level_sort"/>
</resultMap>
<resultMap id="RiskModelPeopleItemResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleItemVO">
<id property="idNo" column="staff_id_card"/>
<result property="staffName" column="staff_name"/>
<result property="staffCode" column="staff_code"/>
<result property="department" column="department"/>
<collection property="modelNames"
column="{projectId=project_id,staffIdCard=staff_id_card,selectedModelCodes=selected_model_codes}"
ofType="java.lang.String"
select="selectRiskModelNamesByScope"/>
<collection property="hitTagList"
column="{projectId=project_id,staffIdCard=staff_id_card,selectedModelCodes=selected_model_codes}"
ofType="com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskHitTagVO"
select="selectRiskHitTagsByScope"/>
</resultMap>
<sql id="resolvedEmployeeRiskBaseSql">
select distinct
tr.id,
tr.project_id,
coalesce(direct_staff.id_card, statement_staff.id_card, family_staff.id_card) as staff_id_card,
coalesce(direct_staff.name, statement_staff.name, family_staff.name) as staff_name,
cast(coalesce(direct_staff.staff_id, statement_staff.staff_id, family_staff.staff_id) as char) as staff_code,
coalesce(direct_staff.dept_id, statement_staff.dept_id, family_staff.dept_id) as dept_id,
tr.rule_code,
tr.rule_name,
tr.model_code,
tr.model_name,
tr.risk_level
from ccdi_bank_statement_tag_result tr
left join ccdi_base_staff direct_staff
on tr.object_type = 'STAFF_ID_CARD'
and tr.object_key = direct_staff.id_card
left join ccdi_bank_statement bs
on tr.bank_statement_id = bs.bank_statement_id
left join ccdi_base_staff statement_staff
on (tr.object_key is null or tr.object_key = '')
and bs.cret_no = statement_staff.id_card
left join ccdi_staff_fmy_relation relation
on relation.status = 1
and (
((tr.object_key is null or tr.object_key = '') and bs.cret_no = relation.relation_cert_no)
or ((tr.object_key is not null and tr.object_key != '') and tr.object_type != 'STAFF_ID_CARD'
and tr.object_key = relation.relation_cert_no)
)
left join ccdi_base_staff family_staff
on relation.person_id = family_staff.id_card
where tr.project_id = #{projectId}
and coalesce(direct_staff.id_card, statement_staff.id_card, family_staff.id_card) is not null
</sql>
<sql id="employeeRiskAggregateSql">
select
agg.staff_id_card,
agg.staff_name,
agg.dept_id,
dept.dept_name,
agg.rule_count,
agg.model_count,
agg.hit_count,
rule_pick.rule_code as top_rule_code,
rule_pick.rule_name as top_rule_name,
risk_points.risk_point,
case
when agg.rule_count >= 5 then 'HIGH'
when agg.rule_count between 2 and 4 then 'MEDIUM'
else 'LOW'
end as risk_level_code,
case
when agg.rule_count >= 5 then '高风险'
when agg.rule_count between 2 and 4 then '中风险'
else '低风险'
end as risk_level_name,
case
when agg.rule_count >= 5 then 1
when agg.rule_count between 2 and 4 then 2
else 3
end as risk_level_sort
from (
select
base.staff_id_card,
max(base.staff_name) as staff_name,
max(base.dept_id) as dept_id,
count(distinct base.rule_code) as rule_count,
count(distinct base.model_code) as model_count,
count(1) as hit_count
from (
<include refid="resolvedEmployeeRiskBaseSql"/>
) base
group by base.staff_id_card
) agg
left join sys_dept dept on agg.dept_id = dept.dept_id
left join (
select
chosen.staff_id_card,
chosen.rule_code,
chosen.rule_name
from (
select
grouped.staff_id_card,
grouped.rule_code,
grouped.rule_name,
grouped.hit_count
from (
select
base.staff_id_card,
base.rule_code,
max(base.rule_name) as rule_name,
count(1) as hit_count
from (
<include refid="resolvedEmployeeRiskBaseSql"/>
) base
group by base.staff_id_card, base.rule_code
) grouped
where not exists (
select 1
from (
select
base.staff_id_card,
base.rule_code,
max(base.rule_name) as rule_name,
count(1) as hit_count
from (
<include refid="resolvedEmployeeRiskBaseSql"/>
) base
group by base.staff_id_card, base.rule_code
) challenger
where challenger.staff_id_card = grouped.staff_id_card
and (
challenger.hit_count > grouped.hit_count
or (challenger.hit_count = grouped.hit_count
and challenger.rule_code &lt; grouped.rule_code)
)
)
) chosen
) rule_pick on rule_pick.staff_id_card = agg.staff_id_card
left join (
select
grouped.staff_id_card,
group_concat(grouped.rule_name order by grouped.hit_count desc, grouped.rule_code asc separator '、') as risk_point
from (
select
base.staff_id_card,
base.rule_code,
max(base.rule_name) as rule_name,
count(1) as hit_count
from (
<include refid="resolvedEmployeeRiskBaseSql"/>
) base
group by base.staff_id_card, base.rule_code
) grouped
group by grouped.staff_id_card
) risk_points on risk_points.staff_id_card = agg.staff_id_card
</sql>
<select id="selectDashboardBaseByProjectId" resultType="com.ruoyi.ccdi.project.domain.CcdiProject">
select
project_id,
project_name,
target_count,
high_risk_count,
medium_risk_count,
low_risk_count
from ccdi_project
where project_id = #{projectId}
and del_flag = '0'
</select>
<select id="selectRiskPeopleOverviewByProjectId" resultMap="EmployeeRiskAggregateResultMap">
<include refid="employeeRiskAggregateSql"/>
order by risk_level_sort asc, model_count desc, rule_count desc, staff_id_card asc
</select>
<select id="selectTopRiskPeopleByProjectId" resultMap="EmployeeRiskAggregateResultMap">
<include refid="employeeRiskAggregateSql"/>
where rule_count >= 2
order by risk_level_sort asc, model_count desc, rule_count desc, staff_id_card asc
limit 10
</select>
<select id="selectRiskModelCardsByProjectId" resultType="com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelCardVO">
select
models.model_code,
models.model_name,
coalesce(stats.warning_count, 0) as warning_count,
coalesce(stats.people_count, 0) as people_count
from (
select
rule.model_code,
max(rule.model_name) as model_name
from ccdi_bank_tag_rule rule
where enabled = 1
group by rule.model_code
) models
left join (
select
base.model_code,
count(1) as warning_count,
count(distinct base.staff_id_card) as people_count
from (
<include refid="resolvedEmployeeRiskBaseSql"/>
) base
group by base.model_code
) stats on models.model_code = stats.model_code
order by warning_count desc, model_code asc
</select>
<select id="selectRiskModelPeoplePage" resultMap="RiskModelPeopleItemResultMap">
<bind name="projectId" value="query.projectId"/>
select
base.project_id,
base.staff_id_card,
max(base.staff_name) as staff_name,
max(base.staff_code) as staff_code,
max(dept.dept_name) as department,
#{query.modelCodesCsv} as selected_model_codes
from (
<include refid="resolvedEmployeeRiskBaseSql"/>
) base
left join sys_dept dept on base.dept_id = dept.dept_id
where 1 = 1
<if test="query.modelCodes != null and query.modelCodes.size() > 0">
and base.model_code in
<foreach collection="query.modelCodes" item="modelCode" open="(" separator="," close=")">
#{modelCode}
</foreach>
</if>
<if test="query.keyword != null and query.keyword != ''">
and (
base.staff_name like concat('%', trim(#{query.keyword}), '%')
or cast(base.staff_code as char) like concat('%', trim(#{query.keyword}), '%')
)
</if>
<if test="query.deptId != null">
and base.dept_id = #{query.deptId}
</if>
group by base.project_id, base.staff_id_card
<if test="query.modelCodes != null and query.modelCodes.size() > 0 and query.matchMode == 'ALL'">
having count(distinct base.model_code) = #{query.modelCodesCount}
</if>
order by max(base.staff_name) asc, base.staff_id_card asc
</select>
<select id="selectRiskModelNamesByScope" resultType="java.lang.String">
select scoped.model_name
from (
<include refid="resolvedEmployeeRiskBaseSql"/>
) scoped
where scoped.project_id = #{projectId}
and scoped.staff_id_card = #{staffIdCard}
<if test="selectedModelCodes != null and selectedModelCodes != ''">
and find_in_set(scoped.model_code, #{selectedModelCodes})
</if>
group by scoped.model_code, scoped.model_name
order by scoped.model_code asc
</select>
<select id="selectRiskHitTagsByScope" resultType="com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskHitTagVO">
select
scoped.rule_code,
max(scoped.rule_name) as rule_name,
max(scoped.risk_level) as risk_level
from (
<include refid="resolvedEmployeeRiskBaseSql"/>
) scoped
where scoped.project_id = #{projectId}
and scoped.staff_id_card = #{staffIdCard}
<if test="selectedModelCodes != null and selectedModelCodes != ''">
and find_in_set(scoped.model_code, #{selectedModelCodes})
</if>
group by scoped.rule_code
order by case max(scoped.risk_level)
when 'HIGH' then 1
when 'MEDIUM' then 2
else 3
end,
scoped.rule_code asc
</select>
<select id="selectRiskCountSummaryByProjectId" resultType="map">
select
coalesce(sum(case when agg.rule_count >= 5 then 1 else 0 end), 0) as highRiskCount,
coalesce(sum(case when agg.rule_count between 2 and 4 then 1 else 0 end), 0) as mediumRiskCount,
coalesce(sum(case when agg.rule_count = 1 then 1 else 0 end), 0) as lowRiskCount
from (
select
base.staff_id_card,
count(distinct base.rule_code) as rule_count
from (
<include refid="resolvedEmployeeRiskBaseSql"/>
) base
group by base.staff_id_card
) agg
</select>
</mapper>

View File

@@ -0,0 +1,66 @@
package com.ruoyi.ccdi.project.controller;
import io.swagger.v3.oas.annotations.Operation;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import org.junit.jupiter.api.Test;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CcdiProjectOverviewControllerContractTest {
@Test
void shouldExposeRiskModelCardsEndpointContract() throws Exception {
Class<?> controllerClass = Class.forName("com.ruoyi.ccdi.project.controller.CcdiProjectOverviewController");
RequestMapping requestMapping = controllerClass.getAnnotation(RequestMapping.class);
Method method = controllerClass.getMethod("getRiskModelCards", Long.class);
GetMapping getMapping = method.getAnnotation(GetMapping.class);
Operation operation = method.getAnnotation(Operation.class);
assertNotNull(requestMapping);
assertEquals("/ccdi/project/overview", requestMapping.value()[0]);
assertNotNull(getMapping);
assertEquals("/risk-models/cards", getMapping.value()[0]);
assertNotNull(operation);
}
@Test
void shouldExposeRiskModelPeopleEndpointContract() throws Exception {
Class<?> controllerClass = Class.forName("com.ruoyi.ccdi.project.controller.CcdiProjectOverviewController");
Class<?> queryDtoClass = Class.forName("com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO");
Method method = controllerClass.getMethod("getRiskModelPeople", queryDtoClass);
GetMapping getMapping = method.getAnnotation(GetMapping.class);
Operation operation = method.getAnnotation(Operation.class);
assertNotNull(getMapping);
assertEquals("/risk-models/people", getMapping.value()[0]);
assertNotNull(operation);
assertEquals(queryDtoClass, method.getParameterTypes()[0]);
}
@Test
void shouldExposeRiskModelPeopleQueryDtoFields() throws Exception {
Class<?> dtoClass = Class.forName("com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO");
List<String> fieldNames = Arrays.stream(dtoClass.getDeclaredFields())
.map(Field::getName)
.collect(Collectors.toList());
assertTrue(fieldNames.contains("projectId"));
assertTrue(fieldNames.contains("modelCodes"));
assertTrue(fieldNames.contains("matchMode"));
assertTrue(fieldNames.contains("keyword"));
assertTrue(fieldNames.contains("deptId"));
assertTrue(fieldNames.contains("pageNum"));
assertTrue(fieldNames.contains("pageSize"));
}
}

View File

@@ -0,0 +1,106 @@
package com.ruoyi.ccdi.project.controller;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewDashboardVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskPeopleOverviewItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskPeopleOverviewVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleVO;
import com.ruoyi.ccdi.project.service.ICcdiProjectOverviewService;
import com.ruoyi.common.core.domain.AjaxResult;
import io.swagger.v3.oas.annotations.Operation;
import java.lang.reflect.Method;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CcdiProjectOverviewControllerTest {
@InjectMocks
private CcdiProjectOverviewController controller;
@Mock
private ICcdiProjectOverviewService overviewService;
@Test
void shouldExposeDashboardEndpoint() throws Exception {
when(overviewService.getDashboard(40L)).thenReturn(new CcdiProjectOverviewDashboardVO());
AjaxResult result = controller.getDashboard(40L);
assertEquals(200, result.get("code"));
verify(overviewService).getDashboard(40L);
RequestMapping mapping = CcdiProjectOverviewController.class.getAnnotation(RequestMapping.class);
Method method = CcdiProjectOverviewController.class.getMethod("getDashboard", Long.class);
GetMapping getMapping = method.getAnnotation(GetMapping.class);
PreAuthorize preAuthorize = method.getAnnotation(PreAuthorize.class);
Operation operation = method.getAnnotation(Operation.class);
assertNotNull(mapping);
assertEquals("/ccdi/project/overview", mapping.value()[0]);
assertNotNull(getMapping);
assertEquals("/dashboard", getMapping.value()[0]);
assertNotNull(preAuthorize);
assertEquals("@ss.hasPermi('ccdi:project:query')", preAuthorize.value());
assertNotNull(operation);
}
@Test
void shouldExposeRiskPeopleEndpoint() throws Exception {
CcdiProjectRiskPeopleOverviewItemVO item = new CcdiProjectRiskPeopleOverviewItemVO();
item.setRiskLevel("中风险");
item.setRiskLevelType("warning");
item.setModelCount(4);
CcdiProjectRiskPeopleOverviewVO overview = new CcdiProjectRiskPeopleOverviewVO();
overview.setOverviewList(List.of(item));
when(overviewService.getRiskPeopleOverview(40L)).thenReturn(overview);
AjaxResult result = controller.getRiskPeople(40L);
assertEquals(200, result.get("code"));
CcdiProjectRiskPeopleOverviewVO data = (CcdiProjectRiskPeopleOverviewVO) result.get("data");
assertEquals("中风险", data.getOverviewList().getFirst().getRiskLevel());
assertEquals("warning", data.getOverviewList().getFirst().getRiskLevelType());
assertEquals(4, data.getOverviewList().getFirst().getModelCount());
verify(overviewService).getRiskPeopleOverview(40L);
Method method = CcdiProjectOverviewController.class.getMethod("getRiskPeople", Long.class);
GetMapping getMapping = method.getAnnotation(GetMapping.class);
PreAuthorize preAuthorize = method.getAnnotation(PreAuthorize.class);
assertNotNull(getMapping);
assertEquals("/risk-people", getMapping.value()[0]);
assertNotNull(preAuthorize);
assertEquals("@ss.hasPermi('ccdi:project:query')", preAuthorize.value());
}
@Test
void shouldExposeTopRiskPeopleEndpoint() throws Exception {
when(overviewService.getTopRiskPeople(40L)).thenReturn(new CcdiProjectTopRiskPeopleVO());
AjaxResult result = controller.getTopRiskPeople(40L);
assertEquals(200, result.get("code"));
verify(overviewService).getTopRiskPeople(40L);
Method method = CcdiProjectOverviewController.class.getMethod("getTopRiskPeople", Long.class);
GetMapping getMapping = method.getAnnotation(GetMapping.class);
PreAuthorize preAuthorize = method.getAnnotation(PreAuthorize.class);
assertNotNull(getMapping);
assertEquals("/top-risk-people", getMapping.value()[0]);
assertNotNull(preAuthorize);
assertEquals("@ss.hasPermi('ccdi:project:query')", preAuthorize.value());
}
}

View File

@@ -121,7 +121,10 @@ class CcdiBankStatementMapperXmlTest {
String xml = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
assertTrue(
xml.contains("LEFT JOIN ccdi_file_upload_record fur ON fur.log_id = bs.batch_id AND fur.project_id = bs.project_id"),
xml.contains("LEFT JOIN (")
&& xml.contains("SELECT latest_record.project_id, latest_record.log_id, latest_record.file_name, latest_record.upload_time")
&& xml.contains("MAX(id) AS max_id")
&& xml.contains("fur.log_id = bs.batch_id AND fur.project_id = bs.project_id"),
xml
);
assertTrue(xml.contains("fur.file_name AS originalFileName"), xml);

View File

@@ -8,6 +8,8 @@ import java.io.StringReader;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -15,31 +17,33 @@ 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> PHASE_ONE_STATEMENT_SELECT_IDS = List.of(
"selectGamblingSensitiveKeywordStatements",
"selectSpecialAmountTransactionStatements",
"selectSuspiciousIncomeKeywordStatements",
"selectForexBuyAmtStatements",
"selectForexSellAmtStatements",
"selectLargePurchaseTransactionStatements",
"selectStockTfrLargeStatements",
"selectLargeStockTradingStatements"
);
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"
);
@@ -74,6 +78,9 @@ class CcdiBankTagAnalysisMapperXmlTest {
@Test
void allPlaceholderRules_shouldExistInAnalysisMapperXml() throws Exception {
String xml = readXml(RESOURCE);
for (String selectId : PHASE_ONE_STATEMENT_SELECT_IDS) {
assertTrue(xml.contains(selectId), () -> "缺少第一期规则 SQL: " + selectId);
}
for (String selectId : PLACEHOLDER_SELECT_IDS) {
assertTrue(xml.contains(selectId), () -> "缺少占位规则 SQL: " + selectId);
}
@@ -83,7 +90,30 @@ class CcdiBankTagAnalysisMapperXmlTest {
void placeholderRules_shouldUseEmptyResultSqlTemplate() throws Exception {
String xml = readXml(RESOURCE);
assertTrue(xml.contains("占位SQL待补充真实规则"));
assertEquals(25, countMatches(xml, "where 1 = 0"));
assertEquals(16, countMatches(xml, "where 1 = 0"));
}
@Test
void phaseOneStatementRules_shouldUseRealSqlAndKeepHitFields() throws Exception {
String xml = readXml(RESOURCE);
for (String selectId : PHASE_ONE_STATEMENT_SELECT_IDS) {
String selectSql = extractSelectSql(xml, selectId);
assertTrue(selectSql.contains("AS bankStatementId"), () -> selectId + " 缺少 bankStatementId");
assertTrue(selectSql.contains("AS groupId"), () -> selectId + " 缺少 groupId");
assertTrue(selectSql.contains("AS logId"), () -> selectId + " 缺少 logId");
assertTrue(selectSql.contains("reasonDetail"), () -> selectId + " 缺少 reasonDetail");
assertTrue(!selectSql.contains("where 1 = 0"), () -> selectId + " 仍是占位 SQL");
}
}
@Test
void withdrawCntObjectRule_shouldUseRealSqlAndKeepObjectHitFields() throws Exception {
String xml = readXml(RESOURCE);
String selectSql = extractSelectSql(xml, "selectWithdrawCntObjects");
assertTrue(selectSql.contains("'STAFF_ID_CARD' AS objectType"));
assertTrue(selectSql.contains("AS objectKey"));
assertTrue(selectSql.contains("reasonDetail"));
assertTrue(!selectSql.contains("where 1 = 0"));
}
@Test
@@ -110,4 +140,13 @@ class CcdiBankTagAnalysisMapperXmlTest {
}
return count;
}
private String extractSelectSql(String xml, String selectId) {
Pattern pattern = Pattern.compile(
"<select\\s+id=\"" + selectId + "\"[\\s\\S]*?</select>"
);
Matcher matcher = pattern.matcher(xml);
assertTrue(matcher.find(), () -> "未找到 select: " + selectId);
return matcher.group();
}
}

View File

@@ -0,0 +1,34 @@
package com.ruoyi.ccdi.project.mapper;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CcdiProjectOverviewMapperRiskModelCardsTest {
@Test
void shouldExposeRiskModelCardsMapperMethod() throws Exception {
Method method = CcdiProjectOverviewMapper.class.getMethod("selectRiskModelCardsByProjectId", Long.class);
assertNotNull(method);
}
@Test
void shouldDefineRiskModelCardsSqlUsingEmployeeResolvedBase() throws Exception {
String xml = Files.readString(Path.of("src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml"));
assertTrue(xml.contains("<select id=\"selectRiskModelCardsByProjectId\""));
assertTrue(xml.contains("from ("));
assertTrue(xml.contains("from ccdi_bank_tag_rule"));
assertTrue(xml.contains("where enabled = 1"));
assertTrue(xml.contains("left join ("));
assertTrue(xml.contains("<include refid=\"resolvedEmployeeRiskBaseSql\"/>"));
assertTrue(xml.contains("coalesce(stats.warning_count, 0) as warning_count"));
assertTrue(xml.contains("coalesce(stats.people_count, 0) as people_count"));
assertTrue(xml.contains("count(1) as warning_count"));
assertTrue(xml.contains("order by warning_count desc, model_code asc"));
}
}

View File

@@ -0,0 +1,51 @@
package com.ruoyi.ccdi.project.mapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CcdiProjectOverviewMapperRiskModelPeopleTest {
@Test
void shouldExposeRiskModelPeoplePageMapperMethod() throws Exception {
Method method = CcdiProjectOverviewMapper.class.getMethod(
"selectRiskModelPeoplePage",
Page.class,
CcdiProjectRiskModelPeopleQueryDTO.class
);
assertEquals(Page.class, method.getReturnType());
assertEquals(Page.class, method.getParameterTypes()[0]);
assertEquals(CcdiProjectRiskModelPeopleQueryDTO.class, method.getParameterTypes()[1]);
}
@Test
void shouldDefineRiskModelPeopleSqlForAnyAllKeywordDeptAndScopedCollections() throws Exception {
String xml = Files.readString(Path.of("src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml"));
assertTrue(xml.contains("<select id=\"selectRiskModelPeoplePage\""));
assertTrue(xml.contains("query.modelCodes != null and query.modelCodes.size() > 0"));
assertTrue(xml.contains("query.matchMode == 'ALL'"));
assertFalse(xml.contains("#{query.modelCodes.size}"));
assertTrue(xml.contains("count(distinct base.model_code) = #{query.modelCodesCount}"));
assertTrue(xml.contains("<bind name=\"projectId\" value=\"query.projectId\"/>"));
assertTrue(xml.contains("base.staff_name like concat('%', trim(#{query.keyword}), '%')"));
assertTrue(xml.contains("cast(base.staff_code as char) like concat('%', trim(#{query.keyword}), '%')"));
assertTrue(xml.contains("base.dept_id = #{query.deptId}"));
assertTrue(xml.contains("select=\"selectRiskModelNamesByScope\""));
assertTrue(xml.contains("select=\"selectRiskHitTagsByScope\""));
assertTrue(xml.contains("find_in_set(scoped.model_code, #{selectedModelCodes})"));
assertFalse(xml.contains("select distinct scoped.model_name"));
assertTrue(xml.contains("group by scoped.model_code, scoped.model_name"));
assertTrue(xml.contains("order by scoped.model_code asc"));
assertTrue(xml.contains("order by case max(scoped.risk_level)"));
assertTrue(xml.contains("scoped.rule_code asc"));
}
}

View File

@@ -0,0 +1,35 @@
package com.ruoyi.ccdi.project.mapper;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CcdiProjectOverviewMapperSqlTest {
@Test
void shouldContainEmployeeRiskAggregationSql() throws Exception {
String xml = Files.readString(Path.of("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("count(1) as hit_count"));
assertTrue(xml.contains("agg.hit_count"));
assertTrue(xml.contains("when agg.rule_count >= 5 then 'HIGH'"));
assertTrue(xml.contains("when agg.rule_count between 2 and 4 then 'MEDIUM'"));
assertTrue(xml.contains("group_concat("));
assertTrue(xml.contains("as risk_point"));
assertTrue(xml.contains("order by grouped.hit_count desc, grouped.rule_code asc"));
}
@Test
void shouldAvoidWindowFunctionsForMysql57Compatibility() throws Exception {
String xml = Files.readString(Path.of("src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml"));
assertFalse(xml.contains("row_number() over"), xml);
assertTrue(xml.contains("not exists"), xml);
}
}

View File

@@ -0,0 +1,18 @@
package com.ruoyi.ccdi.project.service;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertNotNull;
class CcdiProjectOverviewServiceStructureTest {
@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));
}
}

View File

@@ -17,6 +17,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.slf4j.LoggerFactory;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -130,6 +131,38 @@ class BankTagRuleConfigResolverTest {
assertEquals("8888", config.getThresholdValue("ANNUAL_TURNOVER"));
}
@Test
void resolve_shouldMapPhaseOneThresholdRulesToUppercaseParamCodes() {
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
project.setConfigType("default");
when(projectMapper.selectById(40L)).thenReturn(project);
assertSingleThresholdRule("SUSPICIOUS_FOREIGN_EXCHANGE", "FOREX_BUY_AMT",
"SINGLE_PURCHASE_AMOUNT", "50000");
assertSingleThresholdRule("SUSPICIOUS_FOREIGN_EXCHANGE", "FOREX_SELL_AMT",
"SINGLE_SETTLEMENT_AMOUNT", "60000");
assertSingleThresholdRule("ABNORMAL_BEHAVIOR", "WITHDRAW_CNT",
"WITHDRAW_CNT", "3");
assertSingleThresholdRule("ABNORMAL_BEHAVIOR", "STOCK_TFR_LARGE",
"STOCK_TFR_LARGE", "1000000");
assertSingleThresholdRule("ABNORMAL_BEHAVIOR", "LARGE_STOCK_TRADING",
"STOCK_TFR_LARGE", "1000000");
}
@Test
void resolve_shouldKeepEmptyThresholdsForPhaseOneRulesWithoutParams() {
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
project.setConfigType("default");
when(projectMapper.selectById(40L)).thenReturn(project);
assertRuleHasNoThresholds("SUSPICIOUS_GAMBLING", "GAMBLING_SENSITIVE_KEYWORD");
assertRuleHasNoThresholds("SUSPICIOUS_RELATION", "SPECIAL_AMOUNT_TRANSACTION");
assertRuleHasNoThresholds("SUSPICIOUS_PART_TIME", "SUSPICIOUS_INCOME_KEYWORD");
assertRuleHasNoThresholds("SUSPICIOUS_PURCHASE", "LARGE_PURCHASE_TRANSACTION");
}
private CcdiModelParam buildParam(String paramCode, String paramValue) {
CcdiModelParam param = new CcdiModelParam();
param.setProjectId(0L);
@@ -138,4 +171,42 @@ class BankTagRuleConfigResolverTest {
param.setParamValue(paramValue);
return param;
}
private void assertSingleThresholdRule(String modelCode, String ruleCode, String paramCode, String paramValue) {
when(modelParamMapper.selectByProjectAndModel(0L, modelCode)).thenReturn(List.of(
buildParam(modelCode, paramCode, paramValue)
));
CcdiBankTagRule ruleMeta = new CcdiBankTagRule();
ruleMeta.setModelCode(modelCode);
ruleMeta.setRuleCode(ruleCode);
ruleMeta.setIndicatorCode(paramCode);
BankTagRuleExecutionConfig config = resolver.resolve(40L, ruleMeta);
assertEquals(Map.of(paramCode, paramValue), config.getThresholdValues());
}
private void assertRuleHasNoThresholds(String modelCode, String ruleCode) {
when(modelParamMapper.selectByProjectAndModel(0L, modelCode)).thenReturn(List.of(
buildParam(modelCode, "IGNORED_PARAM", "999")
));
CcdiBankTagRule ruleMeta = new CcdiBankTagRule();
ruleMeta.setModelCode(modelCode);
ruleMeta.setRuleCode(ruleCode);
BankTagRuleExecutionConfig config = resolver.resolve(40L, ruleMeta);
assertTrue(config.getThresholdValues().isEmpty());
}
private CcdiModelParam buildParam(String modelCode, String paramCode, String paramValue) {
CcdiModelParam param = new CcdiModelParam();
param.setProjectId(0L);
param.setModelCode(modelCode);
param.setParamCode(paramCode);
param.setParamValue(paramValue);
return param;
}
}

View File

@@ -6,12 +6,14 @@ import ch.qos.logback.core.read.ListAppender;
import com.ruoyi.ccdi.project.domain.entity.CcdiBankTagRule;
import com.ruoyi.ccdi.project.domain.entity.CcdiBankTagTask;
import com.ruoyi.ccdi.project.domain.enums.TriggerType;
import com.ruoyi.ccdi.project.domain.vo.BankTagObjectHitVO;
import com.ruoyi.ccdi.project.domain.vo.BankTagRuleExecutionConfig;
import com.ruoyi.ccdi.project.domain.vo.BankTagStatementHitVO;
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.ICcdiProjectOverviewService;
import com.ruoyi.ccdi.project.service.ICcdiProjectService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -23,6 +25,7 @@ import org.slf4j.LoggerFactory;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executor;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -60,6 +63,9 @@ class CcdiBankTagServiceImplTest {
@Mock
private ICcdiProjectService projectService;
@Mock
private ICcdiProjectOverviewService projectOverviewService;
@Mock
private ProjectBankTagRebuildCoordinator coordinator;
@@ -70,6 +76,13 @@ class CcdiBankTagServiceImplTest {
verify(coordinator).submitAuto(40L, TriggerType.AUTO_PARAM_CHANGE);
}
@Test
void submitAutoRebuild_shouldKeepAutoFileDeleteTriggerType() {
service.submitAutoRebuild(40L, TriggerType.AUTO_FILE_DELETE);
verify(coordinator).submitAuto(40L, TriggerType.AUTO_FILE_DELETE);
}
@Test
void rebuildProject_shouldDeleteOldResultsBeforeSubmittingRuleTasks() {
ReflectionTestUtils.setField(service, "tagRuleExecutor", (Executor) Runnable::run);
@@ -232,6 +245,52 @@ class CcdiBankTagServiceImplTest {
verify(taskMapper).updateTask(argThat(task -> "SUCCESS".equals(task.getStatus()) && task.getFailedRuleCount() == 0));
}
@Test
void rebuildProject_shouldDispatchWithdrawCntObjectRuleWithResolvedThreshold() {
ReflectionTestUtils.setField(service, "tagRuleExecutor", (Executor) Runnable::run);
CcdiBankTagRule rule = buildRule("ABNORMAL_BEHAVIOR", "异常行为",
"WITHDRAW_CNT", "微信支付宝频繁提现", "OBJECT");
BankTagRuleExecutionConfig config = buildConfig(40L, rule);
config.setThresholdValues(Map.of("WITHDRAW_CNT", "3"));
BankTagObjectHitVO hit = new BankTagObjectHitVO();
hit.setObjectType("STAFF_ID_CARD");
hit.setObjectKey("330101199001011234");
hit.setReasonDetail("单日微信提现 4 次,超过阈值 3 次");
when(ruleMapper.selectEnabledRules("ABNORMAL_BEHAVIOR")).thenReturn(List.of(rule));
when(configResolver.resolve(40L, rule)).thenReturn(config);
when(analysisMapper.selectWithdrawCntObjects(40L, 3)).thenReturn(List.of(hit));
service.rebuildProject(40L, "ABNORMAL_BEHAVIOR", "admin", TriggerType.MANUAL);
verify(analysisMapper).selectWithdrawCntObjects(40L, 3);
verify(resultMapper).insertBatch(argThat(results -> results.size() == 1
&& "STAFF_ID_CARD".equals(results.get(0).getObjectType())
&& "330101199001011234".equals(results.get(0).getObjectKey())));
}
@Test
void rebuildProject_shouldTreatEmptyWithdrawCntHitsAsSuccess() {
ReflectionTestUtils.setField(service, "tagRuleExecutor", (Executor) Runnable::run);
CcdiBankTagRule rule = buildRule("ABNORMAL_BEHAVIOR", "异常行为",
"WITHDRAW_CNT", "微信支付宝频繁提现", "OBJECT");
BankTagRuleExecutionConfig config = buildConfig(40L, rule);
config.setThresholdValues(Map.of("WITHDRAW_CNT", "3"));
when(ruleMapper.selectEnabledRules("ABNORMAL_BEHAVIOR")).thenReturn(List.of(rule));
when(configResolver.resolve(40L, rule)).thenReturn(config);
when(analysisMapper.selectWithdrawCntObjects(40L, 3)).thenReturn(List.of());
service.rebuildProject(40L, "ABNORMAL_BEHAVIOR", "admin", TriggerType.MANUAL);
verify(analysisMapper).selectWithdrawCntObjects(40L, 3);
verify(resultMapper, never()).insertBatch(anyList());
verify(taskMapper).updateTask(argThat(task -> "SUCCESS".equals(task.getStatus()) && task.getHitCount() == 0));
}
@Test
void shouldMarkProjectTaggingBeforeExecutingAndCompletedAfterSuccess() {
ReflectionTestUtils.setField(service, "tagRuleExecutor", (Executor) Runnable::run);

View File

@@ -0,0 +1,143 @@
package com.ruoyi.ccdi.project.service.impl;
import com.ruoyi.ccdi.project.domain.entity.CcdiBankTagRule;
import com.ruoyi.ccdi.project.domain.entity.CcdiBankTagTask;
import com.ruoyi.ccdi.project.domain.enums.TriggerType;
import com.ruoyi.ccdi.project.domain.vo.BankTagRuleExecutionConfig;
import com.ruoyi.ccdi.project.domain.vo.BankTagStatementHitVO;
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.ICcdiProjectOverviewService;
import com.ruoyi.ccdi.project.service.ICcdiProjectService;
import java.util.List;
import java.util.concurrent.Executor;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InOrder;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import static org.junit.jupiter.api.Assertions.assertThrows;
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.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CcdiBankTagServiceRiskCountRefreshTest {
@InjectMocks
private CcdiBankTagServiceImpl service;
@Mock
private CcdiBankTagRuleMapper ruleMapper;
@Mock
private CcdiBankTagResultMapper resultMapper;
@Mock
private CcdiBankTagTaskMapper taskMapper;
@Mock
private CcdiBankTagAnalysisMapper analysisMapper;
@Mock
private BankTagRuleConfigResolver configResolver;
@Mock
private ICcdiProjectService projectService;
@Mock
private ICcdiProjectOverviewService projectOverviewService;
@Mock
private ProjectBankTagRebuildCoordinator coordinator;
@Test
void shouldRefreshProjectRiskCountsAfterTagRebuildSuccess() {
ReflectionTestUtils.setField(service, "tagRuleExecutor", (Executor) Runnable::run);
CcdiBankTagRule rule = buildRule();
BankTagRuleExecutionConfig config = buildConfig(rule);
BankTagStatementHitVO hit = buildHit();
doAnswer(invocation -> {
CcdiBankTagTask task = invocation.getArgument(0);
task.setId(101L);
return 1;
}).when(taskMapper).insertTask(any(CcdiBankTagTask.class));
when(ruleMapper.selectEnabledRules(null)).thenReturn(List.of(rule));
when(configResolver.resolve(40L, rule)).thenReturn(config);
when(analysisMapper.selectHouseOrCarExpenseStatements(40L)).thenReturn(List.of(hit));
service.rebuildProject(40L, null, "tester", TriggerType.MANUAL);
InOrder inOrder = inOrder(projectService, resultMapper, projectOverviewService, taskMapper);
inOrder.verify(projectService).updateProjectStatus(40L, "3", "tester");
inOrder.verify(resultMapper).deleteByProjectAndModel(40L, null);
inOrder.verify(resultMapper).insertBatch(anyList());
inOrder.verify(projectOverviewService).refreshProjectRiskCounts(40L, "tester");
inOrder.verify(taskMapper).updateTask(argThat(task -> "SUCCESS".equals(task.getStatus())));
inOrder.verify(projectService).updateProjectStatus(40L, "1", "tester");
}
@Test
void shouldFailTaskWhenRiskCountRefreshFails() {
ReflectionTestUtils.setField(service, "tagRuleExecutor", (Executor) Runnable::run);
CcdiBankTagRule rule = buildRule();
BankTagRuleExecutionConfig config = buildConfig(rule);
doAnswer(invocation -> {
CcdiBankTagTask task = invocation.getArgument(0);
task.setId(102L);
return 1;
}).when(taskMapper).insertTask(any(CcdiBankTagTask.class));
when(ruleMapper.selectEnabledRules(null)).thenReturn(List.of(rule));
when(configResolver.resolve(40L, rule)).thenReturn(config);
when(analysisMapper.selectHouseOrCarExpenseStatements(40L)).thenReturn(List.of(buildHit()));
doThrow(new RuntimeException("refresh failed"))
.when(projectOverviewService).refreshProjectRiskCounts(40L, "tester");
assertThrows(RuntimeException.class,
() -> service.rebuildProject(40L, null, "tester", TriggerType.MANUAL));
verify(taskMapper).updateTask(argThat(task -> "FAILED".equals(task.getStatus())
&& "refresh failed".equals(task.getErrorMessage())));
verify(projectService).updateProjectStatus(40L, "0", "tester");
}
private CcdiBankTagRule buildRule() {
CcdiBankTagRule rule = new CcdiBankTagRule();
rule.setModelCode("LARGE_TRANSACTION");
rule.setModelName("大额交易");
rule.setRuleCode("HOUSE_OR_CAR_EXPENSE");
rule.setRuleName("房车消费支出交易");
rule.setResultType("STATEMENT");
return rule;
}
private BankTagRuleExecutionConfig buildConfig(CcdiBankTagRule rule) {
BankTagRuleExecutionConfig config = new BankTagRuleExecutionConfig();
config.setProjectId(40L);
config.setRuleMeta(rule);
return config;
}
private BankTagStatementHitVO buildHit() {
BankTagStatementHitVO hit = new BankTagStatementHitVO();
hit.setBankStatementId(10L);
hit.setGroupId(40);
hit.setLogId(40001);
hit.setReasonDetail("命中房车消费支出");
return hit;
}
}

View File

@@ -284,6 +284,10 @@ class CcdiFileUploadServiceImplTest {
List<String> events = new ArrayList<>();
AtomicInteger sequence = new AtomicInteger();
captureRecordStatus(events, sequence);
CcdiProject project = new CcdiProject();
project.setProjectId(PROJECT_ID);
when(projectMapper.selectById(PROJECT_ID)).thenReturn(project);
when(bankStatementMapper.countMatchedStaffCountByProjectId(PROJECT_ID)).thenReturn(1);
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any())).thenReturn(buildUploadResponse());
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
@@ -304,6 +308,9 @@ class CcdiFileUploadServiceImplTest {
int successIndex = findEventIndex(events, "record:parsed_success");
assertTrue(fetchIndex >= 0);
assertTrue(successIndex > fetchIndex);
verify(projectMapper).updateById(org.mockito.ArgumentMatchers.<CcdiProject>argThat(item ->
PROJECT_ID.equals(item.getProjectId()) && Integer.valueOf(1).equals(item.getTargetCount())
));
}
@Test
@@ -362,13 +369,18 @@ class CcdiFileUploadServiceImplTest {
record.setLsfxProjectId(LSFX_PROJECT_ID);
record.setLogId(LOG_ID);
record.setFileStatus("parsed_success");
CcdiProject project = new CcdiProject();
project.setProjectId(PROJECT_ID);
when(recordMapper.selectById(RECORD_ID)).thenReturn(record);
when(lsfxClient.deleteFiles(any())).thenReturn(buildDeleteFilesResponse());
when(projectMapper.selectById(PROJECT_ID)).thenReturn(project);
when(bankStatementMapper.countMatchedStaffCountByProjectId(PROJECT_ID)).thenReturn(2);
when(recordMapper.updateById(any(CcdiFileUploadRecord.class))).thenReturn(1);
String result = service.deleteFileUploadRecord(RECORD_ID, 9527L);
assertEquals("删除成功", result);
assertEquals("删除成功已开始项目重新打标", result);
verify(lsfxClient).deleteFiles(argThat(request ->
request.getGroupId().equals(LSFX_PROJECT_ID)
&& request.getUserId().equals(9527)
@@ -379,6 +391,10 @@ class CcdiFileUploadServiceImplTest {
verify(recordMapper).updateById(org.mockito.ArgumentMatchers.<CcdiFileUploadRecord>argThat(item ->
RECORD_ID.equals(item.getId()) && "deleted".equals(item.getFileStatus())
));
verify(bankTagService).submitAutoRebuild(PROJECT_ID, TriggerType.AUTO_FILE_DELETE);
verify(projectMapper).updateById(org.mockito.ArgumentMatchers.<CcdiProject>argThat(item ->
PROJECT_ID.equals(item.getProjectId()) && Integer.valueOf(2).equals(item.getTargetCount())
));
}
@Test
@@ -405,6 +421,7 @@ class CcdiFileUploadServiceImplTest {
assertThrows(RuntimeException.class, () -> service.deleteFileUploadRecord(RECORD_ID, 9527L));
verify(bankStatementMapper, never()).deleteByProjectIdAndBatchId(any(), any());
verify(bankTagService, never()).submitAutoRebuild(any(), any());
verify(recordMapper, never()).updateById(org.mockito.ArgumentMatchers.<CcdiFileUploadRecord>argThat(item ->
"deleted".equals(item.getFileStatus())
));
@@ -544,6 +561,20 @@ class CcdiFileUploadServiceImplTest {
}
}
@Test
void refreshProjectTargetCount_shouldUseMatchedStaffCountOnly() {
CcdiProject project = new CcdiProject();
project.setProjectId(PROJECT_ID);
when(projectMapper.selectById(PROJECT_ID)).thenReturn(project);
when(bankStatementMapper.countMatchedStaffCountByProjectId(PROJECT_ID)).thenReturn(2);
ReflectionTestUtils.invokeMethod(service, "refreshProjectTargetCount", PROJECT_ID);
verify(projectMapper).updateById(org.mockito.ArgumentMatchers.<CcdiProject>argThat(item ->
PROJECT_ID.equals(item.getProjectId()) && Integer.valueOf(2).equals(item.getTargetCount())
));
}
@Test
void processFileAsync_shouldMarkParsedFailedWhenInsertBatchThrowsUnexpectedSqlError() throws IOException {
List<String> events = new ArrayList<>();

View File

@@ -0,0 +1,237 @@
package com.ruoyi.ccdi.project.service.impl;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeRiskAggregateVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewDashboardVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelCardVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelCardsVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskPeopleOverviewVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleVO;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectOverviewMapper;
import com.ruoyi.common.exception.ServiceException;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CcdiProjectOverviewServiceImplTest {
@InjectMocks
private CcdiProjectOverviewServiceImpl service;
@Mock
private CcdiProjectOverviewMapper overviewMapper;
@Mock
private CcdiProjectMapper projectMapper;
@Test
void shouldBuildDashboardWithNoRiskCount() {
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
project.setTargetCount(100);
project.setHighRiskCount(5);
project.setMediumRiskCount(10);
project.setLowRiskCount(15);
when(overviewMapper.selectDashboardBaseByProjectId(40L)).thenReturn(project);
CcdiProjectOverviewDashboardVO dashboard = service.getDashboard(40L);
assertEquals("风险仪表盘", dashboard.getTitle());
assertEquals(70, dashboard.getStats().get(4).getValue());
}
@Test
void shouldMapRiskPeopleOverviewRows() {
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
when(projectMapper.selectById(40L)).thenReturn(project);
CcdiProjectEmployeeRiskAggregateVO aggregate = new CcdiProjectEmployeeRiskAggregateVO();
aggregate.setStaffName("李四");
aggregate.setStaffIdCard("330000000000000001");
aggregate.setDeptName("信息二部");
aggregate.setRuleCount(5);
aggregate.setHitCount(8);
aggregate.setRiskLevelCode("HIGH");
aggregate.setModelCount(3);
aggregate.setRiskPoint("大额单笔收入、疑似兼职");
when(overviewMapper.selectRiskPeopleOverviewByProjectId(40L)).thenReturn(List.of(aggregate));
CcdiProjectRiskPeopleOverviewVO overview = service.getRiskPeopleOverview(40L);
assertEquals(1, overview.getOverviewList().size());
assertEquals(8, overview.getOverviewList().getFirst().getRiskCount());
assertEquals("高风险", overview.getOverviewList().getFirst().getRiskLevel());
assertEquals("danger", overview.getOverviewList().getFirst().getRiskLevelType());
assertEquals(3, overview.getOverviewList().getFirst().getModelCount());
assertEquals("大额单笔收入、疑似兼职", overview.getOverviewList().getFirst().getRiskPoint());
assertEquals("查看详情", overview.getOverviewList().getFirst().getActionLabel());
}
@Test
void shouldMapTopRiskPeopleRows() {
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
when(projectMapper.selectById(40L)).thenReturn(project);
CcdiProjectEmployeeRiskAggregateVO aggregate = new CcdiProjectEmployeeRiskAggregateVO();
aggregate.setStaffName("张三");
aggregate.setStaffIdCard("330000000000000002");
aggregate.setDeptName("信贷部");
aggregate.setRiskLevelCode("HIGH");
aggregate.setModelCount(8);
when(overviewMapper.selectTopRiskPeopleByProjectId(40L)).thenReturn(List.of(aggregate));
CcdiProjectTopRiskPeopleVO topRiskPeople = service.getTopRiskPeople(40L);
assertEquals(1, topRiskPeople.getTopRiskList().size());
assertEquals("高风险", topRiskPeople.getTopRiskList().getFirst().getRiskLevel());
assertEquals("danger", topRiskPeople.getTopRiskList().getFirst().getRiskLevelType());
assertEquals("查看详情", topRiskPeople.getTopRiskList().getFirst().getActionLabel());
}
@Test
void shouldThrowWhenProjectDoesNotExist() {
when(projectMapper.selectById(99L)).thenReturn(null);
assertThrows(ServiceException.class, () -> service.getRiskPeopleOverview(99L));
assertThrows(ServiceException.class, () -> service.getTopRiskPeople(99L));
assertThrows(ServiceException.class, () -> service.getRiskModelCards(99L));
assertThrows(ServiceException.class, () -> service.getRiskModelPeople(buildRiskModelPeopleQuery(99L)));
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void shouldConvertBigDecimalRiskCountsWhenRefreshingProjectRiskCounts() {
CcdiProject project = new CcdiProject();
project.setProjectId(43L);
when(projectMapper.selectById(43L)).thenReturn(project);
HashMap summary = new HashMap();
summary.put("highRiskCount", new BigDecimal("2"));
summary.put("mediumRiskCount", new BigDecimal("1"));
summary.put("lowRiskCount", new BigDecimal("3"));
when(overviewMapper.selectRiskCountSummaryByProjectId(43L)).thenReturn(summary);
service.refreshProjectRiskCounts(43L, "tester");
verify(projectMapper).updateRiskCountsByProjectId(eq(43L), eq(2), eq(1), eq(3), eq("tester"));
}
@Test
void shouldWrapRiskModelCardsIntoCardList() {
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
when(projectMapper.selectById(40L)).thenReturn(project);
CcdiProjectRiskModelCardVO card = new CcdiProjectRiskModelCardVO();
card.setModelCode("MODEL_A");
card.setWarningCount(3);
when(overviewMapper.selectRiskModelCardsByProjectId(40L)).thenReturn(List.of(card));
CcdiProjectRiskModelCardsVO result = service.getRiskModelCards(40L);
assertNotNull(result.getCardList());
assertEquals(1, result.getCardList().size());
assertEquals("MODEL_A", result.getCardList().getFirst().getModelCode());
assertEquals(3, result.getCardList().getFirst().getWarningCount());
}
@Test
void shouldWrapRiskModelPeopleRowsTotalAndActionLabel() {
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
when(projectMapper.selectById(40L)).thenReturn(project);
CcdiProjectRiskModelPeopleItemVO item = new CcdiProjectRiskModelPeopleItemVO();
item.setStaffName("王五");
item.setStaffCode("10001");
Page<CcdiProjectRiskModelPeopleItemVO> page = new Page<>(2, 5);
page.setRecords(List.of(item));
page.setTotal(11);
when(overviewMapper.selectRiskModelPeoplePage(any(Page.class), any(CcdiProjectRiskModelPeopleQueryDTO.class)))
.thenReturn(page);
CcdiProjectRiskModelPeopleVO result = service.getRiskModelPeople(buildRiskModelPeopleQuery(40L));
assertNotNull(result.getRows());
assertEquals(1, result.getRows().size());
assertEquals(11L, result.getTotal());
assertEquals("查看详情", result.getRows().getFirst().getActionLabel());
}
@Test
void shouldReturnEmptyCollectionsForRiskModelCardsAndPeople() {
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
when(projectMapper.selectById(40L)).thenReturn(project);
when(overviewMapper.selectRiskModelCardsByProjectId(40L)).thenReturn(List.of());
Page<CcdiProjectRiskModelPeopleItemVO> emptyPage = new Page<>(1, 10);
emptyPage.setRecords(List.of());
emptyPage.setTotal(0);
when(overviewMapper.selectRiskModelPeoplePage(any(Page.class), any(CcdiProjectRiskModelPeopleQueryDTO.class)))
.thenReturn(emptyPage);
CcdiProjectRiskModelCardsVO cards = service.getRiskModelCards(40L);
CcdiProjectRiskModelPeopleVO people = service.getRiskModelPeople(buildRiskModelPeopleQuery(40L));
assertNotNull(cards.getCardList());
assertEquals(0, cards.getCardList().size());
assertNotNull(people.getRows());
assertEquals(0, people.getRows().size());
assertEquals(0L, people.getTotal());
}
@Test
void shouldDefaultRiskModelPeopleMatchModeToAny() {
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
when(projectMapper.selectById(40L)).thenReturn(project);
Page<CcdiProjectRiskModelPeopleItemVO> emptyPage = new Page<>(1, 10);
emptyPage.setRecords(List.of());
emptyPage.setTotal(0);
when(overviewMapper.selectRiskModelPeoplePage(any(Page.class), any(CcdiProjectRiskModelPeopleQueryDTO.class)))
.thenReturn(emptyPage);
CcdiProjectRiskModelPeopleQueryDTO queryDTO = buildRiskModelPeopleQuery(40L);
queryDTO.setMatchMode(null);
service.getRiskModelPeople(queryDTO);
verify(overviewMapper).selectRiskModelPeoplePage(
any(Page.class),
argThat(query -> "ANY".equals(query.getMatchMode()))
);
}
private CcdiProjectRiskModelPeopleQueryDTO buildRiskModelPeopleQuery(Long projectId) {
CcdiProjectRiskModelPeopleQueryDTO queryDTO = new CcdiProjectRiskModelPeopleQueryDTO();
queryDTO.setProjectId(projectId);
queryDTO.setPageNum(1);
queryDTO.setPageSize(10);
return queryDTO;
}
}

View File

@@ -0,0 +1,48 @@
package com.ruoyi.ccdi.project.sql;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CcdiBankTagRuleSqlMetadataTest {
@Test
void phase1MetadataSql_shouldAlignInitAndMigrationScripts() throws IOException {
String initSql = readProjectFile("sql", "2026-03-16-bank-tagging.sql");
String migrationSql = readProjectFile("sql", "migration", "2026-03-20-sync-bank-tag-phase1-rule-metadata.sql");
assertPhase1Metadata(initSql);
assertPhase1Metadata(migrationSql);
}
private void assertPhase1Metadata(String sqlContent) {
assertAll(
() -> assertTrue(sqlContent.contains("'FOREX_BUY_AMT'")
&& sqlContent.contains("'SINGLE_PURCHASE_AMOUNT'"),
"FOREX_BUY_AMT 应使用 SINGLE_PURCHASE_AMOUNT"),
() -> assertTrue(sqlContent.contains("'FOREX_SELL_AMT'")
&& sqlContent.contains("'SINGLE_SETTLEMENT_AMOUNT'"),
"FOREX_SELL_AMT 应使用 SINGLE_SETTLEMENT_AMOUNT"),
() -> assertTrue(sqlContent.contains("'LARGE_STOCK_TRADING'")
&& sqlContent.contains("'STOCK_TFR_LARGE'"),
"LARGE_STOCK_TRADING 应使用 STOCK_TFR_LARGE"),
() -> assertTrue(sqlContent.contains("真实规则:识别单笔购汇金额超过阈值的流水"),
"应同步 FOREX_BUY_AMT 的真实规则说明"),
() -> assertTrue(sqlContent.contains("真实规则:识别单笔结汇金额超过阈值的流水"),
"应同步 FOREX_SELL_AMT 的真实规则说明"),
() -> assertTrue(sqlContent.contains("真实规则:识别单笔三方资管交易金额超过阈值的流水"),
"应同步 LARGE_STOCK_TRADING 的真实规则说明")
);
}
private String readProjectFile(String... parts) throws IOException {
Path path = Path.of("..", parts);
return Files.readString(path, StandardCharsets.UTF_8);
}
}

View File

@@ -81,7 +81,9 @@
- 统计“员工本人 + 员工亲属”
- 若命中亲属,则归并到所属员工名下
4. 风险人员总览中的 `疑似违规数`
- 统计员工命中的去重规则数
- 统计归并到该员工名下的打标明细数量
- 包含员工本人命中与亲属命中
- 同一规则多次命中按多条明细累计
5. 员工风险等级口径
- 命中规则数 `>= 5`:高风险
- 命中规则数 `2-4`:中风险
@@ -188,7 +190,7 @@
"idNo": "330000000000000001",
"department": "信息二部",
"riskCount": 5,
"riskPoint": "大额单笔收入",
"riskPoint": "大额单笔收入、疑似兼职",
"actionLabel": "查看详情"
}
]
@@ -248,8 +250,10 @@
- `deptName`
- `ruleCount`
- `modelCount`
- `hitCount`
- `topRuleCode`
- `topRuleName`
- `riskPoint`
- `riskLevelCode`
- `riskLevelName`
- `riskLevelSort`
@@ -258,6 +262,7 @@
- `ruleCount = count(distinct rule_code)`
- `modelCount = count(distinct model_code)`
- `hitCount = count(1)`,表示归并到员工名下的打标明细数
- `riskLevelCode` 根据 `ruleCount` 映射为 `HIGH/MEDIUM/LOW`
- `riskLevelName` 映射为 `高风险/中风险/低风险`
- `riskLevelSort` 仅供 SQL 排序使用,`HIGH=1``MEDIUM=2``LOW=3`
@@ -349,7 +354,7 @@ Mapper 只负责以下查询:
建议采用“公共子查询 + 外层聚合”的方式:
1. 先构造标签结果到员工身份证的归并明细
2. 再按员工身份证聚合规则数、模型数和代表性规则
2. 再按员工身份证聚合规则数、模型数和核心异常点拼接结果
3. 最后外层补部门名称、风险等级和排序字段
建议公共子查询输出字段:
@@ -360,16 +365,18 @@ Mapper 只负责以下查询:
- `rule_name`
- `model_code`
### 9.4 代表性异常点选择
风险人员总览中的“疑似违规数”使用员工聚合后的 `hitCount`风险等级、TOP10 和项目人数回写继续使用 `ruleCount`
### 9.4 核心异常点拼接策略
`风险人员总览.riskPoint` 采用以下稳定选择策略:
1. 先按员工 + 规则维度统计命中次数
2. 按命中次数倒序
3. 再按 `rule_code` 升序
4. 取第一条 `rule_name`
4. 将排序后的全部 `rule_name``、` 拼接成字符串
这样不依赖数据库非确定性行为,不会因为同条规则多次命中而随机波动。
这样既能展示员工命中的多条核心异常点,也不依赖数据库非确定性行为,不会因为同条规则多次命中而随机波动。
### 9.5 TOP10 筛选规则
@@ -444,8 +451,8 @@ TOP10 查询仅保留:
2. 风险人员总览
- 校验员工本人命中可正常归并
- 校验亲属命中可正常归并到员工
- 校验 `riskCount = 去重规则数`
- 校验 `riskPoint` 选择稳定
- 校验 `riskCount = 打标明细数`
- 校验 `riskPoint` 多规则拼接顺序稳定
3. TOP10
- 校验仅返回中高风险
- 校验排序规则正确

View File

@@ -0,0 +1,359 @@
# 银行流水模型真实规则两阶段落地设计文档
**模块**: 银行流水打标
**日期**: 2026-03-20
## 一、背景
当前项目已经具备银行流水打标基础链路:
- 规则元数据表 `ccdi_bank_tag_rule`
- 结果表 `ccdi_bank_statement_tag_result`
- 任务表 `ccdi_bank_tag_task`
- 规则执行入口 `CcdiBankTagServiceImpl`
- SQL 承载文件 `CcdiBankTagAnalysisMapper.xml`
- 参数解析器 `BankTagRuleConfigResolver`
其中“大额交易”模型下的首批规则已经具备真实 SQL其余不少规则虽然已经补齐了
- `rule_code`
- `model_code`
- Service 分发入口
- Mapper 方法签名
- XML `select`
但当前 XML 仍是 `where 1 = 0` 的占位 SQL尚未产生真实命中结果。
根据 [assets/模型信息.xlsx](/Users/wkc/Desktop/ccdi/ccdi/assets/模型信息.xlsx) 当前整理结果,现有未真实实现、且在本环境中已判定可直接执行 SQL 的银行流水模型共 19 条。本次需求是将这 19 条规则从“占位规则”替换为“真实规则”。
## 二、目标
本次设计目标如下:
1. 将 19 条未真实实现的银行流水模型接入现有真实打标链路。
2. 保持现有规则式架构不变,不新增平行模块或兼容性补丁链路。
3. 将真实命中逻辑统一收敛到 `CcdiBankTagAnalysisMapper.xml`
4. 补齐规则执行所需的参数映射、初始化 SQL 与验证测试。
5. 为控制改动规模,将 19 条规则拆成两期实施,并分别产出前后端实施计划。
## 三、范围
### 3.1 本次范围
- `CcdiBankTagAnalysisMapper.xml` 中 19 条占位规则替换为真实 SQL
- `CcdiBankTagServiceImpl` 中对应规则的真实执行分发校准
- `BankTagRuleConfigResolver` 中新增规则参数映射补齐
- `sql/` 下与本次规则相关的初始化脚本或增量脚本调整
- 单元测试、结构测试与回归测试补齐
- 新增 1 份设计文档、2 份后端实施计划、2 份前端实施计划
### 3.2 不在本次范围
- 不新增前端页面字段、交互或展示组件
- 不引入动态规则引擎、脚本注册中心或 DSL
- 不新增数据库表结构
- 不处理 `xlsx` 中当前环境不可直接执行的规则
- 不改造“大额交易”已真实落地的 8 条规则逻辑
## 四、现状分析
### 4.1 当前实现结构
当前银行流水打标流程为:
1. `CcdiBankTagServiceImpl.rebuildProject(...)` 加载启用规则。
2.`rule_code` 分发到 `executeStatementRule / executeObjectRule`
3. 通过 `CcdiBankTagAnalysisMapper` 调用 XML 中的具体 SQL。
4. 将命中结果组装为 `CcdiBankTagResult` 后批量入库。
5. 刷新项目风险人数,供结果总览等后续页面使用。
### 4.2 当前核心问题
19 条目标规则目前已具备“入口”,但不具备“真实命中能力”:
- `ccdi_bank_tag_rule` 中已有规则元数据
- `CcdiBankTagServiceImpl` 中已有大部分分发分支
- `CcdiBankTagAnalysisMapper.java` 中已有对应方法
- `CcdiBankTagAnalysisMapper.xml` 中对应 `<select>` 仍为占位 SQL
因此当前的主要缺口不是“补骨架”,而是“把占位 SQL 换成真实规则 SQL并补齐参数映射与测试”。
### 4.3 当前环境可直接落地的 19 条规则
#### 第一期
- `GAMBLING_SENSITIVE_KEYWORD`
- `SPECIAL_AMOUNT_TRANSACTION`
- `SUSPICIOUS_INCOME_KEYWORD`
- `SINGLE_PURCHASE_AMOUNT`
- `SINGLE_SETTLEMENT_AMOUNT`
- `LARGE_PURCHASE_TRANSACTION`
- `STOCK_TFR_LARGE`
- `LARGE_STOCK_TRADING`
- `WITHDRAW_CNT`
#### 第二期
- `LOW_INCOME_RELATIVE_LARGE_TRANSACTION`
- `MULTI_PARTY_GAMBLING_TRANSFER`
- `MONTHLY_FIXED_INCOME`
- `FIXED_COUNTERPARTY_TRANSFER`
- `HOUSE_REGISTRATION_MISMATCH`
- `PROPERTY_FEE_REGISTRATION_MISMATCH`
- `TAX_ASSET_REGISTRATION_MISMATCH`
- `SUPPLIER_CONCENTRATION`
- `SALARY_QUICK_TRANSFER`
- `SALARY_UNUSED`
## 五、方案对比
### 5.1 方案一:一次性直接替换 19 条占位 SQL
优点:
- 最短路径
- 架构最稳定
- 规则口径统一落地后可立即进入现有结果链路
缺点:
- 单次改动面较大
- SQL、参数、测试回归压力集中
### 5.2 方案二:按模型组拆成多轮交付
优点:
- 单次改动更小
缺点:
- 一部分规则真实、一部分规则仍占位,阶段内口径不完整
- 和本次“一次性纳入两批规划”的目标不一致
### 5.3 方案三:改造成更通用的动态规则架构
优点:
- 长期可维护性表面上更统一
缺点:
- 明显超出本次需求
- 会把风险扩大到 Service、Mapper、配置、测试结构整体重构
### 5.4 结论
采用方案一,但执行层面拆成两期实施:
- 总体架构保持不变
- 设计一次性定稿
- 实施按“先易后难”拆成两期
## 六、总体设计
### 6.1 架构原则
本次遵循以下原则:
1. 不引入新模块
2. 不新增兼容性双轨逻辑
3. 不在 Java Service 层补业务口径判断
4. 所有真实命中逻辑统一落在 `CcdiBankTagAnalysisMapper.xml`
5. 参数解析仍通过 `BankTagRuleConfigResolver`
### 6.2 数据流
真实规则执行链路保持为:
1. `CcdiBankTagServiceImpl.rebuildProject(...)` 发起重算。
2. `ruleMapper.selectEnabledRules(modelCode)` 获取启用规则。
3. `BankTagRuleConfigResolver.resolve(projectId, rule)` 解析项目有效参数。
4. `executeStatementRule / executeObjectRule` 依据 `rule_code` 分发。
5. `CcdiBankTagAnalysisMapper.xml` 执行真实 SQL返回 `STATEMENT``OBJECT` 命中结果。
6. Service 将命中结果组装为 `CcdiBankTagResult` 并批量入库。
7. 调用 `projectOverviewService.refreshProjectRiskCounts(projectId, operator)` 刷新项目风险人数。
### 6.3 规则落地边界
#### 流水明细型规则
输出:
- `bankStatementId`
- `groupId`
- `logId`
- `reasonDetail`
代表规则包括:
- `GAMBLING_SENSITIVE_KEYWORD`
- `SPECIAL_AMOUNT_TRANSACTION`
- `SUSPICIOUS_INCOME_KEYWORD`
- `HOUSE_REGISTRATION_MISMATCH`
- `PROPERTY_FEE_REGISTRATION_MISMATCH`
- `TAX_ASSET_REGISTRATION_MISMATCH`
- `LARGE_PURCHASE_TRANSACTION`
- `STOCK_TFR_LARGE`
- `LARGE_STOCK_TRADING`
- 外汇购汇/结汇规则
#### 对象型规则
输出:
- `objectType`
- `objectKey`
- `reasonDetail`
对象维度统一使用员工主键口径:
- `objectType = STAFF_ID_CARD`
- `objectKey = 员工身份证号`
代表规则包括:
- `LOW_INCOME_RELATIVE_LARGE_TRANSACTION`
- `MULTI_PARTY_GAMBLING_TRANSFER`
- `MONTHLY_FIXED_INCOME`
- `FIXED_COUNTERPARTY_TRANSFER`
- `SUPPLIER_CONCENTRATION`
- `WITHDRAW_CNT`
- `SALARY_QUICK_TRANSFER`
- `SALARY_UNUSED`
### 6.4 参数策略
参数只通过现有 `ccdi_model_param` 获取,不新增第二参数源。
本次需要补齐的规则参数映射包括:
- `MULTI_PARTY_GAMBLING_TRANSFER` -> `MULTI_PARTY_AMT_MIN``MULTI_PARTY_AMT_MAX`
- `FIXED_COUNTERPARTY_TRANSFER` -> `FIXED_COUNTERPARTY_TRANSFER_MIN``FIXED_COUNTERPARTY_TRANSFER_MAX`
- `SINGLE_PURCHASE_AMOUNT` 对应购汇类单值阈值
- `SINGLE_SETTLEMENT_AMOUNT` 对应结汇类单值阈值
- `WITHDRAW_CNT` 对应提现频次阈值
- `STOCK_TFR_LARGE``LARGE_STOCK_TRADING` 等单值阈值按现有参数脚本统一校准
对本身不依赖阈值的规则,不强行补充虚假参数。
### 6.5 错误处理策略
本次不增加补丁式兜底逻辑:
- 真实 SQL 报错:整次打标任务按现有逻辑失败
- 规则无命中:任务正常成功,命中数为 0
- 参数缺失:按现有解析结果暴露问题,并通过脚本与测试补齐
## 七、两期拆分设计
### 7.1 第一期设计目标
优先替换依赖简单、回归范围可控的规则,形成第一批真实命中能力。
第一期规则:
- `GAMBLING_SENSITIVE_KEYWORD`
- `SPECIAL_AMOUNT_TRANSACTION`
- `SUSPICIOUS_INCOME_KEYWORD`
- `SINGLE_PURCHASE_AMOUNT`
- `SINGLE_SETTLEMENT_AMOUNT`
- `LARGE_PURCHASE_TRANSACTION`
- `STOCK_TFR_LARGE`
- `LARGE_STOCK_TRADING`
- `WITHDRAW_CNT`
第一期特点:
- 单表筛选为主
- 依赖关系较少
- 参数映射简单
- 适合先完成真实 SQL 替换和基础回归
### 7.2 第二期设计目标
完成跨表比对、对象聚合和窗口口径更复杂的规则。
第二期规则:
- `LOW_INCOME_RELATIVE_LARGE_TRANSACTION`
- `MULTI_PARTY_GAMBLING_TRANSFER`
- `MONTHLY_FIXED_INCOME`
- `FIXED_COUNTERPARTY_TRANSFER`
- `HOUSE_REGISTRATION_MISMATCH`
- `PROPERTY_FEE_REGISTRATION_MISMATCH`
- `TAX_ASSET_REGISTRATION_MISMATCH`
- `SUPPLIER_CONCENTRATION`
- `SALARY_QUICK_TRANSFER`
- `SALARY_UNUSED`
第二期特点:
- 跨表比对较多
- 聚合口径更复杂
- 对交易时间、关系人口径、资产登记口径更敏感
## 八、测试设计
### 8.1 XML 结构测试
`CcdiBankTagAnalysisMapperXmlTest` 中补充:
- 目标规则不再使用 `where 1 = 0`
- 新增真实 SQL 仍满足 XML 结构合法
- `STATEMENT` 规则仍返回完整命中字段
- `OBJECT` 规则仍返回完整命中字段
### 8.2 参数解析测试
`BankTagRuleConfigResolverTest` 中补充:
- 新增规则参数映射断言
- 双阈值规则参数完整性断言
- 无参数规则仍返回空参数集
### 8.3 Service 分发测试
`CcdiBankTagServiceImplTest` 中补充:
- 每条新增规则都能命中正确 Mapper 方法
- 不再落入默认空分支
- 真实规则空命中时任务仍可成功结束
### 8.4 受影响链路回归
至少覆盖:
- `CcdiBankTagAnalysisMapperXmlTest`
- `BankTagRuleConfigResolverTest`
- `CcdiBankTagServiceImplTest`
- 受影响的项目风险人数回写与结果总览后端测试
## 九、文档产物
按仓库规范,本次输出以下文档:
1. 本设计文档:
- `docs/design/2026-03-20-bank-tag-real-rule-two-phase-design.md`
2. 第一期后端实施计划:
- `docs/plans/backend/2026-03-20-bank-tag-real-rule-phase1-backend-implementation.md`
3. 第一期前端实施计划:
- `docs/plans/frontend/2026-03-20-bank-tag-real-rule-phase1-frontend-implementation.md`
4. 第二期后端实施计划:
- `docs/plans/backend/2026-03-20-bank-tag-real-rule-phase2-backend-implementation.md`
5. 第二期前端实施计划:
- `docs/plans/frontend/2026-03-20-bank-tag-real-rule-phase2-frontend-implementation.md`
其中前端实施计划用于明确边界:
- 本期无前端代码改造
- 前端仅做依赖说明和联调预期记录
## 十、结论
本方案采用“现有架构内真实替换占位 SQL”的最短路径设计并将 19 条目标规则拆成两期实施:
- 第一期先处理依赖少、风险低的 9 条规则
- 第二期再处理聚合与跨表逻辑更重的 10 条规则
这样既满足“一次性完成完整设计”的要求,又能在实施阶段有效控制改动规模和回归风险,不会偏离当前项目既有技术路线。

View File

@@ -0,0 +1,398 @@
# 兰溪流水 Mock 随机命中规则设计文档
**模块**: `lsfx-mock-server`
**日期**: 2026-03-20
## 一、背景
当前 `lsfx-mock-server` 已接通以下主链路:
1. `getJZFileOrZjrcuFile` 创建 `logId` 并保存主绑定、员工及亲属身份范围
2. `getBSByLogId``logId` 生成流水并分页返回
3. 同一 `logId` 首次生成的流水结果会缓存,后续分页查询保持稳定
现有问题有两类:
1. 大额交易命中样本当前为固定全量注入,不符合“随机命中一部分”的联调需求
2. 第一期新增真实规则虽然已在主项目后端落地,但 `lsfx-mock-server` 还没有对应的随机命中样本,导致“获取流水列表并存储到兰溪本地”这条链路无法稳定覆盖新增规则
本次需求要求:
1. 大额交易和新增第一期规则都改为随机命中一部分
2. 随机结果对同一个 `logId` 必须稳定,不能每次查询重新变化
3. `getJZFileOrZjrcuFile -> getBSByLogId` 生成出的流水可以被兰溪本地接口正确存储
4. 必要时允许直接修改关联数据库最小量数据,使新增模型可以正确打标
## 二、目标
本次设计目标如下:
1.`lsfx-mock-server` 的固定全量命中样本改造成“按 `logId` 稳定随机命中规则子集”
2. 保持现有 `FileService -> StatementService -> cache` 主链路不变
3. 将样本职责拆到“规则命中计划”和“规则样本拼装”两个层次,避免随机逻辑散落
4. 让大额交易模型与第一期新增规则都具备“随机命中一部分”的流水样本
5. 对无法通过银行流水接口伪造的规则,采用最小数据库基线补齐,不增加兼容性补丁链路
## 三、范围
### 3.1 本次范围
- 修改 `lsfx-mock-server/services/file_service.py`
- 修改 `lsfx-mock-server/services/statement_service.py`
- 修改 `lsfx-mock-server/services/statement_rule_samples.py`
- 修改 `lsfx-mock-server` 相关单元测试与集成测试
- 按最小范围调整关联数据库中的基线业务数据,使非流水表规则可被正确打标
- 补充设计文档与后续实施计划文档
### 3.2 不在本次范围
- 不新增新的 Mock 服务模块
- 不把 Mock 服务改造成实时从数据库拼装整套流水
- 不改造主项目银行流水打标架构
- 不引入兼容性双轨逻辑
- 不为前端新增页面或交互
## 四、现状分析
### 4.1 Mock 服务现状
当前 `lsfx-mock-server` 中:
- `FileService.fetch_inner_flow()` 负责生成 `logId`、主绑定、日期范围和员工亲属身份
- `StatementService._generate_statements()` 会混入固定大额交易命中样本,再补随机噪声流水
- `StatementService.get_bank_statement()` 首次生成后缓存 200 条流水,同一 `logId` 分页结果稳定
这套结构已经满足“同一 `logId` 查询稳定”的基础要求,但样本生成仍是“默认全量命中”,不满足当前需求。
### 4.2 主项目规则现状
根据 [docs/plans/backend/2026-03-20-bank-tag-real-rule-phase1-backend-implementation.md](/Users/wkc/Desktop/ccdi/ccdi/docs/plans/backend/2026-03-20-bank-tag-real-rule-phase1-backend-implementation.md) 与 [docs/design/2026-03-20-bank-tag-real-rule-two-phase-design.md](/Users/wkc/Desktop/ccdi/ccdi/docs/design/2026-03-20-bank-tag-real-rule-two-phase-design.md),第一期真实规则已覆盖:
- `GAMBLING_SENSITIVE_KEYWORD`
- `SPECIAL_AMOUNT_TRANSACTION`
- `SUSPICIOUS_INCOME_KEYWORD`
- `FOREX_BUY_AMT`
- `FOREX_SELL_AMT`
- `LARGE_PURCHASE_TRANSACTION`
- `STOCK_TFR_LARGE`
- `LARGE_STOCK_TRADING`
- `WITHDRAW_CNT`
其中:
- `GAMBLING_SENSITIVE_KEYWORD`
- `SPECIAL_AMOUNT_TRANSACTION`
- `SUSPICIOUS_INCOME_KEYWORD`
- `FOREX_BUY_AMT`
- `FOREX_SELL_AMT`
- `STOCK_TFR_LARGE`
- `LARGE_STOCK_TRADING`
- `WITHDRAW_CNT`
都可以通过 Mock 银行流水直接构造命中条件。
`LARGE_PURCHASE_TRANSACTION` 的真实 SQL 查询的是 `ccdi_purchase_transaction`,不是 `ccdi_bank_statement`,因此不适合伪造成银行流水命中。
### 4.3 本次关键约束
用户已明确确认以下约束:
1. 大额交易和新增第一期规则都改为随机命中一部分,而不是固定全量命中
2. 随机结果采用“同一个 `logId` 首次生成时随机决定,后续保持不变”
因此本次设计必须同时满足:
- 有随机性
- 可复现
- 可测试
- 不因重复查询导致结果漂移
## 五、方案对比
### 5.1 方案 A按规则包稳定随机命中推荐
做法:
- 把“大额交易”和“第一期新增规则”拆成多个独立规则包
- `fetch_inner_flow()` 生成 `logId` 时,基于 `logId` 计算一份稳定的规则命中计划
- `getBSByLogId` 首次查该 `logId` 时,根据命中计划拼装命中样本并补噪声流水
优点:
- 同一 `logId` 结果稳定
- 不同 `logId` 命中子集不同,随机性自然
- 可以按规则粒度精确控制命中范围
- 测试可以直接断言“规则命中计划稳定”
缺点:
- 需要把现有固定全量样本生成器改造成可按规则装配的结构
### 5.2 方案 B按场景模板随机命中
做法:
- 预置若干整套场景模板,如“偏大额交易”“偏新增规则”“混合命中”
- 每个 `logId` 只随机选择一套模板
优点:
- 实现快
缺点:
- 随机粒度粗
- 容易出现某些规则长期捆绑命中,不够贴近真实联调需求
### 5.3 方案 C重度依赖数据库动态拼装流水
做法:
- 先写大量数据库测试数据
- Mock 服务每次从数据库读取对象关系与业务记录,再动态拼装流水
优点:
- 表面上更接近生产链路
缺点:
- 耦合高,调试成本大
- 超出本次最短路径要求
- 会把 Mock 服务从“可控样本生成器”变成“数据库依赖型服务”
### 5.4 结论
采用方案 A。
## 六、总体设计
### 6.1 设计原则
1. 随机决策只在 `FileService` 做一次
2. 流水生成只在 `StatementService` 做一次并缓存
3. 样本模板只在 `statement_rule_samples.py` 维护
4. 不新增新的服务层或中间模块
5. 对不能通过银行流水构造的规则,使用最小数据库基线补齐
### 6.2 数据流
改造后的数据流如下:
1. `getJZFileOrZjrcuFile` 调用 `FileService.fetch_inner_flow()`
2. `FileService` 创建 `FileRecord` 时,基于 `logId` 生成并保存规则命中计划
3. `getBSByLogId` 调用 `StatementService.get_bank_statement()`
4. `StatementService` 首次查询该 `logId` 时,从 `FileRecord` 读取命中计划
5. `statement_rule_samples.py` 根据命中计划生成对应规则样本
6. `StatementService` 再补足噪声流水到固定 200 条
7. 首次生成的结果缓存到内存,后续分页继续复用同一份流水
### 6.3 规则命中计划设计
规则命中计划按两大类保存:
- `largeTransactionHitRules`
- `phase1HitRules`
规则命中计划保存的是规则代码集合,不保存具体流水明细。
同一个 `logId` 的命中计划由固定种子生成,例如:
- `Random("rule-plan:{logId}")`
由此保证:
- 同一个 `logId` 命中子集稳定
- 不同 `logId` 命中子集大概率不同
### 6.4 职责划分
#### `FileService`
职责:
- 创建 `logId`
- 绑定主体、账号、员工及亲属身份
- 生成并保存规则命中计划
不负责:
- 生成具体流水明细
- 执行规则样本拼装
#### `StatementService`
职责:
- 首次读取 `logId` 时,根据规则命中计划生成流水
- 补足噪声流水
- 分页返回并缓存结果
不负责:
- 决定本次命中哪些规则
#### `statement_rule_samples.py`
职责:
- 按规则代码生成对应命中样本
- 提供统一入口,按命中计划拼装样本集合
不负责:
- 随机选择规则
- 缓存或分页
## 七、规则包设计
### 7.1 大额交易规则包
保留现有已实现的大额交易命中样本,但改为按规则粒度单独开关,不再默认全量注入。
每条规则对应一个独立样本 builder由命中计划决定是否加入。
### 7.2 第一期新增规则包
本次通过银行流水构造的第一期规则包为:
- `GAMBLING_SENSITIVE_KEYWORD`
- `SPECIAL_AMOUNT_TRANSACTION`
- `SUSPICIOUS_INCOME_KEYWORD`
- `FOREX_BUY_AMT`
- `FOREX_SELL_AMT`
- `STOCK_TFR_LARGE`
- `LARGE_STOCK_TRADING`
- `WITHDRAW_CNT`
每个规则包只生成命中该规则所需的最小流水集合,不附带额外未请求逻辑。
### 7.3 `LARGE_PURCHASE_TRANSACTION` 边界
`LARGE_PURCHASE_TRANSACTION` 不放入 Mock 银行流水规则包中,原因如下:
1. 主项目真实 SQL 查询来源是 `ccdi_purchase_transaction`
2. `getBSByLogId` 返回的是银行流水,无法自然映射采购业务表
3. 若将其伪造成银行流水命中,会偏离真实打标逻辑
因此该规则采用“数据库最小基线补齐”的方式处理,不强行混入 Mock 流水。
## 八、随机策略设计
### 8.1 随机约束
为避免“完全不命中”或“一次性全命中”的极端情况,本次采用有边界的随机策略:
- 大额交易规则包:随机命中 2 到 4 条
- 第一期新增流水规则包:随机命中 2 到 4 条
- 两大类都至少命中 1 条
- `WITHDRAW_CNT` 允许独立命中,不要求与明细型规则绑定
未命中的规则不生成伪样本,只保留普通噪声流水。
### 8.2 稳定性要求
同一个 `logId`
- 首次生成命中计划后固定不变
- 首次生成流水后缓存结果固定不变
- 后续分页查询不得重新随机
## 九、数据库基线设计
### 9.1 可直接复用的数据
继续复用当前 `StaffIdentityRepository` 从真实数据库读取的:
- 员工身份证
- 有效亲属身份证
这部分已经满足 `SPECIAL_AMOUNT_TRANSACTION``WITHDRAW_CNT` 等规则的对象关系基础。
### 9.2 需要最小补齐的数据
只补齐真实打标必需、但无法通过 Mock 流水自然构造的最小数据:
1. 若被选中的员工缺少足够的亲属关系区分数据,则补齐最小亲属关系记录,确保能区分“配偶/子女”和“非配偶子女”
2.`LARGE_PURCHASE_TRANSACTION` 补最小量 `ccdi_purchase_transaction` 业务数据,使真实 SQL 可命中
### 9.3 明确不做的事
- 不大规模预置 `ccdi_bank_statement`
- 不让 Mock 服务实时依赖数据库生成整套流水
- 不为数据库不存在的业务链路追加补丁式兼容方案
## 十、错误处理
1. 若数据库中无法读取员工及亲属身份,保持现有失败语义,直接暴露错误
2. 若某次规则命中计划生成异常,不写入 `FileRecord`
3.`getBSByLogId` 首次生成流水失败,不写入缓存,避免脏缓存
4. 若某类规则本次未被随机命中,不视为错误,只是本次子集未覆盖
5. `LARGE_PURCHASE_TRANSACTION` 不因未出现在 Mock 流水中而报错,其命中依赖数据库基线数据
## 十一、测试设计
### 11.1 `tests/test_file_service.py`
验证:
- `fetch_inner_flow()` 会生成并保存规则命中计划
- 同一 `logId` 的命中计划稳定
- 命中计划中同时包含大额交易与第一期新增规则两类结果
### 11.2 `tests/test_statement_service.py`
验证:
- 按命中计划只生成对应规则子集样本,而不是固定全量命中
- 同一 `logId` 重复查询时结果不漂移
- 不同 `logId` 大概率得到不同命中子集
- 命中规则对应的样本字段能被主项目真实 SQL 识别
### 11.3 `tests/integration/test_full_workflow.py`
验证:
- `getJZFileOrZjrcuFile -> getBSByLogId` 主链路可正常返回
- 返回结果既包含随机命中样本,也包含普通噪声流水
- 上传状态与流水接口仍共享同一主绑定信息
### 11.4 数据库基线验证
`LARGE_PURCHASE_TRANSACTION` 单独验证:
- 基线业务数据补齐后,主项目真实规则 SQL 可命中
- 不将该规则混入 Mock 流水命中断言
## 十二、实施与文档要求
后续进入实施计划阶段时,本次仅产出后端实施计划文档:
1. 后端实施计划:`docs/plans/backend/`
原因如下:
- 本次改造范围仅涉及 `lsfx-mock-server`、相关测试与最小数据库基线补齐
- 不涉及前端页面、接口封装、交互或展示调整
- 用户已明确要求本次只需要后端实施计划
后端实施计划需覆盖:
- `lsfx-mock-server` 随机命中规则改造
- 单元测试与集成测试调整
- `LARGE_PURCHASE_TRANSACTION` 相关数据库基线补齐
- 实施记录与验证记录沉淀
同时补充实施记录文档,记录本次具体改动内容与验证结果。
## 十三、结论
本次采用“按 `logId` 稳定随机命中规则包”的方式改造 `lsfx-mock-server`
- 保留现有主链路
- 不新增模块
- 不引入补丁式兼容方案
- 让大额交易和第一期新增规则都能随机命中一部分
- 对同一个 `logId` 保持稳定、可复现、可联调
对无法通过银行流水自然构造的 `LARGE_PURCHASE_TRANSACTION`,采用最小数据库基线补齐,不伪造银行流水,以确保整个方案逻辑正确并与主项目真实打标链路一致。

View File

@@ -0,0 +1,268 @@
# 模型信息 XLSX 更新与 SQL 可执行性校验设计文档
**模块**: 模型信息资料维护
**日期**: 2026-03-20
## 一、背景
当前仓库中存在模型信息资料文件 [assets/模型信息.xlsx](/Users/wkc/Desktop/ccdi/ccdi/assets/模型信息.xlsx)其中维护了各类风险模型的业务口径、技术口径、指标、SQL 等信息。
本次需求要求在不改变文件主体结构的前提下,基于当前项目代码和当前数据库真实结构,对该文件进行一次面向落地性的整理:
1. 将项目和数据库中已经明确存在的信息补充回 XLSX。
2. 对尚未在项目中真实实现的模型,检查其 SQL 在当前环境中能否执行。
3. 对不能执行的 SQL明确指出当前缺少的对象、字段或执行条件。
## 二、目标
本次设计目标如下:
1. 直接修改原文件 [assets/模型信息.xlsx](/Users/wkc/Desktop/ccdi/ccdi/assets/模型信息.xlsx),不另存副本。
2. 对表内模型逐行判定“已真实实现”或“未真实实现”。
3. 对未真实实现的模型补齐可明确推出的缺失字段。
4. 在表尾新增两个结论列:
- `当前环境是否可执行SQL`
- `当前缺少内容`
5. 结论以当前仓库代码、当前 `ccdi` 数据库和当前 MySQL 方言能力为准,不做外部依赖默认成立的假设。
## 三、范围
### 3.1 本次范围
- 读取并更新 `Sheet1` 现有模型数据
- 基于项目代码、SQL 脚本、规则定义和数据库结构补充模型信息
- 对未真实实现模型的 SQL 进行当前环境可执行性判断
- 在 XLSX 中新增结论列
- 补充本次操作对应的实施记录
### 3.2 不在本次范围
- 不新增新的业务模型
- 不为了补齐表格而设计新的业务规则
- 不修改项目中的后端实现逻辑
- 不修改数据库表结构以适配 XLSX 中的未实现 SQL
- 不对已真实实现模型的表格行进行主动重写或口径重构
## 四、现状分析
### 4.1 XLSX 现状
`Sheet1` 当前包含 14 列,核心列包括:
1. 序号
2. 模型名称
3. 核心异常点(展示在前端页面)
4. 业务口径
5. 相关指标
6. 指标英文名
7. 风险筛查对象
8. 技术口径
9. 代码
10. 限制阈值指标
11. 可疑结果返回
12. 风险等级
当前表内部分模型已经填有 SQL但存在以下问题
- 部分字段为空或不完整
- 大小写风格不统一
- 部分 SQL 引用了当前环境中不存在的外部库表
- 部分 SQL 包含当前 MySQL 不支持的语法或明显错误
### 4.2 项目与数据库现状
当前环境已确认具备以下能力和对象:
- 数据库可连接,目标库为 `ccdi`
- 已存在核心表:
- `ccdi_base_staff`
- `ccdi_bank_statement`
- `ccdi_staff_fmy_relation`
- `ccdi_asset_info`
- `ccdi_biz_intermediary`
- `ccdi_model_param`
- `ccdi_bank_tag_rule`
- `ccdi_bank_statement_tag_result`
- `ccdi_bank_tag_rule` 已初始化多类模型规则但其中不少规则仍为“占位规则待补充真实SQL”
- 仓库中已有一份核对报告 [docs/reports/implementation/2026-03-17-model-sql-check-and-rewrite.md](/Users/wkc/Desktop/ccdi/ccdi/docs/reports/implementation/2026-03-17-model-sql-check-and-rewrite.md),可作为当前库结构和 SQL 兼容性判断的参考基线
## 五、判定规则
### 5.1 已真实实现模型
只有同时满足以下口径之一的模型,才认定为“已真实实现”并跳过:
1. 项目中已经存在真实 SQL 或明确的命中逻辑实现。
2. 项目中已经存在真实结果产出逻辑,而不是仅有规则占位或参数定义。
以下情况不认定为已真实实现:
- 只有 `ccdi_bank_tag_rule` 占位规则定义
- 只有 `ccdi_model_param` 参数定义
- 备注中明确写明“待补充真实SQL”
- 只有模型名称、规则编码等元数据,没有真实落地逻辑
### 5.2 未真实实现模型
凡是不满足“已真实实现模型”标准的行,一律进入本次补充和校验范围。
对这类行执行两类操作:
1. 补充可从现有项目和数据库中明确推出的信息
2. 校验该行 SQL 在当前环境中的可执行性
## 六、方案对比
### 6.1 方案 A直接在原 XLSX 上逐行补全与校验
做法:
- 逐行读取模型信息
- 判定是否已真实实现
- 对未真实实现模型直接在原表中补全与新增结论列
优点:
- 最短路径实现
- 输出物就是用户要求的原始文件
- 不引入额外中间文件或额外同步成本
缺点:
- 需要严格控制回填规则,避免误改已存在口径
### 6.2 方案 B先生成中间清单再统一回写 XLSX
做法:
- 先把 XLSX 转成结构化清单
- 在清单中完成判定与校验
- 再二次回写 XLSX
优点:
- 处理过程更易追踪
缺点:
- 步骤变长
- 不符合本次“直接修改原文件”的最短路径目标
### 6.3 方案 C只新增校验列不回填缺失字段
做法:
- 保持原字段不动
- 只新增可执行性相关两列
问题:
- 无法满足“根据项目和数据库内有的字段更新到这个 xlsx 中”的要求
### 6.4 结论
采用方案 A。
## 七、XLSX 更新设计
### 7.1 表结构调整
保留 `Sheet1`、原列顺序和原行顺序不变,在现有最后一列后新增两列:
1. `当前环境是否可执行SQL`
2. `当前缺少内容`
### 7.2 回填原则
对未真实实现模型,采用“能明确推出才补,不做主观扩写”的原则:
- `指标英文名`
- 优先使用项目中已有的 `indicator_code``param_code`、规则编码或现有统一命名风格
- `风险筛查对象`
- 优先从 SQL 的 `from/join` 范围、现有规则定义和同类模型写法中归纳
- `技术口径`
- 优先按 SQL 实际筛选逻辑进行摘要式补充
- `可疑结果返回`
- 按 SQL 输出对象判断,例如 `流水明细``对象聚合`
- `风险等级`
- 优先采用当前规则表或同类规则现有等级
若现有材料无法唯一确定,则保留原值,不额外发明新业务口径。
### 7.3 已真实实现模型处理原则
对于已真实实现模型:
- 仅做识别和跳过
- 不主动回填原行缺失字段
- 不因当前表格写法和现有实现口径略有差异而重写整行
## 八、SQL 可执行性校验设计
### 8.1 校验基准
SQL 是否“可在当前环境执行”,以以下条件同时成立为准:
1. 当前 `ccdi` 数据库中相关表和字段已存在
2. SQL 使用的函数和语法兼容当前 MySQL 能力
3. SQL 中引用的对象不依赖当前环境不存在的外部 schema、外部表或临时准备表
4. SQL 本身不存在明显语法错误
### 8.2 结论写法
- 可执行:`是`
- 不可执行:`否`
### 8.3 缺少内容写法
- 若可执行,填写 `/`
- 若不可执行,填写最小必需缺项或明确错误,示例:
- `缺少外部表 odsdb.blfmconf`
- `缺少中介账号清单表`
- `当前 MySQL 不支持 WITH`
- `exists 子句写法错误`
- `SQL 中占位变量 PROJECT_ID 未替换`
### 8.4 不做的事情
本次不会为了让 SQL 勉强通过而:
- 默认补造不存在的中间表
- 默认将外部库对象视为可访问
- 擅自重写业务含义
如果当前环境缺项导致 SQL 无法执行,直接在结论列中说明。
## 九、执行步骤
按以下顺序执行:
1. 读取 XLSX 全部模型行
2. 基于项目代码、规则表、SQL 脚本和数据库结构逐行判定是否已真实实现
3. 对未真实实现模型补齐可明确推出的字段
4. 对未真实实现模型的 SQL 做当前环境可执行性判断
5. 回写两个新增结论列
6. 保存原文件
7.`docs/reports/implementation/` 补充本次实施记录
## 十、风险与控制
### 10.1 主要风险
1. 误把占位规则判定为已实现,导致该行被错误跳过
2. 根据不充分信息补出带有主观推断的业务口径
3. 将“理论可改写后可执行”误写成“当前环境可执行”
### 10.2 控制措施
1. 已实现判定从严,仅认可真实 SQL 或真实结果逻辑
2. 回填坚持“可明确推出才补”
3. 可执行性仅以“当前环境直接执行”为准,不按未来补齐条件判断
## 十一、产出物
本次最终产出包括:
1. 更新后的 [assets/模型信息.xlsx](/Users/wkc/Desktop/ccdi/ccdi/assets/模型信息.xlsx)
2. 本设计文档 [docs/superpowers/specs/2026-03-20-model-info-xlsx-update-design.md](/Users/wkc/Desktop/ccdi/ccdi/docs/superpowers/specs/2026-03-20-model-info-xlsx-update-design.md)
3. 一份实施记录文档,落在 `docs/reports/implementation/`

View File

@@ -0,0 +1,319 @@
# 结果总览风险人员区块收口设计文档
**模块**: 初核项目详情 - 结果总览
**日期**: 2026-03-20
## 一、背景
当前结果总览页面的人员相关信息拆分为两个区块:
- 风险人员总览
- 中高风险人员TOP10
现状问题是:
1. 风险等级、命中模型数只在 `中高风险人员TOP10` 中展示。
2. 风险人员总览与 TOP10 在页面上存在信息拆散,用户需要跨区块比对。
3. 本轮需求要求移除独立的 TOP10 区块,并把关键风险字段收拢到风险人员总览中。
## 二、目标
本次设计目标如下:
1. 移除结果总览页面中的 `中高风险人员TOP10` 区块。
2. 保留 `风险人员总览` 作为唯一人员榜单区块。
3. 在风险人员总览现有列基础上新增:
- `风险等级`
- `命中模型数`
4. 保持当前风险人员总览的排序逻辑不变。
## 三、范围
### 3.1 本次范围
- 调整结果总览页面人员区块结构
- 扩展风险人员总览接口返回字段
- 移除结果总览页面对 TOP10 接口的依赖
- 更新前端静态断言测试
- 补充本次设计文档、后续前后端实施计划与实施记录
### 3.2 不在本次范围
- 不调整风险仪表盘区块
- 不调整风险模型区和风险明细区
- 不修改风险等级计算规则
- 不修改风险人员总览当前排序口径
- 不在本轮删除后端独立 TOP10 接口
## 四、现状分析
### 4.1 页面现状
当前 `RiskPeopleSection.vue` 中包含两个 block
1. `风险人员总览`
2. `中高风险人员TOP10`
风险人员总览当前展示字段为:
- 姓名
- 身份证号
- 所属部门
- 疑似违规数
- 核心异常点
- 操作
TOP10 当前展示字段为:
- 姓名
- 身份证号
- 所属部门
- 风险等级
- 命中模型数
- 操作
### 4.2 后端现状
后端员工聚合结果 `CcdiProjectEmployeeRiskAggregateVO` 已经包含以下字段:
- `modelCount`
- `riskLevelCode`
- `riskLevelName`
- `riskLevelSort`
这说明本轮需求不需要新增新的统计口径,后端已有聚合结果足以支撑风险人员总览增加两列展示。
## 五、方案对比
### 5.1 方案 A扩展风险人员总览接口并移除页面 TOP10 依赖
做法:
- 风险人员总览接口直接补充返回 `riskLevel``riskLevelType``modelCount`
- 前端页面只保留一个风险人员总览表格
- 前端不再请求 TOP10 接口
优点:
- 最短路径实现
- 字段来源单一,口径一致
- 不需要前端做补丁式字段拼装
- 与本轮“移除 TOP10 页面区块”的需求完全一致
缺点:
- 风险人员总览表格横向字段增加,需要适度调整列宽
### 5.2 方案 B前端继续请求 TOP10再回填到风险人员总览
做法:
- 风险人员总览接口不变
- 前端同时请求风险人员总览和 TOP10
- 前端根据人员信息把风险等级和命中模型数拼回总览表格
问题:
- 字段来源分裂
- 口径容易漂移
- 属于补丁式方案,不符合本次最短路径和单一口径要求
### 5.3 方案 C只隐藏 TOP10 区块,不补真实字段
做法:
- 页面隐藏 TOP10
- 风险人员总览接口和表格不扩展
问题:
- 无法满足“新增风险等级和命中模型数”的需求
- 需求不完整
### 5.4 结论
采用方案 A。
## 六、目标页面结构
人员区块调整后,结果总览页面仍保留四大区块:
1. 风险仪表盘
2. 风险人员总览
3. 风险模型
4. 风险明细
其中人员区块收口为单表结构,风险人员总览列顺序为:
1. 序号
2. 姓名
3. 身份证号
4. 所属部门
5. 疑似违规数
6. 风险等级
7. 命中模型数
8. 核心异常点
9. 操作
说明:
- `风险等级` 使用标签展示,继续直接绑定后端返回的 `riskLevelType`
- `命中模型数` 直接展示后端返回的 `modelCount`
- `核心异常点` 继续完整展示,不做压缩式改造
## 七、后端设计
### 7.1 接口边界
继续沿用现有接口:
- `GET /ccdi/project/overview/risk-people`
入参不变:
- `projectId`
### 7.2 返回结构调整
风险人员总览返回项新增以下字段:
- `riskLevel`
- `riskLevelType`
- `modelCount`
调整后示例:
```json
{
"overviewList": [
{
"name": "李四",
"idNo": "330000000000000001",
"department": "信息二部",
"riskCount": 5,
"riskLevel": "中风险",
"riskLevelType": "warning",
"modelCount": 4,
"riskPoint": "大额单笔收入、疑似兼职",
"actionLabel": "查看详情"
}
]
}
```
### 7.3 聚合与映射
后端不新增新的聚合 SQL 口径,直接复用现有员工风险聚合结果:
- `hitCount` 继续作为 `riskCount`
- `riskLevelCode` 用于映射 `riskLevel``riskLevelType`
- `modelCount` 直接透传到风险人员总览项
服务层新增映射规则:
- `HIGH -> 高风险 / danger`
- `MEDIUM -> 中风险 / warning`
- `LOW -> 低风险 / info`
### 7.4 排序口径
风险人员总览继续沿用当前查询顺序:
- `risk_level_sort asc`
- `model_count desc`
- `rule_count desc`
- `staff_id_card asc`
本次不改成新的重点排序规则。
## 八、前端设计
### 8.1 页面结构调整
`RiskPeopleSection.vue` 中:
- 保留风险人员总览 block
- 删除中高风险人员TOP10 block
- 在风险人员总览表格中新增两列:
- 风险等级
- 命中模型数
### 8.2 数据装配调整
`PreliminaryCheck.vue` 中页面加载时:
- 保留 `dashboard` 请求
- 保留 `riskPeople` 请求
- 移除 `topRiskPeople` 请求
`createOverviewLoadedData` 中:
- 保留 `overviewList` 注入
- 移除 `topRiskList` 对页面展示的依赖
### 8.3 Mock 与状态数据
`preliminaryCheck.mock.js` 中:
- `overviewList` 示例数据补充 `riskLevel``riskLevelType``modelCount`
- 页面状态数据不再依赖 `topRiskList`
## 九、异常与空态处理
- `projectId` 为空时,继续展示现有空态
- 接口异常时,继续沿用当前结果总览页面的空态回退
- 风险人员总览无数据时,展示单表空态,不再出现第二个 TOP10 区块
- 风险等级标签由后端直接提供,不在前端新增业务推导逻辑
## 十、验证设计
### 10.1 后端验证
需要验证:
1. 风险人员总览接口返回新增字段:
- `riskLevel`
- `riskLevelType`
- `modelCount`
2. 风险等级口径保持原逻辑不变
3. 疑似违规数仍然来自 `hitCount`
4. 风险人员总览排序不变
### 10.2 前端验证
需要验证:
1. 结果总览页面不再请求 TOP10 接口
2. 人员区块只渲染一个表格
3. 风险人员总览出现新增两列:
- 风险等级
- 命中模型数
4. 加载态、空态、正常态均可正常展示
### 10.3 测试资产更新
前端静态断言测试需要同步调整:
- 移除对 `中高风险人员TOP10` 文案的依赖
- 增加对 `风险等级``命中模型数` 的断言
## 十一、文档沉淀
本次设计确认后,后续需补齐以下文档:
1. 后端实施计划:`docs/plans/backend/`
2. 前端实施计划:`docs/plans/frontend/`
3. 实施记录:`docs/reports/implementation/`
4. 验证记录:`docs/tests/records/`
## 十二、结论
本次改动采用“单表收口”的最短路径方案:
1. 页面移除独立的 `中高风险人员TOP10`
2. `风险人员总览` 补充 `风险等级``命中模型数`
3. 后端扩展现有风险人员总览接口返回字段
4. 前端移除对 TOP10 接口的页面依赖
5. 排序、风险等级计算和其他区块保持不变
该方案满足需求、口径单一、改动边界清晰,且不会把本轮调整扩散到结果总览其他功能块。

View File

@@ -0,0 +1,43 @@
# Java后端一键重启脚本实施记录
## 修改目标
- 新增一个可直接在仓库内执行的后端重启脚本
- 脚本需支持重启前自动执行 `mvn -pl ruoyi-admin -am clean package -DskipTests`
- 重启完成后持续输出后端运行日志,便于直接观察启动结果和后续日志
## 修改内容
- 新增 `bin/restart_java_backend.sh`
- 默认执行 `restart` 动作,无参即可触发一键重启
- 重启流程固定为“先构建,后停机,再启动”
- 停机时同时兼容端口 `62318``ruoyi-admin.jar` 关键字识别
- 启动时改为参考 `bin/run.bat`,切换到 `ruoyi-admin/target/` 后后台执行 `java -jar ruoyi-admin.jar`
- 启动日志统一落到仓库根目录 `logs/backend-console.log`
- 启动后以前台 `tail -F` 持续输出控制台日志,便于直接查看
- 保留 `start``stop``restart``status` 四个动作,便于和现有 `ry.sh` 使用习惯保持一致
- 新增 `docs/tests/scripts/test-restart-java-backend.sh`
- 回归检查脚本是否切换到 `ruoyi-admin/target` 并使用 `java -jar ruoyi-admin.jar` 启动
- 防止后续再次回退到 `spring-boot:run` 启动链路
## 验证记录
- 执行 `sh docs/tests/scripts/test-restart-java-backend.sh`
- 结果:通过,已校验脚本使用 `java -jar` 启动打包产物
- 执行 `sh -n bin/restart_java_backend.sh`
- 结果:通过,脚本语法正确
- 执行 `sh bin/restart_java_backend.sh restart`
- 结果:通过,构建完成后在 `ruoyi-admin/target` 内触发 `java -jar ruoyi-admin.jar`
- 关键日志:
- `Starting RuoYiApplication using Java 21.0.9 with PID ... (/Users/wkc/Desktop/ccdi/ccdi/ruoyi-admin/target/ruoyi-admin.jar started by wkc in /Users/wkc/Desktop/ccdi/ccdi/ruoyi-admin/target)`
- `Started RuoYiApplication in 7.457 seconds`
- 进程核验:
- `java -Duser.timezone=Asia/Shanghai -Xms512m -Xmx1024m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError -jar ruoyi-admin.jar`
- 执行 `sh bin/restart_java_backend.sh stop`
- 结果:测试完成后已停止本次验证拉起的后端进程,避免残留端口占用
## 影响范围
- 新增本地运维辅助脚本,不修改现有 Java 业务代码
- 日志新增输出文件:`logs/backend-console.log`
- 不影响前端、数据库和 Mock 服务逻辑

View File

@@ -0,0 +1,58 @@
# lsfx-mock-server 兰溪本地流水条数调整实施文档
## 变更目标
`lsfx-mock-server` 中兰溪本地流水相关链路的条数统一调整为固定 `200` 条,保证以下两个口径一致:
- `/watson/api/project/getJZFileOrZjrcuFile` 创建的 `FileRecord.total_records`
- `/watson/api/project/getBSByLogId` 返回的 `data.totalCount`
## 实施内容
### 1. 调整流水列表总条数
修改文件:
- `lsfx-mock-server/services/statement_service.py`
实施内容:
- 新增 `StatementService.FIXED_TOTAL_COUNT = 200`
-`get_bank_statement` 首次缓存生成总条数的逻辑由随机 `1200-1500` 调整为固定 `200`
### 2. 调整兰溪本地流水落库条数
修改文件:
- `lsfx-mock-server/services/file_service.py`
实施内容:
- 新增 `FileService.INNER_FLOW_TOTAL_RECORDS = 200`
-`fetch_inner_flow` 创建 `FileRecord` 时的 `total_records` 由随机 `100-300` 调整为固定 `200`
### 3. 补充测试
修改文件:
- `lsfx-mock-server/tests/test_statement_service.py`
- `lsfx-mock-server/tests/test_file_service.py`
实施内容:
- 增加 `get_bank_statement` 总条数固定为 `200` 的断言
- 增加 `fetch_inner_flow` 创建的 `FileRecord.total_records` 固定为 `200` 的断言
## 验证记录
执行命令:
```bash
python3 -m pytest lsfx-mock-server/tests/test_statement_service.py -k fixed_total_count_200 -q
python3 -m pytest lsfx-mock-server/tests/test_file_service.py -k fetch_inner_flow_persists_primary_binding_record -q
```
验证结果:
- 两条目标测试均通过
- 当前环境存在 `PydanticDeprecatedSince20` 警告,但不影响本次条数调整

View File

@@ -0,0 +1,31 @@
# lsfx-mock-server 上传流水条数范围调整实施记录
## 修改目标
-`lsfx-mock-server` 上传文件接口生成的 `totalRecords` 随机范围调整为 `150-200`
- 保持改动只影响上传链路,不扩散到其他非上传接口
## 修改内容
- 修改 `lsfx-mock-server/services/file_service.py`
- `upload_file()` 中创建 `FileRecord` 时的 `total_records` 生成逻辑
-`random.randint(100, 300)` 调整为 `random.randint(150, 200)`
- 修改 `lsfx-mock-server/tests/test_file_service.py`
- 新增上传文件 `totalRecords` 范围测试
- 通过定向劫持旧区间 `random.randint(100, 300)` 返回 `300`,确保旧实现先失败,再验证新实现通过
## 验证记录
- 执行 `python3 -m pytest tests/test_file_service.py -k total_records_range -q`
- 首次执行结果: 失败,旧实现返回 `300`
- 修改后预期: 测试通过,上传文件返回的 `totalRecords` 落在 `150-200`
## 影响范围
- 影响接口: `POST /watson/api/project/remoteUploadSplitFile`
- 关联链路:
- 上传接口响应中的 `uploadLogList[].totalRecords`
- 后续通过真实 `file_records` 读取上传状态时返回的 `logs[].totalRecords`
- 不影响:
- 行内流水拉取接口
- 无真实记录时的 deterministic 回退数据

View File

@@ -0,0 +1,134 @@
# Project Upload File Delete Trigger Retag 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:** 保持现有删除主链路不变,只在 `CcdiFileUploadServiceImpl.deleteFileUploadRecord` 的“平台删除成功、本地流水删除成功、上传记录状态更新成功”之后追加一次自动重打标提交。为避免触发语义混淆,新增独立触发类型 `AUTO_FILE_DELETE`,并通过现有 `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/CcdiFileUploadServiceImplTest.java`
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImplTest.java`
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/enums/TriggerType.java`
- [ ] **Step 1: Write the failing test**
`CcdiFileUploadServiceImplTest` 的删除成功用例中增加断言:
```java
verify(bankTagService).submitAutoRebuild(PROJECT_ID, TriggerType.AUTO_FILE_DELETE);
```
`CcdiBankTagServiceImplTest` 中增加触发类型透传测试:
```java
@Test
void submitAutoRebuild_shouldKeepAutoFileDeleteTriggerType() {
service.submitAutoRebuild(40L, TriggerType.AUTO_FILE_DELETE);
verify(coordinator).submitAuto(40L, TriggerType.AUTO_FILE_DELETE);
}
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
mvn -pl ccdi-project -Dtest=CcdiFileUploadServiceImplTest#deleteFileUploadRecord_shouldDeletePlatformFileBankStatementsAndMarkDeleted,CcdiBankTagServiceImplTest#submitAutoRebuild_shouldKeepAutoFileDeleteTriggerType test
```
Expected:
- `FAIL`
- 原因是还没有 `AUTO_FILE_DELETE` 触发类型,也没有在文件删除成功后触发自动重打标
- [ ] **Step 3: Write minimal implementation**
1.`TriggerType` 中新增:
```java
AUTO_FILE_DELETE
```
2.`CcdiFileUploadServiceImpl.deleteFileUploadRecord` 的状态更新成功后追加:
```java
bankTagService.submitAutoRebuild(record.getProjectId(), TriggerType.AUTO_FILE_DELETE);
```
3. 返回成功提示改为能体现重打标已开始的文案。
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
mvn -pl ccdi-project -Dtest=CcdiFileUploadServiceImplTest#deleteFileUploadRecord_shouldDeletePlatformFileBankStatementsAndMarkDeleted,CcdiBankTagServiceImplTest#submitAutoRebuild_shouldKeepAutoFileDeleteTriggerType 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/CcdiFileUploadServiceImpl.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/CcdiBankTagServiceImplTest.java
git commit -m "删除上传文件后自动触发项目重打标"
```
### Task 2: 验证删除失败分支不会误触发重打标
**Files:**
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java`
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java`
- [ ] **Step 1: Write the failing test**
在“流水分析平台删除失败”用例中增加断言:
```java
verify(bankTagService, never()).submitAutoRebuild(any(), any());
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
mvn -pl ccdi-project -Dtest=CcdiFileUploadServiceImplTest#deleteFileUploadRecord_shouldStopWhenLsfxDeleteFails test
```
Expected:
- 如果实现位置不正确,测试会失败
- [ ] **Step 3: Write minimal implementation**
确保仅在删除主链路全部成功后,才触发自动重打标;任何异常都直接抛出,不补触发。
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
mvn -pl ccdi-project -Dtest=CcdiFileUploadServiceImplTest#deleteFileUploadRecord_shouldStopWhenLsfxDeleteFails 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/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java
git commit -m "补充文件删除触发重打标失败保护"
```

View File

@@ -0,0 +1,57 @@
# Risk People Overview Risk Count 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:** 保持风险等级、TOP10 和项目风险人数回写口径继续基于去重规则数,不做范围外调整。仅在 `ccdi-project` 结果总览员工聚合 SQL 中增加单独的打标数量字段,并在风险人员总览服务映射时使用该字段作为 `riskCount`
**Tech Stack:** Java 21, Spring Boot 3, MyBatis XML, Maven, JUnit 5, Mockito
---
### Task 1: 锁定疑似违规数新口径
**Files:**
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImplTest.java`
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapperSqlTest.java`
- [ ] **Step 1: Write the failing test**
调整服务测试,断言 `riskCount` 读取独立的打标数量字段,而不是 `ruleCount`。调整 SQL 结构测试,锁定员工聚合查询包含 `hit_count` 聚合。
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewServiceImplTest,CcdiProjectOverviewMapperSqlTest
```
Expected:
- `FAIL`
- 原因是聚合 VO 与 SQL 尚未提供打标数量字段
- [ ] **Step 3: Write minimal implementation**
为员工聚合 VO 新增 `hitCount` 字段Mapper XML 增加 `count(1) as hit_count` 聚合并映射到结果集Service 构建风险人员总览项时改为优先使用 `hitCount` 作为 `riskCount`
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewServiceImplTest,CcdiProjectOverviewMapperSqlTest
```
Expected:
- `PASS`
- [ ] **Step 5: Commit**
```bash
git add ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImplTest.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapperSqlTest.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectEmployeeRiskAggregateVO.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImpl.java ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml
git commit -m "调整风险人员总览疑似违规数口径"
```

View File

@@ -0,0 +1,57 @@
# Risk People Overview Risk Point 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` 结果总览聚合 SQL 与服务映射。核心异常点改为先按员工和规则统计命中次数,再按命中次数倒序、规则编码升序拼接多条规则名称,直接返回给前端展示。
**Tech Stack:** Java 21, Spring Boot 3, MyBatis XML, Maven, JUnit 5, Mockito
---
### Task 1: 锁定多规则拼接行为
**Files:**
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImplTest.java`
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapperSqlTest.java`
- [ ] **Step 1: Write the failing test**
在服务测试中新增或调整用例,断言 `riskPoint` 取拼接后的多规则字符串;在 SQL 结构测试中锁定 `group_concat``risk_point` 聚合片段。
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewServiceImplTest,CcdiProjectOverviewMapperSqlTest
```
Expected:
- `FAIL`
- 服务层仍只映射单条规则SQL 尚未包含多规则拼接
- [ ] **Step 3: Write minimal implementation**
为员工聚合 VO 增加 `riskPoint` 字段;在 Mapper XML 中新增员工维度规则拼接子查询Service 改为使用聚合后的 `riskPoint`
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewServiceImplTest,CcdiProjectOverviewMapperSqlTest
```
Expected:
- `PASS`
- [ ] **Step 5: Commit**
```bash
git add ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImplTest.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapperSqlTest.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectEmployeeRiskAggregateVO.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImpl.java ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml
git commit -m "修正风险人员总览核心异常点多规则展示"
```

View File

@@ -0,0 +1,276 @@
# Bank Tag Real Rule Phase 1 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:** 将第一期 9 条银行流水占位规则替换为真实 SQL并接入现有项目级打标、风险人数回写与结果链路。
**Architecture:** 保持现有 `ccdi_bank_tag_rule + CcdiBankTagServiceImpl + CcdiBankTagAnalysisMapper.xml` 规则式架构不变,不新增模块、不保留兼容性双轨。所有真实命中逻辑统一落在 `CcdiBankTagAnalysisMapper.xml`Service 只做规则分发和结果装配,参数仍由 `BankTagRuleConfigResolver` 解析。
**Tech Stack:** Java 21, Spring Boot 3, MyBatis XML, Maven, JUnit 5, Mockito, MySQL
---
### Task 1: 先锁定第一期规则边界与参数映射
**Files:**
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/BankTagRuleConfigResolverTest.java`
- Modify: `sql/ccdi_model_param.sql`
- Modify: `sql/2026-03-16-update-ccdi-model-param-defaults.sql`
- Reference: `assets/模型信息.xlsx`
- Reference: `docs/design/2026-03-20-bank-tag-real-rule-two-phase-design.md`
- [ ] **Step 1: Write the failing test**
`BankTagRuleConfigResolverTest.java` 中补第一期参数映射断言,至少覆盖:
- `SINGLE_PURCHASE_AMOUNT`
- `SINGLE_SETTLEMENT_AMOUNT`
- `WITHDRAW_CNT`
- `STOCK_TFR_LARGE`
- `LARGE_STOCK_TRADING`
并补“无参数规则仍返回空参数集”的断言,至少覆盖:
- `GAMBLING_SENSITIVE_KEYWORD`
- `SPECIAL_AMOUNT_TRANSACTION`
- `SUSPICIOUS_INCOME_KEYWORD`
- `LARGE_PURCHASE_TRANSACTION`
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
mvn test -pl ccdi-project -Dtest=BankTagRuleConfigResolverTest
```
Expected:
- `FAIL`
- 原因是第一期规则参数映射或默认参数脚本尚未全部对齐
- [ ] **Step 3: Write minimal implementation**
按第一期规则校准参数映射与默认参数脚本:
-`BankTagRuleConfigResolver.java` 中补第一期规则的 `RULE_PARAM_MAPPING`
-`sql/ccdi_model_param.sql` 中核对并补齐第一期阈值参数初始化
-`sql/2026-03-16-update-ccdi-model-param-defaults.sql` 中同步已有环境默认值修正
要求:
- 只补第一期需要的参数
- 不修改第二期规则边界
- 所有新增或修改的 `param_code` 保持全大写
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
mvn test -pl ccdi-project -Dtest=BankTagRuleConfigResolverTest
```
Expected:
- `PASS`
- [ ] **Step 5: Commit**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/BankTagRuleConfigResolver.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/BankTagRuleConfigResolverTest.java sql/ccdi_model_param.sql sql/2026-03-16-update-ccdi-model-param-defaults.sql
git commit -m "补齐第一期流水模型参数映射"
```
### Task 2: 为第一期流水明细型规则写失败前 SQL 结构测试
**Files:**
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapperXmlTest.java`
- Modify: `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml`
- [ ] **Step 1: Write the failing test**
`CcdiBankTagAnalysisMapperXmlTest.java` 中为以下规则补结构断言:
- `selectGamblingSensitiveKeywordStatements`
- `selectSpecialAmountTransactionStatements`
- `selectSuspiciousIncomeKeywordStatements`
- `selectForexBuyAmtStatements`
- `selectForexSellAmtStatements`
- `selectLargePurchaseTransactionStatements`
- `selectStockTfrLargeStatements`
- `selectLargeStockTradingStatements`
断言内容:
- 目标 `select` 已存在
- 对应片段不再包含 `where 1 = 0`
- 仍返回 `bankStatementId/groupId/logId/reasonDetail`
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiBankTagAnalysisMapperXmlTest
```
Expected:
- `FAIL`
- 原因是第一期规则仍是占位 SQL
- [ ] **Step 3: Write minimal implementation**
`CcdiBankTagAnalysisMapper.xml` 中把第一期流水明细型规则替换为真实 SQL
- `GAMBLING_SENSITIVE_KEYWORD`
- `SPECIAL_AMOUNT_TRANSACTION`
- `SUSPICIOUS_INCOME_KEYWORD`
- `SINGLE_PURCHASE_AMOUNT`
- `SINGLE_SETTLEMENT_AMOUNT`
- `LARGE_PURCHASE_TRANSACTION`
- `STOCK_TFR_LARGE`
- `LARGE_STOCK_TRADING`
实现要求:
- 继续输出 `bankStatementId/groupId/logId/reasonDetail`
- 条件判断尽量直接复用 `assets/模型信息.xlsx` 已校验可执行的 SQL 口径
- `reasonDetail` 要带出摘要、对手方、金额或阈值关键信息,便于后续结果展示与排障
- 不把业务判断搬到 Java 层
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiBankTagAnalysisMapperXmlTest
```
Expected:
- `PASS`
- [ ] **Step 5: Commit**
```bash
git add ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapperXmlTest.java
git commit -m "实现第一期流水明细规则真实SQL"
```
### Task 3: 为第一期对象型规则补真实 SQL 与分发校验
**Files:**
- Modify: `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml`
- 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`
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapperXmlTest.java`
- [ ] **Step 1: Write the failing test**
补以下断言:
- `selectWithdrawCntObjects` 不再使用占位 SQL
- `CcdiBankTagServiceImplTest``WITHDRAW_CNT` 能正确分发到 `analysisMapper.selectWithdrawCntObjects(projectId)`
- `WITHDRAW_CNT` 空命中时任务仍正常成功
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiBankTagAnalysisMapperXmlTest,CcdiBankTagServiceImplTest
```
Expected:
- `FAIL`
- [ ] **Step 3: Write minimal implementation**
在 XML 中实现 `WITHDRAW_CNT` 的真实对象型 SQL要求
- 按员工身份证号归并
- 统计微信/支付宝相关提现的单日次数
- 频次阈值使用参数解析结果
- 输出 `objectType/objectKey/reasonDetail`
同时在 `CcdiBankTagServiceImpl.java` 中确认:
- 第一期开启真实 SQL 的规则都命中明确分发分支
- 不会退回 `default -> List.of()`
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiBankTagAnalysisMapperXmlTest,CcdiBankTagServiceImplTest
```
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/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImplTest.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapperXmlTest.java
git commit -m "接通第一期对象规则真实分发"
```
### Task 4: 做第一期后端回归与脚本对齐验证
**Files:**
- Modify: `sql/2026-03-16-bank-tagging.sql`
- Create: `docs/reports/implementation/2026-03-20-bank-tag-real-rule-phase1-backend-record.md`
- Create: `docs/tests/records/2026-03-20-bank-tag-real-rule-phase1-backend-verification.md`
- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceRiskCountRefreshTest.java`
- [ ] **Step 1: Align rule metadata SQL**
核对 `sql/2026-03-16-bank-tagging.sql` 中第一期规则元数据,要求:
- `rule_code``indicator_code``param_code` 保持全大写
- `remark` 从“占位规则待补充真实SQL”调整为真实规则描述或至少不再误导为占位
- 不误改第二期规则
- [ ] **Step 2: Run targeted regression**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiBankTagAnalysisMapperXmlTest,BankTagRuleConfigResolverTest,CcdiBankTagServiceImplTest,CcdiBankTagServiceRiskCountRefreshTest
```
Expected:
- `PASS`
- 第一期开启真实 SQL 后,风险人数回写链路不回退
- [ ] **Step 3: Write implementation record**
`docs/reports/implementation/2026-03-20-bank-tag-real-rule-phase1-backend-record.md` 中记录:
- 第一期 9 条规则范围
- 参数映射调整内容
- XML 真实 SQL 替换内容
- Service 分发调整内容
- 与第二期边界
- [ ] **Step 4: Write verification record**
`docs/tests/records/2026-03-20-bank-tag-real-rule-phase1-backend-verification.md` 中记录:
- 执行命令
- 执行时间
- 结果摘要
- 结论
- [ ] **Step 5: Commit**
```bash
git add sql/2026-03-16-bank-tagging.sql docs/reports/implementation/2026-03-20-bank-tag-real-rule-phase1-backend-record.md docs/tests/records/2026-03-20-bank-tag-real-rule-phase1-backend-verification.md
git commit -m "补充第一期流水模型后端实施记录"
```

View File

@@ -0,0 +1,262 @@
# Bank Tag Real Rule Phase 2 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:** 将第二期 10 条复杂对象聚合与跨表比对规则替换为真实 SQL完成 19 条目标银行流水模型的后端真实落地收口。
**Architecture:** 继续沿用现有 `CcdiBankTagServiceImpl + CcdiBankTagAnalysisMapper.xml + BankTagRuleConfigResolver` 链路,不新增平行计算模块。第二期重点放在聚合口径、关系人归并、资产登记比对和时间窗口判断,所有真实规则仍统一落在 XML 中完成。
**Tech Stack:** Java 21, Spring Boot 3, MyBatis XML, Maven, JUnit 5, Mockito, MySQL
---
### Task 1: 先锁定第二期参数与聚合边界
**Files:**
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/BankTagRuleConfigResolverTest.java`
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/BankTagRuleConfigResolver.java`
- Modify: `sql/ccdi_model_param.sql`
- Modify: `sql/2026-03-16-update-ccdi-model-param-defaults.sql`
- [ ] **Step 1: Write the failing test**
为以下规则补参数与无参数边界断言:
- `MULTI_PARTY_GAMBLING_TRANSFER` -> `MULTI_PARTY_AMT_MIN``MULTI_PARTY_AMT_MAX`
- `MONTHLY_FIXED_INCOME` -> `MONTHLY_FIXED_INCOME`
- `FIXED_COUNTERPARTY_TRANSFER` -> `FIXED_COUNTERPARTY_TRANSFER_MIN``FIXED_COUNTERPARTY_TRANSFER_MAX`
- `SALARY_QUICK_TRANSFER``SALARY_UNUSED` 为无参数规则
- `LOW_INCOME_RELATIVE_LARGE_TRANSACTION``SUPPLIER_CONCENTRATION`、三条资产不匹配规则为无参数规则
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
mvn test -pl ccdi-project -Dtest=BankTagRuleConfigResolverTest
```
Expected:
- `FAIL`
- [ ] **Step 3: Write minimal implementation**
补齐第二期规则参数映射和默认值脚本,要求:
- 只补设计文档明确需要阈值的规则
- 不为无参数规则制造虚假参数
- 继续保持大小写统一
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
mvn test -pl ccdi-project -Dtest=BankTagRuleConfigResolverTest
```
Expected:
- `PASS`
- [ ] **Step 5: Commit**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/BankTagRuleConfigResolver.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/BankTagRuleConfigResolverTest.java sql/ccdi_model_param.sql sql/2026-03-16-update-ccdi-model-param-defaults.sql
git commit -m "补齐第二期流水模型参数映射"
```
### Task 2: 实现第二期跨表比对型流水规则
**Files:**
- Modify: `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml`
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapperXmlTest.java`
- [ ] **Step 1: Write the failing test**
为以下规则补结构断言:
- `selectHouseRegistrationMismatchStatements`
- `selectPropertyFeeRegistrationMismatchStatements`
- `selectTaxAssetRegistrationMismatchStatements`
断言要求:
- 不再出现 `where 1 = 0`
- SQL 中显式使用 `ccdi_asset_info`
- 仍返回标准 `STATEMENT` 命中字段
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiBankTagAnalysisMapperXmlTest
```
Expected:
- `FAIL`
- [ ] **Step 3: Write minimal implementation**
在 XML 中实现三条资产不匹配规则:
- 购房交易与房产登记不匹配
- 物业缴费与房产登记不匹配
- 大额纳税与资产登记不匹配
要求:
- 使用当前环境已确认可执行的 `ccdi_asset_info` 字段口径
- 资产类型、状态值以当前仓库和现网样例已确认值为准
- 关系人和员工本人都纳入比对
- `reasonDetail` 能反映交易类型、金额或登记缺失事实
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiBankTagAnalysisMapperXmlTest
```
Expected:
- `PASS`
- [ ] **Step 5: Commit**
```bash
git add ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapperXmlTest.java
git commit -m "实现第二期资产比对规则真实SQL"
```
### Task 3: 实现第二期对象聚合规则
**Files:**
- Modify: `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml`
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapperXmlTest.java`
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImplTest.java`
- [ ] **Step 1: Write the failing test**
覆盖以下规则的 SQL 结构与 Service 分发断言:
- `LOW_INCOME_RELATIVE_LARGE_TRANSACTION`
- `MULTI_PARTY_GAMBLING_TRANSFER`
- `MONTHLY_FIXED_INCOME`
- `FIXED_COUNTERPARTY_TRANSFER`
- `SUPPLIER_CONCENTRATION`
- `SALARY_QUICK_TRANSFER`
- `SALARY_UNUSED`
锁定要点:
- `objectType/objectKey/reasonDetail` 字段完整
- SQL 不再占位
- Service 命中正确 Mapper 方法
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiBankTagAnalysisMapperXmlTest,CcdiBankTagServiceImplTest
```
Expected:
- `FAIL`
- [ ] **Step 3: Write minimal implementation**
在 XML 中按规则口径实现对象型聚合:
- 低收入亲属大额交易:先算月均收入,再判断累计交易额
- 疑似赌博交易:按日期、不同对手方和区间金额聚合
- 疑似兼职两条:按月份或季度聚合并做稳定性/区间判断
- 供应商集中度:基于 `ccdi_purchase_transaction` 计算集中度
- 工资快速转出:工资入账后 24h 内转出比例判断
- 工资无使用记录:工资入账后 30 天内无消费/转账支出口径判断
要求:
- 对象维度统一为员工身份证号
- 不在 Java 层做二次业务判断
- 保留 `reasonDetail` 解释性信息,便于后续结果总览和排障
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiBankTagAnalysisMapperXmlTest,CcdiBankTagServiceImplTest
```
Expected:
- `PASS`
- [ ] **Step 5: Commit**
```bash
git add ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml 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
git commit -m "实现第二期对象聚合规则真实SQL"
```
### Task 4: 完成第二期回归与全量收口
**Files:**
- Modify: `sql/2026-03-16-bank-tagging.sql`
- Create: `docs/reports/implementation/2026-03-20-bank-tag-real-rule-phase2-backend-record.md`
- Create: `docs/tests/records/2026-03-20-bank-tag-real-rule-phase2-backend-verification.md`
- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceRiskCountRefreshTest.java`
- [ ] **Step 1: Align metadata and remarks**
校对第二期规则在 `sql/2026-03-16-bank-tagging.sql` 中的元数据,要求:
- 真实规则不再误写为“占位规则”
- 风险等级、结果类型与设计文档一致
- 第二期规则与第一期规则口径不冲突
- [ ] **Step 2: Run final backend regression**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiBankTagAnalysisMapperXmlTest,BankTagRuleConfigResolverTest,CcdiBankTagServiceImplTest,CcdiBankTagServiceRiskCountRefreshTest,CcdiProjectOverviewServiceStructureTest,CcdiProjectOverviewMapperSqlTest,CcdiProjectOverviewServiceImplTest,CcdiProjectOverviewControllerTest
```
Expected:
- `PASS`
- 19 条目标规则替换完成后,结果总览相关后端链路不回退
- [ ] **Step 3: Write implementation record**
`docs/reports/implementation/2026-03-20-bank-tag-real-rule-phase2-backend-record.md` 中记录:
- 第二期 10 条规则范围
- 跨表比对与对象聚合口径
- 参数和元数据调整
- 与第一期衔接关系
- 全量收口结论
- [ ] **Step 4: Write verification record**
`docs/tests/records/2026-03-20-bank-tag-real-rule-phase2-backend-verification.md` 中记录:
- 执行命令
- 执行时间
- 结果摘要
- 最终结论
- [ ] **Step 5: Commit**
```bash
git add sql/2026-03-16-bank-tagging.sql docs/reports/implementation/2026-03-20-bank-tag-real-rule-phase2-backend-record.md docs/tests/records/2026-03-20-bank-tag-real-rule-phase2-backend-verification.md
git commit -m "补充第二期流水模型后端实施记录"
```

View File

@@ -0,0 +1,648 @@
# LSFX Mock Random Hit Rule 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` 稳定随机命中一部分,同时补齐 `LARGE_PURCHASE_TRANSACTION` 所需的最小数据库基线数据。
**Architecture:** 保持现有 `FileService -> StatementService -> 缓存分页` 主链路不变,不新增平行模块或兼容性双轨。`FileService` 只负责生成并保存规则命中计划,`statement_rule_samples.py` 只负责按规则代码装配命中样本,`StatementService` 负责把命中样本与噪声流水合并后缓存;`LARGE_PURCHASE_TRANSACTION` 不伪造成银行流水,单独通过 SQL 基线脚本补齐真实来源表数据。
**Tech Stack:** Python 3, FastAPI, pytest, PyMySQL, MySQL, Bash
---
## File Structure
- `lsfx-mock-server/services/file_service.py`: 在 `FileRecord` 中保存稳定随机的规则命中计划,并让上传链路与行内流水链路都具备一致的计划字段。
- `lsfx-mock-server/services/statement_rule_samples.py`: 维护大额交易与第一期新增规则的样本 builder 映射,按命中计划生成最小命中样本集合。
- `lsfx-mock-server/services/statement_service.py`: 从 `FileRecord` 读取规则命中计划,拼装命中样本与噪声流水,统一分配 ID 并缓存。
- `lsfx-mock-server/tests/test_file_service.py`: 锁定命中计划的生成与持久化语义。
- `lsfx-mock-server/tests/test_statement_service.py`: 锁定样本装配、缓存稳定性与按规则子集命中。
- `lsfx-mock-server/tests/integration/test_full_workflow.py`: 验证 `getJZFileOrZjrcuFile -> getBSByLogId` 端到端链路。
- `sql/migration/2026-03-20-lsfx-mock-random-hit-rule-purchase-baseline.sql`: 为 `LARGE_PURCHASE_TRANSACTION` 补最小采购业务数据。
- `docs/reports/implementation/2026-03-20-lsfx-mock-random-hit-rule-backend-record.md`: 记录本次后端实施内容。
- `docs/tests/records/2026-03-20-lsfx-mock-random-hit-rule-backend-verification.md`: 记录测试与数据库验证结果。
### Task 1: 在 FileService 中持久化稳定随机的规则命中计划
**Files:**
- Modify: `lsfx-mock-server/services/file_service.py`
- Modify: `lsfx-mock-server/tests/test_file_service.py`
- Reference: `docs/design/2026-03-20-lsfx-mock-random-hit-rule-design.md`
- [ ] **Step 1: Write the failing test**
`lsfx-mock-server/tests/test_file_service.py` 中先补两条最小失败用例,锁定“同一 `logId` 命中计划稳定”和“`fetch_inner_flow()` 会把命中计划落到 `FileRecord`”:
```python
def test_build_rule_hit_plan_should_be_deterministic_for_same_log_id():
service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
plan1 = service._build_rule_hit_plan(10001)
plan2 = service._build_rule_hit_plan(10001)
assert plan1 == plan2
assert 2 <= len(plan1["large_transaction_hit_rules"]) <= 4
assert 2 <= len(plan1["phase1_hit_rules"]) <= 4
def test_fetch_inner_flow_should_persist_rule_hit_plan(monkeypatch):
service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
monkeypatch.setattr(
service,
"_build_rule_hit_plan",
lambda log_id: {
"large_transaction_hit_rules": ["HOUSE_OR_CAR_EXPENSE", "TAX_EXPENSE"],
"phase1_hit_rules": ["GAMBLING_SENSITIVE_KEYWORD", "FOREX_BUY_AMT"],
},
)
response = service.fetch_inner_flow(
{
"groupId": 1001,
"customerNo": "test_customer_001",
"dataChannelCode": "test_code",
"requestDateId": 20240101,
"dataStartDateId": 20240101,
"dataEndDateId": 20240131,
"uploadUserId": 902001,
}
)
log_id = response["data"][0]
record = service.file_records[log_id]
assert record.large_transaction_hit_rules == ["HOUSE_OR_CAR_EXPENSE", "TAX_EXPENSE"]
assert record.phase1_hit_rules == ["GAMBLING_SENSITIVE_KEYWORD", "FOREX_BUY_AMT"]
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd lsfx-mock-server
pytest tests/test_file_service.py -k "rule_hit_plan or persist_rule_hit_plan" -v
```
Expected:
- `FAIL`
- 原因是 `FileRecord` 尚未保存规则命中计划,`FileService` 也没有 `_build_rule_hit_plan()` 能力
- [ ] **Step 3: Write minimal implementation**
`lsfx-mock-server/services/file_service.py` 中只做最小改动:
1.`FileRecord` 新增两个字段,采用现有 Python 代码风格的 snake_case
```python
large_transaction_hit_rules: List[str] = field(default_factory=list)
phase1_hit_rules: List[str] = field(default_factory=list)
```
2. 新增固定规则池与稳定随机 helper
```python
def _build_rule_hit_plan(self, log_id: int) -> dict:
rng = random.Random(f"rule-plan:{log_id}")
return {
"large_transaction_hit_rules": self._pick_rule_subset(rng, LARGE_TRANSACTION_RULE_CODES, 2, 4),
"phase1_hit_rules": self._pick_rule_subset(rng, PHASE1_RULE_CODES, 2, 4),
}
```
3.`_create_file_record()``upload_file()``fetch_inner_flow()` 中都把规则命中计划写入 `FileRecord`,避免 `getBSByLogId` 读取上传链路记录时出现字段缺失。
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
cd lsfx-mock-server
pytest tests/test_file_service.py -k "rule_hit_plan or persist_rule_hit_plan" -v
```
Expected:
- `PASS`
- `FileRecord` 已具备稳定随机的规则命中计划
- [ ] **Step 5: Commit**
```bash
git add lsfx-mock-server/services/file_service.py lsfx-mock-server/tests/test_file_service.py
git commit -m "持久化Mock随机命中规则计划"
```
### Task 2: 将 statement_rule_samples.py 拆成按规则代码装配的样本生成器
**Files:**
- Modify: `lsfx-mock-server/services/statement_rule_samples.py`
- Modify: `lsfx-mock-server/tests/test_statement_service.py`
- [ ] **Step 1: Write the failing test**
`lsfx-mock-server/tests/test_statement_service.py` 中补两条失败用例,锁定“只生成被选中的规则样本,而不是默认全量命中”:
```python
def test_build_seed_statements_for_rule_plan_should_only_include_requested_phase1_rules():
plan = {
"large_transaction_hit_rules": [],
"phase1_hit_rules": ["GAMBLING_SENSITIVE_KEYWORD", "FOREX_BUY_AMT"],
}
statements = build_seed_statements_for_rule_plan(
group_id=1000,
log_id=20001,
rule_plan=plan,
)
assert any("游戏" in item["userMemo"] for item in statements)
assert any("购汇" in item["userMemo"] for item in statements)
assert not any("证券" in item["userMemo"] for item in statements)
def test_build_seed_statements_for_rule_plan_should_generate_withdraw_cnt_samples():
plan = {
"large_transaction_hit_rules": [],
"phase1_hit_rules": ["WITHDRAW_CNT"],
}
statements = build_seed_statements_for_rule_plan(
group_id=1000,
log_id=20001,
rule_plan=plan,
)
assert len([
item for item in statements
if "微信提现" in item["userMemo"] or "支付宝提现" in item["userMemo"]
]) >= 4
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd lsfx-mock-server
pytest tests/test_statement_service.py -k "rule_plan_should_only_include or withdraw_cnt_samples" -v
```
Expected:
- `FAIL`
- 原因是当前样本模块仍然是固定大额交易全量样本,没有“按规则计划装配”入口
- [ ] **Step 3: Write minimal implementation**
`lsfx-mock-server/services/statement_rule_samples.py` 中做结构性改造,但只落最短路径:
1. 保留现有基础 identity 与 `_build_statement()` 工具函数
2. 将大额交易样本拆成按规则代码可调用的 builder
3. 新增第一期规则 builder 映射
4. 提供统一入口 `build_seed_statements_for_rule_plan(...)`
最小结构如下:
```python
LARGE_TRANSACTION_BUILDERS = {
"HOUSE_OR_CAR_EXPENSE": build_house_or_car_samples,
"TAX_EXPENSE": build_tax_samples,
"SINGLE_LARGE_INCOME": build_single_large_income_samples,
"CUMULATIVE_INCOME": build_cumulative_income_samples,
"ANNUAL_TURNOVER": build_annual_turnover_supporting_samples,
"LARGE_CASH_DEPOSIT": build_large_cash_deposit_samples,
"FREQUENT_CASH_DEPOSIT": build_frequent_cash_deposit_samples,
"LARGE_TRANSFER": build_large_transfer_samples,
}
PHASE1_RULE_BUILDERS = {
"GAMBLING_SENSITIVE_KEYWORD": build_gambling_sensitive_keyword_samples,
"SPECIAL_AMOUNT_TRANSACTION": build_special_amount_transaction_samples,
"SUSPICIOUS_INCOME_KEYWORD": build_suspicious_income_keyword_samples,
"FOREX_BUY_AMT": build_forex_buy_samples,
"FOREX_SELL_AMT": build_forex_sell_samples,
"STOCK_TFR_LARGE": build_stock_transfer_large_samples,
"LARGE_STOCK_TRADING": build_large_stock_trading_samples,
"WITHDRAW_CNT": build_withdraw_cnt_samples,
}
def build_seed_statements_for_rule_plan(group_id, log_id, rule_plan, **kwargs):
statements = []
for rule_code in rule_plan["large_transaction_hit_rules"]:
statements.extend(LARGE_TRANSACTION_BUILDERS[rule_code](group_id, log_id, **kwargs))
for rule_code in rule_plan["phase1_hit_rules"]:
statements.extend(PHASE1_RULE_BUILDERS[rule_code](group_id, log_id, **kwargs))
return statements
```
要求:
- 不再默认返回全量命中样本
- 每个 builder 只构造命中该规则所需的最小流水集合
- `WITHDRAW_CNT` 虽然是对象型规则,但依然通过银行流水样本构造触发条件
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
cd lsfx-mock-server
pytest tests/test_statement_service.py -k "rule_plan_should_only_include or withdraw_cnt_samples" -v
```
Expected:
- `PASS`
- 样本模块已按规则子集装配,不再默认全量命中
- [ ] **Step 5: Commit**
```bash
git add lsfx-mock-server/services/statement_rule_samples.py lsfx-mock-server/tests/test_statement_service.py
git commit -m "拆分Mock规则样本构造器"
```
### Task 3: 让 StatementService 按命中计划生成样本并保持缓存稳定
**Files:**
- Modify: `lsfx-mock-server/services/statement_service.py`
- Modify: `lsfx-mock-server/tests/test_statement_service.py`
- Modify: `lsfx-mock-server/tests/integration/test_full_workflow.py`
- [ ] **Step 1: Write the failing test**
先在 `lsfx-mock-server/tests/test_statement_service.py` 补一条服务层测试,明确 `StatementService` 必须读取 `FileRecord` 中的命中计划,而不是继续默认全量样本:
```python
def test_generate_statements_should_follow_rule_hit_plan_from_file_record():
file_service = FileService(staff_identity_repository=FakeStaffIdentityRepository())
statement_service = StatementService(file_service=file_service)
response = file_service.fetch_inner_flow(
{
"groupId": 1001,
"customerNo": "customer_rule_plan",
"dataChannelCode": "test_code",
"requestDateId": 20240101,
"dataStartDateId": 20240101,
"dataEndDateId": 20240131,
"uploadUserId": 902001,
}
)
log_id = response["data"][0]
record = file_service.file_records[log_id]
record.large_transaction_hit_rules = ["HOUSE_OR_CAR_EXPENSE"]
record.phase1_hit_rules = ["GAMBLING_SENSITIVE_KEYWORD"]
statements = statement_service._generate_statements(group_id=1001, log_id=log_id, count=200)
assert any("房产首付款" in item["userMemo"] for item in statements)
assert any("游戏" in item["userMemo"] for item in statements)
assert not any("购汇" in item["userMemo"] for item in statements)
```
再在 `lsfx-mock-server/tests/integration/test_full_workflow.py` 补一条链路测试,锁定同一 `logId` 首次生成后分页稳定:
```python
def test_inner_flow_bank_statement_should_keep_same_rule_subset(client):
fetch_response = client.post(
"/watson/api/project/getJZFileOrZjrcuFile",
data={
"groupId": 1001,
"customerNo": "customer_subset",
"dataChannelCode": "test_code",
"requestDateId": 20240101,
"dataStartDateId": 20240101,
"dataEndDateId": 20240131,
"uploadUserId": 902001,
},
)
log_id = fetch_response.json()["data"][0]
page1 = client.post(
"/watson/api/project/getBSByLogId",
data={"groupId": 1001, "logId": log_id, "pageNow": 1, "pageSize": 10},
).json()
page2 = client.post(
"/watson/api/project/getBSByLogId",
data={"groupId": 1001, "logId": log_id, "pageNow": 1, "pageSize": 10},
).json()
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 "follow_rule_hit_plan" -v
pytest tests/integration/test_full_workflow.py -k "same_rule_subset" -v
```
Expected:
- `FAIL`
- 原因是 `StatementService` 还没有从 `FileRecord` 读取规则命中计划,也没有按子集装配样本
- [ ] **Step 3: Write minimal implementation**
`lsfx-mock-server/services/statement_service.py` 中接通规则命中计划:
```python
from services.statement_rule_samples import build_seed_statements_for_rule_plan
def _generate_statements(self, group_id: int, log_id: int, count: int) -> List[Dict]:
record = self.file_service.get_file_record(log_id) if self.file_service is not None else None
rule_plan = {
"large_transaction_hit_rules": record.large_transaction_hit_rules if record else [],
"phase1_hit_rules": record.phase1_hit_rules if record else [],
}
seeded_statements = build_seed_statements_for_rule_plan(
group_id=group_id,
log_id=log_id,
rule_plan=rule_plan,
primary_enterprise_name=primary_enterprise_name,
primary_account_no=primary_account_no,
staff_id_card=record.staff_id_card if record else None,
family_id_cards=record.family_id_cards if record else None,
)
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,
allowed_identity_cards,
rng,
)
)
return self._assign_statement_ids(statements, group_id, log_id)
```
要求:
- 命中样本生成完后仍统一走 `_assign_statement_ids()`
- 继续保留 200 条固定总数和缓存语义
- 未命中的规则绝不回退为默认全量样本
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
cd lsfx-mock-server
pytest tests/test_statement_service.py -k "follow_rule_hit_plan or fixed_total_count_200 or cached_result" -v
pytest tests/integration/test_full_workflow.py -k "same_rule_subset or share_same_primary_binding" -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 lsfx-mock-server/tests/integration/test_full_workflow.py
git commit -m "接通Mock随机命中流水生成链路"
```
### Task 4: 为 LARGE_PURCHASE_TRANSACTION 补最小数据库基线脚本
**Files:**
- Create: `sql/migration/2026-03-20-lsfx-mock-random-hit-rule-purchase-baseline.sql`
- Reference: `sql/ccdi_purchase_transaction.sql`
- Reference: `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml`
- [ ] **Step 1: Write the failing verification query**
先准备最小验证脚本思路,确认当前库里还没有本次联调专用的采购事项基线数据:
```python
import pymysql
cursor.execute(
"SELECT COUNT(1) AS cnt FROM ccdi_purchase_transaction WHERE purchase_id = 'LSFXMOCKPUR001'"
)
assert cursor.fetchone()["cnt"] == 0
```
- [ ] **Step 2: Run verification to confirm baseline data is absent**
Run:
```bash
python3 - <<'PY'
import pymysql
from pathlib import Path
import re
text = Path('ruoyi-admin/src/main/resources/application-dev.yml').read_text(encoding='utf-8')
match = re.search(r"url:\s*jdbc:mysql://(?P<host>[^:/?#]+):(?P<port>\d+)/(?P<db>[^?\n]+).*?\n\s*username:\s*(?P<user>[^\n]+)\n\s*password:\s*(?P<pwd>[^\n]+)", text, re.S)
conn = pymysql.connect(
host=match.group('host'),
port=int(match.group('port')),
user=match.group('user').strip(),
password=match.group('pwd').strip(),
database=match.group('db').strip(),
charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor,
)
with conn, conn.cursor() as cursor:
cursor.execute("SELECT COUNT(1) AS cnt FROM ccdi_purchase_transaction WHERE purchase_id = 'LSFXMOCKPUR001'")
print(cursor.fetchone()['cnt'])
PY
```
Expected:
- 输出 `0`
- 说明 `LARGE_PURCHASE_TRANSACTION` 所需联调基线数据尚未落库
- [ ] **Step 3: Write minimal implementation**
创建 `sql/migration/2026-03-20-lsfx-mock-random-hit-rule-purchase-baseline.sql`,只插入最小必要采购记录,不扩展多余字段。脚本建议采用“先删后插”的幂等方式:
```sql
DELETE FROM ccdi_purchase_transaction WHERE purchase_id = 'LSFXMOCKPUR001';
INSERT INTO ccdi_purchase_transaction (
purchase_id, purchase_category, project_name, subject_name, subject_desc,
purchase_qty, budget_amount, bid_amount, actual_amount, contract_amount, settlement_amount,
purchase_method, supplier_name, contact_person, contact_phone, supplier_uscc, supplier_bank_account,
apply_date, plan_approve_date, announce_date, bid_open_date, contract_sign_date,
expected_delivery_date, actual_delivery_date, acceptance_date, settlement_date,
applicant_id, applicant_name, apply_department,
purchase_leader_id, purchase_leader_name, purchase_department,
created_by, updated_by
)
SELECT
'LSFXMOCKPUR001', '设备采购', 'LSFX Mock 联调',
'反洗钱终端设备采购', '用于命中 LARGE_PURCHASE_TRANSACTION 真实规则',
1, 188000.00, 186000.00, 186000.00, 186000.00, 186000.00,
'竞争性谈判', '兰溪市联调供应链有限公司', '联调联系人', '13800000000', '91330781MOCKPUR001', '6222000000001234',
CURRENT_DATE, CURRENT_DATE, CURRENT_DATE, CURRENT_DATE, CURRENT_DATE,
CURRENT_DATE, CURRENT_DATE, CURRENT_DATE, CURRENT_DATE,
CAST(s.staff_id AS CHAR), s.name, '纪检初核部',
NULL, NULL, NULL,
'admin', 'admin'
FROM ccdi_base_staff s
WHERE COALESCE(TRIM(CAST(s.staff_id AS CHAR)), '') <> ''
AND COALESCE(TRIM(s.name), '') <> ''
LIMIT 1;
```
要求:
- 只补一条采购记录
- `actual_amount` 必须大于 100000
- 不手写 `mysql -e` 直接执行中文 SQL后续执行必须使用 `bin/mysql_utf8_exec.sh`
- [ ] **Step 4: Run script and verify it works**
Run:
```bash
bin/mysql_utf8_exec.sh sql/migration/2026-03-20-lsfx-mock-random-hit-rule-purchase-baseline.sql
python3 - <<'PY'
import pymysql
from pathlib import Path
import re
text = Path('ruoyi-admin/src/main/resources/application-dev.yml').read_text(encoding='utf-8')
match = re.search(r"url:\s*jdbc:mysql://(?P<host>[^:/?#]+):(?P<port>\d+)/(?P<db>[^?\n]+).*?\n\s*username:\s*(?P<user>[^\n]+)\n\s*password:\s*(?P<pwd>[^\n]+)", text, re.S)
conn = pymysql.connect(
host=match.group('host'),
port=int(match.group('port')),
user=match.group('user').strip(),
password=match.group('pwd').strip(),
database=match.group('db').strip(),
charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor,
)
with conn, conn.cursor() as cursor:
cursor.execute("""
SELECT purchase_id, actual_amount, supplier_name
FROM ccdi_purchase_transaction
WHERE purchase_id = 'LSFXMOCKPUR001'
""")
print(cursor.fetchone())
PY
```
Expected:
- SQL 脚本执行成功
- 查询结果返回 `LSFXMOCKPUR001`
- `actual_amount` 大于 `100000`
- [ ] **Step 5: Commit**
```bash
git add sql/migration/2026-03-20-lsfx-mock-random-hit-rule-purchase-baseline.sql
git commit -m "补齐Mock采购规则数据库基线"
```
### Task 5: 做后端回归并沉淀实施与验证文档
**Files:**
- Create: `docs/reports/implementation/2026-03-20-lsfx-mock-random-hit-rule-backend-record.md`
- Create: `docs/tests/records/2026-03-20-lsfx-mock-random-hit-rule-backend-verification.md`
- Reference: `docs/design/2026-03-20-lsfx-mock-random-hit-rule-design.md`
- Reference: `docs/plans/backend/2026-03-20-lsfx-mock-random-hit-rule-backend-implementation.md`
- [ ] **Step 1: Run the full targeted regression**
Run:
```bash
cd lsfx-mock-server
pytest tests/test_file_service.py tests/test_statement_service.py tests/test_api.py tests/integration/test_full_workflow.py -v
```
Expected:
- `PASS`
- 随机命中计划、样本装配、缓存稳定与端到端链路均通过
- [ ] **Step 2: Verify LARGE_PURCHASE_TRANSACTION baseline separately**
Run:
```bash
python3 - <<'PY'
import pymysql
from pathlib import Path
import re
text = Path('ruoyi-admin/src/main/resources/application-dev.yml').read_text(encoding='utf-8')
match = re.search(r"url:\s*jdbc:mysql://(?P<host>[^:/?#]+):(?P<port>\d+)/(?P<db>[^?\n]+).*?\n\s*username:\s*(?P<user>[^\n]+)\n\s*password:\s*(?P<pwd>[^\n]+)", text, re.S)
conn = pymysql.connect(
host=match.group('host'),
port=int(match.group('port')),
user=match.group('user').strip(),
password=match.group('pwd').strip(),
database=match.group('db').strip(),
charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor,
)
with conn, conn.cursor() as cursor:
cursor.execute("""
SELECT COUNT(1) AS cnt
FROM ccdi_purchase_transaction
WHERE purchase_id = 'LSFXMOCKPUR001'
AND actual_amount > 100000
""")
print(cursor.fetchone()['cnt'])
PY
```
Expected:
- 输出 `1`
- 说明采购基线数据已满足真实规则命中门槛
- [ ] **Step 3: Write implementation and verification records**
`docs/reports/implementation/2026-03-20-lsfx-mock-random-hit-rule-backend-record.md` 中记录:
- 规则命中计划如何生成
- 样本模块如何按规则子集装配
- `StatementService` 如何读取计划并缓存
- `LARGE_PURCHASE_TRANSACTION` 为什么单独补数据库基线
`docs/tests/records/2026-03-20-lsfx-mock-random-hit-rule-backend-verification.md` 中记录:
- `pytest` 执行时间与结果
- SQL 基线脚本执行结果
- 采购基线查询结果
- 是否发现回归
- [ ] **Step 4: Check Git change scope**
Run:
```bash
git status --short
```
Expected:
- 只包含本次任务相关文件
- `.DS_Store` 不纳入提交
- [ ] **Step 5: Commit**
```bash
git add docs/reports/implementation/2026-03-20-lsfx-mock-random-hit-rule-backend-record.md docs/tests/records/2026-03-20-lsfx-mock-random-hit-rule-backend-verification.md
git commit -m "补充Mock随机命中后端实施记录"
```

View File

@@ -0,0 +1,297 @@
# Results Overview Risk Model Linkage 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:** 继续沿用 `CcdiProjectOverviewController + Service + Mapper` 的结果总览链路,复用现有“员工本人 + 亲属归并到员工名下”的基础聚合 SQL不新建平行模块或补丁式接口。模型卡片统计与人员列表查询都建立在同一套员工归并口径上保证模型区与风险人员区统计一致。
**Tech Stack:** Java 21, Spring Boot 3, MyBatis XML, Maven, JUnit 5
---
### Task 1: 定义接口、DTO 与 VO 结构
**Files:**
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewController.java`
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectOverviewService.java`
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiProjectRiskModelPeopleQueryDTO.java`
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectRiskModelCardVO.java`
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectRiskModelCardsVO.java`
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectRiskModelPeopleItemVO.java`
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectRiskModelPeopleVO.java`
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectRiskHitTagVO.java`
- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewControllerContractTest.java`
- [x] **Step 1: Write the failing test**
为控制器契约补静态/反射测试,锁定以下方法和路径:
- `GET /ccdi/project/overview/risk-models/cards`
- `GET /ccdi/project/overview/risk-models/people`
并锁定 DTO 字段:
- `projectId`
- `modelCodes`
- `matchMode`
- `keyword`
- `deptId`
- `pageNum`
- `pageSize`
- [x] **Step 2: Run test to verify it fails**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewControllerContractTest
```
Expected:
- `FAIL`
- 原因是接口与类型尚未创建
- [x] **Step 3: Write minimal implementation**
补齐控制器、服务接口、DTO 与 VO。
DTO 关键约束:
- `modelCodes` 为多值条件
- `matchMode` 只允许 `ANY``ALL`
- `modelCodes` 为空时忽略 `matchMode`
VO 关键字段:
- 卡片:`modelCode``modelName``warningCount``peopleCount`
- 列表:`staffName``staffCode``idNo``department``modelNames``hitTagList``actionLabel`
- 标签:`ruleCode``ruleName``riskLevel`
- [x] **Step 4: Run test to verify it passes**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewControllerContractTest
```
Expected:
- `PASS`
- [x] **Step 5: Commit**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewController.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectOverviewService.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiProjectRiskModelPeopleQueryDTO.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectRiskModelCardVO.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectRiskModelCardsVO.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectRiskModelPeopleItemVO.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectRiskModelPeopleVO.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectRiskHitTagVO.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewControllerContractTest.java
git commit -m "定义结果总览模型区接口结构"
```
### Task 2: 扩展模型卡片统计 SQL
**Files:**
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapper.java`
- Modify: `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml`
- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapperRiskModelCardsTest.java`
- [x] **Step 1: Write the failing test**
为模型卡片统计新增 mapper 测试,锁定以下口径:
- `warningCount` = 当前项目、当前模型的命中结果数
- `peopleCount` = 当前模型归并后的员工人数
- 排序按 `warningCount desc, model_code asc`
- [x] **Step 2: Run test to verify it fails**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewMapperRiskModelCardsTest
```
Expected:
- `FAIL`
- [x] **Step 3: Write minimal implementation**
`CcdiProjectOverviewMapper.xml` 中:
- 复用 `resolvedEmployeeRiskBaseSql`
- 新增模型卡片查询
- 预警次数直接以标签命中记录计数
- 涉及人数按 `staff_id_card` 去重
不要新增新表、不要引入额外统计口径。
- [x] **Step 4: Run test to verify it passes**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewMapperRiskModelCardsTest
```
Expected:
- `PASS`
- [x] **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/CcdiProjectOverviewMapperRiskModelCardsTest.java
git commit -m "补充结果总览模型卡片统计查询"
```
### Task 3: 实现人员分页查询与匹配模式
**Files:**
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapper.java`
- Modify: `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml`
- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapperRiskModelPeopleTest.java`
- [x] **Step 1: Write the failing test**
新增列表查询测试,覆盖:
- 不选模型时返回全部模型命中人员
- 多模型 `ANY` 时按并集返回
- 多模型 `ALL` 时按交集返回
- `keyword` 同时匹配姓名与工号
- `deptId` 精确筛选
- `hitTagList``modelNames` 只返回当前筛选上下文内的数据
- [x] **Step 2: Run test to verify it fails**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewMapperRiskModelPeopleTest
```
Expected:
- `FAIL`
- [x] **Step 3: Write minimal implementation**
列表查询要求:
- 补齐员工工号字段映射;如果员工表实际字段不是 `staff_code`,按真实字段落地
- `ANY`:命中任一所选模型即可
- `ALL`:员工命中的所选模型去重数必须等于所选模型数
- `modelNames` 只拼接当前所选模型范围内的模型名称
- `hitTagList` 只返回当前所选模型范围内的标签
- 标签排序按风险等级、规则编码稳定输出
- [x] **Step 4: Run test to verify it passes**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewMapperRiskModelPeopleTest
```
Expected:
- `PASS`
- [x] **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/CcdiProjectOverviewMapperRiskModelPeopleTest.java
git commit -m "补充结果总览模型区人员分页查询"
```
### Task 4: 完成服务组装与控制器返回
**Files:**
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImpl.java`
- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImplTest.java`
- [x] **Step 1: Write the failing test**
在服务测试中补充:
- 项目不存在时抛出统一异常
- 卡片接口返回 `cardList`
- 人员接口返回 `rows + total`
- 空列表返回空数组,不返回 `null`
- `actionLabel` 固定为“查看详情”
- [x] **Step 2: Run test to verify it fails**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewServiceImplTest
```
Expected:
- `FAIL`
- [x] **Step 3: Write minimal implementation**
在服务层:
- 统一做项目存在性校验
- 卡片接口直接封装 `cardList`
- 人员接口封装 `rows``total`
- `matchMode` 默认值为 `ANY`
- [x] **Step 4: Run test to verify it passes**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewServiceImplTest
```
Expected:
- `PASS`
- [x] **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/impl/CcdiProjectOverviewServiceImplTest.java
git commit -m "完成结果总览模型区服务组装"
```
### Task 5: 后端回归验证与实施记录
**Files:**
- Modify: `docs/plans/backend/2026-03-20-results-overview-risk-model-linkage-backend-implementation.md`
- Create: `docs/tests/records/2026-03-20-results-overview-risk-model-linkage-backend-verification.md`
- Create: `docs/reports/implementation/2026-03-20-results-overview-risk-model-linkage-backend-implementation.md`
- [x] **Step 1: Run backend verification**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiProjectOverview*
```
Expected:
- `PASS`
- [x] **Step 2: Write verification and implementation records**
记录:
- 模型卡片接口
- 人员分页接口
- `ANY / ALL` 两种匹配方式
- 验证命令与结论
- [x] **Step 3: Commit**
```bash
git add docs/plans/backend/2026-03-20-results-overview-risk-model-linkage-backend-implementation.md docs/tests/records/2026-03-20-results-overview-risk-model-linkage-backend-verification.md docs/reports/implementation/2026-03-20-results-overview-risk-model-linkage-backend-implementation.md
git commit -m "补充结果总览模型区后端实施记录"
```

View File

@@ -0,0 +1,179 @@
# Results Overview Risk People Merge 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` 现有员工风险聚合 SQL 与风险等级映射逻辑,不新增接口、不新增统计口径、不删除独立 TOP10 接口。仅扩展风险人员总览 VO、服务映射与相关测试使 `GET /ccdi/project/overview/risk-people` 直接返回 `riskLevel``riskLevelType``modelCount`
**Tech Stack:** Java 21, Spring Boot 3, MyBatis XML, Maven, JUnit 5, Mockito
---
### Task 1: 锁定风险人员总览新增字段的测试预期
**Files:**
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImplTest.java`
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewControllerTest.java`
- [ ] **Step 1: Write the failing test**
调整 `CcdiProjectOverviewServiceImplTest`,在 `shouldMapRiskPeopleOverviewRows` 中增加对 `riskLevel``riskLevelType``modelCount` 的断言;必要时在 `CcdiProjectOverviewControllerTest` 中补充风险人员总览响应结构断言,锁定返回 JSON 中包含这三个字段。
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewServiceImplTest,CcdiProjectOverviewControllerTest
```
Expected:
- `FAIL`
- 原因是当前风险人员总览项尚未暴露 `riskLevel``riskLevelType``modelCount`
- [ ] **Step 3: Write minimal implementation**
保持现有测试数据构造方式不变,只补最小断言:`HIGH -> 高风险 / danger``MEDIUM -> 中风险 / warning``LOW -> 低风险 / info`,以及 `modelCount` 直接透传。
- [ ] **Step 4: Run test to verify it passes after implementation**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewServiceImplTest,CcdiProjectOverviewControllerTest
```
Expected:
- `PASS`
- [ ] **Step 5: Commit**
```bash
git add ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImplTest.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewControllerTest.java
git commit -m "补充风险人员总览接口字段测试"
```
### Task 2: 扩展风险人员总览 VO 与服务映射
**Files:**
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectRiskPeopleOverviewItemVO.java`
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImpl.java`
- Verify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectEmployeeRiskAggregateVO.java`
- [ ] **Step 1: Implement the VO fields**
`CcdiProjectRiskPeopleOverviewItemVO` 中新增:
- `private String riskLevel;`
- `private String riskLevelType;`
- `private Integer modelCount;`
保持原有字段和 Lombok `@Data` 不变,不新增多余兼容字段。
- [ ] **Step 2: Implement service mapping**
`buildRiskPeopleItem` 中补充:
- `riskLevel``aggregate.getRiskLevelCode()` 经现有映射逻辑转换
- `riskLevelType` 由现有 `resolveRiskLevelType` 生成
- `modelCount` 直接读取 `aggregate.getModelCount()`
不要更改:
- `riskCount` 继续读取 `hitCount`
- `riskPoint` 继续读取聚合结果
- 风险人员总览排序逻辑
- `getTopRiskPeople` 行为
- [ ] **Step 3: Run focused backend tests**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewServiceImplTest
```
Expected:
- `PASS`
- [ ] **Step 4: Review for boundary compliance**
人工检查以下边界:
- 未新增新接口
- 未删除 `getTopRiskPeople`
- 未修改风险等级分级口径
- 未修改 `CcdiProjectOverviewMapper.xml` 排序和聚合 SQL
- [ ] **Step 5: Commit**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectRiskPeopleOverviewItemVO.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImpl.java
git commit -m "扩展风险人员总览接口返回字段"
```
### Task 3: 回归验证 SQL 和接口边界未被破坏
**Files:**
- Verify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapperSqlTest.java`
- Verify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/CcdiProjectOverviewServiceStructureTest.java`
- Verify: `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml`
- [ ] **Step 1: Run SQL and structure regression tests**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewMapperSqlTest,CcdiProjectOverviewServiceStructureTest
```
Expected:
- `PASS`
- 证明员工聚合 SQL、Mapper 结构与服务接口边界仍然稳定
- [ ] **Step 2: Perform manual API contract review**
检查风险人员总览接口对应的 Controller、Service、VO确认本轮只增加返回字段不引入新路径、不更改请求参数。
- [ ] **Step 3: Commit**
```bash
git add ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapperSqlTest.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/CcdiProjectOverviewServiceStructureTest.java ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml
git commit -m "回归校验结果总览后端接口边界"
```
### Task 4: 补充后端实施与验证文档
**Files:**
- Create: `docs/reports/implementation/2026-03-20-results-overview-risk-people-merge-backend-implementation.md`
- Create: `docs/tests/records/2026-03-20-results-overview-risk-people-merge-backend-verification.md`
- Verify: `docs/design/2026-03-20-results-overview-risk-people-merge-design.md`
- [ ] **Step 1: Write implementation record**
记录本次后端改动内容:
- 扩展风险人员总览接口字段
- 复用现有风险等级映射
- 明确未删除后端 TOP10 接口
- [ ] **Step 2: Write verification record**
记录执行过的命令、日期、结果和验证结论,至少包含:
```bash
mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewServiceImplTest,CcdiProjectOverviewControllerTest
mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewMapperSqlTest,CcdiProjectOverviewServiceStructureTest
```
- [ ] **Step 3: Commit**
```bash
git add docs/reports/implementation/2026-03-20-results-overview-risk-people-merge-backend-implementation.md docs/tests/records/2026-03-20-results-overview-risk-people-merge-backend-verification.md
git commit -m "补充风险人员总览收口后端实施记录"
```

View File

@@ -0,0 +1,67 @@
# 项目详情打标状态轮询前端实施计划
> **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`,由父组件统一维护 1 秒轮询定时器、请求节流与销毁清理,子组件继续只消费 `projectInfo.projectStatus`。这样可以保证详情页各子标签页共享同一份最新项目状态,并在状态脱离“打标中”后自动停止轮询。
**Tech Stack:** Vue 2、Element UI、现有 `@/api/ccdiProject` 接口层、Node `assert` 单测脚本
---
### Task 1: 补充失败单测
**Files:**
- Create: `ruoyi-ui/tests/unit/project-detail-tagging-polling.test.js`
- Test: `ruoyi-ui/tests/unit/project-detail-tagging-polling.test.js`
- [ ] **Step 1: 编写失败单测**
校验 `detail.vue` 已具备:
- 轮询定时器状态字段
- 仅在 `projectStatus === "3"` 时启动轮询
- 状态变更后停止轮询
- 组件销毁时清理轮询
- [ ] **Step 2: 运行单测确认失败**
Run: `node ruoyi-ui/tests/unit/project-detail-tagging-polling.test.js`
Expected: FAIL提示缺少详情页打标状态轮询逻辑
### Task 2: 在详情页实现最短路径轮询
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiProject/detail.vue`
- [ ] **Step 1: 增加轮询状态字段与清理逻辑**
新增页级定时器、轮询间隔、请求中的互斥标记,并在组件销毁前统一关闭定时器。
- [ ] **Step 2: 在项目详情加载后按状态启停轮询**
首次加载和手动刷新项目详情后,根据接口返回的 `projectStatus` 判断:
- 状态为 `3` 时启动轮询
- 状态不是 `3` 时关闭轮询
- [ ] **Step 3: 轮询期间复用项目详情接口更新页面**
轮询调用 `getProject(projectId)`,更新 `projectInfo`、页面标题和状态标签;如果状态已不再是 `3`,则立即停止轮询。
- [ ] **Step 4: 处理路由切换与重复启动**
切换 `projectId`、离开页面或重复进入轮询分支时,确保不会叠加多个定时器,也不会在已有请求未结束时并发重复请求。
### Task 3: 回归验证与记录
**Files:**
- Modify: `docs/reports/implementation/2026-03-19-project-detail-tagging-status-polling-record.md`
- [ ] **Step 1: 运行相关单测**
Run: `node ruoyi-ui/tests/unit/project-detail-tagging-polling.test.js`
Expected: PASS
- [ ] **Step 2: 补充实施记录**
记录本次修改内容、测试命令和验证结论,便于后续追踪。

View File

@@ -0,0 +1,84 @@
# Project Upload File Delete Trigger Retag 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:** 不改动现有删除按钮、列表刷新和轮询逻辑,只在 `UploadData.vue` 中更新删除确认文案和成功提示文案。继续沿用现有 `deleteFileUploadRecord` 接口,由后端实际触发异步重新打标,前端只负责明确告知用户影响。
**Tech Stack:** Vue 2, Element UI, Node.js, npm
---
### Task 1: 为删除确认和成功提示补齐失败测试
**Files:**
- Create: `ruoyi-ui/tests/unit/upload-data-delete-retag-copy.test.js`
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
- [ ] **Step 1: Write the failing test**
新增一个轻量源码断言测试,验证删除交互文案:
```javascript
assert(
source.includes("删除后将即将重新打标"),
"删除确认框应明确提示即将重新打标"
);
assert(
source.includes("删除成功,已开始项目重新打标"),
"删除成功提示应明确告知已开始项目重新打标"
);
```
实现时可用正则放宽空白和换行,不依赖运行 Vue 环境。
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
node ruoyi-ui/tests/unit/upload-data-delete-retag-copy.test.js
```
Expected:
- `FAIL`
- 原因是当前确认框和成功提示还没有“重新打标”文案
- [ ] **Step 3: Write minimal implementation**
`handleDeleteFile` 中把确认框改为明确提示会重新打标,例如:
```javascript
await this.$confirm(
"删除该文件后将重新打标项目内流水,是否继续?",
"提示",
{ type: "warning" }
);
```
把成功提示改为:
```javascript
this.$message.success("删除成功,已开始项目重新打标");
```
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
node ruoyi-ui/tests/unit/upload-data-delete-retag-copy.test.js
```
Expected:
- `PASS`
- [ ] **Step 5: Commit**
```bash
git add ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue ruoyi-ui/tests/unit/upload-data-delete-retag-copy.test.js
git commit -m "补充上传文件删除重打标提示"
```

View File

@@ -0,0 +1,41 @@
# Risk People Overview Risk Count 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:** 确认风险人员总览前端在后端调整疑似违规数口径后继续直接展示接口返回的 `riskCount`
**Architecture:** 不修改 `RiskPeopleSection.vue` 表格结构和字段绑定,不新增前端兼容逻辑,只做回归验证确保“疑似违规数”仍绑定 `riskCount` 字段。
**Tech Stack:** Vue 2, Element UI, Node.js
---
### Task 1: 回归确认疑似违规数字段绑定
**Files:**
- Verify: `ruoyi-ui/src/views/ccdiProject/components/detail/RiskPeopleSection.vue`
- Verify: `ruoyi-ui/tests/unit/preliminary-check-risk-people-binding.test.js`
- [ ] **Step 1: Review current binding**
确认“疑似违规数”列继续绑定 `riskCount`,不新增前端计算。
- [ ] **Step 2: Run regression check**
Run:
```bash
cd ruoyi-ui
node tests/unit/preliminary-check-risk-people-binding.test.js
```
Expected:
- `PASS`
- [ ] **Step 3: 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 "确认风险人员总览疑似违规数前端展示口径"
```

View File

@@ -0,0 +1,41 @@
# Risk People Overview Risk Point 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:** 保持 `RiskPeopleSection.vue` 表格结构、字段绑定和接口契约不变,不新增补丁逻辑。前端仅做回归验证,确保 `riskPoint` 继续作为普通字符串渲染。
**Tech Stack:** Vue 2, Element UI, Node.js
---
### Task 1: 回归确认风险人员总览字段绑定
**Files:**
- Verify: `ruoyi-ui/src/views/ccdiProject/components/detail/RiskPeopleSection.vue`
- Verify: `ruoyi-ui/tests/unit/preliminary-check-risk-people-binding.test.js`
- [ ] **Step 1: Review current binding**
确认“核心异常点”列仍绑定 `riskPoint`,不拆分数组,不新增格式化逻辑。
- [ ] **Step 2: Run regression check**
Run:
```bash
cd ruoyi-ui
node tests/unit/preliminary-check-risk-people-binding.test.js
```
Expected:
- `PASS`
- [ ] **Step 3: 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 "确认风险人员总览核心异常点前端展示口径"
```

View File

@@ -0,0 +1,122 @@
# Bank Tag Real Rule Phase 1 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:** 前端继续沿用现有结果总览页面与模型区展示结构,不新增字段、不调整交互、不新增接口调用。第一期真实规则落地只影响后端打标结果和统计数据来源,前端本期只做边界校验、回归验证和文档记录。
**Tech Stack:** Vue 2, Element UI, Node.js
---
### Task 1: 锁定第一期前端无代码改造边界
**Files:**
- Test: `ruoyi-ui/tests/unit/project-overview-api.test.js`
- Test: `ruoyi-ui/tests/unit/preliminary-check-api-integration.test.js`
- Test: `ruoyi-ui/tests/unit/preliminary-check-model-linkage-flow.test.js`
- [ ] **Step 1: Write the failing test**
补一个前端边界断言,锁定:
- 本期不新增前端 API 方法
- `PreliminaryCheck.vue` 仍按现有接口集合取数
- `RiskModelSection.vue` 不新增规则编码分支判断
可以在现有测试文件中加入如下静态断言思路:
```js
assert(!source.includes('GAMBLING_SENSITIVE_KEYWORD'))
assert(!source.includes('SPECIAL_AMOUNT_TRANSACTION'))
```
- [ ] **Step 2: Run test to verify it fails**
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-model-linkage-flow.test.js
```
Expected:
- 若当前测试尚未锁定边界,则至少有 1 个断言需要补充并先失败
- [ ] **Step 3: Write minimal implementation**
只补测试断言,不改前端业务代码,确保计划执行时能防止误把后端规则落地扩散成前端结构改造。
- [ ] **Step 4: Run test to verify it passes**
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-model-linkage-flow.test.js
```
Expected:
- `PASS`
- [ ] **Step 5: Commit**
```bash
git add ruoyi-ui/tests/unit/project-overview-api.test.js ruoyi-ui/tests/unit/preliminary-check-api-integration.test.js ruoyi-ui/tests/unit/preliminary-check-model-linkage-flow.test.js
git commit -m "锁定第一期流水模型前端边界"
```
### Task 2: 做第一期前端回归并补文档
**Files:**
- Create: `docs/reports/implementation/2026-03-20-bank-tag-real-rule-phase1-frontend-record.md`
- Create: `docs/tests/records/2026-03-20-bank-tag-real-rule-phase1-frontend-verification.md`
- [ ] **Step 1: Run focused frontend regression**
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-model-api.test.js
node tests/unit/preliminary-check-model-data-loading.test.js
node tests/unit/preliminary-check-model-linkage-flow.test.js
```
Expected:
- `PASS`
- 第一期开启真实规则后,前端现有接口依赖和模型区联动结构保持稳定
- [ ] **Step 2: Write implementation record**
`docs/reports/implementation/2026-03-20-bank-tag-real-rule-phase1-frontend-record.md` 中记录:
- 第一期前端无业务代码改造
- 仅做边界锁定与联调预期说明
- 前端受影响的仅是后端打标结果数据源更真实
- [ ] **Step 3: Write verification record**
`docs/tests/records/2026-03-20-bank-tag-real-rule-phase1-frontend-verification.md` 中记录:
- 执行命令
- 执行时间
- 结果摘要
- 未启动额外前端长期进程;若临时启动,验证后需停止
- [ ] **Step 4: Commit**
```bash
git add docs/reports/implementation/2026-03-20-bank-tag-real-rule-phase1-frontend-record.md docs/tests/records/2026-03-20-bank-tag-real-rule-phase1-frontend-verification.md
git commit -m "补充第一期流水模型前端实施记录"
```

View File

@@ -0,0 +1,123 @@
# Bank Tag Real Rule Phase 2 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:** 第二期后端会补齐对象聚合、时间窗口和资产比对类规则,但前端继续只消费既有结果总览接口和模型区接口,不增加规则级展示逻辑。本期前端计划聚焦无改造边界校验、结果链路回归和最终收口记录。
**Tech Stack:** Vue 2, Element UI, Node.js
---
### Task 1: 锁定第二期前端结构无扩散
**Files:**
- Test: `ruoyi-ui/tests/unit/preliminary-check-summary-and-people.test.js`
- Test: `ruoyi-ui/tests/unit/preliminary-check-risk-people-binding.test.js`
- Test: `ruoyi-ui/tests/unit/preliminary-check-model-and-detail.test.js`
- [ ] **Step 1: Write the failing test**
补静态/绑定断言,锁定:
- 第二期后端规则收口不引起 `RiskPeopleSection.vue` 新增列
- 模型区与风险明细区不增加规则编码硬编码展示
- 页面仍按现有 `riskPeople / riskModels / riskDetails` 结构消费结果
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd ruoyi-ui
node tests/unit/preliminary-check-summary-and-people.test.js
node tests/unit/preliminary-check-risk-people-binding.test.js
node tests/unit/preliminary-check-model-and-detail.test.js
```
Expected:
- 若边界断言未写,则至少有 1 个测试需要先失败
- [ ] **Step 3: Write minimal implementation**
只补测试边界,不改前端业务代码。目标是避免在第二期后端复杂规则落地时,把结果字段变化误扩散成页面结构改造。
- [ ] **Step 4: Run test to verify it passes**
Run:
```bash
cd ruoyi-ui
node tests/unit/preliminary-check-summary-and-people.test.js
node tests/unit/preliminary-check-risk-people-binding.test.js
node tests/unit/preliminary-check-model-and-detail.test.js
```
Expected:
- `PASS`
- [ ] **Step 5: Commit**
```bash
git add ruoyi-ui/tests/unit/preliminary-check-summary-and-people.test.js ruoyi-ui/tests/unit/preliminary-check-risk-people-binding.test.js ruoyi-ui/tests/unit/preliminary-check-model-and-detail.test.js
git commit -m "锁定第二期流水模型前端边界"
```
### Task 2: 做第二期前端收口回归并补文档
**Files:**
- Create: `docs/reports/implementation/2026-03-20-bank-tag-real-rule-phase2-frontend-record.md`
- Create: `docs/tests/records/2026-03-20-bank-tag-real-rule-phase2-frontend-verification.md`
- [ ] **Step 1: Run final frontend regression**
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-summary-and-people.test.js
node tests/unit/preliminary-check-risk-people-binding.test.js
node tests/unit/preliminary-check-model-api.test.js
node tests/unit/preliminary-check-model-data-loading.test.js
node tests/unit/preliminary-check-model-match-mode.test.js
node tests/unit/preliminary-check-model-filters.test.js
node tests/unit/preliminary-check-model-table-columns.test.js
node tests/unit/preliminary-check-model-linkage-flow.test.js
node tests/unit/preliminary-check-model-and-detail.test.js
node tests/unit/preliminary-check-states.test.js
node tests/unit/preliminary-check-layout.test.js
```
Expected:
- `PASS`
- 第二期后端规则全量收口后,前端现有结果总览结构仍稳定
- [ ] **Step 2: Write implementation record**
`docs/reports/implementation/2026-03-20-bank-tag-real-rule-phase2-frontend-record.md` 中记录:
- 第二期前端仍无业务代码改造
- 主要工作为结构边界锁定、联调预期确认与全量收口记录
- 后端真实规则全量落地后,前端继续消费既有接口
- [ ] **Step 3: Write verification record**
`docs/tests/records/2026-03-20-bank-tag-real-rule-phase2-frontend-verification.md` 中记录:
- 执行命令
- 执行时间
- 结果摘要
- 未启动额外前端长期进程;若临时启动,验证后需停止
- [ ] **Step 4: Commit**
```bash
git add docs/reports/implementation/2026-03-20-bank-tag-real-rule-phase2-frontend-record.md docs/tests/records/2026-03-20-bank-tag-real-rule-phase2-frontend-verification.md
git commit -m "补充第二期流水模型前端实施记录"
```

View File

@@ -0,0 +1,338 @@
# Results Overview Risk Model Linkage 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 (`- [x]`) syntax for tracking.
**Goal:** 将结果总览模型区接入真实接口,支持多卡片联动、任意触发/同时触发切换、员工姓名或工号与部门筛选,以及异常标签展示。
**Architecture:** 保持 `PreliminaryCheck.vue` 作为结果总览页面主入口,不新增路由或二级页面。模型卡片接口与人员列表接口分开请求,`RiskModelSection.vue` 内统一维护卡片选择、匹配模式、关键词、部门和分页状态,列表按状态组合查询。
**Tech Stack:** Vue 2, Element UI, Axios (`@/utils/request`), Node.js
---
### Task 1: 补模型区 API 封装
**Files:**
- Modify: `ruoyi-ui/src/api/ccdi/projectOverview.js`
- Test: `ruoyi-ui/tests/unit/preliminary-check-model-api.test.js`
- [x] **Step 1: Write the failing test**
新增接口静态断言,锁定以下方法与路径:
- `getOverviewRiskModelCards`
- `getOverviewRiskModelPeople`
- `/ccdi/project/overview/risk-models/cards`
- `/ccdi/project/overview/risk-models/people`
- `matchMode`
- [x] **Step 2: Run test to verify it fails**
Run:
```bash
cd ruoyi-ui
node tests/unit/preliminary-check-model-api.test.js
```
Expected:
- `FAIL`
- [x] **Step 3: Write minimal implementation**
在 API 模块中新增:
- `getOverviewRiskModelCards(projectId)`
- `getOverviewRiskModelPeople(params)`
列表接口需透传:
- `projectId`
- `modelCodes`
- `matchMode`
- `keyword`
- `deptId`
- `pageNum`
- `pageSize`
- [x] **Step 4: Run test to verify it passes**
Run:
```bash
cd ruoyi-ui
node tests/unit/preliminary-check-model-api.test.js
```
Expected:
- `PASS`
- [x] **Step 5: Commit**
```bash
git add ruoyi-ui/src/api/ccdi/projectOverview.js ruoyi-ui/tests/unit/preliminary-check-model-api.test.js
git commit -m "补充结果总览模型区前端接口封装"
```
### Task 2: 在入口页接入模型区真实数据
**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-model-data-loading.test.js`
- [x] **Step 1: Write the failing test**
新增入口页静态断言,要求:
- `PreliminaryCheck.vue` 引入 `getOverviewRiskModelCards`
- 模型区真实数据不再只依赖旧 mock
- 页面仍保留 `loading / empty / loaded` 状态结构
- [x] **Step 2: Run test to verify it fails**
Run:
```bash
cd ruoyi-ui
node tests/unit/preliminary-check-model-data-loading.test.js
```
Expected:
- `FAIL`
- [x] **Step 3: Write minimal implementation**
`PreliminaryCheck.vue` 中:
- 拉取模型卡片真实数据
- 将模型区交给 `RiskModelSection.vue` 内部继续请求列表接口
- 清理 `preliminaryCheck.mock.js` 中模型区旧的 `warningTypeOptions`、旧 `filterValues.model` 依赖
- 保留空态 mock 作为兜底展示结构,不新增业务逻辑兜底
- [x] **Step 4: Run test to verify it passes**
Run:
```bash
cd ruoyi-ui
node tests/unit/preliminary-check-model-data-loading.test.js
```
Expected:
- `PASS`
- [x] **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-model-data-loading.test.js
git commit -m "接入结果总览模型卡片真实数据"
```
### Task 3: 改造模型卡片多选与触发方式切换
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/RiskModelSection.vue`
- Test: `ruoyi-ui/tests/unit/preliminary-check-model-multiselect.test.js`
- Test: `ruoyi-ui/tests/unit/preliminary-check-model-match-mode.test.js`
- [x] **Step 1: Write the failing test**
新增组件静态/行为断言,锁定:
- 卡片支持多选
- 再次点击已选卡片可取消
- 存在 `matchMode`
- 页面出现“任意触发”“同时触发”文案
- [x] **Step 2: Run test to verify it fails**
Run:
```bash
cd ruoyi-ui
node tests/unit/preliminary-check-model-multiselect.test.js
node tests/unit/preliminary-check-model-match-mode.test.js
```
Expected:
- `FAIL`
- [x] **Step 3: Write minimal implementation**
`RiskModelSection.vue` 中新增状态:
- `selectedModelCodes`
- `matchMode`
- `keyword`
- `deptId`
- `pageNum`
- `pageSize`
- `cardLoading`
- `tableLoading`
交互规则:
- 默认 `selectedModelCodes = []`
- 默认 `matchMode = "ANY"`
- 选中多张卡片时允许切换 `ANY / ALL`
- 不选卡片或仅选一张时,切换模式不报错,仍按当前接口语义请求
- [x] **Step 4: Run test to verify it passes**
Run:
```bash
cd ruoyi-ui
node tests/unit/preliminary-check-model-multiselect.test.js
node tests/unit/preliminary-check-model-match-mode.test.js
```
Expected:
- `PASS`
- [x] **Step 5: Commit**
```bash
git add ruoyi-ui/src/views/ccdiProject/components/detail/RiskModelSection.vue ruoyi-ui/tests/unit/preliminary-check-model-multiselect.test.js ruoyi-ui/tests/unit/preliminary-check-model-match-mode.test.js
git commit -m "补充结果总览模型卡片联动交互"
```
### Task 4: 替换筛选条与人员列表列
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/RiskModelSection.vue`
- Test: `ruoyi-ui/tests/unit/preliminary-check-model-filters.test.js`
- Test: `ruoyi-ui/tests/unit/preliminary-check-model-table-columns.test.js`
- [x] **Step 1: Write the failing test**
锁定以下变更:
- 删除“筛查模型”
- 删除“预警类型”
- 新增“员工姓名或工号”
- 新增“部门”
- 新增“任意触发”“同时触发”
- 删除“预警类型”列
- 新增“工号”列
- 新增“异常标签”列
- [x] **Step 2: Run test to verify it fails**
Run:
```bash
cd ruoyi-ui
node tests/unit/preliminary-check-model-filters.test.js
node tests/unit/preliminary-check-model-table-columns.test.js
```
Expected:
- `FAIL`
- [x] **Step 3: Write minimal implementation**
在筛选区:
- 保留 `查询 / 重置`
- 关键词输入框支持姓名或工号
- 部门优先复用仓库现有部门选项接口
- 重置时恢复:
- `selectedModelCodes = []`
- `matchMode = "ANY"`
- `keyword = ""`
- `deptId = undefined`
- `pageNum = 1`
在表格列:
- 删除 `warningType`
- 增加 `staffCode`
- `modelNames` 显示当前上下文模型名称摘要
- `hitTagList``el-tag` 展示 `ruleName`
- 颜色映射复用 `DetailQuery.vue` 风险等级映射方式
- [x] **Step 4: Run test to verify it passes**
Run:
```bash
cd ruoyi-ui
node tests/unit/preliminary-check-model-filters.test.js
node tests/unit/preliminary-check-model-table-columns.test.js
```
Expected:
- `PASS`
- [x] **Step 5: Commit**
```bash
git add ruoyi-ui/src/views/ccdiProject/components/detail/RiskModelSection.vue ruoyi-ui/tests/unit/preliminary-check-model-filters.test.js ruoyi-ui/tests/unit/preliminary-check-model-table-columns.test.js
git commit -m "调整结果总览模型区筛选与列表列"
```
### Task 5: 前端联调验证与实施记录
**Files:**
- Modify: `ruoyi-ui/tests/unit/preliminary-check-model-and-detail.test.js`
- Create: `ruoyi-ui/tests/unit/preliminary-check-model-linkage-flow.test.js`
- Modify: `docs/plans/frontend/2026-03-20-results-overview-risk-model-linkage-frontend-implementation.md`
- Create: `docs/tests/records/2026-03-20-results-overview-risk-model-linkage-frontend-verification.md`
- Create: `docs/reports/implementation/2026-03-20-results-overview-risk-model-linkage-frontend-implementation.md`
- [x] **Step 1: Write linkage flow assertions**
覆盖以下联动流:
- 默认全部模型
- 选中一张卡片
- 同时选中多张卡片 + `ANY`
- 同时选中多张卡片 + `ALL`
- 取消部分卡片
- 全部取消恢复默认
- 关键词 + 部门 + 多卡片叠加
- [x] **Step 2: Run final frontend verification**
Run:
```bash
cd ruoyi-ui
node tests/unit/preliminary-check-model-api.test.js
node tests/unit/preliminary-check-model-data-loading.test.js
node tests/unit/preliminary-check-model-multiselect.test.js
node tests/unit/preliminary-check-model-match-mode.test.js
node tests/unit/preliminary-check-model-filters.test.js
node tests/unit/preliminary-check-model-table-columns.test.js
node tests/unit/preliminary-check-model-linkage-flow.test.js
```
Expected:
- `PASS`
- [x] **Step 3: Write verification and implementation records**
记录:
- 模型区真实接口接入
- 多卡片联动
- 任意触发 / 同时触发切换
- 关键词、部门、分页联动验证
- [x] **Step 4: Commit**
```bash
git add ruoyi-ui/tests/unit/preliminary-check-model-and-detail.test.js ruoyi-ui/tests/unit/preliminary-check-model-linkage-flow.test.js docs/plans/frontend/2026-03-20-results-overview-risk-model-linkage-frontend-implementation.md docs/tests/records/2026-03-20-results-overview-risk-model-linkage-frontend-verification.md docs/reports/implementation/2026-03-20-results-overview-risk-model-linkage-frontend-implementation.md
git commit -m "补充结果总览模型区前端实施记录"
```

View File

@@ -0,0 +1,207 @@
# Results Overview Risk People Merge 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 区块,并在风险人员总览中新增风险等级与命中模型数展示。
**Architecture:** 继续沿用 `PreliminaryCheck.vue` 作为结果总览入口,不新增页面组件层级、不做前端字段拼装。前端只消费 `dashboard + riskPeople` 两类真实接口,删除页面对 `topRiskPeople` 的依赖,并将风险等级标签和命中模型数并入风险人员总览单表。
**Tech Stack:** Vue 2, Element UI, Node.js
---
### Task 1: 先锁定前端接口依赖收口预期
**Files:**
- Modify: `ruoyi-ui/tests/unit/project-overview-api.test.js`
- Modify: `ruoyi-ui/tests/unit/preliminary-check-api-integration.test.js`
- [ ] **Step 1: Write the failing test**
更新 API 与页面集成断言:
- `project-overview-api.test.js` 保留 `getOverviewDashboard``getOverviewRiskPeople` 断言,并移除 `getOverviewTopRiskPeople``/top-risk-people` 依赖
- `preliminary-check-api-integration.test.js` 改为断言 `PreliminaryCheck.vue` 只引用 `getOverviewDashboard``getOverviewRiskPeople``Promise.all`
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd ruoyi-ui
node tests/unit/project-overview-api.test.js
node tests/unit/preliminary-check-api-integration.test.js
```
Expected:
- `FAIL`
- 原因是当前 API 文件和入口页面仍保留 TOP10 请求
- [ ] **Step 3: Commit the test expectation update**
```bash
git add ruoyi-ui/tests/unit/project-overview-api.test.js ruoyi-ui/tests/unit/preliminary-check-api-integration.test.js
git commit -m "锁定结果总览页面接口收口预期"
```
### Task 2: 移除页面对 TOP10 接口的依赖
**Files:**
- Modify: `ruoyi-ui/src/api/ccdi/projectOverview.js`
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/PreliminaryCheck.vue`
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/preliminaryCheck.mock.js`
- [ ] **Step 1: Update the API module**
删除 `getOverviewTopRiskPeople` 导出,仅保留:
- `getOverviewDashboard`
- `getOverviewRiskPeople`
不要改动两个保留接口的 URL 和调用方式。
- [ ] **Step 2: Update page data loading**
`PreliminaryCheck.vue` 中:
- 删除 `getOverviewTopRiskPeople` 的 import
-`Promise.all` 从 3 个请求收口为 2 个请求
- 移除 `topRiskPeopleData` 相关变量
- `hasOverviewData` 仅依据 `dashboardData.stats``riskPeopleData.overviewList`
- [ ] **Step 3: Update mock and state data**
`preliminaryCheck.mock.js` 中:
-`overviewList` 示例数据补齐 `riskLevel``riskLevelType``modelCount`
- 删除页面展示层对 `topRiskList` 的依赖
- 保持空态、加载态结构可直接复用
- [ ] **Step 4: Run focused frontend tests**
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-states.test.js
```
Expected:
- `PASS`
- [ ] **Step 5: Commit**
```bash
git add ruoyi-ui/src/api/ccdi/projectOverview.js ruoyi-ui/src/views/ccdiProject/components/detail/PreliminaryCheck.vue ruoyi-ui/src/views/ccdiProject/components/detail/preliminaryCheck.mock.js ruoyi-ui/tests/unit/project-overview-api.test.js ruoyi-ui/tests/unit/preliminary-check-api-integration.test.js
git commit -m "移除结果总览页面TOP10接口依赖"
```
### Task 3: 将人员区块收口为单表并补充新增列
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/RiskPeopleSection.vue`
- Modify: `ruoyi-ui/tests/unit/preliminary-check-summary-and-people.test.js`
- Modify: `ruoyi-ui/tests/unit/preliminary-check-risk-people-binding.test.js`
- [ ] **Step 1: Write/update the failing test**
调整静态断言:
- `preliminary-check-summary-and-people.test.js` 去掉对 `中高风险人员TOP10` 的文案断言,增加对 `风险等级``命中模型数` 的断言
- `preliminary-check-risk-people-binding.test.js` 去掉 `sectionData.topRiskList` 依赖,改为断言 `overviewList` 同时绑定 `riskCount``riskPoint``riskLevelType``modelCount`
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd ruoyi-ui
node tests/unit/preliminary-check-summary-and-people.test.js
node tests/unit/preliminary-check-risk-people-binding.test.js
```
Expected:
- `FAIL`
- 原因是当前组件仍包含第二个 TOP10 区块
- [ ] **Step 3: Implement the single-table UI**
`RiskPeopleSection.vue` 中:
- 删除第二个 `block`
- 在风险人员总览表格中新增 `风险等级` 列,沿用 `el-tag` + `scope.row.riskLevelType`
- 新增 `命中模型数` 列,直接绑定 `modelCount`
- 保留 `疑似违规数``核心异常点``查看详情` 回退逻辑
- 适度调整列宽与块间距,但不要重做整体视觉风格
- [ ] **Step 4: Run focused UI tests**
Run:
```bash
cd ruoyi-ui
node tests/unit/preliminary-check-summary-and-people.test.js
node tests/unit/preliminary-check-risk-people-binding.test.js
node tests/unit/preliminary-check-layout.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-summary-and-people.test.js ruoyi-ui/tests/unit/preliminary-check-risk-people-binding.test.js
git commit -m "收口结果总览风险人员展示区块"
```
### Task 4: 做结果总览前端回归并补文档
**Files:**
- Create: `docs/reports/implementation/2026-03-20-results-overview-risk-people-merge-frontend-implementation.md`
- Create: `docs/tests/records/2026-03-20-results-overview-risk-people-merge-frontend-verification.md`
- Verify: `docs/design/2026-03-20-results-overview-risk-people-merge-design.md`
- [ ] **Step 1: Run final regression checks**
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-summary-and-people.test.js
node tests/unit/preliminary-check-risk-people-binding.test.js
node tests/unit/preliminary-check-states.test.js
node tests/unit/preliminary-check-layout.test.js
```
Expected:
- `PASS`
- 页面已不再依赖 TOP10 接口和 TOP10 区块
- [ ] **Step 2: Write implementation record**
记录本次前端改动内容:
- 页面移除 TOP10 区块
- 风险人员总览新增风险等级与命中模型数
- 页面请求从三路接口收口为两路接口
- [ ] **Step 3: Write verification record**
记录命令、执行时间、结果与结论,说明未启动额外前端长期进程;如临时启动调试服务,验证结束后需主动停止。
- [ ] **Step 4: Commit**
```bash
git add docs/reports/implementation/2026-03-20-results-overview-risk-people-merge-frontend-implementation.md docs/tests/records/2026-03-20-results-overview-risk-people-merge-frontend-verification.md
git commit -m "补充风险人员总览收口前端实施记录"
```

View File

@@ -0,0 +1,172 @@
# 模型信息 XLSX 更新实施计划
> **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:** 直接更新 `assets/模型信息.xlsx`,仅对未真实实现模型补齐可明确推出的字段,并新增 SQL 可执行性与缺少内容结论列。
**Architecture:** 先以项目代码、规则定义、数据库表结构为依据,逐行判定模型是否已经具备真实命中逻辑;对未实现模型再执行表格回填和 SQL 可执行性校验,最后补充实施记录。整个过程不新增业务规则,不修改后端实现逻辑,只更新资料文件与文档。
**Tech Stack:** Python 3、openpyxl、rg、MySQL MCP、Git
---
### Task 1: 梳理实现状态与回填依据
**Files:**
- Modify: `assets/模型信息.xlsx`
- Reference: `docs/design/2026-03-20-model-info-xlsx-update-design.md`
- Reference: `docs/reports/implementation/2026-03-17-model-sql-check-and-rewrite.md`
- [ ] **Step 1: 列出 XLSX 中的全部模型行**
Run:
```bash
python3 - <<'PY'
from openpyxl import load_workbook
wb = load_workbook('assets/模型信息.xlsx')
ws = wb['Sheet1']
for idx, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2):
if row[2]:
print(idx, row[2], row[3], row[6], row[7])
PY
```
Expected: 输出每一行对应的序号、模型名称、相关指标和指标英文名。
- [ ] **Step 2: 检索项目内真实规则实现与占位规则**
Run:
```bash
rg -n "HOUSE_OR_CAR_EXPENSE|TAX_EXPENSE|SINGLE_LARGE_INCOME|CUMULATIVE_INCOME|ANNUAL_TURNOVER|LARGE_CASH_DEPOSIT|ABNORMAL_CUSTOMER_TRANSACTION|LOW_INCOME_RELATIVE_LARGE_TRANSACTION|MULTI_PARTY_GAMBLING_TRANSFER|GAMBLING_SENSITIVE_KEYWORD|MONTHLY_FIXED_INCOME|FIXED_COUNTERPARTY_TRANSFER|FOREX_BUY_AMT|INTEREST_PAYMENT_BY_OTHERS|LARGE_PURCHASE_TRANSACTION|SUPPLIER_CONCENTRATION|STOCK_TFR_LARGE|WITHDRAW_CNT|SALARY_QUICK_TRANSFER" ccdi-project ruoyi-ui sql docs
```
Expected: 区分哪些模型已有真实命中逻辑,哪些只有占位规则或文档描述。
- [ ] **Step 3: 用当前数据库结构确认 SQL 校验基线**
Run:
```bash
python3 - <<'PY'
from openpyxl import load_workbook
wb = load_workbook('assets/模型信息.xlsx')
ws = wb['Sheet1']
print('rows=', ws.max_row, 'cols=', ws.max_column)
PY
```
Expected: 确认当前工作簿可正常读取,为后续回写做基线检查。
### Task 2: 更新 XLSX 未实现模型信息
**Files:**
- Modify: `assets/模型信息.xlsx`
- [ ] **Step 1: 追加两列表头**
实现要求:
```text
在现有最后一列后新增:
1. 当前环境是否可执行SQL
2. 当前缺少内容
```
- [ ] **Step 2: 对已真实实现模型保持跳过**
实现要求:
```text
只标记为“已实现并跳过”,不重写该行原有内容,不填新增结论列。
```
- [ ] **Step 3: 对未实现模型补齐可明确推出的字段**
实现要求:
```text
优先补充指标英文名、风险筛查对象、技术口径、可疑结果返回、风险等级;
仅在项目代码、规则表、SQL 或当前库结构能明确支持时回填,不能唯一确定则保留原值。
```
- [ ] **Step 4: 写入 SQL 可执行性结论**
实现要求:
```text
当前环境可直接执行写“是”,否则写“否”;
“当前缺少内容”列中写最小必需缺项或明确错误,可执行则写“/”。
```
- [ ] **Step 5: 保存工作簿**
Run:
```bash
python3 - <<'PY'
from openpyxl import load_workbook
wb = load_workbook('assets/模型信息.xlsx')
wb.save('assets/模型信息.xlsx')
print('saved')
PY
```
Expected: 输出 `saved`,且文件可再次被 `openpyxl` 正常打开。
### Task 3: 校验结果并补充实施记录
**Files:**
- Modify: `assets/模型信息.xlsx`
- Create: `docs/reports/implementation/2026-03-20-model-info-xlsx-update-record.md`
- [ ] **Step 1: 抽样读取更新后的关键行**
Run:
```bash
python3 - <<'PY'
from openpyxl import load_workbook
wb = load_workbook('assets/模型信息.xlsx')
ws = wb['Sheet1']
for row_no in [10, 11, 12, 14, 18, 22, 25, 26, 28, 31]:
vals = [ws.cell(row_no, col).value for col in range(3, ws.max_column + 1)]
print(row_no, vals)
PY
```
Expected: 关键未实现模型行已带有新增结论列,且补充信息与设计口径一致。
- [ ] **Step 2: 记录本次实施内容和判定依据**
实施记录应包含:
```text
1. 已实现模型跳过的判定口径
2. 未实现模型的回填原则
3. 新增列说明
4. SQL 可执行性判断依据
5. 抽样验证结果
```
- [ ] **Step 3: 检查 Git 变更范围**
Run:
```bash
git status --short
```
Expected: 只包含本次任务相关文件;如存在无关变更,不纳入提交。
- [ ] **Step 4: 提交本次修改**
Run:
```bash
git add docs/plans/fullstack/2026-03-20-model-info-xlsx-update-implementation.md docs/reports/implementation/2026-03-20-model-info-xlsx-update-record.md assets/模型信息.xlsx
git commit -m "更新模型信息xlsx并补充校验结论"
```
Expected: 生成仅包含本次任务文件的中文提交。

View File

@@ -0,0 +1,24 @@
# AGENTS后端启动命令修正实施记录
## 修改目标
-`AGENTS.md` 中的后端启动命令更新为仓库当前正确的 Jar 启动方式
- 保持文档说明与 `ry.sh` 的启动逻辑一致
## 修改内容
- 修改 `AGENTS.md`
- 将后端章节中的“启动主应用”由 `mvn -pl ruoyi-admin spring-boot:run` 调整为 `cd ruoyi-admin/target && java -jar ruoyi-admin.jar`
- 将说明文案调整为“启动主应用Jar
## 验证记录
- 计划执行 `rg -n "启动主应用|spring-boot:run|ruoyi-admin.jar|java -jar" AGENTS.md`
- 预期结果:
- `AGENTS.md` 中不再把 `spring-boot:run` 作为默认后端启动命令
- `AGENTS.md` 中明确使用 `java -jar ruoyi-admin.jar`
## 影响范围
- 仅影响仓库协作文档,不修改业务代码
- 后续 AI 协作与人工操作都会按 Jar 启动方式理解后端默认启动链路

View File

@@ -0,0 +1,54 @@
# LSFX Mock 数据库身份绑定实施计划
> **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` 时从员工信息库随机选择一个员工,并读取该员工亲属,后续同一 `logId` 的流水仅使用这组数据库身份。
**Architecture:** 在 Mock 服务中新增一个只读身份仓储,负责从 `ccdi_base_staff``ccdi_staff_fmy_relation` 读取员工及亲属。`FileService` 在创建 `FileRecord` 时写入选中的员工/亲属身份,`StatementService` 只复用该记录中的证件号,不再依赖内置固定身份证池。
**Tech Stack:** FastAPI, Python 3.9, PyMySQL, pytest
---
### Task 1: 补数据库身份绑定失败测试
**Files:**
- Modify: `lsfx-mock-server/tests/test_file_service.py`
- Modify: `lsfx-mock-server/tests/test_statement_service.py`
- [ ] Step 1: 写出 `FileService` 需要保存员工与亲属身份的失败测试
- [ ] Step 2: 运行对应测试,确认当前实现失败
- [ ] Step 3: 写出 `StatementService` 只能使用该员工及其亲属证件号的失败测试
- [ ] Step 4: 运行对应测试,确认当前实现失败
### Task 2: 新增只读身份仓储
**Files:**
- Create: `lsfx-mock-server/services/staff_identity_repository.py`
- Modify: `lsfx-mock-server/config/settings.py`
- Modify: `lsfx-mock-server/requirements.txt`
- [ ] Step 1: 定义员工/亲属身份查询结果结构与仓储接口
- [ ] Step 2: 接入 MySQL 只读查询配置
- [ ] Step 3: 实现“随机取一个有身份证号员工 + 读取该员工有效亲属”的最小查询逻辑
- [ ] Step 4: 保持仓储可被测试替身替换
### Task 3: 将 logId 与数据库身份绑定
**Files:**
- Modify: `lsfx-mock-server/services/file_service.py`
- Modify: `lsfx-mock-server/services/statement_service.py`
- Modify: `lsfx-mock-server/routers/api.py`
- [ ] Step 1: 在 `FileRecord` 中增加员工/亲属身份字段
- [ ] Step 2: 在上传文件与拉取行内流水时写入随机员工及其亲属
- [ ] Step 3: 在流水生成阶段只使用 `FileRecord` 中的员工/亲属证件号
- [ ] Step 4: 保留 deterministic fallback但不影响真实记录链路
### Task 4: 回归验证与文档沉淀
**Files:**
- Create: `docs/reports/implementation/2026-03-19-lsfx-mock-db-backed-identity-selection-fix.md`
- [ ] Step 1: 运行 `lsfx-mock-server` 相关测试
- [ ] Step 2: 记录根因、改动点和验证结果

View File

@@ -0,0 +1,58 @@
# 2026-03-19 LSFX Mock 数据库身份绑定修复记录
## 背景
- 调整目标:一个 `logId` 需要从员工信息库随机选取一个员工身份证号,并且该员工的亲属证件号也必须从数据库读取。
- 业务口径:同一个 `logId` 的流水证件号范围仅允许使用“该员工本人 + 该员工亲属”。
## 根因
- `lsfx-mock-server` 之前使用内置固定身份证池生成 `cretNo`,没有接入 `ccdi_base_staff``ccdi_staff_fmy_relation`
- 即使前一版已经收敛到单员工域,身份来源仍然是 mock 常量,不满足“从数据库读取员工及亲属”的要求。
## 修改内容
### 1. 新增只读员工身份仓储
- 新增 `lsfx-mock-server/services/staff_identity_repository.py`
- 仓储使用 `PyMySQL` 只读查询:
-`ccdi_base_staff` 随机选取一个有身份证号的在职员工
- 优先选择存在有效亲属记录的员工
-`ccdi_staff_fmy_relation` 读取该员工的有效亲属证件号
### 2. FileRecord 绑定数据库身份
- `lsfx-mock-server/services/file_service.py`
- `FileService` 支持注入身份仓储
- `FileRecord` 新增:
- `staff_name`
- `staff_id_card`
- `family_id_cards`
- 上传文件与拉取行内流水时,创建 `logId` 的同时写入该员工及亲属身份
### 3. 流水生成只复用绑定身份
- `lsfx-mock-server/services/statement_service.py`
-`logId` 已存在真实 `FileRecord`,则流水生成阶段只使用该记录中的 `staff_id_card``family_id_cards`
- `lsfx-mock-server/services/statement_rule_samples.py`
- 固定命中样本支持接收外部传入的员工/亲属证件号,避免继续依赖内置固定身份证
### 4. 配置与测试
- `lsfx-mock-server/config/settings.py`
- 从主项目 `ruoyi-admin/src/main/resources/application-dev.yml` 提取数据库连接默认值
- `lsfx-mock-server/requirements.txt`
- 新增 `PyMySQL`
- 测试层新增假仓储,避免单元/集成测试访问真实数据库
## 验证结果
- 执行:
- `cd lsfx-mock-server && python3 -m pytest tests/test_file_service.py tests/test_statement_service.py tests/integration/test_full_workflow.py -q`
- 结果:
- `17 passed`
## 影响说明
- 本次变更仅作用于 `lsfx-mock-server`
- 真实运行时会按数据库随机绑定员工与亲属;测试环境仍通过假仓储保持可控与稳定。

View File

@@ -0,0 +1,42 @@
# 2026-03-19 LSFX Mock 单员工域修复记录
## 背景
- 现象:新建项目后导入单个流水文件,解析完成后结果总览出现两个员工信息。
- 预期:单个上传文件只应归属到一个员工域,结果总览最多出现该员工本人对应的一条员工信息。
## 根因
- `lsfx-mock-server/services/statement_rule_samples.py` 中的大额交易固定样本同时混入了两名员工及两名家属的证件号。
- `lsfx-mock-server/services/statement_service.py` 中的随机噪声流水继续从四个证件号的全量池随机取值。
- 因此同一个 `logId` 返回的流水天然会覆盖两个员工域,主系统按 `cret_no` 聚合后就会出现两个员工。
## 修改内容
### Mock 造数收敛为单员工域
-`lsfx-mock-server/services/statement_rule_samples.py` 新增 `IDENTITY_SCOPES`
- 新增 `resolve_identity_scope(log_id)``resolve_identity_cards(log_id)`,按 `logId` 稳定选择单个员工域。
- 将固定命中样本从“跨两个员工域混用”改为“只使用当前员工域的员工本人及家属”。
### 随机噪声不再污染其他员工
-`lsfx-mock-server/services/statement_service.py` 中改为按 `logId` 读取允许的证件号范围。
- 随机噪声流水的 `cretNo` 仅能从当前员工域的两张证件号中选择。
### 测试补充
-`lsfx-mock-server/tests/test_statement_service.py` 增加“同一 `logId` 只能落在单一员工域”测试。
- 调整大额交易样本测试,改为基于 `resolve_identity_scope(log_id)` 校验,避免继续依赖固定第二名员工。
## 验证结果
- 执行:
- `cd lsfx-mock-server && python3 -m pytest tests/test_statement_service.py tests/test_file_service.py tests/integration/test_full_workflow.py -q`
- 结果:
- `17 passed`
## 影响说明
- 本次只修改 `lsfx-mock-server` 的 mock 造数逻辑,不影响主系统 Java/Vue 代码。
- 已存在库中的历史测试项目数据不会自动回写;如需验证页面结果,需要重启 mock 服务后重新上传文件生成新流水。

View File

@@ -0,0 +1,58 @@
# 项目详情打标状态轮询实施记录
## 修改背景
项目详情页需要在项目状态为“打标中”时自动轮询项目状态,保证页面头部状态标签及依赖该状态的子页面禁用逻辑能够及时刷新。
## 本次修改
### 1. 详情页增加页级项目状态轮询
- 文件:`ruoyi-ui/src/views/ccdiProject/detail.vue`
- 变更点:
- 新增 `projectStatusPollingTimer``projectStatusPollingInterval``projectStatusPollingLoading`
- 抽取 `fetchProjectDetail` 统一项目详情请求与数据归一化
- 新增 `syncProjectStatusPolling``startProjectStatusPolling``stopProjectStatusPolling``pollProjectStatus`
- 在项目状态为 `3`(打标中)时按 1 秒间隔启动轮询
- 在状态脱离 `3`、路由切换或组件销毁时关闭轮询
### 2. 补充轮询回归单测
- 文件:`ruoyi-ui/tests/unit/project-detail-tagging-polling.test.js`
- 校验点:
- 详情页存在轮询定时器字段
- 销毁前清理轮询
-`projectStatus === "3"` 启停轮询
- 轮询请求后状态变化会停止轮询
### 3. 补充实施计划
- 文件:`docs/plans/frontend/2026-03-19-project-detail-tagging-status-polling-frontend-implementation.md`
## 验证记录
### 单测
```bash
node ruoyi-ui/tests/unit/project-detail-tagging-polling.test.js
node ruoyi-ui/tests/unit/upload-data-disabled-cards.test.js
```
结果:
- `project-detail-tagging-polling test passed`
- `upload-data-disabled-cards test passed`
### 构建验证
```bash
cd ruoyi-ui && npm run build:prod
```
结果:
- 构建成功,退出码 `0`
- 存在既有产物体积告警,但不影响本次功能构建通过
## 工作区说明
- 当前工作区存在未由本次任务引入的改动:`.DS_Store``ry.sh`
- 本次提交时应忽略 `.DS_Store`,并避免将无关文件纳入暂存区

View File

@@ -0,0 +1,31 @@
# 上传文件删除触发项目重新打标后端实施记录
## 本次改动
- 为项目上传文件删除新增独立触发类型 `AUTO_FILE_DELETE`
- 在上传文件删除主链路全部成功后自动提交项目重新打标
- 为删除失败分支补充“不触发重打标”保护验证
## 修改内容
-`TriggerType` 中新增 `AUTO_FILE_DELETE`
-`CcdiFileUploadServiceImpl.deleteFileUploadRecord()` 中:
- 校验上传记录状态更新结果
- 仅在上传记录状态成功更新为 `deleted` 后调用 `bankTagService.submitAutoRebuild(projectId, TriggerType.AUTO_FILE_DELETE)`
- 返回成功文案 `删除成功,已开始项目重新打标`
-`CcdiFileUploadServiceImplTest` 中补充:
- 删除成功后触发自动重打标
- 流水分析平台删除失败时不触发自动重打标
-`CcdiBankTagServiceImplTest` 中补充 `AUTO_FILE_DELETE` 触发类型透传校验
## 测试与验证
```bash
mvn -pl ccdi-project -Dtest=CcdiFileUploadServiceImplTest#deleteFileUploadRecord_shouldDeletePlatformFileBankStatementsAndMarkDeleted,CcdiFileUploadServiceImplTest#deleteFileUploadRecord_shouldStopWhenLsfxDeleteFails,CcdiBankTagServiceImplTest#submitAutoRebuild_shouldKeepAutoFileDeleteTriggerType,CcdiFileUploadControllerTest#deleteFile_shouldUseCurrentLoginUserId test
```
## 结果
- 删除文件成功后会自动提交项目重新打标
- 删除失败分支不会误触发重打标
- 本次验证未启动额外前后端进程,无需清理测试进程

View File

@@ -0,0 +1,27 @@
# 上传文件删除触发项目重新打标前端实施记录
## 本次改动
- 调整项目详情上传数据页删除文件确认框文案,明确提示会重新打标
- 调整删除成功提示文案,明确告知项目已开始重新打标
- 保持现有删除按钮、列表刷新和轮询逻辑不变
## 修改内容
-`UploadData.vue``handleDeleteFile()` 中将确认框文案改为:
- 删除平台文件
- 清理本系统流水
- 项目内流水重新打标
- 删除成功后提示改为 `删除成功,已开始项目重新打标`
- 新增 `ruoyi-ui/tests/unit/upload-data-delete-retag-copy.test.js`,校验确认框与成功提示文案均包含重新打标语义
## 测试与验证
```bash
node ruoyi-ui/tests/unit/upload-data-delete-retag-copy.test.js
```
## 结果
- 删除前提示与删除后反馈都已覆盖“重新打标”语义
- 本次验证未启动前端开发进程,无需清理测试进程

View File

@@ -0,0 +1,31 @@
# 结果总览风险接口后端实施记录
## 本次改动
- 新增结果总览专用 Controller、Service、Mapper 与 VO
- 新增风险仪表盘、风险人员总览、中高风险人员 TOP10 三个后端接口
- 新增员工维度风险聚合 SQL按命中去重规则数划分高、中、低风险
- 在项目流水标签重算成功后刷新并回写项目高、中、低风险人数
- 同步补充结构测试、SQL 结构测试、服务层测试、控制器测试以及打标回写测试
- 联调阶段根据真实环境反馈,将代表性规则选择 SQL 从窗口函数改为 MySQL 5.7 兼容的 `not exists` 实现
- 联调阶段根据真实重算反馈,将风险人数汇总结果读取逻辑从 `Integer` 强转改为 `Number.intValue()`,兼容 MySQL 聚合返回 `BigDecimal`
## 未包含内容
- 未扩展风险模型区接口
- 未扩展风险明细区接口
- 未增加设计范围外的导出、降级或补丁逻辑
## 涉及模块
- `ccdi-project`
- `docs/tests/records`
- `docs/reports/implementation`
## 验证情况
- 计划内核心验证 11 个测试全部通过
- 受影响回归用例 8 个测试全部通过
- 真实后端联调已确认 3 个结果总览接口可访问,其中风险人员总览与 TOP10 可返回真实数据
- 真实重算 `projectId=43` 已确认任务成功并写回高风险人数
- 修复 MySQL 5.7 兼容问题与 `BigDecimal` 取值问题后,完整验证总计 21 个测试通过,详见 `docs/tests/records/2026-03-19-results-overview-risk-api-backend-verification.md`

View File

@@ -0,0 +1,27 @@
# 结果总览风险接口前端实施记录
## 本次改动
- 新增 `ruoyi-ui/src/api/ccdi/projectOverview.js`,封装风险仪表盘、风险人员总览、中高风险人员 TOP10 三个结果总览接口
- 在结果总览入口页 `PreliminaryCheck.vue` 新增 `loadOverviewData`,于页面进入和 `projectId` 变化时并发拉取 3 个真实接口
- 新增 `createOverviewLoadedData`,将真实接口返回合并到当前结果总览页面结构,并保留风险模型区、风险明细区原有 mock 数据
- 调整 `RiskPeopleSection.vue` 风险等级标签绑定,直接使用后端返回的 `riskLevelType`,同时保留 `actionLabel` 缺失时的“查看详情”回退
- 新增 3 个前端静态验证脚本,覆盖 API 封装、页面真实接口接入和风险人员区字段映射
## 未包含内容
- 未扩展风险模型区接口
- 未扩展风险明细区接口
- 未增加设计范围外的导出、降级或补丁逻辑
## 涉及模块
- `ruoyi-ui`
- `docs/tests/records`
- `docs/reports/implementation`
## 验证情况
- 计划内 3 个前端静态断言脚本已全部通过
- 前端生产构建已通过,只有现有资源体积告警,无新增编译错误
- 详细验证过程见 `docs/tests/records/2026-03-19-results-overview-risk-api-frontend-verification.md`

View File

@@ -0,0 +1,31 @@
# 风险仪表盘总人数修复实施记录
**日期**: 2026-03-19
**范围**: 后端
## 问题现象
- 风险仪表盘中的“总人数”始终显示 `0`
- 真实数据库中多个项目已存在流水数据,但 `ccdi_project.target_count` 未被维护
## 根因
- 结果总览接口按既定口径直接读取 `ccdi_project.target_count`
- 项目创建时将 `target_count` 初始化为 `0`
- 后续在银行流水导入成功、删除上传记录成功后,都没有重新统计并回写 `target_count`
## 本次修改
1.`CcdiFileUploadServiceImpl` 中新增项目目标人数刷新逻辑
2. 在银行流水入库成功后,按 `ccdi_bank_statement` 中去重身份证号数量回写 `ccdi_project.target_count`
3. 在删除上传记录并清理项目流水后,同步刷新 `ccdi_project.target_count`
4. 新增增量脚本 `sql/migration/2026-03-19-backfill-project-target-count.sql`,用于回填历史项目的目标人数
5. 补充单元测试,覆盖:
- 流水入库成功后刷新目标人数
- 删除上传记录后刷新目标人数
- 目标人数刷新按去重身份证号统计
## 验证
- 执行:`mvn -pl ccdi-project -Dtest=CcdiFileUploadServiceImplTest test`
- 结果:通过

View File

@@ -0,0 +1,25 @@
# 风险仪表盘总人数员工匹配口径修正记录
**日期**: 2026-03-19
**范围**: 后端
## 口径修正
- 原修复将 `target_count` 按项目流水中全部去重身份证号回写
- 用户确认后的正确口径为:
- 统计 `ccdi_bank_statement.cret_no`
- 仅保留能匹配 `ccdi_base_staff.id_card` 的记录
- 再按去重身份证号数量回写 `ccdi_project.target_count`
## 本次修改
1.`CcdiBankStatementMapper` 新增“按项目统计匹配员工主数据后的去重身份证号人数”查询
2.`CcdiFileUploadServiceImpl.refreshProjectTargetCount` 中改为调用该查询
3. 保持导入成功、删除成功后的目标人数刷新时机不变,仅修正统计口径
4. 新增增量脚本 `sql/migration/2026-03-19-fix-project-target-count-match-staff.sql`,用于修正已错误回填的项目人数
5. 调整单元测试,锁定“只统计能匹配员工主数据的人数”这一行为
## 验证
- 执行:`mvn -pl ccdi-project -Dtest=CcdiFileUploadServiceImplTest test`
- 结果:通过

View File

@@ -0,0 +1,32 @@
# 风险人员总览疑似违规数口径调整实施记录
## 本次改动
- 为结果总览员工聚合中间 VO 新增 `hitCount` 字段,用于承接归并到员工名下的打标明细数。
- 调整 [`CcdiProjectOverviewMapper.xml`](/Users/wkc/Desktop/ccdi/ccdi/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml) 员工聚合 SQL新增 `count(1) as hit_count`
- 调整 [`CcdiProjectOverviewServiceImpl.java`](/Users/wkc/Desktop/ccdi/ccdi/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImpl.java) 风险人员总览映射逻辑,`riskCount` 改为读取 `hitCount`
- 保持 `ruleCount` 继续仅用于风险等级计算、TOP10 排序和项目高/中/低风险人数统计,不扩散影响范围。
- 同步补充本次后端、前端计划文档,并更新结果总览风险接口设计文档口径说明。
## 口径说明
- `riskCount`:员工本人及其亲属归并到该员工名下的打标明细数量。
- `ruleCount`:员工命中的去重规则数,仅用于风险等级和相关统计,不再直接展示为“疑似违规数”。
## 前端处理结论
- [`RiskPeopleSection.vue`](/Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui/src/views/ccdiProject/components/detail/RiskPeopleSection.vue) 仍直接渲染接口返回的 `riskCount`
- 本次无需增加前端计算或格式化补丁,只需回归验证字段绑定未变化。
## 验证情况
- 后端定向测试通过:
- `mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewServiceImplTest,CcdiProjectOverviewMapperSqlTest`
- 前端回归检查通过:
- `cd ruoyi-ui && node tests/unit/preliminary-check-risk-people-binding.test.js`
## 未包含内容
- 未调整风险等级分级口径
- 未调整中高风险 TOP10 接口逻辑
- 未调整项目风险人数回写逻辑

View File

@@ -0,0 +1,27 @@
# 风险人员总览核心异常点多规则展示实施记录
## 本次改动
- 调整结果总览后端员工风险聚合 VO新增 `riskPoint` 字段承接多规则拼接结果。
- 调整 [`CcdiProjectOverviewMapper.xml`](/Users/wkc/Desktop/ccdi/ccdi/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml) 中的风险人员总览聚合 SQL。
- 核心异常点改为先按员工和规则统计命中次数,再按命中次数倒序、`rule_code` 升序,将全部命中规则名称用 `、` 拼接。
- 调整 [`CcdiProjectOverviewServiceImpl.java`](/Users/wkc/Desktop/ccdi/ccdi/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImpl.java) 映射逻辑,风险人员总览直接返回拼接后的 `riskPoint`
- 同步更新结果总览设计文档与本次后端、前端实施计划文档。
## 前端处理结论
- [`RiskPeopleSection.vue`](/Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui/src/views/ccdiProject/components/detail/RiskPeopleSection.vue) 现有“核心异常点”列已经直接渲染 `riskPoint` 字符串。
- 本次无需改动前端组件结构或字段绑定,只需回归验证现有展示链路。
## 验证情况
- 后端定向测试通过:
- `mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewServiceImplTest,CcdiProjectOverviewMapperSqlTest`
- 前端回归检查通过:
- `cd ruoyi-ui && node tests/unit/preliminary-check-risk-people-binding.test.js`
## 未包含内容
- 未调整风险人数分级口径
- 未调整中高风险 TOP10 接口与排序
- 未新增前端格式化补丁或兼容分支

View File

@@ -0,0 +1,35 @@
# 2026-03-19 员工收入与亲属补数实施记录
## 背景
- 当前 `ccdi_base_staff` 共 1004 名员工,其中仅 2 名员工已维护 `annual_income`
- 当前 `ccdi_staff_fmy_relation` 已存在 3004 条员工亲属数据,但只覆盖 53 名员工,且单人亲属数量集中在 42-82 条,不符合“每个员工 1-3 个亲属”的目标
- 现有员工资产表中有 1 条亲属关系被 2 条资产记录引用,补数时需要保留该关系,避免资产详情失联
## 本次处理
1. 新增增量脚本 `sql/migration/2026-03-19-backfill-staff-income-and-relatives.sql`
2.`ccdi_base_staff``annual_income IS NULL` 的员工补齐年收入,保留已有收入值不覆盖
3. 识别并保留已与 `ccdi_asset_info` 建立关联的员工亲属关系
4. 清理旧的员工亲属数据后,按员工主数据重新生成每人 1-3 条亲属关系
5. 为新生成和保留的亲属统一补齐 `annual_income`、有效状态和基础元数据
## 数据规则
- 员工年收入仅补空值,使用固定公式生成,保证脚本重复执行时口径稳定
- 员工亲属数量按 `staff_id % 3 + 1` 生成,因此每名员工最终稳定为 1-3 条亲属
- 生成亲属关系类型按顺序落为 `配偶 / 父亲 / 母亲`
- 若某员工已有被资产引用的亲属,则先保留该亲属,再补齐剩余条数
## 验证项
- 执行后 `ccdi_base_staff``annual_income` 空值应为 `0`
- 执行后所有员工的亲属数量分布只允许出现 `1``2``3`
- 已挂到亲属名下的 2 条资产仍可通过保留的亲属证件号关联到员工
## 实际执行结果
- 已执行:`bin/mysql_utf8_exec.sh sql/migration/2026-03-19-backfill-staff-income-and-relatives.sql`
- 员工收入校验结果:`staff_count = 1004``income_null_count = 0``annual_income` 区间为 `120000.00 ~ 274000.00`
- 员工亲属数量分布:`1条=335人``2条=334人``3条=335人`
- 资产保留校验:员工 `330101198802020033` 名下原有测试亲属 `330101199202020044` 已保留,仍关联 `2` 条资产记录

View File

@@ -0,0 +1,38 @@
# 新增模型打标验证计划实施记录
## 修改目标
- 将已确认的“新增模型打标完整验证”文档归档到项目现有测试计划目录
- 基于确认后的验证计划补充一份可直接执行的实施计划
- 保持本次工作只覆盖验证方案与执行步骤,不混入修复内容
## 修改范围
- `docs/tests/plans/2026-03-20-bank-tag-new-model-validation-test-plan.md`
- `docs/tests/plans/2026-03-20-bank-tag-new-model-validation-execution-plan.md`
## 修改内容
### 1. 调整验证计划归档路径
- 将原先放在 `docs/superpowers/specs/` 下的验证设计文档迁移到 `docs/tests/plans/`
- 文件名调整为 `2026-03-20-bank-tag-new-model-validation-test-plan.md`
- 标题与正文表述从“设计”收敛为“验证计划”语境,和目录职责保持一致
### 2. 新增执行计划
- 新增 `docs/tests/plans/2026-03-20-bank-tag-new-model-validation-execution-plan.md`
- 按“环境与基线 -> Mock 自动化 -> 主工程自动化 -> 数据库核验 -> 接口端到端 -> 进程清理”的顺序拆解任务
- 为每个阶段补充了明确的执行命令、预期结果、停点条件和文档沉淀要求
## 目录选择理由
- `docs/tests/plans/` 当前已用于承载测试计划类文档
- 本次文档内容核心是验证目标、执行步骤、通过标准和失败停点
- 相比 `docs/plans/misc/`,该目录与本次任务的语义更一致
## 结果
- 验证计划路径已修正为项目目录下的测试计划位置
- 执行计划已补齐,后续可直接进入验证执行阶段
- 本次改动未触碰业务代码,也未启动前后端或 Mock 服务进程

View File

@@ -0,0 +1,55 @@
# 新增模型打标完整验证实施记录
## 验证目标
- 对 2026-03-20 新加入的模型打标改动执行完整验证。
- 验证范围覆盖 Mock 随机命中、第一期真实规则、数据库事实与接口端到端结果。
- 本次执行仅输出验证过程与结论,不进入修复实现。
## 验证范围
- In Scope:
- `lsfx-mock-server` 随机命中规则计划、样本装配、缓存稳定性相关验证。
- `ccdi-project` 第一期真实规则参数映射、真实 SQL、Service 分发、风险人数刷新相关验证。
- 数据库基线与规则元数据核验。
- 接口端到端调用与结果核验。
- Out of Scope:
- 第二期占位规则。
- 新增规则、补丁逻辑、兼容性处理。
- 任何修复动作。
## 执行阶段
- 阶段 1环境与范围确认
- 阶段 2Mock 随机命中自动化回归
- 阶段 3主工程第一期真实规则自动化回归
- 阶段 4数据库基线与规则元数据核验
- 阶段 5接口端到端验证与环境清理
## 目标项目
- 本次端到端验证选用 `project_id=47`
- `project_name=测试03191`
- `lsfx_project_id=1002`
- `config_type=custom`
## 产物路径
- 执行计划:`docs/tests/plans/2026-03-20-bank-tag-new-model-validation-execution-plan.md`
- 验证计划:`docs/tests/plans/2026-03-20-bank-tag-new-model-validation-test-plan.md`
- 实施记录:`docs/reports/implementation/2026-03-20-bank-tag-new-model-validation-record.md`
- 验证记录:`docs/tests/records/2026-03-20-bank-tag-new-model-validation-verification.md`
## 执行说明
- 验证过程中若任一层失败,立即停在对应层记录证据,不继续给出“验证通过”结论。
- 本次执行基于当前本地开发环境,不额外引入修复或扩展范围。
## 当前进展
- 2026-03-20 15:21:54 CST 完成阶段 1已对齐验证范围、读取来源实施记录、选定 `project_id=47`,并创建实施记录与验证记录骨架。
- 2026-03-20 15:21:54 CST 完成阶段 2`lsfx-mock-server` 聚焦回归与全量回归全部通过,确认规则命中计划、样本装配、缓存稳定性与集成链路未回退。
- 2026-03-20 15:23:10 CST 完成阶段 3`ccdi-project` 第一期真实规则目标测试全部 `BUILD SUCCESS`,规则映射、真实 SQL、规则分发与风险人数刷新链路保持通过。
- 2026-03-20 15:24 左右执行阶段 4采购基线脚本成功重跑`LSFXMOCKPUR001` 基线记录存在且金额满足门槛;但第一期规则元数据查询发现 `indicator_code` 与既有实施记录不一致,判定为“数据基线异常”,按计划停在数据库核验层,不继续执行接口端到端验证。
- 2026-03-20 15:41:06 CST 完成问题修复与复验:
- 已新增第一期规则元数据 SQL 校验测试与增量修复脚本。
- 已将修复脚本落库,确认 `FOREX_BUY_AMT``FOREX_SELL_AMT``LARGE_STOCK_TRADING``indicator_code` 与 9 条一期真实规则 `remark` 均已对齐。
- 已完成项目 `47` 的拉取本行信息、手动重算、任务轮询、命中结果查询与流水详情接口复验。
- Mock 与后端验证进程均已关闭。
- 2026-03-20 16:01 左右完成补充复验:
- 重新启动 Mock 与后端服务,复跑项目 `47` 的登录、拉取本行信息、手动重算、任务轮询与详情接口链路。
- 自动任务 `id=39` 与手动任务 `id=40` 均执行成功,`hit_count=3636``success_rule_count=33``failed_rule_count=0`
- 针对之前出现 `selectOne()` 重复结果异常的样例 `bank_statement_id=67279`,详情接口已返回 `code=200`,并正确带出 `GAMBLING_SENSITIVE_KEYWORD` 命中标签与原始文件名。

View File

@@ -0,0 +1,32 @@
# 第一期银行流水规则元数据修复实施记录
## 问题背景
- 2026-03-20 新增模型打标完整验证在数据库核验阶段发现:
- `FOREX_BUY_AMT.indicator_code` 仍为 `FOREX_BUY_AMT`
- `FOREX_SELL_AMT.indicator_code` 仍为 `FOREX_SELL_AMT`
- `LARGE_STOCK_TRADING.indicator_code``NULL`
- 同时,第一期已落地真实规则的 `remark` 仍停留在“占位规则待补充真实SQL”。
## 根因分析
- 主初始化脚本 [`sql/2026-03-16-bank-tagging.sql`](/Users/wkc/Desktop/ccdi/ccdi/sql/2026-03-16-bank-tagging.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) 仍写入旧的占位元数据。
- 已执行过 2026-03-18 增量脚本、但未补后续迁移的环境,会停留在旧的 `indicator_code``remark` 状态。
## 本次修改
- 新增 SQL 资产校验测试 [`ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiBankTagRuleSqlMetadataTest.java`](/Users/wkc/Desktop/ccdi/ccdi/ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiBankTagRuleSqlMetadataTest.java)
- 先以缺失迁移脚本的红灯方式固定问题。
- 约束初始化脚本与增量脚本必须同时对齐:
- `FOREX_BUY_AMT -> SINGLE_PURCHASE_AMOUNT`
- `FOREX_SELL_AMT -> SINGLE_SETTLEMENT_AMOUNT`
- `LARGE_STOCK_TRADING -> STOCK_TFR_LARGE`
- 三条规则真实说明文案保持一致。
- 新增增量脚本 [`sql/migration/2026-03-20-sync-bank-tag-phase1-rule-metadata.sql`](/Users/wkc/Desktop/ccdi/ccdi/sql/migration/2026-03-20-sync-bank-tag-phase1-rule-metadata.sql)
- 使用 `INSERT ... ON DUPLICATE KEY UPDATE` 同步第一期 9 条真实规则元数据。
- 修复三条规则的 `indicator_code`
- 同步 9 条规则的真实规则 `remark`
- 将增量脚本通过 `bin/mysql_utf8_exec.sh` 落到当前验证数据库。
## 实施结果
- 规则元数据已对齐到第一期真实规则状态。
- 新增 SQL 校验测试可在仓库层拦住“只改初始化脚本、遗漏增量脚本”的回归。
- 修复后重新完成接口链路复验,项目 `47` 的自动拉取、手动重算、命中结果查询与详情接口均已通过。

View File

@@ -0,0 +1,76 @@
# 银行流水真实规则第一期后端实施记录
## 第一期规则范围
- `GAMBLING_SENSITIVE_KEYWORD`
- `SPECIAL_AMOUNT_TRANSACTION`
- `SUSPICIOUS_INCOME_KEYWORD`
- `FOREX_BUY_AMT`
- `FOREX_SELL_AMT`
- `LARGE_PURCHASE_TRANSACTION`
- `STOCK_TFR_LARGE`
- `WITHDRAW_CNT`
- `LARGE_STOCK_TRADING`
## 修改内容
- 补齐第一期规则参数映射
- 替换第一期 8 条明细型占位 SQL
- 接通 `WITHDRAW_CNT` 对象型真实 SQL 与阈值分发
- 对齐第一期规则元数据脚本与实施/验证文档
## 参数映射调整
-`BankTagRuleConfigResolver` 中补齐第一期阈值规则映射:
- `FOREX_BUY_AMT -> SINGLE_PURCHASE_AMOUNT`
- `FOREX_SELL_AMT -> SINGLE_SETTLEMENT_AMOUNT`
- `WITHDRAW_CNT -> WITHDRAW_CNT`
- `STOCK_TFR_LARGE -> STOCK_TFR_LARGE`
- `LARGE_STOCK_TRADING -> STOCK_TFR_LARGE`
- 明确无阈值规则仍返回空参数集,不为第一期无参规则补虚假参数。
## XML 真实 SQL 替换
-`CcdiBankTagAnalysisMapper.xml` 中将以下占位 SQL 替换为真实规则:
- 赌博敏感词支出流水
- 非配偶/子女特殊金额交易流水
- 收入关键词转入流水
- 单笔购汇超阈值流水
- 单笔结汇超阈值流水
- 单笔大额采购事项
- 银证转账超阈值流水
- 三方资管超阈值流水
- 微信/支付宝单日频繁提现对象
- 明细型规则统一继续输出 `bankStatementId/groupId/logId/reasonDetail`
- `LARGE_PURCHASE_TRANSACTION` 由于来源为采购交易表,不直接来自银行流水表,保留明细结果字段别名并在 `reasonDetail` 中输出采购事项、金额和供应商信息。
## Service 分发调整
- `CcdiBankTagServiceImpl` 为以下规则改为显式透传解析后的阈值:
- `FOREX_BUY_AMT`
- `FOREX_SELL_AMT`
- `STOCK_TFR_LARGE`
- `LARGE_STOCK_TRADING`
- `WITHDRAW_CNT`
- `WITHDRAW_CNT` 命中为空时仍保持任务成功,不回退任务状态与风险人数刷新链路。
## 元数据脚本调整
-`sql/2026-03-16-bank-tagging.sql` 中对齐第一期规则元数据:
- `FOREX_BUY_AMT.indicator_code` 改为 `SINGLE_PURCHASE_AMOUNT`
- `FOREX_SELL_AMT.indicator_code` 改为 `SINGLE_SETTLEMENT_AMOUNT`
- `LARGE_STOCK_TRADING.indicator_code` 改为 `STOCK_TFR_LARGE`
- 将第一期已落地规则的 `remark` 从“占位规则待补充真实SQL”更新为真实规则描述。
## 与第二期边界
- 第二期规则仍保持原有占位 SQL不在本次修改范围内
- `LOW_INCOME_RELATIVE_LARGE_TRANSACTION`
- `MULTI_PARTY_GAMBLING_TRANSFER`
- `MONTHLY_FIXED_INCOME`
- `FIXED_COUNTERPARTY_TRANSFER`
- `HOUSE_REGISTRATION_MISMATCH`
- `PROPERTY_FEE_REGISTRATION_MISMATCH`
- `TAX_ASSET_REGISTRATION_MISMATCH`
- `SUPPLIER_CONCENTRATION`
- `SALARY_QUICK_TRANSFER`
- `SALARY_UNUSED`
## 验证执行
- 执行 `mvn test -pl ccdi-project -Dtest=BankTagRuleConfigResolverTest`,补齐参数映射后通过。
- 执行 `mvn test -pl ccdi-project -Dtest=CcdiBankTagAnalysisMapperXmlTest`,第一期 8 条明细规则结构测试通过。
- 执行 `mvn test -pl ccdi-project -Dtest=CcdiBankTagAnalysisMapperXmlTest,CcdiBankTagServiceImplTest`,对象规则与分发测试通过。
- 执行 `mvn test -pl ccdi-project -Dtest=CcdiBankTagAnalysisMapperXmlTest,BankTagRuleConfigResolverTest,CcdiBankTagServiceImplTest,CcdiBankTagServiceRiskCountRefreshTest`,第一期回归共 27 个测试全部通过。

View File

@@ -0,0 +1,43 @@
# LSFX Mock 随机命中规则后端实施记录
## 修改范围
- `lsfx-mock-server/services/file_service.py`
- `lsfx-mock-server/services/statement_rule_samples.py`
- `lsfx-mock-server/services/statement_service.py`
- `lsfx-mock-server/tests/test_file_service.py`
- `lsfx-mock-server/tests/test_statement_service.py`
- `lsfx-mock-server/tests/integration/test_full_workflow.py`
- `sql/migration/2026-03-20-lsfx-mock-random-hit-rule-purchase-baseline.sql`
## 规则命中计划生成方式
-`FileService` 中新增 `LARGE_TRANSACTION_RULE_CODES``PHASE1_RULE_CODES` 两组规则池。
- 新增 `_build_rule_hit_plan(log_id)`,使用 `random.Random(f"rule-plan:{log_id}")` 生成稳定随机源。
- 通过 `_pick_rule_subset()` 从两组规则池内分别稳定选出 `2-4` 条规则,并保留规则池原始顺序。
-`upload_file()``fetch_inner_flow()` 创建 `FileRecord` 时同步写入:
- `large_transaction_hit_rules`
- `phase1_hit_rules`
## 样本模块按规则子集装配
- 将原有“大额交易全量样本”拆成按规则代码独立调用的 builder。
- 新增 `LARGE_TRANSACTION_BUILDERS``PHASE1_RULE_BUILDERS` 两组映射,覆盖:
- 大额交易 8 条规则
- 第一期可由银行流水构造的 8 条规则
- 提供统一入口 `build_seed_statements_for_rule_plan(...)`,仅按 `rule_plan` 中被选中的规则拼装最小命中样本,不再默认返回全量命中样本。
- `build_large_transaction_seed_statements(...)` 保留为兼容测试入口,但内部已改为走新的规则映射。
## StatementService 接通方式
- `StatementService._generate_statements()` 改为优先读取 `FileRecord` 中保存的命中计划。
- 若存在真实 `FileRecord`,则复用其主体、账号、员工及亲属身份证范围,并把命中计划传给 `build_seed_statements_for_rule_plan(...)`
- 命中样本与随机噪声流水继续统一走 `_assign_statement_ids()` 分配稳定 ID。
- 首次生成后仍缓存固定 `200` 条流水;同一 `logId` 重复分页读取保持结果稳定。
## LARGE_PURCHASE_TRANSACTION 单独补数据库基线原因
- `LARGE_PURCHASE_TRANSACTION` 的真实命中来源是 `ccdi_purchase_transaction`,不依赖 `ccdi_bank_statement`
- 为避免伪造银行流水造成业务链路偏移,本次不把该规则塞进 Mock 流水样本。
- 通过新增 `sql/migration/2026-03-20-lsfx-mock-random-hit-rule-purchase-baseline.sql`,只补一条最小采购记录 `LSFXMOCKPUR001`
- 脚本通过 `ccdi_base_staff` 选取一条真实员工主数据作为 `applicant_id/applicant_name` 来源,`actual_amount=186000.00`,满足真实 SQL 的 `>100000` 命中门槛。
## 实施结果
- `FileService -> StatementService -> 缓存分页` 主链路保持不变。
- 大额交易规则与第一期新增规则均已支持“按 `logId` 稳定随机命中一部分”。
- `LARGE_PURCHASE_TRANSACTION` 已通过独立数据库基线补齐联调数据来源。

View File

@@ -0,0 +1,28 @@
# Mock 服务随机 logId 实施记录
## 问题背景
- 2026-03-20 联调过程中,`lsfx-mock-server``logId` 仍使用进程内递增方式分配。
- 仓库文档与接口预期要求 Mock 返回随机 `logId`,避免联调时对顺序值形成隐式依赖。
## 根因分析
- [`lsfx-mock-server/services/file_service.py`](/Users/wkc/Desktop/ccdi/ccdi/lsfx-mock-server/services/file_service.py) 中,`upload_file()``fetch_inner_flow()` 都直接通过 `self.log_counter += 1` 生成 `logId`
- 现有测试只覆盖了 `logId` 落在 `10000-99999` 区间内,没有约束“冲突时需要重试并避让已有记录”。
## 本次修改
- 在 [`lsfx-mock-server/tests/test_file_service.py`](/Users/wkc/Desktop/ccdi/ccdi/lsfx-mock-server/tests/test_file_service.py) 先新增红灯测试 `test_generate_log_id_should_retry_when_random_value_conflicts`
- 固定随机值第一次命中已存在 `logId` 时必须重试。
- 同步把行内流水测试中的旧递增断言改为随机区间断言。
- 在 [`lsfx-mock-server/services/file_service.py`](/Users/wkc/Desktop/ccdi/ccdi/lsfx-mock-server/services/file_service.py) 新增统一 `_generate_log_id()`
-`10000-99999` 区间内随机生成。
- 若命中 `file_records` 中已存在的 `logId`,则继续重试直到拿到未占用值。
- `upload_file()``fetch_inner_flow()` 均切换为调用该方法。
## 验证结果
- `python3 -m pytest lsfx-mock-server/tests/test_file_service.py -k "fetch_inner_flow_persists_primary_binding_record or generate_log_id_should_retry_when_random_value_conflicts" -v`
- 结果:`2 passed`
- `python3 -m pytest lsfx-mock-server/tests/test_file_service.py lsfx-mock-server/tests/test_statement_service.py lsfx-mock-server/tests/test_api.py lsfx-mock-server/tests/integration/test_full_workflow.py -v`
- 结果:`39 passed, 20 warnings`
## 实施结果
- Mock 服务的新建上传记录与行内流水记录已改为随机 `logId`
- 同一 `logId` 下的规则命中计划、流水样本与上传状态复用逻辑保持不变。

View File

@@ -0,0 +1,113 @@
# 模型信息 XLSX 更新实施记录
## 修改目标
- 直接更新 [assets/模型信息.xlsx](/Users/wkc/Desktop/ccdi/ccdi/assets/模型信息.xlsx)
- 按“已有真实 SQL 或真实结果产出逻辑才算已实现”的口径,区分跳过模型和待补充模型
- 对未实现模型新增 SQL 可执行性结论列,并补齐可从项目和数据库明确推出的缺失字段
## 判定口径
- 已实现并跳过:
- 仅认定 `CcdiBankTagServiceImpl` 已接入且 `CcdiBankTagAnalysisMapper.xml` 中存在真实查询逻辑的模型
- 本次跳过的是大额交易前 8 条规则:
- `HOUSE_OR_CAR_EXPENSE`
- `TAX_EXPENSE`
- `SINGLE_LARGE_INCOME`
- `CUMULATIVE_INCOME`
- `ANNUAL_TURNOVER`
- `LARGE_CASH_DEPOSIT`
- `FREQUENT_CASH_DEPOSIT`
- `LARGE_TRANSFER`
- 未实现并处理:
- 虽然项目已挂规则入口,但 `CcdiBankTagAnalysisMapper.xml` 中仍是 `where 1 = 0` 的占位 SQL
- 原始 SQL 引用了当前环境不存在的外部库表、字段或不兼容语法
- 原始 SQL 为空
## XLSX 修改内容
-`Sheet1` 末尾新增两列:
- `当前环境是否可执行SQL`
- `当前缺少内容`
- 对未实现模型补齐了以下信息:
- 指标英文名统一补到当前项目规则编码风格,如 `ABNORMAL_CUSTOMER_TRANSACTION``WITHDRAW_AMT``SALARY_UNUSED`
- 少量技术口径按当前库表字段修正为真实表结构描述,例如:
- 房产相关规则由历史描述中的 `ccdi_family_liability` 调整为当前实际存在的 `ccdi_asset_info`
- 采购规则技术口径改为基于 `ccdi_purchase_transaction.actual_amount`
- 将 SQL 片段中引用的交易时间字段统一由 `trx_time` 修正为当前表结构实际字段 `TRX_DATE`
- 将资产信息 SQL 中引用的更新时间字段统一由 `updated_at` 修正为当前表结构实际字段 `update_time`
- 将“供应商集中度”原始 SQL 中的 `WITH` 公共表达式改写为当前 MySQL 可执行的派生表写法
- 将剩余未实现 SQL 中缺少派生表别名的子查询统一补齐别名
- 将剩余 SQL 中不兼容 MySQL 的 `add_months(current_date(), -12)` 统一替换为 `DATE_SUB(CURDATE(), INTERVAL 12 MONTH)`
-`$$$$` 类占位符按 `ccdi_model_param``BankTagRuleConfigResolver` 核对为模型参数占位,并补齐对应参数编码说明
- 已确认匹配的参数编码包括:
- `LARGE_CASH_DEPOSIT`
- `FREQUENT_TRANSFER`
- `MULTI_PARTY_AMT_MIN / MULTI_PARTY_AMT_MAX`
- `MONTHLY_FIXED_INCOME`
- `FIXED_COUNTERPARTY_TRANSFER_MIN / FIXED_COUNTERPARTY_TRANSFER_MAX`
- `SINGLE_PURCHASE_AMOUNT`
- `SINGLE_SETTLEMENT_AMOUNT`
- `STOCK_TFR_LARGE`
- 对缺少原始 SQL 的行保留原业务口径同时在新增列中明确写明“缺少原始SQL”
## SQL 可执行性结论
- 判定标准:
- 以当前 `ccdi` 库真实表结构为准
- 以当前 MySQL 能力为准
- 不假设外部 schema、外部表和临时表已经准备好
- `PROJECT_ID` 视为后续改造成 MyBatis 传参,不单独作为不可执行原因
- 本次结论概况:
- 标记为 `是` 的 19 行:
- `2.2 低收入亲属大额交易 / LOW_INCOME_RELATIVE_LARGE_TRANSACTION`
- `3.1 疑似赌博交易 / MULTI_PARTY_AMT_MIN / MULTI_PARTY_AMT_MAX`
- `3.2 疑似敏感交易 / GAMBLING_SENSITIVE_KEYWORD`
- `4 可疑关系 / SPECIAL_AMOUNT_TRANSACTION`
- `5.1 疑似兼职 / MONTHLY_FIXED_INCOME`
- `5.2 疑似兼职 / FIXED_COUNTERPARTY_TRANSFER_MIN / FIXED_COUNTERPARTY_TRANSFER_MAX`
- `5.3 疑似兼职关键词收入 / SUSPICIOUS_INCOME_KEYWORD`
- `6.1 可疑财产 / HOUSE_REGISTRATION_MISMATCH`
- `6.2 可疑财产 / PROPERTY_FEE_REGISTRATION_MISMATCH`
- `6.3 可疑财产 / TAX_ASSET_REGISTRATION_MISMATCH`
- `7 可疑外汇交易-购汇 / SINGLE_PURCHASE_AMOUNT`
- `7 可疑外汇交易-结汇 / SINGLE_SETTLEMENT_AMOUNT`
- `9.1 可疑采购 / LARGE_PURCHASE_TRANSACTION`
- `9.2 供应商集中度 / SUPPLIER_CONCENTRATION`
- `10.1 可疑银证大额转账 / STOCK_TFR_LARGE`
- `10.2 微信支付宝频繁提现 / WITHDRAW_CNT`
- `10.3 工资快速转出 / SALARY_QUICK_TRANSFER`
- `异常行为-工资无使用记录 / SALARY_UNUSED`
- `10.4 大额炒股 / LARGE_STOCK_TRADING`
- 标记为 `否` 的未实现行,主要原因包括:
- 缺少外部表,如 `odsdb.blfmconf``sjfx_pro.bdfmhqaa_orc``xdzx.*`
- 原始 SQL 为空
- 码值补正依据:
- `relation_type` 已通过 `sys_dict_data.dict_type = 'ccdi_relation_type'``ccdi_staff_fmy_relation` 现网样例值确认,当前环境实际存储值为 `配偶``子女` 等中文值,因此 `4 可疑关系` 改为 `not in ('配偶','子女')`
- `asset_status` 已通过 `sys_dict_data.dict_type = 'ccdi_asset_status'` 确认为中文值,当前环境使用 `正常`
- `assetMainType``assetSubType` 在前端页面中为自由录入文本,不走字典;结合 `ccdi_asset_info` 当前样例数据,房产记录使用 `房产 / 住宅 / 正常`,因此 `6.1``6.2``6.3` 改为字符串字面量并判定为可执行
## 验证记录
- 已执行工作簿结构校验,确认当前工作簿为 14 列,新增的 `当前环境是否可执行SQL``当前缺少内容` 两列仍在表尾
- 已重新核对“码值不明确”的 4 行,确认 `SPECIAL_AMOUNT_TRANSACTION``HOUSE_REGISTRATION_MISMATCH``PROPERTY_FEE_REGISTRATION_MISMATCH``TAX_ASSET_REGISTRATION_MISMATCH` 当前均为 `是 /`
- 已再次扫描整表,确认 `当前缺少内容` 中已不存在“码值”“枚举”“引号”“中文常量”等剩余原因
- 已抽样回读以下关键行,确认新增列和补齐字段已写入:
- 第 10 行:异常交易 2.1
- 第 12 行:疑似赌博 3.1
- 第 15 行:可疑兼职 5.1
- 第 18 行:可疑财产 6.1
- 第 21 行:可疑财产 收入资产不符
- 第 24 行:可疑外汇交易 跨境汇款
- 第 26 行:可疑采购 9.1
- 第 27 行:可疑采购 9.2
- 第 30 行:异常行为 微信支付宝提现超额
- 第 32 行:异常行为 工资无使用记录
- 第 34 行:异常行为 疑似代理他人账户
## 本次涉及文件
- 更新 [assets/模型信息.xlsx](/Users/wkc/Desktop/ccdi/ccdi/assets/模型信息.xlsx)
- 新增 [docs/design/2026-03-20-model-info-xlsx-update-design.md](/Users/wkc/Desktop/ccdi/ccdi/docs/design/2026-03-20-model-info-xlsx-update-design.md)
- 新增 [docs/plans/fullstack/2026-03-20-model-info-xlsx-update-implementation.md](/Users/wkc/Desktop/ccdi/ccdi/docs/plans/fullstack/2026-03-20-model-info-xlsx-update-implementation.md)
- 新增 [docs/reports/implementation/2026-03-20-model-info-xlsx-update-record.md](/Users/wkc/Desktop/ccdi/ccdi/docs/reports/implementation/2026-03-20-model-info-xlsx-update-record.md)

View File

@@ -0,0 +1,52 @@
# 结果总览模型区联动筛选后端实施记录
## 本次改动
- 在 [`CcdiProjectOverviewController.java`](/Users/wkc/Desktop/ccdi/ccdi/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewController.java) 新增 `GET /ccdi/project/overview/risk-models/cards``GET /ccdi/project/overview/risk-models/people` 两个接口。
- 在 [`ICcdiProjectOverviewService.java`](/Users/wkc/Desktop/ccdi/ccdi/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectOverviewService.java) 与 [`CcdiProjectOverviewServiceImpl.java`](/Users/wkc/Desktop/ccdi/ccdi/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImpl.java) 补齐模型卡片与模型人员分页服务。
- 新增 [`CcdiProjectRiskModelPeopleQueryDTO.java`](/Users/wkc/Desktop/ccdi/ccdi/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiProjectRiskModelPeopleQueryDTO.java) 及一组风险模型区 VO用于承载卡片、分页行和命中标签结构。
- 在 [`CcdiProjectOverviewMapper.java`](/Users/wkc/Desktop/ccdi/ccdi/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapper.java) 与 [`CcdiProjectOverviewMapper.xml`](/Users/wkc/Desktop/ccdi/ccdi/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml) 新增模型卡片统计与模型人员分页 SQL。
- 新增或扩展 [`CcdiProjectOverviewControllerContractTest.java`](/Users/wkc/Desktop/ccdi/ccdi/ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewControllerContractTest.java)、[`CcdiProjectOverviewMapperRiskModelCardsTest.java`](/Users/wkc/Desktop/ccdi/ccdi/ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapperRiskModelCardsTest.java)、[`CcdiProjectOverviewMapperRiskModelPeopleTest.java`](/Users/wkc/Desktop/ccdi/ccdi/ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapperRiskModelPeopleTest.java)、[`CcdiProjectOverviewServiceImplTest.java`](/Users/wkc/Desktop/ccdi/ccdi/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImplTest.java) 以覆盖契约、SQL 和服务封装。
- 根据 2026-03-20 线上分页异常,在 [`CcdiProjectOverviewMapper.xml`](/Users/wkc/Desktop/ccdi/ccdi/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml) 的 `selectRiskModelPeoplePage` 增加 `<bind name="projectId" value="query.projectId"/>`,并在 [`CcdiProjectOverviewMapperRiskModelPeopleTest.java`](/Users/wkc/Desktop/ccdi/ccdi/ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapperRiskModelPeopleTest.java) 补充对应断言,防止再次出现参数绑定异常。
- 根据 2026-03-20 线上 MySQL 3065 异常,将 [`CcdiProjectOverviewMapper.xml`](/Users/wkc/Desktop/ccdi/ccdi/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml) 中 `selectRiskModelNamesByScope``select distinct` 调整为 `group by scoped.model_code, scoped.model_name`,并在 [`CcdiProjectOverviewMapperRiskModelPeopleTest.java`](/Users/wkc/Desktop/ccdi/ccdi/ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapperRiskModelPeopleTest.java) 补充兼容性断言。
- 根据 2026-03-20 线上 `matchMode=ALL``UnsupportedOperationException`,在 [`CcdiProjectRiskModelPeopleQueryDTO.java`](/Users/wkc/Desktop/ccdi/ccdi/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiProjectRiskModelPeopleQueryDTO.java) 新增显式只读字段 `modelCodesCount`,并将 [`CcdiProjectOverviewMapper.xml`](/Users/wkc/Desktop/ccdi/ccdi/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml) 中 `having count(distinct base.model_code) = #{query.modelCodes.size}` 改为 `#{query.modelCodesCount}`,同时更新 [`CcdiProjectOverviewMapperRiskModelPeopleTest.java`](/Users/wkc/Desktop/ccdi/ccdi/ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapperRiskModelPeopleTest.java) 回归断言。
- 根据“模型触发次数为 0 也要展示卡片”的新要求,将 [`CcdiProjectOverviewMapper.xml`](/Users/wkc/Desktop/ccdi/ccdi/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml) 中 `selectRiskModelCardsByProjectId` 从命中结果直聚合改为以 `ccdi_bank_tag_rule` 启用模型为主表、左连项目命中聚合,并更新 [`CcdiProjectOverviewMapperRiskModelCardsTest.java`](/Users/wkc/Desktop/ccdi/ccdi/ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapperRiskModelCardsTest.java) 回归断言。
## 处理说明
- 继续复用现有 `CcdiProjectOverviewController + Service + Mapper` 结果总览链路,没有新增平行模块或补丁式接口。
- 模型卡片展示口径以 `ccdi_bank_tag_rule` 中启用的全量模型定义为准,不再只返回已命中的模型;卡片命中统计与人员分页仍统一建立在“员工本人 + 亲属归并到员工名下”的基础归并 SQL 上,确保与既有风险人员口径一致。
- 人员分页中的员工工号使用 `ccdi_base_staff.staff_id` 作为真实字段来源,并在 SQL 中转换为字符后映射到 `staffCode`
- `ANY` 模式通过模型范围过滤直接返回并集结果;`ALL` 模式通过 `having count(distinct base.model_code) = #{query.modelCodesCount}` 约束交集结果,避免在 MyBatis 参数绑定阶段直接读取集合包装器属性。
- `modelNames``hitTagList` 使用同上下文子查询回填,只返回当前筛选模型范围内的数据。
- 风险模型人员分页复用公共归并 SQL 时,必须先把 `query.projectId` 绑定到顶层 `projectId`,否则 `resolvedEmployeeRiskBaseSql` 中的 `#{projectId}` 会在 MyBatis 分页查询阶段直接报绑定异常。
- 风险模型名称子查询需要按 `model_code` 保序返回,但不能使用 `distinct + order by 非 select 列` 组合;这里改为 `group by scoped.model_code, scoped.model_name` 后再排序,以兼容当前 MySQL 配置。
- 风险模型卡片查询改为从 `ccdi_bank_tag_rule` 先聚合出启用模型,再左连项目命中统计,`warningCount/peopleCount` 统一通过 `coalesce(..., 0)` 回填,确保 0 次模型卡片也能展示。
- 服务层对 `matchMode` 缺省值统一收口为 `ANY`,并确保空列表返回空数组、分页行统一附加“查看详情”。
## 验证情况
- 已执行如下定向验证:
```bash
mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewControllerContractTest
mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewMapperRiskModelCardsTest
mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewMapperRiskModelPeopleTest
mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewServiceImplTest
```
- 已执行结果总览相关总体验证:
```bash
mvn test -pl ccdi-project '-Dtest=CcdiProjectOverview*'
```
- 已执行重新打包与真实接口回归:
```bash
mvn -pl ruoyi-admin -am package -DskipTests
java -jar ruoyi-admin/target/ruoyi-admin.jar --server.port=62319
```
- 真实接口回归覆盖 `/risk-models/cards`、人员默认分页、单模型 `ANY`、单模型 `ALL`,项目 `42` 的实际返回均为 `code=200`,其中卡片接口返回 `10` 张模型卡片,仅 `1` 张存在真实命中,其余 `9` 张为 `warningCount=0``peopleCount=0`
- 总体验证结果22 个测试全部通过0 failure0 error临时启动的 `62319` 实例已在验证完成后关闭。

View File

@@ -0,0 +1,37 @@
# 结果总览模型区联动筛选前端实施记录
## 本次改动
- 在 [`projectOverview.js`](/Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui/src/api/ccdi/projectOverview.js) 新增 `getOverviewRiskModelCards``getOverviewRiskModelPeople`,统一封装模型卡片和模型区人员分页接口。
- 在 [`PreliminaryCheck.vue`](/Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui/src/views/ccdiProject/components/detail/PreliminaryCheck.vue) 接入模型卡片真实请求,并把模型区列表查询职责下沉到 [`RiskModelSection.vue`](/Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui/src/views/ccdiProject/components/detail/RiskModelSection.vue)。
- 在 [`preliminaryCheck.mock.js`](/Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui/src/views/ccdiProject/components/detail/preliminaryCheck.mock.js) 清理旧的模型筛选 mock 依赖,补齐模型卡片归一化逻辑和空态结构。
- 在 [`RiskModelSection.vue`](/Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui/src/views/ccdiProject/components/detail/RiskModelSection.vue) 实现多卡片选中/取消、`ANY / ALL` 匹配方式、关键词与部门筛选、分页请求、模型摘要和异常标签展示。
- 新增或更新 [`preliminary-check-model-api.test.js`](/Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui/tests/unit/preliminary-check-model-api.test.js)、[`preliminary-check-model-data-loading.test.js`](/Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui/tests/unit/preliminary-check-model-data-loading.test.js)、[`preliminary-check-model-multiselect.test.js`](/Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui/tests/unit/preliminary-check-model-multiselect.test.js)、[`preliminary-check-model-match-mode.test.js`](/Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui/tests/unit/preliminary-check-model-match-mode.test.js)、[`preliminary-check-model-filters.test.js`](/Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui/tests/unit/preliminary-check-model-filters.test.js)、[`preliminary-check-model-table-columns.test.js`](/Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui/tests/unit/preliminary-check-model-table-columns.test.js)、[`preliminary-check-model-linkage-flow.test.js`](/Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui/tests/unit/preliminary-check-model-linkage-flow.test.js)、[`preliminary-check-model-and-detail.test.js`](/Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui/tests/unit/preliminary-check-model-and-detail.test.js) 与 [`preliminary-check-model-card-grid.test.js`](/Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui/tests/unit/preliminary-check-model-card-grid.test.js),覆盖接口、状态、筛选、联动流和卡片布局断点。
## 处理说明
- 继续复用现有 `PreliminaryCheck.vue` 作为结果总览页入口,没有新增路由、二级页面或平行容器组件。
- 模型卡片接口与人员分页接口拆分请求,避免入口页承担过多筛选状态,保持模型区联动逻辑集中在组件内部。
- 模型卡片支持多选,再次点击可取消;请求参数统一携带 `modelCodes + matchMode + keyword + deptId + pageNum + pageSize`
- 模型区人员列表改为展示姓名、工号、身份证号、所属部门、命中模型摘要和异常标签;异常标签复用 [`DetailQuery.vue`](/Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui/src/views/ccdiProject/components/detail/DetailQuery.vue) 的风险等级颜色映射。
- 部门选项复用系统现有的 `deptTreeSelect` 接口,并在前端展开为可筛选下拉项,保持最短路径实现。
- 模型卡片网格在桌面端调整为固定 5 列,使 10 张卡片按两行展示;平板端降为 2 列,手机端降为 1 列,避免窄屏下卡片挤压。
## 验证情况
- 已执行如下定向验证:
```bash
cd ruoyi-ui
node tests/unit/preliminary-check-model-api.test.js
node tests/unit/preliminary-check-model-data-loading.test.js
node tests/unit/preliminary-check-model-multiselect.test.js
node tests/unit/preliminary-check-model-match-mode.test.js
node tests/unit/preliminary-check-model-filters.test.js
node tests/unit/preliminary-check-model-table-columns.test.js
node tests/unit/preliminary-check-model-linkage-flow.test.js
node tests/unit/preliminary-check-model-and-detail.test.js
node tests/unit/preliminary-check-model-card-grid.test.js
```
- 总体验证结果9 个前端单测命令全部通过0 failure0 error。

View File

@@ -0,0 +1,33 @@
# 结果总览模型卡片联动筛选计划记录
## 变更概述
- 新增结果总览模型区后端实施计划 1 份。
- 新增结果总览模型区前端实施计划 1 份。
- 本次计划已纳入模型卡片多选联动,以及“任意触发 / 同时触发”两种筛选模式。
- 本轮仅完成实施计划沉淀,尚未进入代码实现阶段。
## 新增文件
- `docs/plans/backend/2026-03-20-results-overview-risk-model-linkage-backend-implementation.md`
- `docs/plans/frontend/2026-03-20-results-overview-risk-model-linkage-frontend-implementation.md`
## 计划结论
- 后端计划聚焦结果总览模型区两个接口:模型卡片统计、命中模型人员分页。
- 人员分页查询统一支持:
- 多模型选择
- `ANY` 任意触发
- `ALL` 同时触发
- 员工姓名或工号查询
- 部门筛选
- 前端计划聚焦:
- 模型卡片多选
- 触发方式切换
- 列表筛选项替换
- 异常标签展示
## 说明
- 本次按仓库规范,将实施计划分别落到 `docs/plans/backend/``docs/plans/frontend/`
- 因本轮需求是补充与提交文档,故同步新增本计划记录,作为本次改动的实施文档。

View File

@@ -0,0 +1,26 @@
# 结果总览模型卡片 loading 与筛选标签样式修复记录
## 本次改动
- 在 [`RiskModelSection.vue`](/Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui/src/views/ccdiProject/components/detail/RiskModelSection.vue) 调整模型卡片点击联动逻辑,点击选中或取消模型时,向人员列表请求附带 `syncCardLoading` 开关,让“模型预警次数统计”卡片区在联动查询期间显示 loading。
- 在 [`RiskModelSection.vue`](/Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui/src/views/ccdiProject/components/detail/RiskModelSection.vue) 将 `fetchPeopleList` 改为支持可选参数,保证只有模型卡片切换场景会联动卡片 loading普通查询、分页和部门筛选仍只影响下方表格 loading。
- 新增 [`preliminary-check-model-loading-and-label.test.js`](/Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui/tests/unit/preliminary-check-model-loading-and-label.test.js),校验模型卡片联动 loading 代码路径和“员工姓名或工号”标签单行样式已落地。
## 处理说明
- 保持现有接口和父子组件数据流不变,只在模型区组件内部补充交互反馈,避免引入额外状态同步。
- 卡片区 loading 与表格 loading 同步开始,但只在模型卡片点击触发时打开,符合“选择模型后对统计卡片添加 loading”的需求边界。
- “员工姓名或工号”标签通过 `white-space: nowrap``flex-shrink: 0` 保持单行显示,不影响筛选栏其余控件布局。
## 验证情况
- 已执行如下验证:
```bash
npx mocha ruoyi-ui/tests/unit/preliminary-check-model-loading-and-label.test.js ruoyi-ui/tests/unit/preliminary-check-model-linkage-flow.test.js ruoyi-ui/tests/unit/preliminary-check-model-filters.test.js
npm --prefix ruoyi-ui run build:prod
```
- 结果说明:
- 定向单测已通过。
- 前端生产构建已执行,结果以本次命令输出为准。

View File

@@ -0,0 +1,24 @@
# 结果总览风险人员总览字段扩展后端实施记录
## 本次改动
- 为 [`CcdiProjectRiskPeopleOverviewItemVO.java`](/Users/wkc/Desktop/ccdi/ccdi/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectRiskPeopleOverviewItemVO.java) 新增 `riskLevel``riskLevelType``modelCount` 字段。
- 调整 [`CcdiProjectOverviewServiceImpl.java`](/Users/wkc/Desktop/ccdi/ccdi/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImpl.java) 中 `buildRiskPeopleItem` 映射逻辑,风险人员总览直接返回风险等级、标签类型和命中模型数。
- 调整 [`CcdiProjectOverviewServiceImplTest.java`](/Users/wkc/Desktop/ccdi/ccdi/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImplTest.java),锁定风险人员总览返回新字段。
- 调整 [`CcdiProjectOverviewControllerTest.java`](/Users/wkc/Desktop/ccdi/ccdi/ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewControllerTest.java),校验控制器返回数据中包含新字段。
## 处理说明
- 本次未新增接口,继续复用 `GET /ccdi/project/overview/risk-people`
- 本次未修改员工风险聚合 SQL也未调整风险等级分级规则。
- 本次未删除后端独立的 `top-risk-people` 接口,收口范围只限于风险人员总览接口字段扩展。
## 验证情况
- 已执行结果总览后端定向测试:
```bash
mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewServiceImplTest,CcdiProjectOverviewControllerTest,CcdiProjectOverviewMapperSqlTest,CcdiProjectOverviewServiceStructureTest
```
- 上述命令执行通过证明服务映射、控制器、SQL 结构与服务接口边界未被本次调整破坏。

View File

@@ -0,0 +1,32 @@
# 结果总览风险人员区块收口前端实施记录
## 本次改动
- 调整 [`projectOverview.js`](/Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui/src/api/ccdi/projectOverview.js),移除页面不再使用的 `getOverviewTopRiskPeople` 封装。
- 调整 [`PreliminaryCheck.vue`](/Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui/src/views/ccdiProject/components/detail/PreliminaryCheck.vue),页面并发请求从 `dashboard + riskPeople + topRiskPeople` 收口为 `dashboard + riskPeople`
- 调整 [`preliminaryCheck.mock.js`](/Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui/src/views/ccdiProject/components/detail/preliminaryCheck.mock.js),为 `overviewList` 补充 `riskLevel``riskLevelType``modelCount`,并移除展示层对 `topRiskList` 的依赖。
- 调整 [`RiskPeopleSection.vue`](/Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui/src/views/ccdiProject/components/detail/RiskPeopleSection.vue),删除独立的 `中高风险人员TOP10` 区块,在风险人员总览单表中新增 `风险等级``命中模型数` 两列。
- 调整 4 个前端静态断言脚本,锁定页面不再依赖 TOP10 接口,且风险人员总览单表直接绑定新字段。
## 处理说明
- 本次未新增页面组件,也未修改风险模型区和风险明细区。
- 本次未增加前端拼装补丁逻辑,风险等级标签继续直接使用后端返回的 `riskLevelType`
- 本次未启动长期前端开发进程,因此不存在额外进程清理动作。
## 验证情况
- 已执行结果总览前端静态断言回归:
```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-summary-and-people.test.js
node tests/unit/preliminary-check-risk-people-binding.test.js
node tests/unit/preliminary-check-states.test.js
node tests/unit/preliminary-check-layout.test.js
npm run build:prod
```
- 上述命令全部通过;生产构建仅出现现有资源体积告警,无新增编译错误,说明页面接口依赖、单表结构、字段绑定和基础状态展示均符合本轮需求。

View File

@@ -0,0 +1,30 @@
# 风险人员总览核心异常点标签化展示实施记录
## 本次改动
- 将风险人员总览中的“核心异常点”从纯文本展示改为标签列表展示,样式与“命中模型涉及人员”的异常标签保持一致。
- 调整 [`RiskPeopleSection.vue`](/Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui/src/views/ccdiProject/components/detail/RiskPeopleSection.vue)
- 新增 `riskPointTagList` 归一化逻辑。
- 兼容后端返回标签数组、字符串数组、以及历史 `riskPoint` 拼接字符串三种输入形式。
- 按风险等级映射 `el-tag` 颜色,空值场景显示 `-`
- 调整 [`preliminaryCheck.mock.js`](/Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui/src/views/ccdiProject/components/detail/preliminaryCheck.mock.js),补充标签列 mock 数据。
- 新增 [`preliminary-check-risk-people-hit-tags.test.js`](/Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui/tests/unit/preliminary-check-risk-people-hit-tags.test.js) 锁定核心异常点标签化渲染结构。
## 实现说明
- 不修改后端接口口径,展示层在前端做最小归一化处理。
- 若接口继续返回 `riskPoint` 字符串,则按 `、``,````;``` 拆分为多个标签。
- 若接口后续直接返回 `riskPointTagList`,则优先使用该字段,避免重复拆分。
## 验证情况
- 前端单测:
- `node ruoyi-ui/tests/unit/preliminary-check-risk-people-hit-tags.test.js`
- `node ruoyi-ui/tests/unit/preliminary-check-risk-people-binding.test.js`
- `node ruoyi-ui/tests/unit/preliminary-check-summary-and-people.test.js`
## 未包含内容
- 未调整风险人员总览接口返回结构
- 未改动风险等级口径与统计逻辑
- 未改动“命中模型涉及人员”区块的接口或交互

View File

@@ -0,0 +1,521 @@
# 新增模型打标完整验证 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:** 对 2026-03-20 新加入的模型打标改动执行一轮完整验证,覆盖 Mock 随机命中、第一期真实规则、数据库事实和接口端到端结果。
**Architecture:** 这次工作不是改代码,而是按“环境与基线 -> 自动化回归 -> 数据库核验 -> 接口链路 -> 文档沉淀”五层顺序执行。只要某一层失败,就停在该层记录证据,不继续给出“验证通过”的结论,也不进入修复。
**Tech Stack:** Bash, Python 3, FastAPI, Java 21, Spring Boot 3, Maven, pytest, MySQL, curl, jq
---
## File Structure
- `docs/tests/plans/2026-03-20-bank-tag-new-model-validation-test-plan.md`: 已确认的验证计划,执行时必须严格对齐范围与停点。
- `docs/tests/plans/2026-03-20-bank-tag-new-model-validation-execution-plan.md`: 本执行计划,负责把验证计划拆成可执行步骤。
- `docs/reports/implementation/2026-03-20-bank-tag-new-model-validation-record.md`: 本次实施记录,记录执行内容与范围。
- `docs/tests/records/2026-03-20-bank-tag-new-model-validation-verification.md`: 本次验证记录记录命令、SQL、接口结果和结论。
- `docs/reports/implementation/2026-03-20-lsfx-mock-random-hit-rule-backend-record.md`: Mock 随机命中改动的既有实施记录。
- `docs/tests/records/2026-03-20-lsfx-mock-random-hit-rule-backend-verification.md`: Mock 随机命中改动的既有验证记录。
- `docs/reports/implementation/2026-03-20-bank-tag-real-rule-phase1-backend-record.md`: 第一期真实规则改动的既有实施记录。
- `docs/tests/records/2026-03-20-bank-tag-real-rule-phase1-backend-verification.md`: 第一期真实规则改动的既有验证记录。
- `ruoyi-admin/src/main/resources/application-dev.yml`: 读取数据库连接与后端本地配置。
- `sql/migration/2026-03-20-lsfx-mock-random-hit-rule-purchase-baseline.sql`: `LARGE_PURCHASE_TRANSACTION` 采购基线脚本。
- `lsfx-mock-server/tests/test_file_service.py`: Mock 规则命中计划测试。
- `lsfx-mock-server/tests/test_statement_service.py`: Mock 样本装配与缓存稳定性测试。
- `lsfx-mock-server/tests/integration/test_full_workflow.py`: Mock 端到端链路测试。
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/BankTagRuleConfigResolverTest.java`: 规则参数映射测试。
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapperXmlTest.java`: 真实 SQL 结构测试。
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImplTest.java`: 规则分发测试。
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceRiskCountRefreshTest.java`: 风险人数刷新回归测试。
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadController.java`: 拉取本行信息接口。
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiBankTagController.java`: 手动重算接口。
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiBankStatementController.java`: 流水详情接口。
- `bin/restart_java_backend.sh`: 后端 Jar 启停脚本。
### Task 1: 锁定执行环境、目标项目和记录文档
**Files:**
- Create: `docs/reports/implementation/2026-03-20-bank-tag-new-model-validation-record.md`
- Create: `docs/tests/records/2026-03-20-bank-tag-new-model-validation-verification.md`
- Reference: `docs/tests/plans/2026-03-20-bank-tag-new-model-validation-test-plan.md`
- Reference: `ruoyi-admin/src/main/resources/application-dev.yml`
- Reference: `docs/reports/implementation/2026-03-20-lsfx-mock-random-hit-rule-backend-record.md`
- Reference: `docs/reports/implementation/2026-03-20-bank-tag-real-rule-phase1-backend-record.md`
- [ ] **Step 1: 阅读既有计划与两份来源实施记录**
Run:
```bash
sed -n '1,220p' docs/tests/plans/2026-03-20-bank-tag-new-model-validation-test-plan.md
sed -n '1,220p' docs/reports/implementation/2026-03-20-lsfx-mock-random-hit-rule-backend-record.md
sed -n '1,220p' docs/reports/implementation/2026-03-20-bank-tag-real-rule-phase1-backend-record.md
```
Expected:
- 明确本次只验证双线已落地内容
- 不把第二期规则和修复方案带进执行范围
- [ ] **Step 2: 从数据库中选出本次端到端验证使用的项目**
Run:
```bash
python3 - <<'PY'
from pathlib import Path
import pymysql, re
text = Path('ruoyi-admin/src/main/resources/application-dev.yml').read_text(encoding='utf-8')
match = re.search(r"url:\s*jdbc:mysql://(?P<host>[^:/?#]+):(?P<port>\d+)/(?P<db>[^?\n]+).*?\n\s*username:\s*(?P<user>[^\n]+)\n\s*password:\s*(?P<pwd>[^\n]+)", text, re.S)
conn = pymysql.connect(
host=match.group('host'),
port=int(match.group('port')),
user=match.group('user').strip(),
password=match.group('pwd').strip(),
database=match.group('db').strip(),
charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor,
)
with conn, conn.cursor() as cursor:
cursor.execute("""
SELECT project_id, project_name, lsfx_project_id, config_type
FROM ccdi_project
WHERE del_flag = '0'
AND lsfx_project_id IS NOT NULL
ORDER BY update_time DESC, project_id DESC
LIMIT 10
""")
for row in cursor.fetchall():
print(row)
PY
```
Expected:
- 至少找到 1 个可用于 LSFX 联调的项目
- 在实施记录和验证记录中记下最终采用的 `project_id`
- [ ] **Step 3: 创建本次实施记录与验证记录骨架**
在两个文档中先写入以下固定章节:
- 实施记录:验证目标、范围、执行阶段、产物路径
- 验证记录:执行命令、数据库核验、接口验证、结论、环境清理
- [ ] **Step 4: Commit**
```bash
git add docs/reports/implementation/2026-03-20-bank-tag-new-model-validation-record.md docs/tests/records/2026-03-20-bank-tag-new-model-validation-verification.md docs/tests/plans/2026-03-20-bank-tag-new-model-validation-execution-plan.md
git commit -m "补充新增模型打标验证执行计划"
```
### Task 2: 先完成 Mock 随机命中自动化回归
**Files:**
- Reference: `lsfx-mock-server/tests/test_file_service.py`
- Reference: `lsfx-mock-server/tests/test_statement_service.py`
- Reference: `lsfx-mock-server/tests/integration/test_full_workflow.py`
- Modify: `docs/tests/records/2026-03-20-bank-tag-new-model-validation-verification.md`
- Modify: `docs/reports/implementation/2026-03-20-bank-tag-new-model-validation-record.md`
- [ ] **Step 1: 跑 Mock 聚焦回归**
Run:
```bash
cd lsfx-mock-server
python3 -m pytest tests/test_file_service.py -k "rule_hit_plan or persist_rule_hit_plan" -v
python3 -m pytest tests/test_statement_service.py -k "rule_plan_should_only_include or withdraw_cnt_samples" -v
python3 -m pytest tests/test_statement_service.py -k "follow_rule_hit_plan or fixed_total_count_200 or cached_result" -v
python3 -m pytest tests/integration/test_full_workflow.py -k "same_rule_subset or share_same_primary_binding" -v
```
Expected:
- 全部 `PASS`
- 能证明规则命中计划、样本装配和缓存稳定性未回退
- [ ] **Step 2: 跑 Mock 全量回归**
Run:
```bash
cd lsfx-mock-server
python3 -m pytest tests/test_file_service.py tests/test_statement_service.py tests/test_api.py tests/integration/test_full_workflow.py -v
```
Expected:
- `PASS`
- 若失败,停止后续阶段并把失败用例和首个错误栈写入验证记录
- [ ] **Step 3: 将结果写入验证记录**
记录:
- 实际执行时间
- `passed / failed / warnings` 摘要
- 若存在 warning只说明是否为既有 warning不做修复
### Task 3: 完成主工程第一期真实规则自动化回归
**Files:**
- Reference: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/BankTagRuleConfigResolverTest.java`
- Reference: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapperXmlTest.java`
- Reference: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImplTest.java`
- Reference: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceRiskCountRefreshTest.java`
- Modify: `docs/tests/records/2026-03-20-bank-tag-new-model-validation-verification.md`
- [ ] **Step 1: 逐步跑第一期目标测试**
Run:
```bash
mvn test -pl ccdi-project -Dtest=BankTagRuleConfigResolverTest
mvn test -pl ccdi-project -Dtest=CcdiBankTagAnalysisMapperXmlTest
mvn test -pl ccdi-project -Dtest=CcdiBankTagAnalysisMapperXmlTest,CcdiBankTagServiceImplTest
mvn test -pl ccdi-project -Dtest=CcdiBankTagAnalysisMapperXmlTest,BankTagRuleConfigResolverTest,CcdiBankTagServiceImplTest,CcdiBankTagServiceRiskCountRefreshTest
```
Expected:
- 全部 `BUILD SUCCESS`
- 若任一命令失败,停止后续阶段并把失败命令、失败类名和错误摘要写入验证记录
- [ ] **Step 2: 在验证记录中沉淀主工程自动化结果**
记录:
- 每条命令的执行结果
- 规则映射、真实 SQL、对象分发、风险人数刷新是否都保持通过
### Task 4: 做数据库基线和规则元数据核验
**Files:**
- Reference: `sql/migration/2026-03-20-lsfx-mock-random-hit-rule-purchase-baseline.sql`
- Reference: `sql/2026-03-16-bank-tagging.sql`
- Modify: `docs/tests/records/2026-03-20-bank-tag-new-model-validation-verification.md`
- Modify: `docs/reports/implementation/2026-03-20-bank-tag-new-model-validation-record.md`
- [ ] **Step 1: 幂等执行采购基线脚本**
Run:
```bash
bin/mysql_utf8_exec.sh sql/migration/2026-03-20-lsfx-mock-random-hit-rule-purchase-baseline.sql
```
Expected:
- 脚本执行成功
- 无中文乱码、无 SQL 报错
- [ ] **Step 2: 校验采购基线记录存在**
Run:
```bash
python3 - <<'PY'
from pathlib import Path
import pymysql, re
text = Path('ruoyi-admin/src/main/resources/application-dev.yml').read_text(encoding='utf-8')
match = re.search(r"url:\s*jdbc:mysql://(?P<host>[^:/?#]+):(?P<port>\d+)/(?P<db>[^?\n]+).*?\n\s*username:\s*(?P<user>[^\n]+)\n\s*password:\s*(?P<pwd>[^\n]+)", text, re.S)
conn = pymysql.connect(
host=match.group('host'),
port=int(match.group('port')),
user=match.group('user').strip(),
password=match.group('pwd').strip(),
database=match.group('db').strip(),
charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor,
)
with conn, conn.cursor() as cursor:
cursor.execute("""
SELECT purchase_id, actual_amount, supplier_name
FROM ccdi_purchase_transaction
WHERE purchase_id = 'LSFXMOCKPUR001'
AND actual_amount > 100000
""")
print(cursor.fetchone())
PY
```
Expected:
- 返回 `LSFXMOCKPUR001`
- `actual_amount` 大于 `100000`
- [ ] **Step 3: 校验第一期规则元数据保持大写且可识别**
Run:
```bash
python3 - <<'PY'
from pathlib import Path
import pymysql, re
TARGET_RULES = (
'GAMBLING_SENSITIVE_KEYWORD','SPECIAL_AMOUNT_TRANSACTION','SUSPICIOUS_INCOME_KEYWORD',
'FOREX_BUY_AMT','FOREX_SELL_AMT','LARGE_PURCHASE_TRANSACTION',
'STOCK_TFR_LARGE','WITHDRAW_CNT','LARGE_STOCK_TRADING'
)
text = Path('ruoyi-admin/src/main/resources/application-dev.yml').read_text(encoding='utf-8')
match = re.search(r"url:\s*jdbc:mysql://(?P<host>[^:/?#]+):(?P<port>\d+)/(?P<db>[^?\n]+).*?\n\s*username:\s*(?P<user>[^\n]+)\n\s*password:\s*(?P<pwd>[^\n]+)", text, re.S)
conn = pymysql.connect(
host=match.group('host'),
port=int(match.group('port')),
user=match.group('user').strip(),
password=match.group('pwd').strip(),
database=match.group('db').strip(),
charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor,
)
sql = f"""
SELECT model_code, rule_code, indicator_code
FROM ccdi_bank_tag_rule
WHERE rule_code IN ({','.join(['%s'] * len(TARGET_RULES))})
ORDER BY model_code, sort_order, rule_code
"""
with conn, conn.cursor() as cursor:
cursor.execute(sql, TARGET_RULES)
for row in cursor.fetchall():
print(row)
PY
```
Expected:
- 所有目标规则均能查到
- `rule_code``indicator_code` 继续保持全大写风格
- [ ] **Step 4: 将 SQL 与结果摘要写入验证记录**
记录:
- 执行的 SQL 或脚本命令
- 返回结果摘要
- 若数据缺失,明确归类为“数据基线异常”
### Task 5: 执行接口端到端验证并清理进程
**Files:**
- Reference: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadController.java`
- Reference: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiBankTagController.java`
- Reference: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiBankStatementController.java`
- Modify: `docs/tests/records/2026-03-20-bank-tag-new-model-validation-verification.md`
- Modify: `docs/reports/implementation/2026-03-20-bank-tag-new-model-validation-record.md`
- [ ] **Step 1: 启动 Mock 服务**
Run:
```bash
mkdir -p logs
cd lsfx-mock-server
nohup python3 main.py > ../logs/lsfx-mock-validation.log 2>&1 &
echo $! > ../logs/lsfx-mock-validation.pid
cd ..
```
Expected:
- `logs/lsfx-mock-validation.pid` 已生成
- `http://localhost:8000/docs` 可访问
- [ ] **Step 2: 启动后端 Jar 服务**
Run:
```bash
./bin/restart_java_backend.sh stop
nohup ./bin/restart_java_backend.sh start > logs/backend-validation.log 2>&1 &
echo $! > logs/backend-validation.pid
```
Expected:
- 后端监听在 `http://localhost:62318`
- 若启动失败,先看 `logs/backend-console.log`,记录失败后停止执行
- [ ] **Step 3: 登录并取 token**
Run:
```bash
curl -s http://localhost:62318/login/test \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"admin123"}' | tee /tmp/bank-tag-login.json
jq -r '.token' /tmp/bank-tag-login.json
```
Expected:
- 返回非空 token
- [ ] **Step 4: 触发拉取本行信息链路生成新的 Mock 流水**
先从数据库挑 1-3 个真实身份证号,再调用接口:
```bash
python3 - <<'PY' >/tmp/bank-tag-id-cards.json
from pathlib import Path
import json, pymysql, re
text = Path('ruoyi-admin/src/main/resources/application-dev.yml').read_text(encoding='utf-8')
match = re.search(r"url:\s*jdbc:mysql://(?P<host>[^:/?#]+):(?P<port>\d+)/(?P<db>[^?\n]+).*?\n\s*username:\s*(?P<user>[^\n]+)\n\s*password:\s*(?P<pwd>[^\n]+)", text, re.S)
conn = pymysql.connect(
host=match.group('host'),
port=int(match.group('port')),
user=match.group('user').strip(),
password=match.group('pwd').strip(),
database=match.group('db').strip(),
charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor,
)
with conn, conn.cursor() as cursor:
cursor.execute("""
SELECT DISTINCT id_card
FROM ccdi_base_staff
WHERE del_flag = '0'
AND id_card IS NOT NULL
AND id_card <> ''
ORDER BY id ASC
LIMIT 3
""")
print(json.dumps([row['id_card'] for row in cursor.fetchall()], ensure_ascii=False))
PY
TOKEN=$(jq -r '.token' /tmp/bank-tag-login.json)
PROJECT_ID=<把 Task 1 选定的 project_id 填到这里>
curl -s http://localhost:62318/ccdi/file-upload/pull-bank-info \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"projectId\":${PROJECT_ID},\"idCards\":$(cat /tmp/bank-tag-id-cards.json),\"startDate\":\"2026-03-01\",\"endDate\":\"2026-03-20\"}"
```
Expected:
- 返回 `拉取任务已提交`
- 说明 Mock 随机命中链路已被主工程实际调用
- [ ] **Step 5: 触发整项目手动重算**
Run:
```bash
TOKEN=$(jq -r '.token' /tmp/bank-tag-login.json)
curl -s http://localhost:62318/ccdi/project/tags/rebuild \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"projectId\":${PROJECT_ID},\"modelCode\":null}"
```
Expected:
- 返回 `{"code":200,...}`
- 手动重算任务成功提交
- [ ] **Step 6: 轮询数据库确认重算任务成功,并查出一条新增模型命中记录**
Run:
```bash
python3 - <<'PY'
from pathlib import Path
import pymysql, re, time
PROJECT_ID = int("<把 Task 1 选定的 project_id 填到这里>")
TARGET_RULES = (
'GAMBLING_SENSITIVE_KEYWORD','SPECIAL_AMOUNT_TRANSACTION','SUSPICIOUS_INCOME_KEYWORD',
'FOREX_BUY_AMT','FOREX_SELL_AMT','LARGE_PURCHASE_TRANSACTION',
'STOCK_TFR_LARGE','WITHDRAW_CNT','LARGE_STOCK_TRADING'
)
text = Path('ruoyi-admin/src/main/resources/application-dev.yml').read_text(encoding='utf-8')
match = re.search(r"url:\s*jdbc:mysql://(?P<host>[^:/?#]+):(?P<port>\d+)/(?P<db>[^?\n]+).*?\n\s*username:\s*(?P<user>[^\n]+)\n\s*password:\s*(?P<pwd>[^\n]+)", text, re.S)
conn = pymysql.connect(
host=match.group('host'),
port=int(match.group('port')),
user=match.group('user').strip(),
password=match.group('pwd').strip(),
database=match.group('db').strip(),
charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor,
)
with conn, conn.cursor() as cursor:
task = None
for _ in range(30):
cursor.execute("""
SELECT id, status, model_code, hit_count, success_rule_count, failed_rule_count
FROM ccdi_bank_tag_task
WHERE project_id = %s
ORDER BY id DESC
LIMIT 1
""", (PROJECT_ID,))
task = cursor.fetchone()
print(task)
if task and task['status'] == 'SUCCESS':
break
time.sleep(2)
cursor.execute(f"""
SELECT id, rule_code, bank_statement_id, object_key, reason_detail
FROM ccdi_bank_statement_tag_result
WHERE project_id = %s
AND rule_code IN ({','.join(['%s'] * len(TARGET_RULES))})
ORDER BY id DESC
LIMIT 10
""", (PROJECT_ID, *TARGET_RULES))
for row in cursor.fetchall():
print(row)
PY
```
Expected:
- 最新任务状态为 `SUCCESS`
- 至少查到 1 条目标规则命中结果
- [ ] **Step 7: 用命中的 `bank_statement_id` 回查接口详情**
Run:
```bash
TOKEN=$(jq -r '.token' /tmp/bank-tag-login.json)
BANK_STATEMENT_ID=<把 Step 6 查到的 bank_statement_id 填到这里>
curl -s "http://localhost:62318/ccdi/project/bank-statement/detail/${BANK_STATEMENT_ID}" \
-H "Authorization: Bearer $TOKEN"
```
Expected:
- 返回 `code = 200`
- `data.hitTags` 中能看到至少 1 个目标规则对应的命中标签
- [ ] **Step 8: 写结论并关闭本次启动的进程**
Run:
```bash
if [ -f logs/lsfx-mock-validation.pid ]; then kill "$(cat logs/lsfx-mock-validation.pid)" || true; rm -f logs/lsfx-mock-validation.pid; fi
./bin/restart_java_backend.sh stop || true
rm -f logs/backend-validation.pid
```
Expected:
- Mock 进程已关闭
- 后端 Jar 进程已关闭
- 验证记录中明确写出“已完成进程清理”
- [ ] **Step 9: Commit**
```bash
git add docs/reports/implementation/2026-03-20-bank-tag-new-model-validation-record.md docs/tests/records/2026-03-20-bank-tag-new-model-validation-verification.md docs/tests/plans/2026-03-20-bank-tag-new-model-validation-execution-plan.md
git commit -m "补充新增模型打标完整验证记录"
```

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