Compare commits

...

5 Commits

Author SHA1 Message Date
wkc
57fd1f486d git ignore 2026-03-18 10:25:16 +08:00
wkc
52301e02c4 新增银行流水模型补齐占位设计文档 2026-03-18 10:22:38 +08:00
wkc
8f9fc09338 新增信息维护年收入字段全链路支持 2026-03-17 18:07:57 +08:00
wkc
82cb751b8f 调整季度稳定收入参数并补充UTF8执行脚本 2026-03-17 17:22:27 +08:00
wkc
88186f37a6 提交模型SQL核对与改写报告 2026-03-17 16:59:39 +08:00
44 changed files with 2235 additions and 26 deletions

2
.gitignore vendored
View File

@@ -75,4 +75,6 @@ db_config.conf
output/
logs/
ruoyi-admin/src/main/resources/logback.xml

View File

@@ -25,6 +25,7 @@
- 前端开发直接在当前分支进行,不需要额外创建 git worktree
- 测试结束后,自动关闭测试过程中启动的前后端进程
- 遇到 MCP 数据库操作时,使用项目配置文件中的数据库连接信息
- 执行包含中文内容的 MySQL SQL 脚本时,禁止直接手写 `mysql -e` 或普通重定向执行;必须优先使用 `bin/mysql_utf8_exec.sh <sql-file>`,确保会话字符集为 `utf8mb4`,避免写入乱码
---
@@ -160,6 +161,7 @@ return AjaxResult.success(result);
- 非业务字段如 `create_by``create_time` 由后端自动维护
- 前端表单不要暴露通用审计字段
- 新增菜单、字典、初始化数据时,同步补充 SQL 脚本
- 执行数据库脚本前,需确认客户端会话字符集为 `utf8mb4`;涉及中文插入、更新时默认使用 `bin/mysql_utf8_exec.sh`
### 前端规范

View File

@@ -7,7 +7,8 @@ id,project_id,model_code,model_name,param_code,param_name,param_desc,param_value
6,0,LARGE_TRANSACTION,大额交易模型,FREQUENT_TRANSFER,单笔大额转账金额,单日转账次数超过,100001,次/日
,,,,,,,,
7,0,SUSPICIOUS_PART_TIME,可疑兼职模型,MONTHLY_FIXED_INCOME,月度非本行工资收入金额,"除本行工资外,每月固定收入超过",5000,元/月
8,0,SUSPICIOUS_PART_TIME,可疑兼职模型,FIXED_COUNTERPARTY_TRANSFER,季度稳定收入金额,每季从固定交易对手转入金额,15000,元/季
8,0,SUSPICIOUS_PART_TIME,可疑兼职模型,FIXED_COUNTERPARTY_TRANSFER_MIN,季度稳定收入金额下限,每季从固定交易对手转入金额下限,3000,元/季
9,0,SUSPICIOUS_PART_TIME,可疑兼职模型,FIXED_COUNTERPARTY_TRANSFER_MAX,季度稳定收入金额上限,每季从固定交易对手转入金额上限,15000,元/季
,,,,,,,,
10,0,SUSPICIOUS_FOREIGN_EXCHANGE,可疑外汇交易模型,SINGLE_PURCHASE_AMOUNT,单笔购汇金额,单笔购汇超过该金额,50000,美元/笔
11,0,SUSPICIOUS_FOREIGN_EXCHANGE,可疑外汇交易模型,SINGLE_SETTLEMENT_AMOUNT,单笔结汇金额,单笔结汇超过该金额,50000,美元/笔
1 id project_id model_code model_name param_code param_name param_desc param_value param_unit
7 6 0 LARGE_TRANSACTION 大额交易模型 FREQUENT_TRANSFER 单笔大额转账金额 单日转账次数超过 100001 次/日
8
9 7 0 SUSPICIOUS_PART_TIME 可疑兼职模型 MONTHLY_FIXED_INCOME 月度非本行工资收入金额 除本行工资外,每月固定收入超过 5000 元/月
10 8 0 SUSPICIOUS_PART_TIME 可疑兼职模型 FIXED_COUNTERPARTY_TRANSFER FIXED_COUNTERPARTY_TRANSFER_MIN 季度稳定收入金额 季度稳定收入金额下限 每季从固定交易对手转入金额 每季从固定交易对手转入金额下限 15000 3000 元/季
11 9 0 SUSPICIOUS_PART_TIME 可疑兼职模型 FIXED_COUNTERPARTY_TRANSFER_MAX 季度稳定收入金额上限 每季从固定交易对手转入金额上限 15000 元/季
12
13 10 0 SUSPICIOUS_FOREIGN_EXCHANGE 可疑外汇交易模型 SINGLE_PURCHASE_AMOUNT 单笔购汇金额 单笔购汇超过该金额 50000 美元/笔
14 11 0 SUSPICIOUS_FOREIGN_EXCHANGE 可疑外汇交易模型 SINGLE_SETTLEMENT_AMOUNT 单笔结汇金额 单笔结汇超过该金额 50000 美元/笔

55
bin/mysql_utf8_exec.sh Executable file
View File

@@ -0,0 +1,55 @@
#!/usr/bin/env bash
set -euo pipefail
if [ "$#" -ne 1 ]; then
echo "Usage: bin/mysql_utf8_exec.sh <sql-file>" >&2
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
APP_CONFIG="${PROJECT_ROOT}/ruoyi-admin/src/main/resources/application-dev.yml"
SQL_FILE_INPUT="$1"
if [ ! -f "${APP_CONFIG}" ]; then
echo "Config file not found: ${APP_CONFIG}" >&2
exit 1
fi
if [ ! -f "${SQL_FILE_INPUT}" ]; then
echo "SQL file not found: ${SQL_FILE_INPUT}" >&2
exit 1
fi
if ! command -v mysql >/dev/null 2>&1; then
echo "mysql command not found in PATH" >&2
exit 1
fi
DB_URL="$(awk '/master:/{flag=1;next} flag && /url: jdbc:mysql:\/\//{sub(/.*url: /, ""); print; exit}' "${APP_CONFIG}")"
DB_USER="$(awk '/master:/{flag=1;next} flag && /username:/{sub(/.*username: /, ""); print; exit}' "${APP_CONFIG}")"
DB_PASS="$(awk '/master:/{flag=1;next} flag && /password:/{sub(/.*password: /, ""); print; exit}' "${APP_CONFIG}")"
if [ -z "${DB_URL}" ] || [ -z "${DB_USER}" ]; then
echo "Failed to parse database config from application-dev.yml" >&2
exit 1
fi
DB_URL_NO_PREFIX="${DB_URL#jdbc:mysql://}"
DB_HOST_PORT="${DB_URL_NO_PREFIX%%/*}"
DB_NAME="${DB_URL_NO_PREFIX#*/}"
DB_NAME="${DB_NAME%%\?*}"
DB_HOST="${DB_HOST_PORT%%:*}"
DB_PORT="${DB_HOST_PORT##*:}"
ABS_SQL_FILE="$(cd "$(dirname "${SQL_FILE_INPUT}")" && pwd)/$(basename "${SQL_FILE_INPUT}")"
MYSQL_PWD="${DB_PASS}" mysql \
-h "${DB_HOST}" \
-P "${DB_PORT}" \
-u "${DB_USER}" \
--default-character-set=utf8mb4 \
--init-command="SET NAMES utf8mb4" \
"${DB_NAME}" < "${ABS_SQL_FILE}"

View File

@@ -5,6 +5,7 @@ import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
/**
@@ -36,6 +37,9 @@ public class CcdiBaseStaff implements Serializable {
/** 电话 */
private String phone;
/** 年收入 */
private BigDecimal annualIncome;
/** 入职时间 */
private Date hireDate;

View File

@@ -8,6 +8,7 @@ import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
/**
@@ -53,6 +54,9 @@ public class CcdiStaffFmyRelation implements Serializable {
/** 手机号码2 */
private String mobilePhone2;
/** 家庭成员年收入 */
private BigDecimal annualIncome;
/** 微信名称1 */
private String wechatNo1;

View File

@@ -8,6 +8,7 @@ import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
@@ -46,6 +47,9 @@ public class CcdiBaseStaffAddDTO implements Serializable {
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "电话格式不正确")
private String phone;
/** 年收入 */
private BigDecimal annualIncome;
/** 入职时间 */
private Date hireDate;

View File

@@ -8,6 +8,7 @@ import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
@@ -45,6 +46,9 @@ public class CcdiBaseStaffEditDTO implements Serializable {
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "电话格式不正确")
private String phone;
/** 年收入 */
private BigDecimal annualIncome;
/** 入职时间 */
private Date hireDate;

View File

