deep-risk/backend/app/services/risk_detection/algorithms/private_account.py
2025-12-14 20:08:27 +08:00

323 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
私户收款检测算法
检测银行流水是否存在向个人账户转账的风险
"""
from typing import Dict, Any, List, Optional
from datetime import datetime, timedelta
from sqlalchemy import select, func, and_, or_
from sqlalchemy.ext.asyncio import AsyncSession
from loguru import logger
from .base import (
RiskDetectionAlgorithm,
DetectionContext,
DetectionResult,
RiskEvidence,
)
from app.models.risk_detection import RiskLevel
from app.models.bank_transaction import BankTransaction
class PrivateAccountDetectionAlgorithm(RiskDetectionAlgorithm):
"""私户收款检测算法"""
def get_algorithm_code(self) -> str:
return "PRIVATE_ACCOUNT_DETECTION"
def get_algorithm_name(self) -> str:
return "私户收款检测"
def get_description(self) -> str:
return (
"通过分析银行流水记录,"
"检测是否存在向个人账户转账、使用私人账户收款等违规行为"
)
async def _do_detect(self, context: DetectionContext) -> DetectionResult:
"""执行私户收款检测"""
# 获取参数
account_no = context.get_parameter("account_no")
period = context.get_parameter("period") # 格式YYYY-MM
threshold_amount = context.get_parameter("threshold_amount", 10000) # 阈值金额
if not account_no or not period:
return self._create_error_result(context, "缺少必要参数account_no 或 period")
db_session = context.db_session
if not db_session:
return self._create_error_result(context, "缺少数据库会话")
try:
# 获取银行流水
transactions = await self._get_bank_transactions(
db_session, account_no, period
)
# 分析私户收款情况
private_transfers = self._analyze_private_transfers(
transactions, threshold_amount
)
# 计算风险指标
total_private_amount = sum(t["amount"] for t in private_transfers)
private_count = len(private_transfers)
total_inflow = sum(
t["amount"]
for t in transactions
if t["transaction_type"] == "转入"
)
private_ratio = (
(total_private_amount / total_inflow * 100)
if total_inflow > 0
else 0
)
# 判断风险等级
risk_level, risk_score = self._calculate_risk_level(
private_ratio, private_count, total_private_amount, threshold_amount
)
# 生成风险描述和建议
description, suggestion = self._generate_risk_description(
private_ratio, private_count, total_private_amount, total_inflow
)
# 创建检测结果
result = DetectionResult(
task_id=context.task_id,
rule_id=context.rule_id,
entity_id=account_no,
entity_type="bank_account",
risk_level=risk_level,
risk_score=risk_score,
description=description,
suggestion=suggestion,
risk_data={
"total_transactions": len(transactions),
"private_transfers": private_transfers,
"private_count": private_count,
"total_private_amount": total_private_amount,
"total_inflow": total_inflow,
"private_ratio": private_ratio,
"threshold_amount": threshold_amount,
"period": period,
},
)
# 添加证据
result.add_evidence(RiskEvidence(
evidence_type="account_summary",
description=f"账户 {account_no}{period} 期间共有 {len(transactions)} 笔交易",
data={
"account_no": account_no,
"period": period,
"total_transactions": len(transactions),
},
))
result.add_evidence(RiskEvidence(
evidence_type="private_transfer_summary",
description=f"检测到 {private_count} 笔私人账户转账,总金额 {total_private_amount:,.2f}",
data={
"count": private_count,
"total_amount": total_private_amount,
"ratio": private_ratio,
},
))
# 添加主要违规记录作为证据
for transfer in private_transfers[:5]: # 只记录前5条
result.add_evidence(RiskEvidence(
evidence_type="private_transfer_detail",
description=f"{transfer['date']}: {transfer['counterparty']} - {transfer['amount']:,.2f}",
data=transfer,
))
return result
except Exception as e:
logger.error(f"私户收款检测执行失败: {str(e)}", exc_info=True)
return self._create_error_result(context, f"检测执行失败: {str(e)}")
async def _get_bank_transactions(
self,
db_session: AsyncSession,
account_no: str,
period: str,
) -> List[Dict[str, Any]]:
"""获取银行流水记录"""
try:
# 解析期间
start_date, end_date = self._parse_period(period)
stmt = select(BankTransaction).where(
and_(
BankTransaction.account_no == account_no,
BankTransaction.transaction_date >= start_date,
BankTransaction.transaction_date <= end_date,
)
).order_by(BankTransaction.transaction_date.desc())
result = await db_session.execute(stmt)
transactions = result.scalars().all()
# 转换为字典格式
return [
{
"transaction_id": t.transaction_id,
"transaction_date": t.transaction_date,
"transaction_type": t.transaction_type,
"amount": t.transaction_amount,
"counterparty_name": t.counterparty_account_name,
"counterparty_account": t.counterparty_account_no,
"bank_name": t.counterparty_bank_name,
"purpose": t.transaction_purpose,
}
for t in transactions
]
except Exception as e:
logger.error(f"获取银行流水失败: {str(e)}")
return []
def _analyze_private_transfers(
self, transactions: List[Dict[str, Any]], threshold_amount: float
) -> List[Dict[str, Any]]:
"""分析私人账户转账"""
private_patterns = [
"个人", "有限公司", "有限责任公司", "股份有限公司", "集团",
"传媒", "科技", "文化", "商贸", "贸易", "工作室",
]
private_transfers = []
for t in transactions:
# 只检查转入交易
if t["transaction_type"] != "转入":
continue
# 检查金额是否超过阈值
if t["amount"] < threshold_amount:
continue
# 检查对手方账户名
counterparty = t["counterparty_name"] or ""
account_no = t["counterparty_account"] or ""
# 判断是否为私人账户
is_private = self._is_private_account(counterparty, private_patterns)
if is_private:
private_transfers.append({
"date": t["transaction_date"].strftime("%Y-%m-%d"),
"amount": t["amount"],
"counterparty": counterparty,
"counterparty_account": account_no,
"bank": t["bank_name"],
"purpose": t["purpose"],
})
return private_transfers
def _is_private_account(
self, account_name: str, private_patterns: List[str]
) -> bool:
"""判断是否为私人账户"""
if not account_name:
return True # 没有账户名,疑似私人账户
# 检查是否包含企业标识
for pattern in private_patterns:
if pattern in account_name:
return False
# 检查是否是个人姓名2-4个汉字
if len(account_name) <= 4 and len(account_name) >= 2:
# 简单判断:如果都是汉字,可能是个人姓名
if all(ord(char) >= 0x4e00 and ord(char) <= 0x9fa5 for char in account_name):
return True
return False
def _calculate_risk_level(
self,
private_ratio: float,
private_count: int,
total_amount: float,
threshold_amount: float,
) -> tuple[RiskLevel, float]:
"""计算风险等级"""
if private_count == 0:
return RiskLevel.LOW, 0.0
# 基于私户比例、笔数和金额综合判断
if private_ratio > 80 or (private_count > 10 and total_amount > 500000):
return RiskLevel.CRITICAL, 95.0
elif private_ratio > 60 or (private_count > 5 and total_amount > 200000):
return RiskLevel.HIGH, 80.0
elif private_ratio > 40 or (private_count > 3 and total_amount > 100000):
return RiskLevel.MEDIUM, 60.0
elif private_ratio > 20 or private_count > 1:
return RiskLevel.LOW, 30.0
else:
return RiskLevel.LOW, 10.0
def _generate_risk_description(
self,
private_ratio: float,
private_count: int,
total_private_amount: float,
total_inflow: float,
) -> tuple[str, str]:
"""生成风险描述和建议"""
if private_count > 0:
description = (
f"检测到私户收款风险:该账户共收到 {private_count} 笔来自私人账户的转账,"
f"总金额 {total_private_amount:,.2f}元,"
f"占全部流入资金的比例为 {private_ratio:.2f}%"
f"可能存在使用私人账户违规收款的税务风险。"
)
suggestion = (
"1. 核实私人账户转账的合法性和真实性;\n"
"2. 检查是否存在未申报的收入;\n"
"3. 补充相关业务合同和发票证明;\n"
"4. 如涉及税务问题,建议及时自查补报;\n"
"5. 建立完善的资金流水管理制度。"
)
else:
description = "未检测到明显的私户收款风险,银行流水基本正常。"
suggestion = "继续保持规范的账户使用习惯。"
return description, suggestion
def _parse_period(self, period: str) -> tuple[datetime, datetime]:
"""解析期间为开始和结束日期"""
try:
year, month = map(int, period.split("-"))
start_date = datetime(year, month, 1)
if month == 12:
end_date = datetime(year + 1, 1, 1) - timedelta(days=1)
else:
end_date = datetime(year, month + 1, 1) - timedelta(days=1)
return start_date, end_date
except Exception as e:
logger.error(f"解析期间失败: {period}, 错误: {str(e)}")
# 默认返回当前月
now = datetime.now()
start_date = datetime(now.year, now.month, 1)
end_date = now
return start_date, end_date
def _create_error_result(self, context: DetectionContext, error_message: str) -> DetectionResult:
"""创建错误结果"""
return DetectionResult(
task_id=context.task_id,
rule_id=context.rule_id,
entity_id=context.get_parameter("account_no", ""),
entity_type="bank_account",
risk_level=RiskLevel.UNKNOWN,
risk_score=0.0,
description=f"私户收款检测失败: {error_message}",
suggestion="请检查参数设置或联系系统管理员",
)