feat:新增接口

This commit is contained in:
zhangyongcai
2026-09-12 20:59:10 +08:00
parent b95fb5d4f7
commit 98751d592f
8 changed files with 258 additions and 84 deletions
+9
View File
@@ -44,3 +44,12 @@ async def require_customer(user: SysUser = Depends(get_current_user)) -> SysUser
if user.user_type != "CUSTOMER": if user.user_type != "CUSTOMER":
raise ForbiddenError("仅客户用户可以访问 client_agent") raise ForbiddenError("仅客户用户可以访问 client_agent")
return user return user
async def require_risk_officer(user: SysUser = Depends(get_current_user)) -> SysUser:
"""仅允许管理员或风控专员执行风控处置/工单操作。"""
if user.user_type == "ADMIN":
return user
if user.user_type != "EMPLOYEE" or user.employee_role != "风控专员":
raise ForbiddenError("仅风控专员可执行风控处置")
return user
+3 -1
View File
@@ -5,7 +5,7 @@ from fastapi import APIRouter
from api.chat import client_agent, customer_agent, knowledge from api.chat import client_agent, customer_agent, knowledge
from api.routers import product, questionnaire from api.routers import product, questionnaire
from api.routers import account, auth, holdings, purchase, redeem from api.routers import account, auth, holdings, purchase, redeem, risk, work_order
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(auth.router, prefix="/api", tags=["认证"]) api_router.include_router(auth.router, prefix="/api", tags=["认证"])
@@ -13,6 +13,8 @@ api_router.include_router(account.router, prefix="/api", tags=["资金账户"])
api_router.include_router(holdings.router, prefix="/api", tags=["持仓"]) api_router.include_router(holdings.router, prefix="/api", tags=["持仓"])
api_router.include_router(purchase.router, prefix="/api", tags=["交易"]) api_router.include_router(purchase.router, prefix="/api", tags=["交易"])
api_router.include_router(redeem.router, prefix="/api", tags=["交易"]) api_router.include_router(redeem.router, prefix="/api", tags=["交易"])
api_router.include_router(risk.router, prefix="/api", tags=["风控"])
api_router.include_router(work_order.router, prefix="/api", tags=["工单"])
api_router.include_router(customer_agent.router, prefix="/api/agent/customer", tags=["客服Agent"]) api_router.include_router(customer_agent.router, prefix="/api/agent/customer", tags=["客服Agent"])
api_router.include_router(client_agent.router, prefix="/api/agent/client", tags=["ClientAgent"]) api_router.include_router(client_agent.router, prefix="/api/agent/client", tags=["ClientAgent"])
api_router.include_router(knowledge.router, prefix="/api/knowledge", tags=["知识库"]) api_router.include_router(knowledge.router, prefix="/api/knowledge", tags=["知识库"])
+14 -20
View File
@@ -1,5 +1,4 @@
"""biz_work_order 工单表 ORM 模型。""" """biz_work_order 通用业务工单 ORM 模型(拦截/冻结后开启的独立审批链)。"""
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
@@ -12,26 +11,21 @@ from model.base import Base
class BizWorkOrder(Base): class BizWorkOrder(Base):
"""客户工单状态记录。"""
__tablename__ = "biz_work_order" __tablename__ = "biz_work_order"
__table_args__ = {"comment": "通用业务工单表"} __table_args__ = {"comment": "通用业务工单表(投标顾签约、可疑交易上报等流程载体)"}
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
work_order_no: Mapped[str] = mapped_column(String(32), nullable=False) work_order_no: Mapped[str] = mapped_column(String(32), unique=True)
order_type: Mapped[str] = mapped_column(String(32), nullable=False) order_type: Mapped[str] = mapped_column(String(32))
sub_type: Mapped[str | None] = mapped_column(String(32), nullable=True) sub_type: Mapped[str | None] = mapped_column(String(32))
customer_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) customer_id: Mapped[int | None] = mapped_column(BigInteger)
submitter_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) submitter_id: Mapped[int | None] = mapped_column(BigInteger)
handler_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) handler_id: Mapped[int | None] = mapped_column(BigInteger)
current_node: Mapped[str] = mapped_column(String(32), nullable=False) current_node: Mapped[str] = mapped_column(String(32), server_default="初审")
priority: Mapped[str] = mapped_column(String(8), nullable=False) priority: Mapped[str] = mapped_column(String(8), server_default="普通")
status: Mapped[str] = mapped_column(String(16), nullable=False) status: Mapped[str] = mapped_column(String(16), server_default="待处理")
biz_content: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) biz_content: Mapped[dict[str, Any] | None] = mapped_column(JSON)
create_time: Mapped[datetime] = mapped_column( create_time: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
DateTime, server_default=func.now(), nullable=False
)
update_time: Mapped[datetime] = mapped_column( update_time: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now(), nullable=False DateTime, server_default=func.now(), onupdate=func.now()
) )
+9 -2
View File
@@ -1,7 +1,7 @@
"""sys_user 仓储:认证用按用户名/主键取用户。""" """sys_user 仓储:认证用按用户名/主键取用户 + 账号状态更新。"""
from __future__ import annotations from __future__ import annotations
from sqlalchemy import select from sqlalchemy import select, update
from model.sys_user import SysUser from model.sys_user import SysUser
from repositories.base import BaseRepository from repositories.base import BaseRepository
@@ -15,3 +15,10 @@ class SysUserRepo(BaseRepository):
async def get_by_phone(self, phone: str) -> SysUser | None: async def get_by_phone(self, phone: str) -> SysUser | None:
return await self.db.scalar(select(SysUser).where(SysUser.phone == phone)) return await self.db.scalar(select(SysUser).where(SysUser.phone == phone))
async def update_status(self, user_id: int, status: str) -> int:
"""更新账号状态(如风控冻结客户)。不 commit,由 service 层事务统一提交。"""
result = await self.db.execute(
update(SysUser).where(SysUser.id == user_id).values(status=status)
)
return result.rowcount
+11 -5
View File
@@ -14,11 +14,17 @@ class PurchaseReq(BaseModel):
class PurchaseResp(BaseModel): class PurchaseResp(BaseModel):
"""申购结果:最新余额 + 该产品最新持仓。""" """申购结果:order_no + status(已确认 / 风控挂起)。
balance: Decimal 已确认时返回最新余额 + 该产品持仓;风控挂起时账户/持仓不变,仅返回预警关联。
holding: HoldingResp """
order_no: str
status: str
balance: Decimal | None = None
holding: HoldingResp | None = None
alert_id: int | None = None
@field_serializer("balance") @field_serializer("balance")
def _fmt_balance(self, value: Decimal) -> str: def _fmt_balance(self, value: Decimal | None) -> str | None:
return f"{value:.2f}" return f"{value:.2f}" if value is not None else None
+11 -5
View File
@@ -14,11 +14,17 @@ class RedeemReq(BaseModel):
class RedeemResp(BaseModel): class RedeemResp(BaseModel):
"""赎回结果:最新余额 + 该产品最新持仓。""" """赎回结果:order_no + status(已确认 / 风控挂起)。
balance: Decimal 已确认时返回最新余额 + 该产品持仓;风控挂起时账户/持仓不变,仅返回预警关联。
holding: HoldingResp """
order_no: str
status: str
balance: Decimal | None = None
holding: HoldingResp | None = None
alert_id: int | None = None
@field_serializer("balance") @field_serializer("balance")
def _fmt_balance(self, value: Decimal) -> str: def _fmt_balance(self, value: Decimal | None) -> str | None:
return f"{value:.2f}" return f"{value:.2f}" if value is not None else None
+98 -24
View File
@@ -1,23 +1,31 @@
"""申购服务:风险匹配校验 + 余额扣减 + 持仓加仓(单事务原子)。""" """申购服务:适当性校验 → 建单(待确认) → 风控检测 → 落账/挂起(单事务原子)。"""
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from decimal import ROUND_HALF_UP, Decimal from decimal import ROUND_HALF_UP, Decimal
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from model.fin_risk_alert import FinRiskAlert
from model.fin_transaction import FinTransaction
from model.sys_user import SysUser from model.sys_user import SysUser
from model.trade_order import TradeOrder
from repositories.fin_account import FinAccountRepo from repositories.fin_account import FinAccountRepo
from repositories.fin_customer_profile import FinCustomerProfileRepo from repositories.fin_customer_profile import FinCustomerProfileRepo
from repositories.fin_holdings import FinHoldingsRepo from repositories.fin_holdings import FinHoldingsRepo
from repositories.fin_product import FinProductRepo from repositories.fin_product import FinProductRepo
from repositories.trade_order import TradeOrderRepo
from schemas.holdings import HoldingResp from schemas.holdings import HoldingResp
from schemas.purchase import PurchaseResp from schemas.purchase import PurchaseResp
from service.risk.engine import RiskEngine, summarize
from service.risk.settle import settle
from utils.exceptions import ( from utils.exceptions import (
ForbiddenError, ForbiddenError,
NotFoundError, NotFoundError,
NotSuitableError, NotSuitableError,
ParamError, ParamError,
) )
from utils.order_no import gen_order_no
_MONEY = Decimal("0.01") _MONEY = Decimal("0.01")
_SHARES = Decimal("0.0001") _SHARES = Decimal("0.0001")
@@ -36,15 +44,30 @@ def _risk_rank(level: str | None) -> int | None:
return _RISK_RANK.get(level.strip()) return _RISK_RANK.get(level.strip())
def _holding_resp(holding) -> HoldingResp:
return HoldingResp(
id=holding.id,
customer_id=holding.customer_id,
product_id=holding.product_id,
shares=holding.shares,
cost_amount=holding.cost_amount,
current_value=holding.current_value,
profit_loss=holding.profit_loss,
profit_ratio=holding.profit_ratio,
status=holding.status,
)
async def purchase( async def purchase(
db: AsyncSession, user: SysUser, product_id: int, amount: Decimal db: AsyncSession, user: SysUser, product_id: int, amount: Decimal
) -> PurchaseResp: ) -> PurchaseResp:
"""申购基金:校验通过后扣减余额并加仓,全程单事务。 """申购基金:适当性校验 → 建单 → 风控检测 → 落账/挂起,全程单事务。
- 仅客户可申购; - 仅客户可申购;
- 产品须在售且净值非空; - 产品须在售且净值非空;
- 客户风险等级序号 >= 产品风险等级序号,否则 1005 拦截; - 适当性不匹配(1005)在建单前直接拒绝,不生成订单;
- 余额不足拦截;扣款 + 加仓要么都成、要么都回滚。 - 未命中规则:落账 + 写成交流水,订单「已确认」;
- 命中规则:生成预警,订单「风控挂起」,账户与持仓不变。
""" """
if user.user_type != "CUSTOMER": if user.user_type != "CUSTOMER":
raise ForbiddenError("仅客户账号可申购") raise ForbiddenError("仅客户账号可申购")
@@ -69,31 +92,82 @@ async def purchase(
shares = (amount / product.nav).quantize(_SHARES, rounding=ROUND_HALF_UP) shares = (amount / product.nav).quantize(_SHARES, rounding=ROUND_HALF_UP)
now = datetime.now()
order = TradeOrder(
order_no=gen_order_no("PO"),
customer_id=user.id,
product_id=product_id,
advisor_id=None,
order_type="申购",
amount=amount,
shares=shares,
nav=product.nav,
fee=Decimal("0"),
status="待确认",
create_time=now,
)
db.add(order)
await db.flush() # 生成 order.id,供流水/预警关联
hits = await RiskEngine(db).detect(order, user)
account_repo = FinAccountRepo(db) account_repo = FinAccountRepo(db)
holdings_repo = FinHoldingsRepo(db) holdings_repo = FinHoldingsRepo(db)
try: try:
if not await account_repo.deduct_balance(user.id, amount): if not hits:
raise ParamError("可用余额不足") # 未命中:落账 + 写流水 + 订单确认
await holdings_repo.upsert(user.id, product_id, shares, amount) await settle(db, order)
db.add(
FinTransaction(
transaction_no=gen_order_no("TR"),
order_id=order.id,
customer_id=user.id,
product_id=product_id,
operator_id=None,
transaction_type="申购",
amount=amount,
shares=shares,
nav=product.nav,
fee=Decimal("0"),
status="已确认",
create_time=now,
)
)
await TradeOrderRepo(db).update_status(
order.id, status="已确认", confirm_time=now
)
await db.commit()
account = await account_repo.get_by_customer_id(user.id)
holding = await holdings_repo.get_by_customer_product(user.id, product_id)
return PurchaseResp(
order_no=order.order_no,
status="已确认",
balance=account.balance,
holding=_holding_resp(holding),
)
# 命中:生成预警 + 订单挂起
summary = summarize(hits)
alert = FinRiskAlert(
customer_id=user.id,
order_id=order.id,
alert_type=summary.alert_type,
alert_level=summary.alert_level,
trigger_detail=summary.trigger_detail,
transaction_ids=summary.transaction_ids or None,
confidence=summary.confidence,
status="未处理",
create_time=now,
)
db.add(alert)
await db.flush() # 生成 alert.id,供订单回填 risk_alert_id
await TradeOrderRepo(db).update_status(
order.id, status="风控挂起", risk_alert_id=alert.id
)
await db.commit() await db.commit()
return PurchaseResp(
order_no=order.order_no, status="风控挂起", alert_id=alert.id
)
except Exception: except Exception:
await db.rollback() await db.rollback()
raise raise
account = await account_repo.get_by_customer_id(user.id)
holding = await holdings_repo.get_by_customer_product(user.id, product_id)
return PurchaseResp(
balance=account.balance,
holding=HoldingResp(
id=holding.id,
customer_id=holding.customer_id,
product_id=holding.product_id,
shares=holding.shares,
cost_amount=holding.cost_amount,
current_value=holding.current_value,
profit_loss=holding.profit_loss,
profit_ratio=holding.profit_ratio,
status=holding.status,
),
)
+102 -26
View File
@@ -1,31 +1,53 @@
"""赎回服务:校验持仓 → 减仓 + 余额入账(单事务原子)。""" """赎回服务:校验持仓 → 建单(待确认) → 风控检测 → 落账/挂起(单事务原子)。"""
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from decimal import ROUND_HALF_UP, Decimal from decimal import ROUND_HALF_UP, Decimal
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from model.fin_risk_alert import FinRiskAlert
from model.fin_transaction import FinTransaction
from model.sys_user import SysUser from model.sys_user import SysUser
from model.trade_order import TradeOrder
from repositories.fin_account import FinAccountRepo from repositories.fin_account import FinAccountRepo
from repositories.fin_holdings import FinHoldingsRepo from repositories.fin_holdings import FinHoldingsRepo
from repositories.fin_product import FinProductRepo from repositories.fin_product import FinProductRepo
from repositories.trade_order import TradeOrderRepo
from schemas.holdings import HoldingResp from schemas.holdings import HoldingResp
from schemas.redeem import RedeemResp from schemas.redeem import RedeemResp
from service.risk.engine import RiskEngine, summarize
from service.risk.settle import settle
from utils.exceptions import ForbiddenError, NotFoundError, ParamError from utils.exceptions import ForbiddenError, NotFoundError, ParamError
from utils.order_no import gen_order_no
_MONEY = Decimal("0.01") _MONEY = Decimal("0.01")
_SHARES = Decimal("0.0001") _SHARES = Decimal("0.0001")
def _holding_resp(holding) -> HoldingResp:
return HoldingResp(
id=holding.id,
customer_id=holding.customer_id,
product_id=holding.product_id,
shares=holding.shares,
cost_amount=holding.cost_amount,
current_value=holding.current_value,
profit_loss=holding.profit_loss,
profit_ratio=holding.profit_ratio,
status=holding.status,
)
async def redeem( async def redeem(
db: AsyncSession, user: SysUser, product_id: int, shares: Decimal db: AsyncSession, user: SysUser, product_id: int, shares: Decimal
) -> RedeemResp: ) -> RedeemResp:
"""赎回基金:校验通过后减仓并按净值入账,全程单事务。 """赎回基金:校验持仓 → 建单 → 风控检测 → 落账/挂起,全程单事务。
- 仅客户可赎回; - 仅客户可赎回;
- 产品须存在且净值非空; - 产品须存在且净值非空,持仓须足额;
- 持仓须存在且份额 > 0,赎回份额不得超过持仓份额; - 未命中规则:减仓 + 入账 + 写流水,订单「已确认」;
- 减仓(归 0 时置'已清仓')+ 入账要么都成、要么都回滚。 - 命中规则:生成预警,订单「风控挂起」,账户与持仓不变。
""" """
if user.user_type != "CUSTOMER": if user.user_type != "CUSTOMER":
raise ForbiddenError("仅客户账号可赎回") raise ForbiddenError("仅客户账号可赎回")
@@ -40,8 +62,8 @@ async def redeem(
if product.nav is None: if product.nav is None:
raise ParamError("产品暂无净值,无法赎回") raise ParamError("产品暂无净值,无法赎回")
account_repo = FinAccountRepo(db)
holdings_repo = FinHoldingsRepo(db) holdings_repo = FinHoldingsRepo(db)
account_repo = FinAccountRepo(db)
holding = await holdings_repo.get_by_customer_product(user.id, product_id) holding = await holdings_repo.get_by_customer_product(user.id, product_id)
if holding is None or holding.shares <= 0: if holding is None or holding.shares <= 0:
@@ -53,28 +75,82 @@ async def redeem(
if credited <= 0: if credited <= 0:
raise ParamError("赎回金额过低") raise ParamError("赎回金额过低")
now = datetime.now()
order = TradeOrder(
order_no=gen_order_no("PO"),
customer_id=user.id,
product_id=product_id,
advisor_id=None,
order_type="赎回",
amount=credited,
shares=shares,
nav=product.nav,
fee=Decimal("0"),
status="待确认",
create_time=now,
)
db.add(order)
await db.flush() # 生成 order.id,供流水/预警关联
hits = await RiskEngine(db).detect(order, user)
try: try:
if not await holdings_repo.redeem(user.id, product_id, shares): if not hits:
raise ParamError("可赎回份额不足") # 未命中:减仓 + 入账 + 写流水 + 订单确认
await account_repo.credit_balance(user.id, credited) await settle(db, order)
db.add(
FinTransaction(
transaction_no=gen_order_no("TR"),
order_id=order.id,
customer_id=user.id,
product_id=product_id,
operator_id=None,
transaction_type="赎回",
amount=credited,
shares=shares,
nav=product.nav,
fee=Decimal("0"),
status="已确认",
create_time=now,
)
)
await TradeOrderRepo(db).update_status(
order.id, status="已确认", confirm_time=now
)
await db.commit()
account = await account_repo.get_by_customer_id(user.id)
current_holding = await holdings_repo.get_by_customer_product(
user.id, product_id
)
return RedeemResp(
order_no=order.order_no,
status="已确认",
balance=account.balance,
holding=_holding_resp(current_holding),
)
# 命中:生成预警 + 订单挂起
summary = summarize(hits)
alert = FinRiskAlert(
customer_id=user.id,
order_id=order.id,
alert_type=summary.alert_type,
alert_level=summary.alert_level,
trigger_detail=summary.trigger_detail,
transaction_ids=summary.transaction_ids or None,
confidence=summary.confidence,
status="未处理",
create_time=now,
)
db.add(alert)
await db.flush() # 生成 alert.id,供订单回填 risk_alert_id
await TradeOrderRepo(db).update_status(
order.id, status="风控挂起", risk_alert_id=alert.id
)
await db.commit() await db.commit()
return RedeemResp(
order_no=order.order_no, status="风控挂起", alert_id=alert.id
)
except Exception: except Exception:
await db.rollback() await db.rollback()
raise raise
account = await account_repo.get_by_customer_id(user.id)
holding = await holdings_repo.get_by_customer_product(user.id, product_id)
return RedeemResp(
balance=account.balance,
holding=HoldingResp(
id=holding.id,
customer_id=holding.customer_id,
product_id=holding.product_id,
shares=holding.shares,
cost_amount=holding.cost_amount,
current_value=holding.current_value,
profit_loss=holding.profit_loss,
profit_ratio=holding.profit_ratio,
status=holding.status,
),
)