@@ -9,6 +9,7 @@ import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
@@ -75,6 +76,10 @@ public class CcdiStaffFmyRelationAddDTO implements Serializable {
@Schema(description = "手机号码2")
private String mobilePhone2;
/** 家庭成员年收入 */
@Schema(description = "家庭成员年收入(元/年)")
private BigDecimal annualIncome;
/** 微信名称1 */
@Size(max = 50, message = "微信名称1长度不能超过50个字符")
@Schema(description = "微信名称1")

View File

@@ -10,6 +10,7 @@ import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
@@ -81,6 +82,10 @@ public class CcdiStaffFmyRelationEditDTO implements Serializable {
@Schema(description = "手机号码2")
private String mobilePhone2;
/** 家庭成员年收入 */
@Schema(description = "家庭成员年收入(元/年)")
private BigDecimal annualIncome;
/** 微信名称1 */
@Size(max = 50, message = "微信名称1长度不能超过50个字符")
@Schema(description = "微信名称1")

View File

@@ -8,6 +8,7 @@ import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
/**
@@ -52,13 +53,18 @@ public class CcdiBaseStaffExcel implements Serializable {
@Required
private String phone;
/** 年收入 */
@ExcelProperty(value = "年收入(元/年)", index = 5)
@ColumnWidth(18)
private BigDecimal annualIncome;
/** 入职时间 */
@ExcelProperty(value = "入职时间", index = 5)
@ExcelProperty(value = "入职时间", index = 6)
@ColumnWidth(15)
private Date hireDate;
/** 状态 */
@ExcelProperty(value = "状态", index = 6)
@ExcelProperty(value = "状态", index = 7)
@ColumnWidth(10)
@DictDropdown(dictType = "ccdi_employee_status")
@Required

View File

@@ -9,6 +9,7 @@ import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
/**
@@ -77,43 +78,48 @@ public class CcdiStaffFmyRelationExcel implements Serializable {
@ColumnWidth(15)
private String mobilePhone2;
/** 家庭成员年收入 */
@ExcelProperty(value = "家庭成员年收入(元/年)", index = 9)
@ColumnWidth(18)
private BigDecimal annualIncome;
/** 微信名称1 */
@ExcelProperty(value = "微信名称1", index = 9)
@ExcelProperty(value = "微信名称1", index = 10)
@ColumnWidth(15)
private String wechatNo1;
/** 微信名称2 */
@ExcelProperty(value = "微信名称2", index = 10)
@ExcelProperty(value = "微信名称2", index = 11)
@ColumnWidth(15)
private String wechatNo2;
/** 微信名称3 */
@ExcelProperty(value = "微信名称3", index = 11)
@ExcelProperty(value = "微信名称3", index = 12)
@ColumnWidth(15)
private String wechatNo3;
/** 详细联系地址 */
@ExcelProperty(value = "详细联系地址", index = 12)
@ExcelProperty(value = "详细联系地址", index = 13)
@ColumnWidth(30)
private String contactAddress;
/** 关系详细描述 */
@ExcelProperty(value = "关系详细描述", index = 13)
@ExcelProperty(value = "关系详细描述", index = 14)
@ColumnWidth(30)
private String relationDesc;
/** 生效日期 */
@ExcelProperty(value = "生效日期", index = 14)
@ExcelProperty(value = "生效日期", index = 15)
@ColumnWidth(15)
private Date effectiveDate;
/** 失效日期 */
@ExcelProperty(value = "失效日期", index = 15)
@ExcelProperty(value = "失效日期", index = 16)
@ColumnWidth(15)
private Date invalidDate;
/** 备注 */
@ExcelProperty(value = "备注", index = 16)
@ExcelProperty(value = "备注", index = 17)
@ColumnWidth(30)
private String remark;
}

View File

@@ -4,6 +4,7 @@ import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
@@ -37,6 +38,9 @@ public class CcdiBaseStaffVO implements Serializable {
/** 电话 */
private String phone;
/** 年收入 */
private BigDecimal annualIncome;
/** 入职时间 */
private Date hireDate;

View File

@@ -6,6 +6,7 @@ import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
@@ -71,6 +72,10 @@ public class CcdiStaffFmyRelationVO implements Serializable {
@Schema(description = "手机号码2")
private String mobilePhone2;
/** 家庭成员年收入 */
@Schema(description = "家庭成员年收入(元/年)")
private BigDecimal annualIncome;
/** 微信名称1 */
@Schema(description = "微信名称1")
private String wechatNo1;

View File

@@ -3,6 +3,8 @@ package com.ruoyi.info.collection.domain.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
/**
* 导入失败记录VO
*
@@ -27,6 +29,9 @@ public class ImportFailureVO {
@Schema(description = "电话")
private String phone;
@Schema(description = "年收入")
private BigDecimal annualIncome;
@Schema(description = "状态")
private String status;

View File

@@ -3,6 +3,8 @@ package com.ruoyi.info.collection.domain.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
/**
* 员工亲属关系信息导入失败记录VO
*
@@ -41,6 +43,10 @@ public class StaffFmyRelationImportFailureVO {
@Schema(description = "手机号码1")
private String mobilePhone1;
/** 家庭成员年收入 */
@Schema(description = "家庭成员年收入")
private BigDecimal annualIncome;
/** 状态 */
@Schema(description = "状态")
private Integer status;

View File

@@ -22,6 +22,7 @@ import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@@ -356,5 +357,19 @@ public class CcdiBaseStaffImportServiceImpl implements ICcdiBaseStaffImportServi
if (!"0".equals(addDTO.getStatus()) && !"1".equals(addDTO.getStatus())) {
throw new RuntimeException("状态只能填写'在职'或'离职'");
}
validateAnnualIncome(addDTO.getAnnualIncome(), "年收入");
}
private void validateAnnualIncome(BigDecimal annualIncome, String fieldLabel) {
if (annualIncome == null) {
return;
}
if (annualIncome.compareTo(BigDecimal.ZERO) < 0) {
throw new RuntimeException(fieldLabel + "不能为负数");
}
if (annualIncome.scale() > 2) {
throw new RuntimeException(fieldLabel + "最多保留2位小数");
}
}
}

View File

@@ -22,6 +22,7 @@ import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.*;
/**
@@ -129,6 +130,7 @@ public class CcdiBaseStaffServiceImpl implements ICcdiBaseStaffService {
@Override
@Transactional
public int insertBaseStaff(CcdiBaseStaffAddDTO addDTO) {
validateAnnualIncome(addDTO.getAnnualIncome(), "年收入");
// 检查员工ID唯一性
if (baseStaffMapper.selectById(addDTO.getStaffId()) != null) {
throw new RuntimeException("该员工ID已存在");
@@ -158,6 +160,7 @@ public class CcdiBaseStaffServiceImpl implements ICcdiBaseStaffService {
@Override
@Transactional
public int updateBaseStaff(CcdiBaseStaffEditDTO editDTO) {
validateAnnualIncome(editDTO.getAnnualIncome(), "年收入");
CcdiBaseStaff existing = baseStaffMapper.selectById(editDTO.getStaffId());
if (existing == null) {
throw new RuntimeException("员工不存在");
@@ -276,4 +279,16 @@ public class CcdiBaseStaffServiceImpl implements ICcdiBaseStaffService {
return vo;
}
private void validateAnnualIncome(BigDecimal annualIncome, String fieldLabel) {
if (annualIncome == null) {
return;
}
if (annualIncome.compareTo(BigDecimal.ZERO) < 0) {
throw new RuntimeException(fieldLabel + "不能为负数");
}
if (annualIncome.scale() > 2) {
throw new RuntimeException(fieldLabel + "最多保留2位小数");
}
}
}

View File

@@ -25,6 +25,7 @@ import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@@ -338,5 +339,19 @@ public class CcdiStaffFmyRelationImportServiceImpl implements ICcdiStaffFmyRelat
throw new RuntimeException("性别只能是:男、女、其他 或 M、F、O");
}
}
validateAnnualIncome(addDTO.getAnnualIncome(), "家庭成员年收入");
}
private void validateAnnualIncome(BigDecimal annualIncome, String fieldLabel) {
if (annualIncome == null) {
return;
}
if (annualIncome.compareTo(BigDecimal.ZERO) < 0) {
throw new RuntimeException(fieldLabel + "不能为负数");
}
if (annualIncome.scale() > 2) {
throw new RuntimeException(fieldLabel + "最多保留2位小数");
}
}
}

View File

@@ -21,6 +21,7 @@ import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -121,6 +122,7 @@ public class CcdiStaffFmyRelationServiceImpl implements ICcdiStaffFmyRelationSer
@Override
@Transactional
public int insertRelation(CcdiStaffFmyRelationAddDTO addDTO) {
validateAnnualIncome(addDTO.getAnnualIncome(), "家庭成员年收入");
CcdiStaffFmyRelation relation = new CcdiStaffFmyRelation();
BeanUtils.copyProperties(addDTO, relation);
@@ -146,6 +148,7 @@ public class CcdiStaffFmyRelationServiceImpl implements ICcdiStaffFmyRelationSer
@Override
@Transactional
public int updateRelation(CcdiStaffFmyRelationEditDTO editDTO) {
validateAnnualIncome(editDTO.getAnnualIncome(), "家庭成员年收入");
CcdiStaffFmyRelation existing = relationMapper.selectById(editDTO.getId());
if (existing == null) {
throw new RuntimeException("员工亲属关系不存在");
@@ -228,4 +231,16 @@ public class CcdiStaffFmyRelationServiceImpl implements ICcdiStaffFmyRelationSer
BeanUtils.copyProperties(assetInfo, assetInfoVO);
return assetInfoVO;
}
private void validateAnnualIncome(BigDecimal annualIncome, String fieldLabel) {
if (annualIncome == null) {
return;
}
if (annualIncome.compareTo(BigDecimal.ZERO) < 0) {
throw new RuntimeException(fieldLabel + "不能为负数");
}
if (annualIncome.scale() > 2) {
throw new RuntimeException(fieldLabel + "最多保留2位小数");
}
}
}

View File

@@ -12,6 +12,7 @@
<result property="deptName" column="dept_name"/>
<result property="idCard" column="id_card"/>
<result property="phone" column="phone"/>
<result property="annualIncome" column="annual_income"/>
<result property="hireDate" column="hire_date"/>
<result property="status" column="status"/>
<result property="createTime" column="create_time"/>
@@ -19,7 +20,7 @@
<select id="selectBaseStaffPageWithDept" resultMap="CcdiBaseStaffVOResult">
SELECT
e.staff_id, e.name, e.dept_id, e.id_card, e.phone, e.hire_date, e.status, e.create_time,
e.staff_id, e.name, e.dept_id, e.id_card, e.phone, e.annual_income, e.hire_date, e.status, e.create_time,
d.dept_name
FROM ccdi_base_staff e
LEFT JOIN sys_dept d ON e.dept_id = d.dept_id
@@ -46,18 +47,19 @@
<!-- 批量插入或更新员工信息只更新非null字段 -->
<insert id="insertOrUpdateBatch" parameterType="java.util.List">
INSERT INTO ccdi_base_staff
(staff_id, name, dept_id, id_card, phone, hire_date, status,
(staff_id, name, dept_id, id_card, phone, annual_income, hire_date, status,
create_time, create_by, update_by, update_time)
VALUES
<foreach collection="list" item="item" separator=",">
(#{item.staffId}, #{item.name}, #{item.deptId}, #{item.idCard},
#{item.phone}, #{item.hireDate}, #{item.status}, NOW(),
#{item.phone}, #{item.annualIncome}, #{item.hireDate}, #{item.status}, NOW(),
#{item.createBy}, #{item.updateBy}, NOW())
</foreach>
ON DUPLICATE KEY UPDATE
name = COALESCE(VALUES(name), name),
dept_id = COALESCE(VALUES(dept_id), dept_id),
phone = COALESCE(VALUES(phone), phone),
annual_income = COALESCE(VALUES(annual_income), annual_income),
hire_date = COALESCE(VALUES(hire_date), hire_date),
status = COALESCE(VALUES(status), status),
update_by = COALESCE(VALUES(update_by), update_by),
@@ -67,12 +69,12 @@
<!-- 批量插入员工信息 -->
<insert id="insertBatch" parameterType="java.util.List">
INSERT INTO ccdi_base_staff
(staff_id, name, dept_id, id_card, phone, hire_date, status,
(staff_id, name, dept_id, id_card, phone, annual_income, hire_date, status,
create_time, create_by, update_by, update_time)
VALUES
<foreach collection="list" item="item" separator=",">
(#{item.staffId}, #{item.name}, #{item.deptId}, #{item.idCard},
#{item.phone}, #{item.hireDate}, #{item.status}, NOW(),
#{item.phone}, #{item.annualIncome}, #{item.hireDate}, #{item.status}, NOW(),
#{item.createBy}, #{item.updateBy}, NOW())
</foreach>
</insert>

View File

@@ -17,6 +17,7 @@
<result property="relationCertNo" column="relation_cert_no"/>
<result property="mobilePhone1" column="mobile_phone1"/>
<result property="mobilePhone2" column="mobile_phone2"/>
<result property="annualIncome" column="annual_income"/>
<result property="wechatNo1" column="wechat_no1"/>
<result property="wechatNo2" column="wechat_no2"/>
<result property="wechatNo3" column="wechat_no3"/>
@@ -40,7 +41,7 @@
SELECT
r.id, r.person_id, s.name as person_name, r.relation_type, r.relation_name,
r.gender, r.birth_date, r.relation_cert_type, r.relation_cert_no,
r.mobile_phone1, r.mobile_phone2, r.wechat_no1, r.wechat_no2, r.wechat_no3,
r.mobile_phone1, r.mobile_phone2, r.annual_income, r.wechat_no1, r.wechat_no2, r.wechat_no3,
r.contact_address, r.relation_desc, r.effective_date, r.invalid_date,
r.status, r.remark, r.data_source, r.is_emp_family, r.is_cust_family,
r.created_by, r.create_time, r.updated_by, r.update_time
@@ -80,7 +81,7 @@
SELECT
r.id, r.person_id, s.name as person_name, r.relation_type, r.relation_name,
r.gender, r.birth_date, r.relation_cert_type, r.relation_cert_no,
r.mobile_phone1, r.mobile_phone2, r.wechat_no1, r.wechat_no2, r.wechat_no3,
r.mobile_phone1, r.mobile_phone2, r.annual_income, r.wechat_no1, r.wechat_no2, r.wechat_no3,
r.contact_address, r.relation_desc, r.effective_date, r.invalid_date,
r.status, r.remark, r.data_source, r.is_emp_family, r.is_cust_family,
r.created_by, r.create_time, r.updated_by, r.update_time
@@ -94,7 +95,7 @@
SELECT
r.id, r.person_id, s.name as person_name, r.relation_type, r.relation_name,
r.gender, r.birth_date, r.relation_cert_type, r.relation_cert_no,
r.mobile_phone1, r.mobile_phone2, r.wechat_no1, r.wechat_no2, r.wechat_no3,
r.mobile_phone1, r.mobile_phone2, r.annual_income, r.wechat_no1, r.wechat_no2, r.wechat_no3,
r.contact_address, r.relation_desc, r.effective_date, r.invalid_date,
r.status, r.remark, r.data_source, r.is_emp_family, r.is_cust_family,
r.created_by, r.create_time, r.updated_by, r.update_time
@@ -133,14 +134,14 @@
<insert id="insertBatch">
INSERT INTO ccdi_staff_fmy_relation
(person_id, relation_type, relation_name, gender, birth_date, relation_cert_type, relation_cert_no,
mobile_phone1, mobile_phone2, wechat_no1, wechat_no2, wechat_no3, contact_address, relation_desc,
mobile_phone1, mobile_phone2, annual_income, wechat_no1, wechat_no2, wechat_no3, contact_address, relation_desc,
effective_date, invalid_date, status, remark, data_source, is_emp_family, is_cust_family,
created_by, create_time, updated_by, update_time)
VALUES
<foreach collection="list" item="item" separator=",">
(#{item.personId}, #{item.relationType}, #{item.relationName}, #{item.gender},
#{item.birthDate}, #{item.relationCertType}, #{item.relationCertNo}, #{item.mobilePhone1},
#{item.mobilePhone2}, #{item.wechatNo1}, #{item.wechatNo2}, #{item.wechatNo3}, #{item.contactAddress},
#{item.mobilePhone2}, #{item.annualIncome}, #{item.wechatNo1}, #{item.wechatNo2}, #{item.wechatNo3}, #{item.contactAddress},
#{item.relationDesc}, #{item.effectiveDate}, #{item.invalidDate}, #{item.status}, #{item.remark},
#{item.dataSource}, #{item.isEmpFamily}, #{item.isCustFamily}, #{item.createdBy}, NOW(), #{item.updatedBy}, NOW())
</foreach>

View File

@@ -0,0 +1,22 @@
package com.ruoyi.info.collection.mapper;
import org.junit.jupiter.api.Test;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CcdiBaseStaffMapperTest {
@Test
void mapperXml_shouldContainAnnualIncomeColumnsInCustomSql() throws Exception {
try (InputStream inputStream = getClass().getClassLoader()
.getResourceAsStream("mapper/info/collection/CcdiBaseStaffMapper.xml")) {
String xml = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
assertTrue(xml.contains("annual_income"), xml);
assertTrue(xml.contains("#{item.annualIncome}"), xml);
}
}
}

View File

@@ -41,6 +41,16 @@ class CcdiStaffFmyRelationMapperTest {
assertTrue(sql.contains("WHERE 1 = 1 AND r.is_emp_family = 1 AND r.person_id = ?"), sql);
assertFalse(sql.contains("1AND"), sql);
assertFalse(countSql.contains("1AND"), countSql);
assertTrue(sql.contains("r.annual_income"), sql);
}
@Test
void mapperXml_shouldContainAnnualIncomeInBatchInsert() throws Exception {
try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(RESOURCE)) {
String xml = new String(inputStream.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
assertTrue(xml.contains("annual_income"), xml);
assertTrue(xml.contains("#{item.annualIncome}"), xml);
}
}
private MappedStatement loadMappedStatement(String statementId) throws Exception {

View File

@@ -0,0 +1,57 @@
package com.ruoyi.info.collection.service;
import com.ruoyi.info.collection.domain.dto.CcdiBaseStaffAddDTO;
import com.ruoyi.info.collection.service.impl.CcdiBaseStaffImportServiceImpl;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.Collections;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class CcdiBaseStaffImportServiceImplTest {
private final CcdiBaseStaffImportServiceImpl service = new CcdiBaseStaffImportServiceImpl();
@Test
void validateStaffData_shouldAllowEmptyAnnualIncome() {
assertDoesNotThrow(() -> service.validateStaffData(buildDto(null), false, Collections.emptySet(), Collections.emptySet()));
}
@Test
void validateStaffData_shouldAllowZeroAndTwoDecimalAnnualIncome() {
assertDoesNotThrow(() -> service.validateStaffData(buildDto(new BigDecimal("0.00")), false, Collections.emptySet(), Collections.emptySet()));
assertDoesNotThrow(() -> service.validateStaffData(buildDto(new BigDecimal("12345.67")), false, Collections.emptySet(), Collections.emptySet()));
}
@Test
void validateStaffData_shouldRejectNegativeAnnualIncome() {
RuntimeException exception = assertThrows(RuntimeException.class,
() -> service.validateStaffData(buildDto(new BigDecimal("-1.00")), false, Set.of(), Set.of()));
assertEquals("年收入不能为负数", exception.getMessage());
}
@Test
void validateStaffData_shouldRejectAnnualIncomeWithMoreThanTwoDecimals() {
RuntimeException exception = assertThrows(RuntimeException.class,
() -> service.validateStaffData(buildDto(new BigDecimal("12.345")), false, Set.of(), Set.of()));
assertEquals("年收入最多保留2位小数", exception.getMessage());
}
private CcdiBaseStaffAddDTO buildDto(BigDecimal annualIncome) {
CcdiBaseStaffAddDTO dto = new CcdiBaseStaffAddDTO();
dto.setName("张三");
dto.setStaffId(1001L);
dto.setDeptId(10L);
dto.setIdCard("320101199001010014");
dto.setPhone("13812345678");
dto.setStatus("0");
dto.setAnnualIncome(annualIncome);
return dto;
}
}

View File

@@ -55,6 +55,7 @@ class CcdiBaseStaffServiceImplTest {
addDTO.setIdCard("320101199001010011");
addDTO.setPhone("13812345678");
addDTO.setStatus("0");
addDTO.setAnnualIncome(new BigDecimal("12345.67"));
addDTO.setAssetInfoList(List.of(
buildAssetDto("房产"),
buildAssetDto("车辆")
@@ -67,6 +68,9 @@ class CcdiBaseStaffServiceImplTest {
int result = service.insertBaseStaff(addDTO);
assertEquals(1, result);
ArgumentCaptor<CcdiBaseStaff> staffCaptor = ArgumentCaptor.forClass(CcdiBaseStaff.class);
verify(baseStaffMapper).insert(staffCaptor.capture());
assertEquals(new BigDecimal("12345.67"), staffCaptor.getValue().getAnnualIncome());
ArgumentCaptor<List<CcdiAssetInfoDTO>> captor = ArgumentCaptor.forClass(List.class);
verify(assetInfoService).replaceByFamilyId(eq("320101199001010011"), captor.capture());
List<CcdiAssetInfoDTO> savedAssets = captor.getValue();
@@ -88,6 +92,7 @@ class CcdiBaseStaffServiceImplTest {
editDTO.setIdCard("320101199001010011");
editDTO.setPhone("13812345678");
editDTO.setStatus("0");
editDTO.setAnnualIncome(new BigDecimal("45678.90"));
editDTO.setAssetInfoList(List.of(buildAssetDto("车辆")));
when(baseStaffMapper.selectById(1001L)).thenReturn(existing);
@@ -97,6 +102,9 @@ class CcdiBaseStaffServiceImplTest {
int result = service.updateBaseStaff(editDTO);
assertEquals(1, result);
ArgumentCaptor<CcdiBaseStaff> staffCaptor = ArgumentCaptor.forClass(CcdiBaseStaff.class);
verify(baseStaffMapper).updateById(staffCaptor.capture());
assertEquals(new BigDecimal("45678.90"), staffCaptor.getValue().getAnnualIncome());
verify(assetInfoService, never()).deleteByFamilyId("320101199001010011");
verify(assetInfoService).replaceByFamilyId("320101199001010011", editDTO.getAssetInfoList());
}
@@ -133,6 +141,7 @@ class CcdiBaseStaffServiceImplTest {
staff.setName("张三");
staff.setIdCard("320101199001010011");
staff.setStatus("0");
staff.setAnnualIncome(new BigDecimal("88888.88"));
CcdiAssetInfo assetInfo = new CcdiAssetInfo();
assetInfo.setFamilyId("320101199001010011");
@@ -149,6 +158,7 @@ class CcdiBaseStaffServiceImplTest {
CcdiBaseStaffVO result = service.selectBaseStaffById(1001L);
assertNotNull(result.getAssetInfoList());
assertEquals(new BigDecimal("88888.88"), result.getAnnualIncome());
assertEquals(1, result.getAssetInfoList().size());
assertEquals("320101199201010022", result.getAssetInfoList().get(0).getPersonId());
assertEquals("车辆", result.getAssetInfoList().get(0).getAssetMainType());

View File

@@ -0,0 +1,59 @@
package com.ruoyi.info.collection.service;
import com.ruoyi.info.collection.domain.dto.CcdiStaffFmyRelationAddDTO;
import com.ruoyi.info.collection.service.impl.CcdiStaffFmyRelationImportServiceImpl;
import org.junit.jupiter.api.Test;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class CcdiStaffFmyRelationImportServiceImplTest {
private final CcdiStaffFmyRelationImportServiceImpl service = new CcdiStaffFmyRelationImportServiceImpl();
@Test
void validateRelationData_shouldRejectNegativeAnnualIncome() throws Exception {
RuntimeException exception = assertThrows(RuntimeException.class,
() -> invokeValidateRelationData(buildDto(new BigDecimal("-1.00"))));
assertEquals("家庭成员年收入不能为负数", exception.getMessage());
}
@Test
void validateRelationData_shouldRejectAnnualIncomeWithMoreThanTwoDecimals() throws Exception {
RuntimeException exception = assertThrows(RuntimeException.class,
() -> invokeValidateRelationData(buildDto(new BigDecimal("12.345"))));
assertEquals("家庭成员年收入最多保留2位小数", exception.getMessage());
}
private void invokeValidateRelationData(CcdiStaffFmyRelationAddDTO dto) throws Exception {
Method method = CcdiStaffFmyRelationImportServiceImpl.class
.getDeclaredMethod("validateRelationData", CcdiStaffFmyRelationAddDTO.class);
method.setAccessible(true);
try {
method.invoke(service, dto);
} catch (InvocationTargetException ex) {
Throwable targetException = ex.getTargetException();
if (targetException instanceof RuntimeException runtimeException) {
throw runtimeException;
}
throw ex;
}
}
private CcdiStaffFmyRelationAddDTO buildDto(BigDecimal annualIncome) {
CcdiStaffFmyRelationAddDTO dto = new CcdiStaffFmyRelationAddDTO();
dto.setPersonId("320101199001010014");
dto.setRelationType("配偶");
dto.setRelationName("李四");
dto.setRelationCertType("护照");
dto.setRelationCertNo("A123456789");
dto.setAnnualIncome(annualIncome);
return dto;
}
}

View File

@@ -77,6 +77,7 @@ class CcdiStaffFmyRelationServiceImplTest {
addDTO.setRelationName("李四");
addDTO.setRelationCertType("护照");
addDTO.setRelationCertNo("A123456789");
addDTO.setAnnualIncome(new BigDecimal("23456.78"));
addDTO.setAssetInfoList(List.of(buildAssetDto("房产")));
when(relationMapper.insert(any(CcdiStaffFmyRelation.class))).thenReturn(1);
@@ -89,6 +90,7 @@ class CcdiStaffFmyRelationServiceImplTest {
assertEquals("MANUAL", relationCaptor.getValue().getDataSource());
assertEquals(Boolean.TRUE, relationCaptor.getValue().getIsEmpFamily());
assertEquals(Boolean.FALSE, relationCaptor.getValue().getIsCustFamily());
assertEquals(new BigDecimal("23456.78"), relationCaptor.getValue().getAnnualIncome());
verify(assetInfoService).replaceByFamilyIdAndPersonId("320101199001010011", "A123456789", addDTO.getAssetInfoList());
}
@@ -154,6 +156,7 @@ class CcdiStaffFmyRelationServiceImplTest {
editDTO.setRelationName("李四");
editDTO.setRelationCertType("护照");
editDTO.setRelationCertNo("A123456789");
editDTO.setAnnualIncome(new BigDecimal("76543.21"));
editDTO.setAssetInfoList(List.of(buildAssetDto("车辆")));
when(relationMapper.selectById(10L)).thenReturn(existing);
@@ -162,6 +165,9 @@ class CcdiStaffFmyRelationServiceImplTest {
int result = service.updateRelation(editDTO);
assertEquals(1, result);
ArgumentCaptor<CcdiStaffFmyRelation> relationCaptor = ArgumentCaptor.forClass(CcdiStaffFmyRelation.class);
verify(relationMapper).updateById(relationCaptor.capture());
assertEquals(new BigDecimal("76543.21"), relationCaptor.getValue().getAnnualIncome());
verify(assetInfoService).replaceByFamilyIdAndPersonId("320101199001010011", "A123456789", editDTO.getAssetInfoList());
}

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.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CcdiModelParamSqlDefaultsTest {
@Test
void defaultSql_shouldUseQuarterlyStableIncomeMinAndMaxParams() throws IOException {
String initSql = readProjectFile("sql", "ccdi_model_param.sql");
String updateSql = readProjectFile("sql", "2026-03-16-update-ccdi-model-param-defaults.sql");
assertQuarterlyStableIncomeRangeConfig(initSql);
assertQuarterlyStableIncomeRangeConfig(updateSql);
}
private void assertQuarterlyStableIncomeRangeConfig(String sqlContent) {
assertAll(
() -> assertTrue(sqlContent.contains("FIXED_COUNTERPARTY_TRANSFER_MIN"),
"应包含季度稳定收入金额下限参数编码"),
() -> assertTrue(sqlContent.contains("FIXED_COUNTERPARTY_TRANSFER_MAX"),
"应包含季度稳定收入金额上限参数编码"),
() -> assertTrue(sqlContent.contains("季度稳定收入金额下限"),
"应包含季度稳定收入金额下限参数名称"),
() -> assertTrue(sqlContent.contains("季度稳定收入金额上限"),
"应包含季度稳定收入金额上限参数名称"),
() -> assertTrue(sqlContent.contains("'3000'"),
"应包含季度稳定收入金额下限默认值3000"),
() -> assertTrue(sqlContent.contains("'15000'"),
"应包含季度稳定收入金额上限默认值15000"),
() -> assertFalse(sqlContent.contains("'FIXED_COUNTERPARTY_TRANSFER'"),
"不应继续保留旧的单值季度稳定收入金额参数编码")
);
}
private String readProjectFile(String... parts) throws IOException {
Path path = Path.of("..", parts);
return Files.readString(path, StandardCharsets.UTF_8);
}
}

View File

@@ -0,0 +1,34 @@
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 MysqlUtf8ExecScriptTest {
@Test
void mysqlUtf8ExecScript_shouldForceUtf8SessionAndSourceSqlFile() throws IOException {
String script = Files.readString(
Path.of("..", "bin", "mysql_utf8_exec.sh"),
StandardCharsets.UTF_8
);
assertAll(
() -> assertTrue(script.contains("application-dev.yml"),
"脚本应读取 application-dev.yml 中的数据库连接信息"),
() -> assertTrue(script.contains("--default-character-set=utf8mb4"),
"脚本应强制 mysql 客户端使用 utf8mb4"),
() -> assertTrue(script.contains("--init-command")
&& script.contains("SET NAMES utf8mb4"),
"脚本应在 mysql 会话初始化时显式执行 SET NAMES utf8mb4"),
() -> assertTrue(script.contains("< \"${ABS_SQL_FILE}\""),
"脚本应直接重定向 SQL 文件执行")
);
}
}

View File

@@ -0,0 +1,37 @@
# 信息维护年收入字段后端实施计划
## 目标
- 员工信息维护新增 `annual_income`
- 员工亲属关系维护新增 `annual_income`
- 覆盖数据库、实体、DTO/VO、Mapper、导入导出、失败记录、服务层校验
## 实施内容
1. 数据库变更
-`ccdi_base_staff` 新增 `annual_income DECIMAL(15,2) NULL`
-`ccdi_staff_fmy_relation` 新增 `annual_income DECIMAL(15,2) NULL`
- 增量脚本落库到 `sql/migration/2026-03-17-add-annual-income-to-info-maintenance.sql`
2. 员工信息链路
- 扩展 `CcdiBaseStaff``CcdiBaseStaffAddDTO``CcdiBaseStaffEditDTO``CcdiBaseStaffVO`
- 扩展 `CcdiBaseStaffExcel``ImportFailureVO`
- 更新 `CcdiBaseStaffMapper.xml` 的分页查询、批量插入、批量更新 SQL
-`CcdiBaseStaffServiceImpl``CcdiBaseStaffImportServiceImpl` 增加非负且最多两位小数校验
3. 员工亲属关系链路
- 扩展 `CcdiStaffFmyRelation``CcdiStaffFmyRelationAddDTO``CcdiStaffFmyRelationEditDTO``CcdiStaffFmyRelationVO`
- 扩展 `CcdiStaffFmyRelationExcel``StaffFmyRelationImportFailureVO`
- 更新 `CcdiStaffFmyRelationMapper.xml` 的列表、详情、导出、批量插入 SQL
-`CcdiStaffFmyRelationServiceImpl``CcdiStaffFmyRelationImportServiceImpl` 增加非负且最多两位小数校验
## 测试
- `CcdiBaseStaffServiceImplTest`
- `CcdiStaffFmyRelationServiceImplTest`
- `CcdiBaseStaffImportServiceImplTest`
- `CcdiStaffFmyRelationImportServiceImplTest`
- `CcdiBaseStaffMapperTest`
- `CcdiStaffFmyRelationMapperTest`
## 验收标准
- 列表、详情、导出和导入都能透传 `annualIncome`
- 服务层可拦截负数与超过两位小数
- 失败记录可返回原始年收入值

View File

@@ -0,0 +1,38 @@
# 信息维护年收入字段前端实施计划
## 目标
- 在员工信息维护页面新增“年收入”
- 在员工亲属关系维护页面新增“家庭成员年收入”
- 保持现有页面结构和接口调用方式不变
## 实施内容
1. 员工信息维护页
- 列表新增“年收入”列
- 新增/编辑弹窗新增“年收入”输入框
- 详情弹窗展示“年收入”
- 导入失败记录弹窗新增“年收入”列
- 提交前做空值兼容与金额格式校验
2. 员工亲属关系维护页
- 列表新增“家庭成员年收入”列
- 新增/编辑弹窗新增“家庭成员年收入”输入框
- 详情弹窗展示“家庭成员年收入”
- 导入失败记录弹窗新增“家庭成员年收入”列
- 提交前做空值兼容与金额格式校验
3. 前端校验规则
- 允许空值
- 非空时仅允许非负金额
- 最多保留两位小数
- 不做单位换算,直接按“元/年”展示和提交
## 测试
- `ruoyi-ui/tests/unit/employee-asset-submit-flow.test.js`
- `ruoyi-ui/tests/unit/employee-asset-maintenance-layout.test.js`
- `ruoyi-ui/tests/unit/staff-family-asset-submit-flow.test.js`
- `ruoyi-ui/tests/unit/staff-family-asset-maintenance-layout.test.js`
## 验收标准
- 两个页面都能新增、编辑、查看年收入字段
- 历史空值数据页面正常展示
- 前端能阻止非法金额提交

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,286 @@
# 银行流水模型补齐占位设计
## 背景
当前项目的银行流水打标能力已经具备基础框架,包括:
- 规则定义表 `ccdi_bank_tag_rule`
- 结果表 `ccdi_bank_statement_tag_result`
- 任务表 `ccdi_bank_tag_task`
- 规则执行入口 `CcdiBankTagServiceImpl`
- 技术口径 SQL `CcdiBankTagAnalysisMapper.xml`
现阶段仅落地了“大额交易”模型下的 8 条规则。根据 [assets/模型信息.xlsx](../../../assets/模型信息.xlsx)其余模型规则尚未补齐到现有打标框架中导致规则元数据、执行分发、XML SQL 坑位与参数配置之间不完整。
本次需求是基于现有银行流水模型打标能力,补齐尚未添加的模型规则,并自动填充必要字段。由于这些规则的正式 SQL 尚未准备好,本期先在 XML 中预留规则级 SQL 位置,并使用恒不命中的假 SQL 保证执行时不报错。
## 目标
-`assets/模型信息.xlsx` 补齐现有缺失的模型规则
- 保证新增规则进入现有打标执行框架,可被统一调度执行
- 对每条新增规则预留独立的 XML SQL 坑位,便于后续逐条替换为真实 SQL
- 在缺少正式 SQL 的阶段,保证规则执行返回空结果,不影响现有任务稳定性
- 自动补齐规则初始化所需的最小字段,减少手工维护成本
## 非目标
- 本期不编写真实业务 SQL
- 本期不调整现有 8 条“大额交易”规则的编码、参数与 SQL 逻辑
- 本期不新增前端页面或结果展示逻辑
- 本期不为 Excel 中缺少英文指标名的规则补充参数配置
- 本期不引入动态 SQL 配置能力
## 现状分析
### 现有规则实现范围
当前 `CcdiBankTagServiceImpl` 仅实现以下规则分发:
- `HOUSE_OR_CAR_EXPENSE`
- `TAX_EXPENSE`
- `SINGLE_LARGE_INCOME`
- `CUMULATIVE_INCOME`
- `ANNUAL_TURNOVER`
- `LARGE_CASH_DEPOSIT`
- `FREQUENT_CASH_DEPOSIT`
- `LARGE_TRANSFER`
这些规则均属于 `LARGE_TRANSACTION` 模型。
### 现有参数配置范围
当前默认参数脚本中已经包含以下模型:
- `LARGE_TRANSACTION`
- `SUSPICIOUS_PART_TIME`
- `SUSPICIOUS_FOREIGN_EXCHANGE`
- `ABNORMAL_BEHAVIOR`
- `SUSPICIOUS_GAMBLING`
但其中多数模型只补了参数,尚未补规则元数据和打标执行入口。
### Excel 规则范围
`assets/模型信息.xlsx` 共 33 条规则,其中:
- 已落地 8 条
- 待补齐 25 条
涉及以下模型组:
- 异常交易
- 疑似赌博
- 可疑关系
- 可疑兼职
- 可疑财产
- 可疑外汇交易
- 可疑付息
- 可疑采购
- 异常行为
## 方案对比
### 方案一:仅补规则初始化 SQL
优点:
- 改动最少
缺点:
- Java 分发与 XML 坑位仍然缺失
- 后续接入真实 SQL 时仍需再次补完整链路
- 无法满足“先留好位置”的目标
### 方案二补规则元数据、Java 分发与 XML 占位 SQL
优点:
- 与现有大额交易模型实现方式一致
- 每条规则都有独立执行入口与独立 SQL 坑位
- 后续替换真实 SQL 时只需改对应 XML 或局部参数映射
- 风险可控,不会产生误命中数据
缺点:
- 本期一次性需要补齐较多占位方法与规则定义
### 方案三:新增统一空实现兜底
优点:
- 代码量更少
缺点:
- 规则边界不清晰
- 后续逐条补 SQL 时定位成本更高
- 不利于维护和评审
## 最终方案
采用方案二:
- 补齐规则元数据
- 补齐 `CcdiBankTagAnalysisMapper` 方法定义
- 补齐 `CcdiBankTagServiceImpl` 的规则分发
-`CcdiBankTagAnalysisMapper.xml` 中为每条新规则增加单独的占位 SQL
- 对缺少正式 SQL 的规则统一返回空结果,保证执行成功但不命中
## 编码设计
### 模型编码映射
新增模型统一映射为稳定的 `model_code`
- `异常交易` -> `ABNORMAL_TRANSACTION`
- `疑似赌博` -> `SUSPICIOUS_GAMBLING`
- `可疑关系` -> `SUSPICIOUS_RELATION`
- `可疑兼职` -> `SUSPICIOUS_PART_TIME`
- `可疑财产` -> `SUSPICIOUS_PROPERTY`
- `可疑外汇交易` -> `SUSPICIOUS_FOREIGN_EXCHANGE`
- `可疑付息` -> `SUSPICIOUS_INTEREST_PAYMENT`
- `可疑采购` -> `SUSPICIOUS_PURCHASE`
- `异常行为` -> `ABNORMAL_BEHAVIOR`
已在参数表中使用的模型编码保持不变,避免后续参数关联断裂。
### 规则编码生成
规则编码采用以下优先级:
1. 已有现存规则编码的,保持不变
2. Excel 提供了明确英文指标名且适合复用为规则编码的,直接使用英文指标名
3. Excel 未提供英文指标名的,使用稳定占位编码
占位编码格式:
- `<MODEL_CODE>_<两位序号>`
示例:
- `ABNORMAL_TRANSACTION_01`
- `SUSPICIOUS_PROPERTY_03`
这样可确保编码稳定、可批量生成、可回溯到 Excel 顺序,同时避免为无英文名规则主观造词。
### 自动补齐字段规则
- `model_code`:由中文模型名映射生成
- `model_name`:使用 Excel 中的模型名称
- `rule_code`:按上述规则生成
- `rule_name`:使用“核心异常点(展示在前端页面)”
- `indicator_code`:有英文指标名则写入,无则留空
- `business_caliber`:使用 Excel 中“业务口径”
- `result_type`
- `可疑结果返回` 包含“流水明细” -> `STATEMENT`
- 其他返回形式 -> `OBJECT`
- `risk_level`
- `高风险` -> `HIGH`
- `一般` -> `GENERAL`
- 空值 -> `NULL`
## 参数配置策略
只为 Excel 中明确给出英文指标名、且明显属于阈值参数的规则补默认参数。
处理原则:
- 有英文指标名且已存在默认参数的,保持现状
- 有英文指标名但默认参数缺失的,补充默认参数初始化数据
- 无英文指标名的,不新增参数记录
- `BankTagRuleConfigResolver` 仅补需要参数解析的规则映射
本期默认假设:
- 无英文指标名规则不依赖参数配置
-`indicator_code` 可为空
## 占位 SQL 设计
### 设计原则
- 每条规则拥有单独 `<select>`,不共用通用 SQL
- SQL 必须可被 MyBatis 正常解析和执行
- SQL 必须返回与结果映射一致的字段结构
- SQL 必须恒不命中,避免生成伪造结果
### STATEMENT 规则占位模板
对流水级结果使用如下结构:
```sql
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
```
### OBJECT 规则占位模板
对对象级结果使用如下结构:
```sql
select
'STAFF_ID_CARD' AS objectType,
'' AS objectKey,
'占位SQL待补充真实规则' AS reasonDetail
from dual
where 1 = 0
```
如果数据库兼容性不适合 `dual`,则改为从现有业务表取空结果,核心要求是不报错且字段齐全。
## 详细变更范围
### 后端代码
需要修改以下文件:
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapper.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImpl.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/BankTagRuleConfigResolver.java`
- `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml`
### SQL 脚本
需要修改以下文件:
- `sql/2026-03-16-bank-tagging.sql`
- `sql/ccdi_model_param.sql`
- `sql/2026-03-16-update-ccdi-model-param-defaults.sql`
## 兼容性与风险控制
- 现有 8 条大额交易规则不改编码、不改 SQL、不改参数映射
- 新增规则即使被执行,也只会返回空结果
- 不会写入脏数据到 `ccdi_bank_statement_tag_result`
- 不会因为找不到 Mapper 方法或 XML 语句导致任务失败
- 无参数规则不会加入必填参数集合,避免产生无意义缺参告警
## 验证方案
最小验证包括:
- 编译通过,确保 Java 接口与 XML 绑定一致
- 触发打标任务时,不出现 `Invalid bound statement` 或参数解析异常
- 现有 8 条规则仍保持原有执行行为
- 新增占位规则执行后返回空结果,不新增命中数据
建议执行:
- `mvn -pl ccdi-project -am test`
- 如测试范围过大,至少执行 `mvn -pl ccdi-project -am compile`
## 实施边界
本期完成后,系统将具备“规则骨架完整、真实 SQL 待补”的状态。后续每补一条真实规则时,只需要:
1. 替换对应 XML 占位 SQL
2. 如需阈值参数,再补 `BankTagRuleConfigResolver` 映射或默认参数
3. 按规则粒度补测试
无需再次调整规则框架、结果表、任务调度和规则定义结构。

Binary file not shown.

View File

@@ -115,6 +115,7 @@
<el-table-column label="身份证号" align="center" prop="idCard" :show-overflow-tooltip="true"/>
<el-table-column label="所属部门" align="center" prop="deptName" :show-overflow-tooltip="true"/>
<el-table-column label="电话" align="center" prop="phone" width="120"/>
<el-table-column label="年收入" align="center" prop="annualIncome" width="140"/>
<el-table-column label="状态" align="center" prop="status" width="100">
<template slot-scope="scope">
<el-tag v-if="scope.row.status === '0'" type="success">在职</el-tag>
@@ -204,11 +205,19 @@
<el-input v-model="form.phone" placeholder="请输入电话" maxlength="11" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="年收入" prop="annualIncome">
<el-input v-model="form.annualIncome" placeholder="请输入年收入(元/年)" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="入职时间" prop="hireDate">
<el-date-picker v-model="form.hireDate" type="date" placeholder="选择入职时间" value-format="yyyy-MM-dd" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12" />
</el-row>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
@@ -318,6 +327,9 @@
<el-descriptions-item label="所属部门">{{ employeeDetail.deptName || '-' }}</el-descriptions-item>
<el-descriptions-item label="身份证号">{{ employeeDetail.idCard || '-' }}</el-descriptions-item>
<el-descriptions-item label="电话">{{ employeeDetail.phone || '-' }}</el-descriptions-item>
<el-descriptions-item label="年收入">
{{ employeeDetail.annualIncome !== null && employeeDetail.annualIncome !== undefined && employeeDetail.annualIncome !== '' ? employeeDetail.annualIncome : '-' }}
</el-descriptions-item>
<el-descriptions-item label="入职时间">
{{ employeeDetail.hireDate ? parseTime(employeeDetail.hireDate, '{y}-{m}-{d}') : '-' }}
</el-descriptions-item>
@@ -453,6 +465,7 @@
<el-table-column label="柜员号" prop="staffId" align="center" />
<el-table-column label="身份证号" prop="idCard" align="center" />
<el-table-column label="电话" prop="phone" align="center" />
<el-table-column label="年收入" prop="annualIncome" align="center" width="140" />
<el-table-column label="失败原因" prop="errorMessage" align="center" min-width="200" :show-overflow-tooltip="true" />
</el-table>
@@ -606,6 +619,9 @@ export default {
{ required: true, message: "电话不能为空", trigger: "blur" },
{ pattern: phonePattern, message: "请输入正确的11位手机号", trigger: "blur" }
],
annualIncome: [
{ validator: (rule, value, callback) => this.validateAnnualIncomeRule(value, callback, "年收入"), trigger: "blur" }
],
status: [
{ required: true, message: "请选择状态", trigger: "change" }
]
@@ -938,6 +954,7 @@ export default {
deptId: null,
idCard: null,
phone: null,
annualIncome: null,
hireDate: null,
status: "0",
relatives: [],
@@ -945,6 +962,36 @@ export default {
};
this.resetForm("form");
},
normalizeAnnualIncome(value) {
if (value === null || value === undefined) {
return null;
}
const normalized = String(value).trim();
return normalized === "" ? null : normalized;
},
validateAnnualIncome(value, fieldLabel = "年收入") {
const normalized = this.normalizeAnnualIncome(value);
if (normalized === null) {
return true;
}
if (!/^\d+(\.\d{1,2})?$/.test(normalized)) {
this.$modal.msgError(`${fieldLabel}格式不正确需为非负金额且最多保留2位小数`);
return false;
}
return true;
},
validateAnnualIncomeRule(value, callback, fieldLabel = "年收入") {
const normalized = this.normalizeAnnualIncome(value);
if (normalized === null) {
callback();
return;
}
if (!/^\d+(\.\d{1,2})?$/.test(normalized)) {
callback(new Error(`${fieldLabel}格式不正确需为非负金额且最多保留2位小数`));
return;
}
callback();
},
normalizeAssetInfoList() {
const assetInfoList = Array.isArray(this.form.assetInfoList)
? this.form.assetInfoList
@@ -1114,6 +1161,7 @@ export default {
...response.data,
assetInfoList: response.data.assetInfoList || []
};
this.form.annualIncome = response.data.annualIncome;
this.open = true;
this.title = "编辑员工";
});
@@ -1123,18 +1171,27 @@ export default {
this.$refs["form"].validate(valid => {
if (valid) {
this.form.assetInfoList = this.normalizeAssetInfoList();
if (!this.validateAssetInfoList(this.form.assetInfoList)) {
const payload = {
...this.form,
assetInfoList: this.form.assetInfoList
};
payload.annualIncome = this.normalizeAnnualIncome(payload.annualIncome);
this.form.assetInfoList = payload.assetInfoList;
if (!this.validateAnnualIncome(payload.annualIncome, "年收入")) {
return;
}
if (!this.validateAssetInfoList(payload.assetInfoList)) {
return;
}
if (this.isAdd) {
addBaseStaff(this.form).then(response => {
addBaseStaff(payload).then(response => {
this.$modal.msgSuccess("新增成功");
this.open = false;
this.isAdd = false;
this.getList();
});
} else {
updateBaseStaff(this.form).then(response => {
updateBaseStaff(payload).then(response => {
this.$modal.msgSuccess("修改成功");
this.open = false;
this.getList();

View File

@@ -123,6 +123,7 @@
</template>
</el-table-column>
<el-table-column label="关系人姓名" align="center" prop="relationName" :show-overflow-tooltip="true"/>
<el-table-column label="家庭成员年收入" align="center" prop="annualIncome" width="160"/>
<el-table-column label="性别" align="center" prop="gender" width="80">
<template slot-scope="scope">
<dict-tag :options="dict.type.ccdi_indiv_gender" :value="scope.row.gender"/>
@@ -282,6 +283,13 @@
<el-input v-model="form.mobilePhone2" placeholder="请输入手机号码2" maxlength="20" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="家庭成员年收入" prop="annualIncome">
<el-input v-model="form.annualIncome" placeholder="请输入家庭成员年收入(元/年)" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="微信名称1" prop="wechatNo1">
<el-input v-model="form.wechatNo1" placeholder="请输入微信名称1" maxlength="50" />
@@ -466,6 +474,9 @@
<el-descriptions-item label="出生日期">{{ relationDetail.birthDate || '-' }}</el-descriptions-item>
<el-descriptions-item label="手机号码1">{{ relationDetail.mobilePhone1 || '-' }}</el-descriptions-item>
<el-descriptions-item label="手机号码2">{{ relationDetail.mobilePhone2 || '-' }}</el-descriptions-item>
<el-descriptions-item label="家庭成员年收入">
{{ relationDetail.annualIncome !== null && relationDetail.annualIncome !== undefined && relationDetail.annualIncome !== '' ? relationDetail.annualIncome : '-' }}
</el-descriptions-item>
<el-descriptions-item label="微信名称1">{{ relationDetail.wechatNo1 || '-' }}</el-descriptions-item>
<el-descriptions-item label="微信名称2">{{ relationDetail.wechatNo2 || '-' }}</el-descriptions-item>
<el-descriptions-item label="微信名称3">{{ relationDetail.wechatNo3 || '-' }}</el-descriptions-item>
@@ -616,6 +627,7 @@
<el-table-column label="员工身份证号" prop="personId" align="center" width="180"/>
<el-table-column label="关系类型" prop="relationType" align="center" width="100"/>
<el-table-column label="关系人姓名" prop="relationName" align="center" width="120"/>
<el-table-column label="家庭成员年收入" prop="annualIncome" align="center" width="160"/>
<el-table-column label="失败原因" prop="errorMessage" align="center" min-width="200" :show-overflow-tooltip="true" />
</el-table>
@@ -766,6 +778,9 @@ export default {
],
mobilePhone2: [
{ pattern: /^1[3-9]\d{9}$/, message: "请输入正确的手机号码", trigger: "blur" }
],
annualIncome: [
{ validator: (rule, value, callback) => this.validateAnnualIncomeRule(value, callback, "家庭成员年收入"), trigger: "blur" }
]
},
// 导入参数
@@ -963,6 +978,7 @@ export default {
relationCertNo: null,
mobilePhone1: null,
mobilePhone2: null,
annualIncome: null,
wechatNo1: null,
wechatNo2: null,
wechatNo3: null,
@@ -977,6 +993,36 @@ export default {
this.staffOptions = [];
this.resetForm("form");
},
normalizeAnnualIncome(value) {
if (value === null || value === undefined) {
return null;
}
const normalized = String(value).trim();
return normalized === "" ? null : normalized;
},
validateAnnualIncome(value, fieldLabel = "家庭成员年收入") {
const normalized = this.normalizeAnnualIncome(value);
if (normalized === null) {
return true;
}
if (!/^\d+(\.\d{1,2})?$/.test(normalized)) {
this.$modal.msgError(`${fieldLabel}格式不正确需为非负金额且最多保留2位小数`);
return false;
}
return true;
},
validateAnnualIncomeRule(value, callback, fieldLabel = "家庭成员年收入") {
const normalized = this.normalizeAnnualIncome(value);
if (normalized === null) {
callback();
return;
}
if (!/^\d+(\.\d{1,2})?$/.test(normalized)) {
callback(new Error(`${fieldLabel}格式不正确需为非负金额且最多保留2位小数`));
return;
}
callback();
},
createEmptyAssetRow() {
return {
assetMainType: "",
@@ -1098,6 +1144,7 @@ export default {
...response.data,
assetInfoList: response.data.assetInfoList || []
};
this.form.annualIncome = response.data.annualIncome;
// 加载员工信息以支持下拉显示
if (this.form.personId) {
this.searchStaff(this.form.personId);
@@ -1131,6 +1178,10 @@ export default {
...this.form,
assetInfoList: this.form.assetInfoList.map(item => ({ ...item }))
};
payload.annualIncome = this.normalizeAnnualIncome(payload.annualIncome);
if (!this.validateAnnualIncome(payload.annualIncome, "家庭成员年收入")) {
return;
}
payload.assetInfoList.forEach((asset, index) => {
delete payload.assetInfoList[index].familyId;
delete payload.assetInfoList[index].personId;

View File

@@ -9,6 +9,9 @@ const componentPath = path.resolve(
const source = fs.readFileSync(componentPath, "utf8");
[
"年收入",
"v-model=\"form.annualIncome\"",
"employeeDetail.annualIncome",
"<el-dialog :title=\"title\" :visible.sync=\"open\" width=\"80%\"",
"<el-dialog title=\"员工详情\" :visible.sync=\"detailOpen\" width=\"80%\"",
"资产信息",

View File

@@ -9,6 +9,13 @@ const componentPath = path.resolve(
const source = fs.readFileSync(componentPath, "utf8");
[
"annualIncome: null",
"label=\"年收入\"",
"v-model=\"form.annualIncome\"",
"this.form.annualIncome = response.data.annualIncome",
"payload.annualIncome = this.normalizeAnnualIncome(payload.annualIncome);",
"normalizeAnnualIncome(value)",
"validateAnnualIncome(value, fieldLabel = \"年收入\")",
"assetInfoList: []",
"normalizeAssetInfoList()",
"this.form.assetInfoList = [];",

View File

@@ -9,6 +9,9 @@ const componentPath = path.resolve(
const source = fs.readFileSync(componentPath, "utf8");
[
"家庭成员年收入",
"v-model=\"form.annualIncome\"",
"relationDetail.annualIncome",
"亲属资产信息",
"新增资产",
"handleAddAsset()",

View File

@@ -9,6 +9,13 @@ const componentPath = path.resolve(
const source = fs.readFileSync(componentPath, "utf8");
[
"annualIncome: null",
"label=\"家庭成员年收入\"",
"v-model=\"form.annualIncome\"",
"this.form.annualIncome = response.data.annualIncome",
"payload.annualIncome = this.normalizeAnnualIncome(payload.annualIncome);",
"normalizeAnnualIncome(value)",
"validateAnnualIncome(value, fieldLabel = \"家庭成员年收入\")",
"assetInfoList: []",
"normalizeAssetInfoList()",
"response.data.assetInfoList || []",

View File

@@ -23,7 +23,8 @@ INSERT INTO ccdi_model_param (
(0, 'LARGE_TRANSACTION', '大额交易模型', 'FREQUENT_CASH_DEPOSIT', '单日多次存现次数', '24小时内累计存现超过', '5', '', 5, 'admin', '系统默认参数'),
(0, 'LARGE_TRANSACTION', '大额交易模型', 'FREQUENT_TRANSFER', '单笔大额转账金额', '单日转账次数超过', '100001', '次/日', 6, 'admin', '系统默认参数'),
(0, 'SUSPICIOUS_PART_TIME', '可疑兼职模型', 'MONTHLY_FIXED_INCOME', '月度非本行工资收入金额', '除本行工资外,每月固定收入超过', '5000', '元/月', 1, 'admin', '系统默认参数'),
(0, 'SUSPICIOUS_PART_TIME', '可疑兼职模型', 'FIXED_COUNTERPARTY_TRANSFER', '季度稳定收入金额', '每季从固定交易对手转入金额', '15000', '元/季', 2, 'admin', '系统默认参数'),
(0, 'SUSPICIOUS_PART_TIME', '可疑兼职模型', 'FIXED_COUNTERPARTY_TRANSFER_MIN', '季度稳定收入金额下限', '每季从固定交易对手转入金额下限', '3000', '元/季', 2, 'admin', '系统默认参数'),
(0, 'SUSPICIOUS_PART_TIME', '可疑兼职模型', 'FIXED_COUNTERPARTY_TRANSFER_MAX', '季度稳定收入金额上限', '每季从固定交易对手转入金额上限', '15000', '元/季', 3, 'admin', '系统默认参数'),
(0, 'SUSPICIOUS_FOREIGN_EXCHANGE', '可疑外汇交易模型', 'SINGLE_PURCHASE_AMOUNT', '单笔购汇金额', '单笔购汇超过该金额', '50000', '美元/笔', 1, 'admin', '系统默认参数'),
(0, 'SUSPICIOUS_FOREIGN_EXCHANGE', '可疑外汇交易模型', 'SINGLE_SETTLEMENT_AMOUNT', '单笔结汇金额', '单笔结汇超过该金额', '50000', '美元/笔', 2, 'admin', '系统默认参数'),
(0, 'SUSPICIOUS_FOREIGN_EXCHANGE', '可疑外汇交易模型', 'CROSS_BORDER_REMITTANCE', '跨境汇款金额', '跨境汇款金额超过', '200000', '美元/笔', 3, 'admin', '系统默认参数'),

View File

@@ -35,7 +35,8 @@ INSERT INTO ccdi_model_param (project_id, model_code, model_name, param_code, pa
(0, 'LARGE_TRANSACTION', '大额交易模型', 'FREQUENT_CASH_DEPOSIT', '单日多次存现次数', '24小时内累计存现超过', '5', '', 5, 'admin', '系统默认参数'),
(0, 'LARGE_TRANSACTION', '大额交易模型', 'FREQUENT_TRANSFER', '单笔大额转账金额', '单日转账次数超过', '100001', '次/日', 6, 'admin', '系统默认参数'),
(0, 'SUSPICIOUS_PART_TIME', '可疑兼职模型', 'MONTHLY_FIXED_INCOME', '月度非本行工资收入金额', '除本行工资外,每月固定收入超过', '5000', '元/月', 1, 'admin', '系统默认参数'),
(0, 'SUSPICIOUS_PART_TIME', '可疑兼职模型', 'FIXED_COUNTERPARTY_TRANSFER', '季度稳定收入金额', '每季从固定交易对手转入金额', '15000', '元/季', 2, 'admin', '系统默认参数'),
(0, 'SUSPICIOUS_PART_TIME', '可疑兼职模型', 'FIXED_COUNTERPARTY_TRANSFER_MIN', '季度稳定收入金额下限', '每季从固定交易对手转入金额下限', '3000', '元/季', 2, 'admin', '系统默认参数'),
(0, 'SUSPICIOUS_PART_TIME', '可疑兼职模型', 'FIXED_COUNTERPARTY_TRANSFER_MAX', '季度稳定收入金额上限', '每季从固定交易对手转入金额上限', '15000', '元/季', 3, 'admin', '系统默认参数'),
(0, 'SUSPICIOUS_FOREIGN_EXCHANGE', '可疑外汇交易模型', 'SINGLE_PURCHASE_AMOUNT', '单笔购汇金额', '单笔购汇超过该金额', '50000', '美元/笔', 1, 'admin', '系统默认参数'),
(0, 'SUSPICIOUS_FOREIGN_EXCHANGE', '可疑外汇交易模型', 'SINGLE_SETTLEMENT_AMOUNT', '单笔结汇金额', '单笔结汇超过该金额', '50000', '美元/笔', 2, 'admin', '系统默认参数'),
(0, 'SUSPICIOUS_FOREIGN_EXCHANGE', '可疑外汇交易模型', 'CROSS_BORDER_REMITTANCE', '跨境汇款金额', '跨境汇款金额超过', '200000', '美元/笔', 3, 'admin', '系统默认参数'),

View File

@@ -0,0 +1,31 @@
SET @base_staff_annual_income_sql = IF(
EXISTS(
SELECT 1
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'ccdi_base_staff'
AND column_name = 'annual_income'
),
'SELECT 1',
'ALTER TABLE `ccdi_base_staff` ADD COLUMN `annual_income` DECIMAL(15, 2) NULL COMMENT ''年收入(元/年)'' AFTER `phone`'
);
PREPARE base_staff_annual_income_stmt FROM @base_staff_annual_income_sql;
EXECUTE base_staff_annual_income_stmt;
DEALLOCATE PREPARE base_staff_annual_income_stmt;
SET @staff_fmy_relation_annual_income_sql = IF(
EXISTS(
SELECT 1
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'ccdi_staff_fmy_relation'
AND column_name = 'annual_income'
),
'SELECT 1',
'ALTER TABLE `ccdi_staff_fmy_relation` ADD COLUMN `annual_income` DECIMAL(15, 2) NULL COMMENT ''家庭成员年收入(元/年)'' AFTER `mobile_phone2`'
);
PREPARE staff_fmy_relation_annual_income_stmt FROM @staff_fmy_relation_annual_income_sql;
EXECUTE staff_fmy_relation_annual_income_stmt;
DEALLOCATE PREPARE staff_fmy_relation_annual_income_stmt;