feat(api): 实现代销平台 API v0.1,包括客户、产品、理财师、合规及员工接口

- 新增多个 API 路由:`/api/customers`, `/api/products`, `/api/advisors`, `/api/compliance`, `/api/staff`,支持客户信息、产品详情、理财师客户列表、合规判定及员工上下文查询。
- 引入平台服务层,封装核心只读操作,支持数据脱敏功能。
- 更新依赖注入,确保各 API 路由的权限控制与数据访问一致性。
- 添加相应的单元测试,确保新接口的功能完整性与稳定性。

此更新为代销平台提供了基础的 REST API 支持,增强了系统的可扩展性与可维护性。
This commit is contained in:
2026-09-08 21:01:07 +08:00
parent f0bce1270b
commit ef56c56435
25 changed files with 940 additions and 15 deletions
+235
View File
@@ -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**"