Files
group_fqcd_jr/app/repository/risk_repository.py
T
lzf_0626 3ab7f33d64 fix(risk): 预警行带出 close_reason,日报的"误报原因"不再是空
docs/25 P2。核实确认报告准确,而且这一处缺口造成两个症状:

_alert_row 是**列表 / 详情 / 日报共用**的行构造器
(risk_repository.py:174 / :276 / :321),而它的字段列表里没有 close_reason。于是:

- 日报的"误报原因"分布恒为"未填写"
  (risk_daily_report_service.py:139 用 item.get("close_reason") or "未填写");
- :243 的明细里同一字段同样拿不到值。

断的是"关闭误报时写入原因"这条链路的**下半段**:risk_action_service.py:70 把原因赋给
lert.close_reason、也确实存进了库(app/model/fund.py:368 有该字段),只是读取时没带出来。

修法是一行:在 _alert_row 里补上 close_reason。三个调用点同时受益;读取方都是风控侧
接口(需要 risk:alert:read),不涉及客户可见面。

新增 tests/unit/repository/test_risk_alert_row_close_reason.py(2 条):关闭原因出现在行里;
未关闭时为 None 但**键必须在** —— 读取方靠 or "未填写" 兜底,键一旦缺失就永远只能走
兜底分支,那正是修复前的状态。

ruff / mypy(136 文件) / 639 unit+contract / 29 integration 全绿。
2026-09-11 13:51:37 +08:00

