feat(api): 实现代销平台 API v0.1,包括客户、产品、理财师、合规及员工接口
- 新增多个 API 路由:`/api/customers`, `/api/products`, `/api/advisors`, `/api/compliance`, `/api/staff`,支持客户信息、产品详情、理财师客户列表、合规判定及员工上下文查询。 - 引入平台服务层,封装核心只读操作,支持数据脱敏功能。 - 更新依赖注入,确保各 API 路由的权限控制与数据访问一致性。 - 添加相应的单元测试,确保新接口的功能完整性与稳定性。 此更新为代销平台提供了基础的 REST API 支持,增强了系统的可扩展性与可维护性。
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
"""代销平台 API v0.1(customers/products/advisors/staff/compliance)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import text
|
||||
|
||||
from _ddl import create_sqlite_engine, seed_suitability_matrix
|
||||
|
||||
from app.config.settings import settings
|
||||
from app.main import app
|
||||
from app.repository.risk_repository import RiskRepository
|
||||
from app.service.risk import redis_gateway
|
||||
|
||||
|
||||
class FakeGateway:
|
||||
def publish(self, channel, payload):
|
||||
pass
|
||||
|
||||
def delete(self, *keys):
|
||||
pass
|
||||
|
||||
|
||||
CUST_A = {"X-Debug-Role": "customer", "X-Debug-Actor": "CUST-1001"}
|
||||
CUST_B = {"X-Debug-Role": "customer", "X-Debug-Actor": "CUST-1002"}
|
||||
ADVISOR = {"X-Debug-Role": "advisor", "X-Debug-Actor": "STAFF-10086"}
|
||||
ANALYST = {"X-Debug-Role": "analyst", "X-Debug-Actor": "STAFF-20001"}
|
||||
OTHER_ADV = {"X-Debug-Role": "advisor", "X-Debug-Actor": "STAFF-99999"}
|
||||
|
||||
|
||||
def _seed_core(engine) -> None:
|
||||
future = (datetime.now() + timedelta(days=180)).isoformat(sep=" ")
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO core_customer (customer_id, display_name, age, is_active) "
|
||||
"VALUES ('CUST-1001','CustomerA',35,1), ('CUST-1002','CustomerB',40,1)"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO core_customer_risk (customer_id, risk_code, expires_at, investor_category) "
|
||||
"VALUES ('CUST-1001','C2',:exp,'ordinary'), ('CUST-1002','C3',:exp,'ordinary')"
|
||||
),
|
||||
{"exp": future},
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO core_customer_advisor (advisor_id, customer_id, rel_status) "
|
||||
"VALUES ('STAFF-10086','CUST-1001','active')"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO core_product (product_id, product_name, min_risk_code, is_open) "
|
||||
"VALUES ('PROD-001','稳健债基','R2',1), ('PROD-CLOSED','已下架','R1',0)"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO core_holding (customer_id, product_id, market_value, quantity) "
|
||||
"VALUES ('CUST-1001','PROD-001',100000,1000)"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO core_trade (trade_id, customer_id, product_id, trade_type, amount, "
|
||||
"trade_status, traded_at) VALUES ('TRD-P-001','CUST-1001','PROD-001','subscribe',"
|
||||
"5000,'confirmed',datetime('now','localtime'))"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO core_product_nav (product_id, nav_date, nav, daily_chg_pct) "
|
||||
"VALUES ('PROD-001', date('now'), 1.25, 0.01)"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO core_staff (staff_id, display_name, staff_type, roles, is_active) "
|
||||
"VALUES ('STAFF-10086','王理财','advisor',:roles,1), "
|
||||
"('STAFF-20001','分析员','analyst',:aroles,1)"
|
||||
),
|
||||
{"roles": json.dumps(["advisor"]), "aroles": json.dumps(["analyst"])},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
engine = create_sqlite_engine()
|
||||
seed_suitability_matrix(engine)
|
||||
_seed_core(engine)
|
||||
repo = RiskRepository(engine=engine)
|
||||
|
||||
monkeypatch.setattr("app.utils.db.get_engine", lambda name=None: engine)
|
||||
monkeypatch.setattr("app.repository.core_ro.get_engine", lambda name=None: engine)
|
||||
|
||||
from app.api import advisors as advisors_api
|
||||
from app.api import compliance as compliance_api
|
||||
from app.api import customers as customers_api
|
||||
from app.api import deps as deps_mod
|
||||
from app.api import staff as staff_api
|
||||
|
||||
monkeypatch.setattr(deps_mod, "RiskRepository", lambda: repo)
|
||||
monkeypatch.setattr(customers_api, "_risk_repo", lambda: repo)
|
||||
monkeypatch.setattr(advisors_api, "_risk_repo", lambda: repo)
|
||||
monkeypatch.setattr(staff_api, "_risk_repo", lambda: repo)
|
||||
monkeypatch.setattr(compliance_api, "_risk_repo", lambda: repo)
|
||||
|
||||
with TestClient(app) as c:
|
||||
monkeypatch.setattr(redis_gateway, "_gateway", FakeGateway())
|
||||
yield c
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_get_customer_owner_ok(client):
|
||||
r = client.get("/api/customers/CUST-1001", headers=CUST_A)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["code"] == 0
|
||||
assert body["data"]["customer_id"] == "CUST-1001"
|
||||
assert body["data"]["display_name"] == "CustomerA"
|
||||
|
||||
|
||||
def test_get_customer_other_forbidden(client):
|
||||
r = client.get("/api/customers/CUST-1002", headers=CUST_A)
|
||||
assert r.status_code == 403
|
||||
assert r.json()["error_code"] == "AUTH_403_NOT_OWNER"
|
||||
|
||||
|
||||
def test_get_customer_analyst_ok(client):
|
||||
r = client.get("/api/customers/CUST-1002", headers=ANALYST)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_get_customer_not_found(client):
|
||||
r = client.get("/api/customers/CUST-404", headers=ANALYST)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_list_holdings(client):
|
||||
r = client.get("/api/customers/CUST-1001/holdings", headers=CUST_A)
|
||||
assert r.status_code == 200
|
||||
items = r.json()["data"]["items"]
|
||||
assert len(items) == 1
|
||||
assert items[0]["product_id"] == "PROD-001"
|
||||
|
||||
|
||||
def test_list_trades(client):
|
||||
r = client.get("/api/customers/CUST-1001/trades", headers=CUST_A)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["data"]["total"] >= 1
|
||||
|
||||
|
||||
def test_list_customer_products(client):
|
||||
r = client.get("/api/customers/CUST-1001/products", headers=CUST_A)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["data"]["total"] >= 1
|
||||
|
||||
|
||||
def test_advisor_roster_self(client):
|
||||
r = client.get("/api/advisors/STAFF-10086/customers", headers=ADVISOR)
|
||||
assert r.status_code == 200
|
||||
items = r.json()["data"]["items"]
|
||||
assert len(items) == 1
|
||||
assert items[0]["customer_id"] == "CUST-1001"
|
||||
|
||||
|
||||
def test_advisor_roster_other_forbidden(client):
|
||||
r = client.get("/api/advisors/STAFF-10086/customers", headers=OTHER_ADV)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_list_products_excludes_closed(client):
|
||||
r = client.get("/api/products", headers=ANALYST)
|
||||
assert r.status_code == 200
|
||||
ids = [i["product_id"] for i in r.json()["data"]["items"]]
|
||||
assert "PROD-001" in ids
|
||||
assert "PROD-CLOSED" not in ids
|
||||
|
||||
|
||||
def test_get_product_and_nav(client):
|
||||
r = client.get("/api/products/PROD-001", headers=CUST_A)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["data"]["product_name"] == "稳健债基"
|
||||
r2 = client.get("/api/products/PROD-001/nav", headers=CUST_A)
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["data"]["nav"] == 1.25
|
||||
|
||||
|
||||
def test_staff_me(client):
|
||||
r = client.get("/api/staff/me", headers=ADVISOR)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["data"]["staff_id"] == "STAFF-10086"
|
||||
assert "advisor" in r.json()["data"]["roles"]
|
||||
|
||||
|
||||
def test_staff_me_customer_forbidden(client):
|
||||
r = client.get("/api/staff/me", headers=CUST_A)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_compliance_suitability_check(client):
|
||||
r = client.post(
|
||||
"/api/compliance/suitability-check",
|
||||
headers=CUST_A,
|
||||
json={"customer_id": "CUST-1001", "product_id": "PROD-001"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()["data"]
|
||||
assert data["is_matched"] is True
|
||||
assert data["blocked"] is False
|
||||
|
||||
|
||||
def test_platform_no_agent_type_header(client):
|
||||
"""平台 JWT/debug 通道不要求 X-Agent-Type。"""
|
||||
r = client.get("/api/customers/CUST-1001", headers=CUST_A)
|
||||
assert r.status_code == 200
|
||||
assert "X-Agent-Type" not in CUST_A
|
||||
|
||||
|
||||
def test_desensitize_switch_off_by_default(client, monkeypatch):
|
||||
monkeypatch.setattr(settings, "platform_response_desensitize", False)
|
||||
r = client.get("/api/customers/CUST-1001", headers=CUST_A)
|
||||
assert r.json()["data"]["display_name"] == "CustomerA"
|
||||
|
||||
|
||||
def test_desensitize_switch_on(client, monkeypatch):
|
||||
monkeypatch.setattr(settings, "platform_response_desensitize", True)
|
||||
r = client.get("/api/customers/CUST-1001", headers=CUST_A)
|
||||
assert r.json()["data"]["display_name"] == "C**"
|
||||
Reference in New Issue
Block a user