docs/25 P1 #6。根因是"库内存 UTC naive"这个约定在**业务判断层**没被遵守, 而展示层其实已经是对的(risk_daily_report_service.py:418 按配置时区转换)。 1. 新增 app/core/timeutil.py 作为统一换算入口:约定库内 UTC naive,提供 local_hour / local_date / local_day_bounds(后两者用于查库时必须返回 UTC naive, 否则区间与库内值整体错开 8 小时),带时区的入参按其自身时区解释。 2. risk_scan_service.py:248:0 <= confirmed_at.hour < 6 → local_hour(...)。 原先 [0,6) UTC 被当成"凌晨",实际是北京时间 08:00-14:00,整条 「凌晨时段小额操作」规则判的是上午。 3. risk_judgement_service.py:238/243:判断**与展示**都换算。展示不改的话, 风控专员看到的时刻与直觉差 8 小时,无法与客户核对。 4. risk_daily_report_service.py:89:日界改用 local_day_bounds(按北京时间自然日, 再折回 UTC naive)。原先按 UTC 日期切日,北京 08:00 前生成的日报统计窗口跨零点。 测试: - 新增 tests/unit/core/test_timeutil.py(8 条),含"UTC 凌晨 0-6 点不是北京凌晨" 这一缺陷复现,以及"日界必须返回 UTC naive"。 - 改写 test_risk_scan_service.py::test_night_small_trade_boundaries:它原本就拿 UTC 小时构造数据(写 0 点/6 点),等于在测北京 08:00/14:00;语义一并修正为 北京 00:00(含)与 06:00(不含)两个边界,三个边界场景保持不变。 ruff / mypy(135 文件) / 603 unit+contract 全绿。
582 lines
18 KiB
Python
582 lines
18 KiB
Python
from datetime import date, datetime
|
||
from decimal import Decimal
|
||
|
||
import pytest
|
||
|
||
from app.core.contracts import RequestContext
|
||
from app.model.fund import (
|
||
FundCapitalFlow,
|
||
FundCustomerProfile,
|
||
FundProduct,
|
||
FundRiskAlert,
|
||
FundTransaction,
|
||
)
|
||
from app.model.risk import RiskLoginRecord, RiskUser, RiskWorkOrder
|
||
from app.service.risk_scan_service import (
|
||
RiskRuleEngine,
|
||
RiskScanService,
|
||
_new_alert_id,
|
||
)
|
||
|
||
|
||
class ScalarRows:
|
||
def __init__(self, rows):
|
||
self.rows = rows
|
||
|
||
def all(self):
|
||
return list(self.rows)
|
||
|
||
|
||
class FakeSession:
|
||
def __init__(self, *, scalar_values=None, rows=None, get_values=None):
|
||
self.scalar_values = list(scalar_values or [])
|
||
self.rows = list(rows or [])
|
||
self.get_values = list(get_values or [])
|
||
self.statements = []
|
||
self.committed = False
|
||
self.rolled_back = False
|
||
|
||
async def scalar(self, statement):
|
||
self.statements.append(statement)
|
||
return self.scalar_values.pop(0) if self.scalar_values else None
|
||
|
||
async def scalars(self, statement):
|
||
self.statements.append(statement)
|
||
return ScalarRows(self.rows)
|
||
|
||
async def get(self, _model, _identifier):
|
||
return self.get_values.pop(0) if self.get_values else None
|
||
|
||
def add(self, _value):
|
||
return None
|
||
|
||
async def flush(self):
|
||
return None
|
||
|
||
async def commit(self):
|
||
self.committed = True
|
||
|
||
async def rollback(self):
|
||
self.rolled_back = True
|
||
|
||
def begin_nested(self):
|
||
return _NestedTransaction()
|
||
|
||
|
||
class _NestedTransaction:
|
||
async def __aenter__(self):
|
||
return self
|
||
|
||
async def __aexit__(self, _exc_type, _exc, _traceback):
|
||
return False
|
||
|
||
|
||
def transaction() -> FundTransaction:
|
||
return FundTransaction(
|
||
id=100,
|
||
transaction_no="TX-100",
|
||
order_id=200,
|
||
work_order_id=300,
|
||
customer_id=1,
|
||
account_id=2,
|
||
product_id=3,
|
||
order_side="sell",
|
||
transaction_type="赎回",
|
||
executed_price=Decimal("1.000000"),
|
||
nav=Decimal("1.000000"),
|
||
executed_quantity=Decimal("750000.0000"),
|
||
shares=Decimal("750000.0000"),
|
||
gross_amount=Decimal("750000.00"),
|
||
amount=Decimal("750000.00"),
|
||
fee_rate_snapshot=Decimal("0.000000"),
|
||
fee_amount=Decimal("0.00"),
|
||
net_amount=Decimal("750000.00"),
|
||
quote_at=datetime(2026, 9, 10, 8, 0),
|
||
quote_source="test",
|
||
executed_at=datetime(2026, 9, 10, 8, 0),
|
||
confirmed_at=datetime(2026, 9, 10, 8, 0),
|
||
auto_confirmed=0,
|
||
created_at=datetime(2026, 9, 10, 8, 0),
|
||
)
|
||
|
||
|
||
def capital_flow() -> FundCapitalFlow:
|
||
return FundCapitalFlow(
|
||
id=400,
|
||
flow_no="FLOW-400",
|
||
customer_id=1,
|
||
flow_type="入金",
|
||
amount=Decimal("800000.00"),
|
||
status="成功",
|
||
settled_at=datetime(2026, 9, 8, 8, 0),
|
||
occurred_at=datetime(2026, 9, 8, 8, 0),
|
||
source_type="银行转入",
|
||
match_status="已匹配",
|
||
created_at=datetime(2026, 9, 8, 8, 0),
|
||
updated_at=datetime(2026, 9, 8, 8, 0),
|
||
)
|
||
|
||
|
||
def risk_user(investor_type: str = "C2") -> RiskUser:
|
||
return RiskUser(
|
||
id=1,
|
||
user_no="CUST-001",
|
||
username="customer001",
|
||
user_type="CUSTOMER",
|
||
investor_type=investor_type,
|
||
is_professional_investor=0,
|
||
professional_investor_status="未申请",
|
||
fund_account_status="已开户",
|
||
status="正常",
|
||
created_at=datetime(2026, 1, 1),
|
||
updated_at=datetime(2026, 9, 10),
|
||
)
|
||
|
||
|
||
def product(
|
||
risk_level: str = "R5",
|
||
*,
|
||
disclosure: int = 1,
|
||
confirmation: int = 1,
|
||
recording: int = 1,
|
||
) -> FundProduct:
|
||
return FundProduct(
|
||
id=3,
|
||
product_code="P-001",
|
||
product_name="测试产品",
|
||
exchange_code="159999",
|
||
product_category="股票型",
|
||
risk_level=risk_level,
|
||
currency="CNY",
|
||
lot_size=Decimal("100.0000"),
|
||
price_tick=Decimal("0.000100"),
|
||
min_amount=Decimal("100.00"),
|
||
single_investor_max_holding_ratio=Decimal("100.0000"),
|
||
risk_disclosure_required=disclosure,
|
||
second_confirmation_required=confirmation,
|
||
recording_required=recording,
|
||
status="在售",
|
||
created_at=datetime(2026, 1, 1),
|
||
updated_at=datetime(2026, 9, 10),
|
||
)
|
||
|
||
|
||
def work_order(
|
||
*,
|
||
disclosure_at: datetime | None = None,
|
||
confirmation_at: datetime | None = None,
|
||
recording_reference: str | None = None,
|
||
channel: str | None = "手机应用",
|
||
) -> RiskWorkOrder:
|
||
return RiskWorkOrder(
|
||
id=300,
|
||
work_order_no="WO-300",
|
||
customer_id=1,
|
||
product_id=3,
|
||
channel=channel,
|
||
risk_disclosure_ack_at=disclosure_at,
|
||
second_confirmation_at=confirmation_at,
|
||
recording_reference=recording_reference,
|
||
status="已提交",
|
||
created_at=datetime(2026, 9, 1),
|
||
updated_at=datetime(2026, 9, 10),
|
||
)
|
||
|
||
|
||
def customer_profile(birth_date: date = date(1954, 1, 1)) -> FundCustomerProfile:
|
||
return FundCustomerProfile(
|
||
customer_id=1,
|
||
trade_account="ACC-001",
|
||
real_name="张三",
|
||
birth_date=birth_date,
|
||
investor_type="C2",
|
||
total_asset=Decimal("1000000.00"),
|
||
behavior_score=20,
|
||
updated_at=datetime(2026, 9, 10),
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_fast_in_fast_out_builds_fact_based_alert() -> None:
|
||
session = FakeSession(
|
||
rows=[transaction()],
|
||
scalar_values=[capital_flow(), None],
|
||
)
|
||
|
||
alerts = await RiskRuleEngine(session)._fast_in_fast_out()
|
||
|
||
assert len(alerts) == 1
|
||
assert alerts[0].trigger_rule_codes == ["RW-003"]
|
||
assert alerts[0].alert_level == "高"
|
||
assert "800000" in alerts[0].evidence_summary
|
||
assert alerts[0].evidence_snapshot["ratio"] == "0.9375"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_duplicate_rule_hit_is_suppressed() -> None:
|
||
session = FakeSession(
|
||
rows=[transaction()],
|
||
scalar_values=[capital_flow(), 1],
|
||
)
|
||
|
||
alerts = await RiskRuleEngine(session)._fast_in_fast_out()
|
||
|
||
assert alerts == []
|
||
|
||
|
||
def test_same_transaction_alerts_are_merged() -> None:
|
||
first = FundRiskAlert(
|
||
id=1,
|
||
alert_no="AL-1",
|
||
customer_id=1,
|
||
related_transaction_id=100,
|
||
alert_type="大额快进快出",
|
||
alert_level="高",
|
||
trigger_rule_codes=["RW-003"],
|
||
evidence_summary="摘要一",
|
||
evidence_snapshot={"product_id": 3},
|
||
priority_score=98,
|
||
event_status="正在发生",
|
||
status="待处理",
|
||
ack_status="未确认",
|
||
is_escalated=0,
|
||
created_at=datetime(2026, 9, 10),
|
||
updated_at=datetime(2026, 9, 10),
|
||
)
|
||
second = FundRiskAlert(
|
||
id=2,
|
||
alert_no="AL-2",
|
||
customer_id=1,
|
||
related_transaction_id=100,
|
||
alert_type="老年客户异常大额赎回",
|
||
alert_level="中",
|
||
trigger_rule_codes=["RW-012"],
|
||
evidence_summary="摘要二",
|
||
evidence_snapshot={"age": 72},
|
||
priority_score=96,
|
||
event_status="正在发生",
|
||
status="待处理",
|
||
ack_status="未确认",
|
||
is_escalated=0,
|
||
created_at=datetime(2026, 9, 10),
|
||
updated_at=datetime(2026, 9, 10),
|
||
)
|
||
|
||
merged = RiskRuleEngine._merge_same_transaction_alerts([first, second])
|
||
|
||
assert len(merged) == 1
|
||
assert merged[0].trigger_rule_codes == ["RW-003", "RW-012"]
|
||
assert merged[0].alert_level == "高"
|
||
assert merged[0].evidence_summary == "摘要一;摘要二"
|
||
assert len(merged[0].evidence_snapshot["merged_alerts"]) == 2
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_scan_service_uses_transaction_boundary() -> None:
|
||
class FakeRuleEngine:
|
||
async def refresh_alerts(self):
|
||
return [
|
||
FundRiskAlert(
|
||
id=1,
|
||
alert_no="AL-1",
|
||
customer_id=1,
|
||
alert_type="大额快进快出",
|
||
alert_level="高",
|
||
trigger_rule_codes=["RW-003"],
|
||
evidence_summary="摘要",
|
||
evidence_snapshot={},
|
||
priority_score=98,
|
||
event_status="正在发生",
|
||
status="待处理",
|
||
ack_status="未确认",
|
||
is_escalated=0,
|
||
created_at=datetime(2026, 9, 10),
|
||
updated_at=datetime(2026, 9, 10),
|
||
)
|
||
]
|
||
|
||
session = FakeSession()
|
||
context = RequestContext(
|
||
user_id="990000002",
|
||
trace_id="scan-trace",
|
||
permissions=("risk:alert:scan",),
|
||
data_scope="all",
|
||
)
|
||
|
||
result = await RiskScanService(
|
||
session,
|
||
rule_engine=FakeRuleEngine(),
|
||
notification_enabled=False,
|
||
).scan(context)
|
||
|
||
assert result == {
|
||
"message": "规则扫描完成",
|
||
"created_count": 1,
|
||
"high_risk_count": 1,
|
||
"notification_count": 0,
|
||
}
|
||
assert session.committed is True
|
||
assert session.rolled_back is False
|
||
|
||
|
||
def test_alert_id_is_nonzero_positive() -> None:
|
||
alert_id = _new_alert_id()
|
||
|
||
assert 0 < alert_id < 2**63
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_scan_creates_high_risk_notifications_in_savepoint() -> None:
|
||
class FakeRuleEngine:
|
||
async def refresh_alerts(self):
|
||
return [
|
||
FundRiskAlert(
|
||
id=1,
|
||
alert_no="AL-1",
|
||
customer_id=1,
|
||
alert_type="大额快进快出",
|
||
alert_level="高",
|
||
trigger_rule_codes=["RW-003"],
|
||
evidence_summary="摘要",
|
||
evidence_snapshot={},
|
||
priority_score=98,
|
||
event_status="正在发生",
|
||
status="待处理",
|
||
ack_status="未确认",
|
||
handler_id=990000002,
|
||
is_escalated=0,
|
||
created_at=datetime(2026, 9, 10),
|
||
updated_at=datetime(2026, 9, 10),
|
||
)
|
||
]
|
||
|
||
class FakeNotificationService:
|
||
def __init__(self):
|
||
self.calls = []
|
||
|
||
def create_in_app(self, alert, **kwargs):
|
||
self.calls.append(("in_app", alert.alert_no, kwargs))
|
||
|
||
def create_mail_record(self, alert, **kwargs):
|
||
self.calls.append(("mail", alert.alert_no, kwargs))
|
||
|
||
notifier = FakeNotificationService()
|
||
context_value = RequestContext(
|
||
user_id="990000002",
|
||
trace_id="scan-trace",
|
||
permissions=("risk:alert:scan",),
|
||
data_scope="all",
|
||
)
|
||
|
||
result = await RiskScanService(
|
||
FakeSession(),
|
||
rule_engine=FakeRuleEngine(),
|
||
notification_service=notifier,
|
||
notification_email="risk@example.com",
|
||
).scan(context_value)
|
||
|
||
assert result["notification_count"] == 2
|
||
assert [call[0] for call in notifier.calls] == ["in_app", "mail"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_notification_failure_does_not_rollback_scan() -> None:
|
||
class FakeRuleEngine:
|
||
async def refresh_alerts(self):
|
||
return [
|
||
FundRiskAlert(
|
||
id=1,
|
||
alert_no="AL-1",
|
||
customer_id=1,
|
||
alert_type="大额快进快出",
|
||
alert_level="高",
|
||
trigger_rule_codes=["RW-003"],
|
||
evidence_summary="摘要",
|
||
evidence_snapshot={},
|
||
priority_score=98,
|
||
event_status="正在发生",
|
||
status="待处理",
|
||
ack_status="未确认",
|
||
is_escalated=0,
|
||
created_at=datetime(2026, 9, 10),
|
||
updated_at=datetime(2026, 9, 10),
|
||
)
|
||
]
|
||
|
||
class FailingNotificationService:
|
||
def create_in_app(self, *_args, **_kwargs):
|
||
raise RuntimeError("notification failed")
|
||
|
||
session = FakeSession()
|
||
context_value = RequestContext(
|
||
user_id="990000002",
|
||
trace_id="scan-trace",
|
||
permissions=("risk:alert:scan",),
|
||
data_scope="all",
|
||
)
|
||
|
||
result = await RiskScanService(
|
||
session,
|
||
rule_engine=FakeRuleEngine(),
|
||
notification_service=FailingNotificationService(),
|
||
).scan(context_value)
|
||
|
||
assert result["created_count"] == 1
|
||
assert result["notification_count"] == 0
|
||
assert session.committed is True
|
||
assert session.rolled_back is False
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_suitability_mismatch_high_and_medium_boundaries() -> None:
|
||
session = FakeSession(
|
||
rows=[transaction()],
|
||
get_values=[risk_user("C2"), product("R5"), work_order()],
|
||
scalar_values=[None],
|
||
)
|
||
alerts = await RiskRuleEngine(session)._suitability_mismatch()
|
||
assert len(alerts) == 1
|
||
assert alerts[0].alert_level == "高"
|
||
|
||
session = FakeSession(
|
||
rows=[transaction()],
|
||
get_values=[risk_user("C4"), product("R5"), work_order()],
|
||
scalar_values=[None],
|
||
)
|
||
alerts = await RiskRuleEngine(session)._suitability_mismatch()
|
||
assert len(alerts) == 1
|
||
assert alerts[0].alert_level == "中"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_suitability_mismatch_rejects_complete_trace_and_matching_level() -> None:
|
||
session = FakeSession(
|
||
rows=[transaction()],
|
||
get_values=[
|
||
risk_user("C2"),
|
||
product("R5"),
|
||
work_order(
|
||
disclosure_at=datetime(2026, 9, 1),
|
||
confirmation_at=datetime(2026, 9, 1),
|
||
recording_reference="REC-1",
|
||
),
|
||
],
|
||
scalar_values=[None],
|
||
)
|
||
assert await RiskRuleEngine(session)._suitability_mismatch() == []
|
||
|
||
session = FakeSession(
|
||
rows=[transaction()],
|
||
get_values=[risk_user("C5"), product("R5"), work_order()],
|
||
scalar_values=[None],
|
||
)
|
||
assert await RiskRuleEngine(session)._suitability_mismatch() == []
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_elderly_redemption_requires_age_amount_average_and_uncommon_device() -> None:
|
||
login = RiskLoginRecord(
|
||
id=500,
|
||
user_id=1,
|
||
login_at=datetime(2026, 9, 9),
|
||
login_result="成功",
|
||
device_id="DEVICE-NEW",
|
||
is_common_device=0,
|
||
created_at=datetime(2026, 9, 9),
|
||
)
|
||
session = FakeSession(
|
||
rows=[transaction()],
|
||
get_values=[customer_profile()],
|
||
scalar_values=[Decimal("100000.00"), login, None],
|
||
)
|
||
alerts = await RiskRuleEngine(session)._elderly_redemption()
|
||
assert len(alerts) == 1
|
||
assert alerts[0].trigger_rule_codes == ["RW-012"]
|
||
|
||
session = FakeSession(
|
||
rows=[transaction()],
|
||
get_values=[customer_profile()],
|
||
scalar_values=[Decimal("250000.00"), login, None],
|
||
)
|
||
boundary_alerts = await RiskRuleEngine(session)._elderly_redemption()
|
||
assert len(boundary_alerts) == 1
|
||
|
||
session = FakeSession(
|
||
rows=[transaction()],
|
||
get_values=[customer_profile()],
|
||
scalar_values=[Decimal("260000.00")],
|
||
)
|
||
assert await RiskRuleEngine(session)._elderly_redemption() == []
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_elderly_redemption_rejects_common_device() -> None:
|
||
login = RiskLoginRecord(
|
||
id=500,
|
||
user_id=1,
|
||
login_at=datetime(2026, 9, 9),
|
||
login_result="成功",
|
||
device_id="DEVICE-COMMON",
|
||
is_common_device=1,
|
||
created_at=datetime(2026, 9, 9),
|
||
)
|
||
session = FakeSession(
|
||
rows=[transaction()],
|
||
get_values=[customer_profile()],
|
||
scalar_values=[Decimal("100000.00"), login],
|
||
)
|
||
assert await RiskRuleEngine(session)._elderly_redemption() == []
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_night_small_trade_boundaries() -> None:
|
||
# confirmed_at 在库里是 **UTC**(见 app/infrastructure/db.py:15-21),而"凌晨"是
|
||
# **北京时间**概念:北京 00:00–06:00 对应 UTC 的前一日 16:00–22:00。
|
||
# 原先这里直接拿 UTC 小时当北京时间(写 0 点 / 6 点),等于在测"北京 08:00 / 14:00",
|
||
# 规则修正后这些用例的语义也一并修正(docs/25 P1 #6)。
|
||
night = transaction()
|
||
night.confirmed_at = datetime(2026, 9, 9, 16, 0) # 北京 09-10 00:00,凌晨起点(含)
|
||
night.amount = Decimal("10000.00")
|
||
session = FakeSession(rows=[night], scalar_values=[None])
|
||
alerts = await RiskRuleEngine(session)._low_risk_night_trade()
|
||
assert len(alerts) == 1
|
||
|
||
regular = transaction()
|
||
regular.confirmed_at = datetime(2026, 9, 9, 22, 0) # 北京 09-10 06:00,凌晨终点(不含)
|
||
session = FakeSession(rows=[regular], scalar_values=[None])
|
||
assert await RiskRuleEngine(session)._low_risk_night_trade() == []
|
||
|
||
too_large = transaction()
|
||
too_large.confirmed_at = datetime(2026, 9, 9, 18, 0) # 北京 09-10 02:00,在凌晨但金额超限
|
||
too_large.amount = Decimal("10000.01")
|
||
session = FakeSession(rows=[too_large], scalar_values=[None])
|
||
assert await RiskRuleEngine(session)._low_risk_night_trade() == []
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_auto_investment_false_positive_uses_valid_work_order() -> None:
|
||
tx = transaction()
|
||
tx.work_order_id = 300
|
||
session = FakeSession(
|
||
rows=[tx],
|
||
get_values=[work_order(channel="自动定投")],
|
||
scalar_values=[None],
|
||
)
|
||
alerts = await RiskRuleEngine(session)._auto_investment_false_positive()
|
||
assert len(alerts) == 1
|
||
assert alerts[0].trigger_rule_codes == ["RW-018"]
|
||
|
||
session = FakeSession(
|
||
rows=[tx],
|
||
get_values=[work_order(channel="定投")],
|
||
scalar_values=[None],
|
||
)
|
||
assert len(await RiskRuleEngine(session)._auto_investment_false_positive()) == 1
|
||
|
||
session = FakeSession(
|
||
rows=[tx],
|
||
get_values=[work_order(channel="手机应用")],
|
||
scalar_values=[None],
|
||
)
|
||
assert await RiskRuleEngine(session)._auto_investment_false_positive() == []
|