1028 lines
37 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.
"""风控领域只读查询 Repository。
本模块只提供查询能力,不修改预警、客户、交易、资金、持仓或工单数据。
简单单表查询优先复用 `FundQueryRepository`;需要客户身份、登录记录、工单联表
或预警详情聚合时,由本模块实现。
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, date, datetime
from decimal import Decimal
from types import MappingProxyType
from typing import Any
from sqlalchemy import ColumnElement, Select, and_, case, false, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.model.fund import (
FundCapitalFlow,
FundCustomerProfile,
FundHolding,
FundProduct,
FundRiskAlert,
FundRiskAssessment,
FundRiskNotification,
FundTransaction,
)
from app.model.risk import RiskLoginRecord, RiskUser, RiskWorkOrder
from app.repository.fund_query_repository import (
AnyOf,
CustomerScope,
FieldFilter,
FundFilterOperator,
FundPage,
FundQueryRepository,
FundQuerySpec,
FundRecord,
PageRequest,
)
OPEN_STATUSES = ("待处理", "调查中")
LOW_RISK = "低"
MEDIUM_RISK = "中"
HIGH_RISK = "高"
BEHAVIOR_SCORE_RANGES = {
"normal": (16, 20),
"slight": (11, 15),
"attention": (6, 10),
"high": (1, 5),
"immediate": (0, 0),
}
__all__ = ["RiskReportSnapshot", "RiskRepository"]
@dataclass(frozen=True, slots=True)
class RiskReportSnapshot:
daily: tuple[FundRecord, ...]
unresolved: tuple[FundRecord, ...]
false_positive: tuple[FundRecord, ...]
dispositions: tuple[FundRecord, ...]
class RiskRepository:
"""风控只读查询入口;未提供合法客户范围时默认拒绝。"""
def __init__(self, session: AsyncSession, *, scope: CustomerScope | None = None) -> None:
self.session = session
self.scope = scope
async def overview(self) -> dict[str, Any]:
"""返回未闭环预警概览和重点预警。"""
scope_condition = self._scope_condition(
FundRiskAlert.customer_id,
FundCustomerProfile.trade_account,
)
open_conditions = [FundRiskAlert.status.in_(OPEN_STATUSES)]
if scope_condition is not None:
open_conditions.append(scope_condition)
rows = (
await self.session.execute(
select(FundRiskAlert.alert_level, func.count(FundRiskAlert.id))
.where(*open_conditions)
.group_by(FundRiskAlert.alert_level)
)
).all()
levels = {str(level): int(count or 0) for level, count in rows}
pending = int(
await self.session.scalar(
select(func.count(FundRiskAlert.id)).where(
*open_conditions,
FundRiskAlert.status == "待处理",
)
)
or 0
)
overdue = int(
await self.session.scalar(
select(func.count(FundRiskAlert.id)).where(
*open_conditions,
FundRiskAlert.due_at.is_not(None),
FundRiskAlert.due_at <= _utc_now_naive(),
)
)
or 0
)
high_priority = await self.list_alerts(
risk_level=HIGH_RISK,
page=PageRequest(limit=3),
)
return {
"total": sum(levels.values()),
"levels": {
LOW_RISK: levels.get(LOW_RISK, 0),
MEDIUM_RISK: levels.get(MEDIUM_RISK, 0),
HIGH_RISK: levels.get(HIGH_RISK, 0),
},
"pending": pending,
"overdue": overdue,
"high_priority": high_priority.items,
}
async def list_alerts(
self,
*,
keyword: str | None = None,
customer_no: str | None = None,
product_code: str | None = None,
product_name: str | None = None,
risk_level: str | None = None,
rule_code: str | None = None,
start_time: datetime | None = None,
end_time: datetime | None = None,
open_only: bool = True,
page: PageRequest | None = None,
) -> FundPage:
"""查询预警列表,默认只返回未闭环预警。"""
request = page or PageRequest()
statement = self._alert_list_statement(
keyword=keyword,
customer_no=customer_no,
product_code=product_code,
product_name=product_name,
risk_level=risk_level,
rule_code=rule_code,
start_time=start_time,
end_time=end_time,
open_only=open_only,
)
rows = (
await self.session.execute(
statement.order_by(
case(
(FundRiskAlert.alert_level == HIGH_RISK, 0),
(FundRiskAlert.alert_level == MEDIUM_RISK, 1),
(FundRiskAlert.alert_level == LOW_RISK, 2),
else_=3,
),
FundRiskAlert.created_at.desc(),
FundRiskAlert.id.desc(),
)
.limit(request.limit + 1)
.offset(request.offset)
)
).all()
has_more = len(rows) > request.limit
records = tuple(
FundRecord(
entity="risk_alert",
values=MappingProxyType(
self._alert_row(
alert,
customer_no_value,
name,
product_code_value,
product_name_value,
)
),
)
for (
alert,
customer_no_value,
name,
product_code_value,
product_name_value,
) in rows[: request.limit]
)
return FundPage(
entity="risk_alert",
items=records,
limit=request.limit,
offset=request.offset,
next_offset=request.offset + request.limit if has_more else None,
)
async def get_alert_detail(self, alert_no: str) -> FundRecord | None:
"""聚合指定预警的客户、交易、产品、工单和证据快照。"""
statement = select(FundRiskAlert).where(FundRiskAlert.alert_no == alert_no)
scope_condition = self._scope_condition(
FundRiskAlert.customer_id,
FundCustomerProfile.trade_account,
)
if scope_condition is not None:
statement = statement.where(scope_condition)
alert = await self.session.scalar(statement)
if alert is None:
return None
profile = await self.session.scalar(
select(FundCustomerProfile).where(
FundCustomerProfile.customer_id == alert.customer_id
)
)
customer = await self.session.scalar(
select(RiskUser).where(RiskUser.id == alert.customer_id)
)
transaction = (
await self.session.scalar(
select(FundTransaction).where(
FundTransaction.id == alert.related_transaction_id
)
)
if alert.related_transaction_id is not None
else None
)
snapshot = alert.evidence_snapshot if isinstance(alert.evidence_snapshot, dict) else {}
product_id = (
transaction.product_id
if transaction is not None
else snapshot.get("product_id")
)
product = (
await self.session.scalar(
select(FundProduct).where(FundProduct.id == int(product_id))
)
if product_id is not None
else None
)
work_order = (
await self.session.scalar(
select(RiskWorkOrder).where(
RiskWorkOrder.id == alert.related_work_order_id
)
)
if alert.related_work_order_id is not None
else None
)
capital_flows = list(
await self.session.scalars(
select(FundCapitalFlow)
.where(FundCapitalFlow.customer_id == alert.customer_id)
.order_by(
FundCapitalFlow.settled_at.desc(),
FundCapitalFlow.id.desc(),
)
)
)
holdings = list(
await self.session.scalars(
select(FundHolding)
.where(FundHolding.customer_id == alert.customer_id)
.order_by(FundHolding.id.desc())
)
)
login_records = list(
await self.session.scalars(
select(RiskLoginRecord)
.where(RiskLoginRecord.user_id == alert.customer_id)
.order_by(RiskLoginRecord.login_at.desc(), RiskLoginRecord.id.desc())
)
)
values = {
"alert": self._alert_row(alert, customer.user_no if customer else None,
profile.real_name if profile else None,
product.product_code if product else None,
product.product_name if product else None),
"customer": self._profile_row(
profile,
customer.user_no if customer else None,
),
"transaction": self._model_values(transaction),
"product": self._model_values(product),
"work_order": self._model_values(work_order),
"capital_flows": [
self._model_values(item) for item in capital_flows
],
"holdings": [
self._model_values(item) for item in holdings
],
"login_records": [
self._model_values(item) for item in login_records
],
"evidence_snapshot": snapshot,
}
return FundRecord(entity="risk_alert_detail", values=MappingProxyType(values))
async def daily_report_snapshot(
self,
day_start: datetime,
next_day: datetime,
) -> RiskReportSnapshot:
"""一次读取日报所需的四组预警,历史未闭环不应用分页。"""
async def load(*conditions: Any) -> tuple[FundRecord, ...]:
statement = select(FundRiskAlert).where(*conditions)
scope = self._scope_condition(
FundRiskAlert.customer_id,
FundCustomerProfile.trade_account,
)
if scope is not None:
statement = statement.where(scope)
rows = (await self.session.scalars(
statement.order_by(FundRiskAlert.id.asc())
)).all()
return tuple(
FundRecord(
entity="risk_alert",
values=MappingProxyType(
self._alert_row(item, None, None, None, None)
),
)
for item in rows
)
daily = await load(
FundRiskAlert.created_at >= day_start,
FundRiskAlert.created_at < next_day,
)
unresolved = await load(FundRiskAlert.status.in_(OPEN_STATUSES))
false_positive = await load(
FundRiskAlert.status == "已排除",
FundRiskAlert.closed_at >= day_start,
FundRiskAlert.closed_at < next_day,
)
dispositions = await load(or_(
and_(FundRiskAlert.ack_at >= day_start, FundRiskAlert.ack_at < next_day),
and_(FundRiskAlert.closed_at >= day_start, FundRiskAlert.closed_at < next_day),
and_(FundRiskAlert.escalated_at >= day_start, FundRiskAlert.escalated_at < next_day),
and_(
FundRiskAlert.status == "调查中",
FundRiskAlert.updated_at >= day_start,
FundRiskAlert.updated_at < next_day,
),
))
return RiskReportSnapshot(
daily=daily,
unresolved=unresolved,
false_positive=false_positive,
dispositions=dispositions,
)
async def list_customers(
self,
*,
keyword: str | None = None,
behavior_level: str | None = None,
page: PageRequest | None = None,
) -> FundPage:
"""客户画像、行为分和最新风险测评分页查询。"""
request = page or PageRequest()
latest = (
select(
FundRiskAssessment.customer_id,
func.max(FundRiskAssessment.assessed_at).label("assessed_at"),
)
.group_by(FundRiskAssessment.customer_id)
.subquery()
)
statement = (
select(RiskUser, FundCustomerProfile, FundRiskAssessment)
.join(FundCustomerProfile, FundCustomerProfile.customer_id == RiskUser.id)
.outerjoin(latest, latest.c.customer_id == RiskUser.id)
.outerjoin(
FundRiskAssessment,
(FundRiskAssessment.customer_id == RiskUser.id)
& (FundRiskAssessment.assessed_at == latest.c.assessed_at),
)
.where(RiskUser.user_type == "CUSTOMER")
)
conditions: list[ColumnElement[bool]] = []
scope_condition = self._scope_condition(
RiskUser.id,
FundCustomerProfile.trade_account,
)
if scope_condition is not None:
conditions.append(scope_condition)
if keyword and keyword.strip():
like = f"%{keyword.strip()}%"
conditions.append(
or_(
RiskUser.user_no.like(like),
RiskUser.username.like(like),
FundCustomerProfile.real_name.like(like),
)
)
if behavior_level:
minimum, maximum = BEHAVIOR_SCORE_RANGES.get(behavior_level, (0, 20))
conditions.append(FundCustomerProfile.behavior_score.between(minimum, maximum))
return await self._page(
statement.where(*conditions).order_by(RiskUser.id.asc()),
request,
"risk_customer",
self._customer_row,
)
async def list_products(
self,
*,
keyword: str | None = None,
page: PageRequest | None = None,
) -> FundPage:
"""产品为公共数据,直接复用 FundQueryRepository。"""
spec = None
if keyword and keyword.strip():
like = f"%{keyword.strip()}%"
spec = FundQuerySpec.where(
AnyOf(
(
FieldFilter("product_code", like, FundFilterOperator.LIKE),
FieldFilter("product_name", like, FundFilterOperator.LIKE),
)
)
)
return await FundQueryRepository(
self.session,
scope=CustomerScope.unrestricted(),
).products(spec=spec, page=page)
async def list_transactions(
self,
*,
keyword: str | None = None,
start_time: datetime | None = None,
end_time: datetime | None = None,
page: PageRequest | None = None,
) -> FundPage:
"""交易流水查询,并补充客户、产品、渠道和风险留痕。"""
request = page or PageRequest()
statement = (
select(
FundTransaction,
RiskUser.user_no,
FundProduct.product_code,
FundProduct.product_name,
RiskWorkOrder,
)
.join(RiskUser, RiskUser.id == FundTransaction.customer_id)
.join(
FundCustomerProfile,
FundCustomerProfile.customer_id == FundTransaction.customer_id,
)
.join(FundProduct, FundProduct.id == FundTransaction.product_id)
.outerjoin(RiskWorkOrder, RiskWorkOrder.id == FundTransaction.work_order_id)
)
conditions = self._conditions_with_scope(
FundTransaction.customer_id,
FundCustomerProfile.trade_account,
)
if keyword and keyword.strip():
like = f"%{keyword.strip()}%"
conditions.append(
or_(
FundTransaction.transaction_no.like(like),
RiskUser.user_no.like(like),
FundProduct.product_code.like(like),
FundProduct.product_name.like(like),
)
)
if start_time is not None:
conditions.append(FundTransaction.executed_at >= start_time)
if end_time is not None:
conditions.append(FundTransaction.executed_at <= end_time)
return await self._page(
statement.where(*conditions).order_by(
FundTransaction.executed_at.desc(),
FundTransaction.id.desc(),
),
request,
"risk_transaction",
self._transaction_row,
)
async def list_capital_flows(
self,
*,
keyword: str | None = None,
start_time: datetime | None = None,
end_time: datetime | None = None,
page: PageRequest | None = None,
) -> FundPage:
"""资金流水查询。"""
request = page or PageRequest()
statement = (
select(FundCapitalFlow, RiskUser.user_no)
.join(RiskUser, RiskUser.id == FundCapitalFlow.customer_id)
.join(
FundCustomerProfile,
FundCustomerProfile.customer_id == FundCapitalFlow.customer_id,
)
)
conditions = self._conditions_with_scope(
FundCapitalFlow.customer_id,
FundCustomerProfile.trade_account,
)
if keyword and keyword.strip():
like = f"%{keyword.strip()}%"
conditions.append(
or_(
FundCapitalFlow.flow_no.like(like),
RiskUser.user_no.like(like),
)
)
if start_time is not None:
conditions.append(FundCapitalFlow.occurred_at >= start_time)
if end_time is not None:
conditions.append(FundCapitalFlow.occurred_at <= end_time)
return await self._page(
statement.where(*conditions).order_by(
FundCapitalFlow.created_at.desc(),
FundCapitalFlow.id.desc(),
),
request,
"risk_capital_flow",
self._capital_flow_row,
)
async def list_holdings(
self,
*,
keyword: str | None = None,
page: PageRequest | None = None,
) -> FundPage:
"""持仓查询,并计算持有天数和持仓占比。"""
request = page or PageRequest()
statement = (
select(
FundHolding,
RiskUser.user_no,
FundProduct.product_code,
FundProduct.product_name,
FundCustomerProfile.total_asset,
)
.join(RiskUser, RiskUser.id == FundHolding.customer_id)
.join(FundCustomerProfile, FundCustomerProfile.customer_id == FundHolding.customer_id)
.join(FundProduct, FundProduct.id == FundHolding.product_id)
)
conditions = self._conditions_with_scope(
FundHolding.customer_id,
FundCustomerProfile.trade_account,
)
if keyword and keyword.strip():
like = f"%{keyword.strip()}%"
conditions.append(
or_(
RiskUser.user_no.like(like),
FundProduct.product_code.like(like),
FundProduct.product_name.like(like),
)
)
return await self._page(
statement.where(*conditions).order_by(
FundHolding.updated_at.desc(),
FundHolding.id.desc(),
),
request,
"risk_holding",
self._holding_row,
)
async def list_login_records(
self,
*,
keyword: str | None = None,
start_time: datetime | None = None,
end_time: datetime | None = None,
page: PageRequest | None = None,
) -> FundPage:
"""登录记录查询。"""
request = page or PageRequest()
statement = (
select(RiskLoginRecord, RiskUser.user_no)
.join(RiskUser, RiskUser.id == RiskLoginRecord.user_id)
.join(FundCustomerProfile, FundCustomerProfile.customer_id == RiskLoginRecord.user_id)
)
conditions = self._conditions_with_scope(
RiskLoginRecord.user_id,
FundCustomerProfile.trade_account,
)
if keyword and keyword.strip():
like = f"%{keyword.strip()}%"
conditions.append(
or_(
RiskUser.user_no.like(like),
RiskLoginRecord.device_id.like(like),
RiskLoginRecord.ip_region.like(like),
)
)
if start_time is not None:
conditions.append(RiskLoginRecord.login_at >= start_time)
if end_time is not None:
conditions.append(RiskLoginRecord.login_at <= end_time)
return await self._page(
statement.where(*conditions).order_by(
RiskLoginRecord.login_at.desc(),
RiskLoginRecord.id.desc(),
),
request,
"risk_login_record",
self._login_record_row,
)
async def list_notifications(
self,
*,
keyword: str | None = None,
send_status: str | None = None,
start_time: datetime | None = None,
end_time: datetime | None = None,
page: PageRequest | None = None,
) -> FundPage:
"""通知记录查询,并返回预警编号。"""
request = page or PageRequest()
statement = (
select(
FundRiskNotification,
FundRiskAlert.alert_no,
RiskUser.user_no,
)
.join(FundRiskAlert, FundRiskAlert.id == FundRiskNotification.alert_id)
.join(RiskUser, RiskUser.id == FundRiskAlert.customer_id)
.join(
FundCustomerProfile,
FundCustomerProfile.customer_id == FundRiskAlert.customer_id,
)
)
conditions = self._conditions_with_scope(
FundRiskAlert.customer_id,
FundCustomerProfile.trade_account,
)
if keyword and keyword.strip():
like = f"%{keyword.strip()}%"
conditions.append(
or_(
FundRiskNotification.notification_no.like(like),
FundRiskAlert.alert_no.like(like),
FundRiskNotification.title.like(like),
FundRiskNotification.send_status.like(like),
FundRiskNotification.receiver_email.like(like),
)
)
if send_status:
conditions.append(FundRiskNotification.send_status == send_status)
if start_time is not None:
conditions.append(FundRiskNotification.created_at >= start_time)
if end_time is not None:
conditions.append(FundRiskNotification.created_at <= end_time)
return await self._page(
statement.where(*conditions).order_by(
FundRiskNotification.created_at.desc(),
FundRiskNotification.id.desc(),
),
request,
"risk_notification",
self._notification_row,
)
def _alert_list_statement(
self,
*,
keyword: str | None,
customer_no: str | None,
product_code: str | None,
product_name: str | None,
risk_level: str | None,
rule_code: str | None,
start_time: datetime | None,
end_time: datetime | None,
open_only: bool,
) -> Select[Any]:
statement = (
select(
FundRiskAlert,
RiskUser.user_no,
FundCustomerProfile.real_name,
FundProduct.product_code,
FundProduct.product_name,
)
.outerjoin(RiskUser, RiskUser.id == FundRiskAlert.customer_id)
.outerjoin(
FundCustomerProfile,
FundCustomerProfile.customer_id == FundRiskAlert.customer_id,
)
.outerjoin(
FundTransaction,
FundTransaction.id == FundRiskAlert.related_transaction_id,
)
.outerjoin(FundProduct, FundProduct.id == FundTransaction.product_id)
)
conditions: list[ColumnElement[bool]] = []
if open_only:
conditions.append(FundRiskAlert.status.in_(OPEN_STATUSES))
if keyword and keyword.strip():
like = f"%{keyword.strip()}%"
conditions.append(
or_(
FundRiskAlert.alert_no.like(like),
FundRiskAlert.alert_type.like(like),
FundRiskAlert.evidence_summary.like(like),
RiskUser.user_no.like(like),
FundCustomerProfile.real_name.like(like),
)
)
if customer_no:
conditions.append(RiskUser.user_no == customer_no)
if product_code:
conditions.append(FundProduct.product_code == product_code)
if product_name:
conditions.append(FundProduct.product_name.like(f"%{product_name}%"))
if risk_level:
conditions.append(FundRiskAlert.alert_level == risk_level)
if rule_code:
conditions.append(FundRiskAlert.trigger_rule_codes.contains([rule_code]))
if start_time is not None:
conditions.append(FundRiskAlert.created_at >= start_time)
if end_time is not None:
conditions.append(FundRiskAlert.created_at <= end_time)
scope_condition = self._scope_condition(
FundRiskAlert.customer_id,
FundCustomerProfile.trade_account,
)
if scope_condition is not None:
conditions.append(scope_condition)
return statement.where(*conditions)
def _scope_condition(
self,
customer_column: Any,
trade_account_column: Any | None = None,
) -> Any:
if self.scope is None or self.scope.is_denied:
return false()
conditions = []
if self.scope.customer_ids is not None:
conditions.append(customer_column.in_(sorted(self.scope.customer_ids)))
if self.scope.trade_accounts is not None:
if trade_account_column is None:
return false()
conditions.append(
customer_column.in_(
select(FundCustomerProfile.customer_id).where(
trade_account_column.in_(sorted(self.scope.trade_accounts))
)
)
)
if not conditions:
return None
return and_(*conditions) if len(conditions) > 1 else conditions[0]
def _conditions_with_scope(
self,
customer_column: Any,
trade_account_column: Any | None = None,
) -> list[Any]:
condition = self._scope_condition(customer_column, trade_account_column)
return [] if condition is None else [condition]
async def _page(
self,
statement: Select[Any],
page: PageRequest,
entity: str,
row_builder: Callable[..., dict[str, Any]],
) -> FundPage:
rows = (
await self.session.execute(
statement.limit(page.limit + 1).offset(page.offset)
)
).all()
has_more = len(rows) > page.limit
records = tuple(
FundRecord(
entity=entity,
values=MappingProxyType(row_builder(*row)),
)
for row in rows[: page.limit]
)
return FundPage(
entity=entity,
items=records,
limit=page.limit,
offset=page.offset,
next_offset=page.offset + page.limit if has_more else None,
)
@staticmethod
def _customer_row(
user: RiskUser,
profile: FundCustomerProfile,
assessment: FundRiskAssessment | None,
) -> dict[str, Any]:
return {
"customer_id": str(user.id),
"customer_no": user.user_no,
"username": user.username,
"name": mask_name(profile.real_name),
"age": _age(profile.birth_date),
"occupation": profile.occupation,
"mobile_masked": profile.mobile_masked,
"total_asset": profile.total_asset,
"customer_tier": user.customer_tier,
"risk_level": profile.investor_type,
"risk_score": assessment.total_score if assessment else None,
"assessment_date": assessment.assessed_at if assessment else None,
"assessment_valid_until": assessment.valid_until if assessment else None,
"assessment_expired": bool(
assessment
and assessment.valid_until
and assessment.valid_until < _utc_now_naive()
),
"behavior_score": profile.behavior_score,
"risk_tags": profile.risk_tags or [],
"opened_at": profile.opened_at,
"status": user.status,
}
@staticmethod
def _transaction_row(
transaction: FundTransaction,
customer_no: str,
product_code: str,
product_name: str,
work_order: RiskWorkOrder | None,
) -> dict[str, Any]:
return {
"transaction_no": transaction.transaction_no,
"customer_no": customer_no,
"product_code": product_code,
"product_name": product_name,
"transaction_type": transaction.transaction_type,
"amount": transaction.amount,
"channel": work_order.channel if work_order else None,
"trade_status": work_order.status if work_order else "已确认",
"risk_disclosure_signed": bool(
work_order and work_order.risk_disclosure_ack_at
),
"second_confirmation": bool(
work_order and work_order.second_confirmation_at
),
"recording_id": work_order.recording_reference if work_order else None,
"work_order_no": work_order.work_order_no if work_order else None,
"confirmed_at": transaction.confirmed_at,
"executed_at": transaction.executed_at,
}
@staticmethod
def _capital_flow_row(
flow: FundCapitalFlow,
customer_no: str,
) -> dict[str, Any]:
return {
"flow_no": flow.flow_no,
"customer_no": customer_no,
"flow_type": flow.flow_type,
"amount": flow.amount,
"status": flow.status,
"settled_at": flow.settled_at,
"occurred_at": flow.occurred_at,
"source_type": flow.source_type,
"match_status": flow.match_status,
}
@staticmethod
def _holding_row(
holding: FundHolding,
customer_no: str,
product_code: str,
product_name: str,
total_asset: Decimal | None,
) -> dict[str, Any]:
current_value = holding.current_value or holding.market_value or Decimal("0")
holding_days = None
if holding.first_acquired_at is not None:
holding_days = (datetime.now(UTC).date() - holding.first_acquired_at.date()).days
holding_ratio = None
if total_asset is not None and total_asset != 0:
holding_ratio = (current_value / total_asset).quantize(Decimal("0.0001"))
return {
"customer_no": customer_no,
"product_code": product_code,
"product_name": product_name,
"shares": holding.shares or holding.total_quantity,
"cost_amount": holding.cost_amount,
"current_value": current_value,
"profit_loss": holding.profit_loss,
"holding_days": holding_days,
"holding_ratio": holding_ratio,
}
@staticmethod
def _login_record_row(
record: RiskLoginRecord,
customer_no: str,
) -> dict[str, Any]:
return {
"id": str(record.id),
"customer_no": customer_no,
"login_at": record.login_at,
"login_result": record.login_result,
"ip_region": record.ip_region,
"device_id": record.device_id,
"is_common_device": bool(record.is_common_device),
"failure_reason": record.failure_reason,
}
@staticmethod
def _notification_row(
notification: FundRiskNotification,
alert_no: str,
customer_no: str,
) -> dict[str, Any]:
return {
"notification_id": notification.notification_no,
"notification_no": notification.notification_no,
"alert_no": alert_no,
"customer_no": customer_no,
"channel": notification.channel,
"title": notification.title,
"send_status": notification.send_status,
"receiver_email": notification.receiver_email,
"send_time": notification.sent_at,
}
@staticmethod
def _alert_row(
alert: FundRiskAlert,
customer_no: str | None,
real_name: str | None,
product_code: str | None,
product_name: str | None,
) -> dict[str, Any]:
snapshot = alert.evidence_snapshot if isinstance(alert.evidence_snapshot, dict) else {}
return {
"alert_no": alert.alert_no,
"customer_id": str(alert.customer_id),
"customer_no": customer_no,
"customer_name": mask_name(real_name),
"product_code": product_code,
"product_name": product_name,
"alert_type": alert.alert_type,
"risk_level": alert.alert_level,
"rule_codes": tuple(alert.trigger_rule_codes or []),
"evidence_summary": alert.evidence_summary,
"evidence_snapshot": snapshot,
"priority_score": alert.priority_score,
"event_status": alert.event_status,
"status": alert.status,
"ack_status": alert.ack_status,
"ack_at": alert.ack_at,
"due_at": alert.due_at,
"is_escalated": bool(alert.is_escalated),
"escalated_at": alert.escalated_at,
"evidence_archived": bool(snapshot.get("evidence_archive")),
# 关闭误报时写入的原因(risk_action_service.py:70 赋值给 alert.close_reason)。
# 原先这一行不在,导致两个症状同一个原因 —— 行里根本没带出来:
# · 日报的"误报原因"分布恒为"未填写"
# (risk_daily_report_service.py:139 用 `item.get("close_reason") or "未填写"`);
# · `:243` 的明细里同一字段同样拿不到值。
# 读取方都是风控侧接口(需要 risk:alert:read),带上它不涉及客户可见面。
"close_reason": alert.close_reason,
"created_at": alert.created_at,
"updated_at": alert.updated_at,
}
@staticmethod
def _profile_row(
profile: FundCustomerProfile | None,
customer_no: str | None = None,
) -> dict[str, Any] | None:
if profile is None:
return None
return {
# 兼容既有前端字段名,但值必须是业务客户编号,不能暴露内部主键。
"customer_id": customer_no,
"customer_no": customer_no,
"name": mask_name(profile.real_name),
"birth_date": profile.birth_date,
"occupation": profile.occupation,
"mobile_masked": profile.mobile_masked,
"investor_type": profile.investor_type,
"investment_horizon": profile.investment_horizon,
"trading_frequency": profile.trading_frequency,
"total_asset": profile.total_asset,
"behavior_score": profile.behavior_score,
"risk_tags": profile.risk_tags or [],
"updated_at": profile.updated_at,
}
@staticmethod
def _model_values(model: Any | None) -> dict[str, Any] | None:
if model is None:
return None
return {
column.key: getattr(model, column.key)
for column in model.__table__.columns
}
def mask_name(name: str | None) -> str | None:
"""只保留姓名首字,其余使用 `*`。"""
if not name:
return name
return name[0] + "*" * max(len(name) - 1, 1)
def _age(birth_date: date | None) -> int | None:
if birth_date is None:
return None
today = datetime.now(UTC).date()
return today.year - birth_date.year - (
(today.month, today.day) < (birth_date.month, birth_date.day)
)
def _utc_now_naive() -> datetime:
return datetime.now(UTC).replace(tzinfo=None)