diff --git a/.env.example b/.env.example index 69a1be7..31c080b 100644 --- a/.env.example +++ b/.env.example @@ -67,3 +67,6 @@ RISK_AGENT_BEHAVIOR_B_WINDOW_HOURS=72 RISK_AGENT_BEHAVIOR_B_COUNT=5 RISK_AGENT_BEHAVIOR_C_WINDOW_HOURS=24 RISK_AGENT_BEHAVIOR_C_COUNT=10 + +# Platform API (v0.1): false = raw L0 for mock Core; true = desensitize on Service exit +PLATFORM_RESPONSE_DESENSITIZE=false diff --git a/app/api/advisors.py b/app/api/advisors.py new file mode 100644 index 0000000..ce259e7 --- /dev/null +++ b/app/api/advisors.py @@ -0,0 +1,36 @@ +"""代销平台 · 理财师名下客户。""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request + +from app.api.deps import ( + AuthContext, + assert_advisor_roster_access, + get_platform_auth_context, +) +from app.repository.risk_repository import RiskRepository +from app.service.platform import advisor_service +from app.utils.response import ok + +router = APIRouter(prefix="/api/advisors", tags=["platform-advisors"]) + + +def _risk_repo() -> RiskRepository: + return RiskRepository() + + +def _trace_id(request: Request) -> str: + return getattr(request.state, "trace_id", "unknown") + + +@router.get("/{advisor_id}/customers") +def list_advisor_customers_api( + advisor_id: str, + request: Request, + auth: AuthContext = Depends(get_platform_auth_context), +) -> dict: + repo = _risk_repo() + assert_advisor_roster_access(auth, advisor_id, risk_repo=repo) + data = advisor_service.list_advisor_customers(advisor_id) + return ok(data, _trace_id(request)).model_dump() diff --git a/app/api/compliance.py b/app/api/compliance.py new file mode 100644 index 0000000..5c19e50 --- /dev/null +++ b/app/api/compliance.py @@ -0,0 +1,55 @@ +"""代销平台 · 合规判定(canonical 适当性 · 与 /api/risk/suitability/check 并存过渡)。""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel, Field + +from app.api.deps import ( + AuthContext, + assert_platform_customer_access, + get_platform_auth_context, +) +from app.repository.core_ro import CoreReadOnlyRepository +from app.repository.risk_repository import RiskRepository +from app.service.platform import compliance_service +from app.utils.response import ok + +router = APIRouter(prefix="/api/compliance", tags=["platform-compliance"]) + + +class SuitabilityCheckRequest(BaseModel): + customer_id: str = Field(..., min_length=1) + product_id: str = Field(..., min_length=1) + + +def _core_ro() -> CoreReadOnlyRepository: + return CoreReadOnlyRepository() + + +def _risk_repo() -> RiskRepository: + return RiskRepository() + + +def _trace_id(request: Request) -> str: + return getattr(request.state, "trace_id", "unknown") + + +@router.post("/suitability-check") +def suitability_check_api( + req: SuitabilityCheckRequest, + request: Request, + auth: AuthContext = Depends(get_platform_auth_context), +) -> dict: + core = _core_ro() + repo = _risk_repo() + assert_platform_customer_access(auth, req.customer_id, core_ro=core, risk_repo=repo) + result = compliance_service.run_suitability_check( + req.customer_id, + req.product_id, + actor_id=auth.actor_id, + core_ro=core, + risk_repo=repo, + ) + body = compliance_service.suitability_check_payload(result) + return ok(body, _trace_id(request)).model_dump() diff --git a/app/api/customers.py b/app/api/customers.py new file mode 100644 index 0000000..937f522 --- /dev/null +++ b/app/api/customers.py @@ -0,0 +1,90 @@ +"""代销平台 · 客户 L0 与资产读 API(canonical · 不要求 X-Agent-Type)。""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query, Request + +from app.api.deps import ( + AuthContext, + assert_platform_customer_access, + get_platform_auth_context, +) +from app.repository.core_ro import CoreReadOnlyRepository +from app.repository.risk_repository import RiskRepository +from app.service.platform import customer_service +from app.utils.exceptions import ApiError +from app.utils.response import ok + +router = APIRouter(prefix="/api/customers", tags=["platform-customers"]) + + +def _core_ro() -> CoreReadOnlyRepository: + return CoreReadOnlyRepository() + + +def _risk_repo() -> RiskRepository: + return RiskRepository() + + +def _trace_id(request: Request) -> str: + return getattr(request.state, "trace_id", "unknown") + + +@router.get("/{customer_id}") +def get_customer_api( + customer_id: str, + request: Request, + auth: AuthContext = Depends(get_platform_auth_context), +) -> dict: + core = _core_ro() + repo = _risk_repo() + assert_platform_customer_access(auth, customer_id, core_ro=core, risk_repo=repo) + row = customer_service.get_customer_l0(customer_id, core_ro=core) + if row is None: + raise ApiError(404, "NOT_FOUND", f"customer not found: {customer_id}") + return ok(row, _trace_id(request)).model_dump() + + +@router.get("/{customer_id}/holdings") +def list_holdings_api( + customer_id: str, + request: Request, + limit: int = Query(500, ge=1, le=500), + auth: AuthContext = Depends(get_platform_auth_context), +) -> dict: + core = _core_ro() + repo = _risk_repo() + assert_platform_customer_access(auth, customer_id, core_ro=core, risk_repo=repo) + data = customer_service.list_holdings(customer_id, limit=limit, core_ro=core) + return ok(data, _trace_id(request)).model_dump() + + +@router.get("/{customer_id}/trades") +def list_trades_api( + customer_id: str, + request: Request, + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + auth: AuthContext = Depends(get_platform_auth_context), +) -> dict: + core = _core_ro() + repo = _risk_repo() + assert_platform_customer_access(auth, customer_id, core_ro=core, risk_repo=repo) + data = customer_service.list_trades( + customer_id, limit=limit, offset=offset, core_ro=core + ) + return ok(data, _trace_id(request)).model_dump() + + +@router.get("/{customer_id}/products") +def list_customer_products_api( + customer_id: str, + request: Request, + limit: int = Query(50, ge=1, le=200), + auth: AuthContext = Depends(get_platform_auth_context), +) -> dict: + core = _core_ro() + repo = _risk_repo() + assert_platform_customer_access(auth, customer_id, core_ro=core, risk_repo=repo) + data = customer_service.list_customer_products(customer_id, limit=limit, core_ro=core) + return ok(data, _trace_id(request)).model_dump() diff --git a/app/api/deps.py b/app/api/deps.py index 8bd20a5..0cd37a9 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -37,6 +37,7 @@ from app.utils.trace import current_trace, new_trace logger = logging.getLogger(__name__) STAFF_FULL_ACCESS_ROLES = ("risk_officer",) +PLATFORM_FULL_READ_ROLES = ("risk_officer", "risk_manager", "risk_demo", "analyst") DEBUG_ROLE_HEADER = "X-Debug-Role" DEBUG_ACTOR_HEADER = "X-Debug-Actor" AGENT_TYPE_HEADER = "X-Agent-Type" @@ -279,6 +280,82 @@ def get_auth_context(request: Request) -> AuthContext: ) +def get_platform_auth_context(request: Request) -> AuthContext: + """平台 API 鉴权:JWT 通道 **不要求** X-Agent-Type(代销 App/柜面)。""" + auth_header = request.headers.get("Authorization", "") + if auth_header[:7].lower() == "bearer ": + token = auth_header[7:].strip() + if not token: + _safe_unauth_audit("anonymous", "AUTH_401_INVALID_TOKEN") + raise ApiError(401, "AUTH_401_INVALID_TOKEN", "empty bearer token") + try: + claims = verify_token(token) + except TokenInvalid as exc: + _safe_unauth_audit("anonymous", exc.code) + raise ApiError(401, exc.code, exc.message) from exc + return _bind_state(request, _claims_to_auth(claims)) + + if settings.app_env != "development" or settings.jwt_public_key_path: + _safe_unauth_audit("anonymous", "AUTH_401_MISSING_BEARER") + raise ApiError(401, "AUTH_401_MISSING_BEARER", "missing Authorization bearer token") + + roles = [r.strip() for r in request.headers.get(DEBUG_ROLE_HEADER, "").split(",") if r.strip()] + actor_id = request.headers.get(DEBUG_ACTOR_HEADER, "").strip() + if not roles or not actor_id: + _safe_unauth_audit(actor_id, "AUTH_401_MISSING_DEBUG_HEADERS") + raise ApiError( + 401, "AUTH_401_MISSING_DEBUG_HEADERS", "missing X-Debug-Role/X-Debug-Actor headers" + ) + return _bind_state( + request, + AuthContext( + actor_id=actor_id, + roles=roles, + customer_id=actor_id if "customer" in roles else None, + token_type="customer" if "customer" in roles else "staff", + ), + ) + + +def assert_platform_customer_access( + auth: AuthContext, + customer_id: str, + core_ro: CoreReadOnlyRepository, + risk_repo: RiskRepository, +) -> None: + """平台 G-01:customer 本人 / advisor 名下 / analyst·risk_*·risk_demo 全量只读。""" + if auth.has_role(*PLATFORM_FULL_READ_ROLES): + return + if "customer" in auth.roles: + if auth.customer_id == customer_id: + return + deny(auth, "AUTH_403_NOT_OWNER", risk_repo, customer_id, agent_type="platform") + if "advisor" in auth.roles: + if core_ro.is_advisor_assigned(auth.actor_id, customer_id): + return + deny(auth, "AUTH_403_NOT_ASSIGNED", risk_repo, customer_id, agent_type="platform") + deny(auth, "AUTH_403_SCOPE", risk_repo, customer_id, agent_type="platform") + + +def assert_advisor_roster_access( + auth: AuthContext, + advisor_id: str, + risk_repo: RiskRepository, +) -> None: + """理财师客户列表:advisor 仅查本人;内勤/风控角色可查任意 advisor_id。""" + if auth.has_role(*PLATFORM_FULL_READ_ROLES): + return + if "advisor" in auth.roles and auth.actor_id == advisor_id: + return + deny(auth, "AUTH_403_SCOPE", risk_repo, message="advisor roster forbidden", agent_type="platform") + + +def require_staff_token(auth: AuthContext, risk_repo: RiskRepository) -> None: + if auth.token_type == "staff": + return + deny(auth, "AUTH_403_SCOPE", risk_repo, message="staff token required", agent_type="platform") + + def assert_customer_access( auth: AuthContext, customer_id: str, diff --git a/app/api/products.py b/app/api/products.py new file mode 100644 index 0000000..a7ec0c8 --- /dev/null +++ b/app/api/products.py @@ -0,0 +1,55 @@ +"""代销平台 · 产品货架 / 详情 / 净值。""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query, Request + +from app.api.deps import AuthContext, get_platform_auth_context +from app.repository.core_ro import CoreReadOnlyRepository +from app.service.platform import product_service +from app.utils.exceptions import ApiError +from app.utils.response import ok + +router = APIRouter(prefix="/api/products", tags=["platform-products"]) + + +def _trace_id(request: Request) -> str: + return getattr(request.state, "trace_id", "unknown") + + +@router.get("") +def list_products_api( + request: Request, + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + auth: AuthContext = Depends(get_platform_auth_context), +) -> dict: + data = product_service.list_products(limit=limit, offset=offset) + return ok(data, _trace_id(request)).model_dump() + + +@router.get("/{product_id}") +def get_product_api( + product_id: str, + request: Request, + auth: AuthContext = Depends(get_platform_auth_context), +) -> dict: + row = product_service.get_product(product_id) + if row is None: + raise ApiError(404, "NOT_FOUND", f"product not found: {product_id}") + return ok(row, _trace_id(request)).model_dump() + + +@router.get("/{product_id}/nav") +def get_product_nav_api( + product_id: str, + request: Request, + auth: AuthContext = Depends(get_platform_auth_context), +) -> dict: + core = CoreReadOnlyRepository() + if core.get_product(product_id) is None: + raise ApiError(404, "NOT_FOUND", f"product not found: {product_id}") + nav = product_service.get_latest_nav(product_id, core_ro=core) + if nav is None: + raise ApiError(404, "NOT_FOUND", f"nav not found for product: {product_id}") + return ok(nav, _trace_id(request)).model_dump() diff --git a/app/api/staff.py b/app/api/staff.py new file mode 100644 index 0000000..035a142 --- /dev/null +++ b/app/api/staff.py @@ -0,0 +1,34 @@ +"""代销平台 · 当前登录员工上下文。""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request + +from app.api.deps import AuthContext, get_platform_auth_context, require_staff_token +from app.repository.risk_repository import RiskRepository +from app.service.platform import staff_service +from app.utils.exceptions import ApiError +from app.utils.response import ok + +router = APIRouter(prefix="/api/staff", tags=["platform-staff"]) + + +def _risk_repo() -> RiskRepository: + return RiskRepository() + + +def _trace_id(request: Request) -> str: + return getattr(request.state, "trace_id", "unknown") + + +@router.get("/me") +def staff_me_api( + request: Request, + auth: AuthContext = Depends(get_platform_auth_context), +) -> dict: + repo = _risk_repo() + require_staff_token(auth, risk_repo=repo) + row = staff_service.get_staff_me(auth.actor_id) + if row is None: + raise ApiError(404, "NOT_FOUND", f"staff not found: {auth.actor_id}") + return ok(row, _trace_id(request)).model_dump() diff --git a/app/config/settings.py b/app/config/settings.py index 0dc8256..a205784 100644 --- a/app/config/settings.py +++ b/app/config/settings.py @@ -71,5 +71,8 @@ class Settings(BaseSettings): guard_rate_limit_max: int = 30 guard_rate_limit_window_seconds: int = 60 + # ===== 代销平台 API(v0.1)===== + platform_response_desensitize: bool = False + settings = Settings() diff --git a/app/main.py b/app/main.py index 787f095..57fb8cc 100644 --- a/app/main.py +++ b/app/main.py @@ -14,9 +14,14 @@ from starlette.exceptions import HTTPException as StarletteHTTPException from app.api import deps from app.api.audit_middleware import audit_middleware from app.api.auth import router as auth_router +from app.api.advisors import router as advisors_router from app.api.chat import router as chat_router +from app.api.compliance import router as compliance_router +from app.api.customers import router as customers_router +from app.api.products import router as products_router from app.api.risk import router as risk_router from app.api.simulate import router as simulate_router +from app.api.staff import router as staff_router from app.config.settings import settings from app.middleware.trace import TraceMiddleware from app.repository.audit_repository import AuditRepository @@ -76,6 +81,11 @@ app = FastAPI(title="JinRong Agent Platform", version="0.2.0", lifespan=lifespan register_error_handlers(app) app.add_middleware(TraceMiddleware) app.include_router(auth_router) +app.include_router(customers_router) +app.include_router(products_router) +app.include_router(advisors_router) +app.include_router(staff_router) +app.include_router(compliance_router) app.include_router(chat_router) app.include_router(risk_router) app.include_router(simulate_router) diff --git a/app/model/suitability.py b/app/model/suitability.py index 790f0fe..9bda1bf 100644 --- a/app/model/suitability.py +++ b/app/model/suitability.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any, Literal -CheckSource = Literal["r02_trade", "r02_chat", "c11_inquiry", "manual"] +CheckSource = Literal["r02_trade", "r02_chat", "c11_inquiry", "manual", "platform"] def compute_rule_refs(check: dict[str, Any]) -> list[str]: diff --git a/app/repository/core_ro.py b/app/repository/core_ro.py index 6b51928..0a451c7 100644 --- a/app/repository/core_ro.py +++ b/app/repository/core_ro.py @@ -441,6 +441,25 @@ class CoreReadOnlyRepository: ).scalar_one() return Decimal(total) + def list_products(self, limit: int = 50, offset: int = 0) -> tuple[list[dict[str, Any]], int]: + """开放产品货架(分页;仅 is_open=1)。""" + count_sql = text("SELECT COUNT(*) FROM core_product WHERE is_open = 1") + sql = text( + """ + SELECT * FROM core_product + WHERE is_open = 1 + ORDER BY product_id + LIMIT :lim OFFSET :off + """ + ) + with self._engine.connect() as conn: + total = int(conn.execute(count_sql).scalar_one()) + rows = [ + dict(r) + for r in conn.execute(sql, {"lim": limit, "off": offset}).mappings() + ] + return rows, total + def get_product(self, product_id: str) -> dict[str, Any] | None: sql = text("SELECT * FROM core_product WHERE product_id = :pid") with self._engine.connect() as conn: diff --git a/app/service/platform/__init__.py b/app/service/platform/__init__.py new file mode 100644 index 0000000..0bbfdb1 --- /dev/null +++ b/app/service/platform/__init__.py @@ -0,0 +1 @@ +"""代销平台 Service 层(REST 与日后 Agent 适配共用;封装 core_ro + 脱敏开关)。""" diff --git a/app/service/platform/advisor_service.py b/app/service/platform/advisor_service.py new file mode 100644 index 0000000..e215c8d --- /dev/null +++ b/app/service/platform/advisor_service.py @@ -0,0 +1,28 @@ +"""理财师归属客户列表(Platform Service)。""" + +from __future__ import annotations + +from typing import Any + +from app.repository.core_ro import CoreReadOnlyRepository + + +def list_advisor_customers( + advisor_id: str, + core_ro: CoreReadOnlyRepository | None = None, +) -> dict[str, Any]: + repo = core_ro or CoreReadOnlyRepository() + customer_ids = repo.list_customers_by_advisor(advisor_id) + items = [] + for cid in customer_ids: + row = repo.get_customer_l0(cid) + if row: + items.append( + { + "customer_id": cid, + "display_name": row.get("display_name"), + } + ) + else: + items.append({"customer_id": cid, "display_name": None}) + return {"items": items, "total": len(items), "advisor_id": advisor_id} diff --git a/app/service/platform/common.py b/app/service/platform/common.py new file mode 100644 index 0000000..8b78db4 --- /dev/null +++ b/app/service/platform/common.py @@ -0,0 +1,46 @@ +"""Platform Service 公共:JSON 序列化与可选脱敏。""" + +from __future__ import annotations + +import datetime as _dt +from decimal import Decimal +from typing import Any + +from app.config.settings import settings +from app.utils import desensitize as d + + +def to_jsonable(value: Any) -> Any: + if isinstance(value, Decimal): + return float(round(value, 2)) + if isinstance(value, (_dt.datetime, _dt.date)): + return value.isoformat() + if isinstance(value, dict): + return {k: to_jsonable(v) for k, v in value.items()} + if isinstance(value, list): + return [to_jsonable(v) for v in value] + return value + + +def maybe_desensitize_row(row: dict[str, Any] | None) -> dict[str, Any] | None: + if row is None or not settings.platform_response_desensitize: + return row + out = dict(row) + for key, masker in ( + ("display_name", d.mask_name), + ("full_name", d.mask_name), + ("mobile_phone", d.mask_phone), + ("phone", d.mask_phone), + ("id_card_no", d.mask_id_card), + ("id_no", d.mask_id_card), + ("bank_card_no", d.mask_bank_card), + ): + if key in out and out[key]: + out[key] = masker(str(out[key])) + return out + + +def prepare_row(row: dict[str, Any] | None) -> dict[str, Any] | None: + if row is None: + return None + return to_jsonable(maybe_desensitize_row(row) or row) diff --git a/app/service/platform/compliance_service.py b/app/service/platform/compliance_service.py new file mode 100644 index 0000000..2077cac --- /dev/null +++ b/app/service/platform/compliance_service.py @@ -0,0 +1,61 @@ +"""合规判定(Platform Service · canonical 适当性)。""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from app.repository.core_ro import CoreReadOnlyRepository +from app.repository.risk_repository import RiskRepository +from app.service.suitability import SuitabilityResult, suitability_check +from app.utils.trace import current_trace, new_trace + + +def run_suitability_check( + customer_id: str, + product_id: str, + actor_id: str, + core_ro: CoreReadOnlyRepository | None = None, + risk_repo: RiskRepository | None = None, +) -> SuitabilityResult: + repo = risk_repo or RiskRepository() + result = suitability_check( + customer_id, + product_id, + core_ro=core_ro or CoreReadOnlyRepository(), + risk_repo=repo, + check_source="platform", + actor_id=actor_id, + request_ref="api:platform_suitability_check", + ) + repo.insert_audit_log( + { + "trace_id": current_trace() or new_trace(), + "event_type": "suitability_check", + "agent_type": "platform", + "actor_id": actor_id, + "customer_id": customer_id, + "rule_id": result.rule_id, + "input_summary": { + "product_id": product_id, + "request_ref": "api:platform_suitability_check", + "reasons": list(result.reasons), + }, + "decision": "suitability_blocked" if result.blocked else "suitability_passed", + "risk_score": None, + "handler_id": None, + "handler_result": None, + "handler_comment": None, + } + ) + return result + + +def suitability_check_payload(result: SuitabilityResult) -> dict[str, Any]: + body = asdict(result) + if result.blocked: + from app.gateway.trade_gateway import ADVICE, RECORDED_NOTICE + + body["advice"] = ADVICE + body["notice"] = RECORDED_NOTICE + return body diff --git a/app/service/platform/customer_service.py b/app/service/platform/customer_service.py new file mode 100644 index 0000000..fe75d1a --- /dev/null +++ b/app/service/platform/customer_service.py @@ -0,0 +1,53 @@ +"""客户 L0 / 持仓 / 流水 / 可购产品(Platform Service)。""" + +from __future__ import annotations + +from typing import Any + +from app.repository.core_ro import CoreReadOnlyRepository +from app.service.platform.common import prepare_row, to_jsonable + + +def get_customer_l0( + customer_id: str, core_ro: CoreReadOnlyRepository | None = None +) -> dict[str, Any] | None: + repo = core_ro or CoreReadOnlyRepository() + row = repo.get_customer_l0(customer_id) + return prepare_row(row) + + +def list_holdings( + customer_id: str, + limit: int = 500, + core_ro: CoreReadOnlyRepository | None = None, +) -> dict[str, Any]: + repo = core_ro or CoreReadOnlyRepository() + rows = repo.list_holdings(customer_id, limit=limit) + items = [prepare_row(r) for r in rows] + truncated = len(rows) >= limit + return {"items": items, "total": len(items), "limit": limit, "truncated": truncated} + + +def list_trades( + customer_id: str, + limit: int = 50, + offset: int = 0, + core_ro: CoreReadOnlyRepository | None = None, +) -> dict[str, Any]: + repo = core_ro or CoreReadOnlyRepository() + fetch = min(limit + offset, 500) + rows = repo.list_trades(customer_id, limit=fetch) + page = rows[offset : offset + limit] + items = [to_jsonable(r) for r in page] + return {"items": items, "total": len(rows), "limit": limit, "offset": offset} + + +def list_customer_products( + customer_id: str, + limit: int = 50, + core_ro: CoreReadOnlyRepository | None = None, +) -> dict[str, Any]: + repo = core_ro or CoreReadOnlyRepository() + rows = repo.list_products_for_customer(customer_id, limit=limit) + items = [to_jsonable(r) for r in rows] + return {"items": items, "total": len(items), "limit": limit} diff --git a/app/service/platform/product_service.py b/app/service/platform/product_service.py new file mode 100644 index 0000000..a54a6a0 --- /dev/null +++ b/app/service/platform/product_service.py @@ -0,0 +1,33 @@ +"""产品货架 / 详情 / 净值(Platform Service)。""" + +from __future__ import annotations + +from typing import Any + +from app.repository.core_ro import CoreReadOnlyRepository +from app.service.platform.common import prepare_row, to_jsonable + + +def list_products( + limit: int = 50, + offset: int = 0, + core_ro: CoreReadOnlyRepository | None = None, +) -> dict[str, Any]: + repo = core_ro or CoreReadOnlyRepository() + rows, total = repo.list_products(limit=limit, offset=offset) + items = [to_jsonable(r) for r in rows] + return {"items": items, "total": total, "limit": limit, "offset": offset} + + +def get_product( + product_id: str, core_ro: CoreReadOnlyRepository | None = None +) -> dict[str, Any] | None: + repo = core_ro or CoreReadOnlyRepository() + return prepare_row(repo.get_product(product_id)) + + +def get_latest_nav( + product_id: str, core_ro: CoreReadOnlyRepository | None = None +) -> dict[str, Any] | None: + repo = core_ro or CoreReadOnlyRepository() + return to_jsonable(repo.get_latest_nav(product_id)) diff --git a/app/service/platform/staff_service.py b/app/service/platform/staff_service.py new file mode 100644 index 0000000..fd266ec --- /dev/null +++ b/app/service/platform/staff_service.py @@ -0,0 +1,27 @@ +"""员工上下文(Platform Service)。""" + +from __future__ import annotations + +from typing import Any + +from app.repository.core_ro import CoreReadOnlyRepository +from app.service.platform.common import prepare_row + + +def get_staff_me( + staff_id: str, core_ro: CoreReadOnlyRepository | None = None +) -> dict[str, Any] | None: + repo = core_ro or CoreReadOnlyRepository() + row = repo.get_staff(staff_id) + if row is None: + return None + prepared = prepare_row(row) + assert prepared is not None + if isinstance(prepared.get("roles"), str): + import json + + try: + prepared["roles"] = json.loads(prepared["roles"]) + except json.JSONDecodeError: + prepared["roles"] = [prepared["roles"]] + return prepared diff --git a/docs/memory/ITERATION.md b/docs/memory/ITERATION.md index b6b78db..835b411 100644 --- a/docs/memory/ITERATION.md +++ b/docs/memory/ITERATION.md @@ -16,3 +16,4 @@ | 2026-09-08 | **前端接入方案 B/C**:chat 拉侧三端点 `8328c24` · SSE 流式 `01ec5fc` · 503→502 测试基线 | 前端联调前置 | MEMORY / TODO / chat / session_repository / agent_service | | 2026-09-08 | **AL-09 合并接线完成**(`merger` 分支):JWT 统一 · 模块 chat/agent/memory 恢复 · auth_adapter S2 接缝 · trace 中间件 ApiError re-raise · test_module_boundary 绿 · **502 passed 1 skipped** | 风控模块并入宿主 Wave 0 | MEMORY / TODO / FRAMEWORK / FLOW / REQUIREMENTS / ENVIRONMENT / 合并注意事项 | | 2026-09-08 | **代销平台 API 口径拍板**:路由 A(业务域)· 重复功能以平台 API 为准 · 《接口契约-代销平台API-v0.1》 | 统筹 P1 实现前定契约 | 04 / MEMORY / TODO / 接口契约 | +| 2026-09-08 | **代销平台 API v0.1 落地**:MVC 分层 api→service/platform→core_ro · 17 端点测试 · 519 pytest 绿 | v0.1 实现约定 | 接口契约 / 04 / ITERATION | diff --git a/docs/memory/TODO.md b/docs/memory/TODO.md index d9b8a95..1732e5c 100644 --- a/docs/memory/TODO.md +++ b/docs/memory/TODO.md @@ -9,7 +9,7 @@ ## 待办(统筹 · P1 推荐顺序) -- [ ] **代销平台 API v0.1 实现**(契约:`docs/项目框架设计/接口契约-代销平台API-v0.1.md` — customers/products/advisors/compliance/staff + 补 `list_products` RO) +- [x] **代销平台 API v0.1 实现**(2026-09-08):`customers/products/advisors/staff/compliance` + `service/platform/` + `PLATFORM_RESPONSE_DESENSITIZE` · **519 passed 1 skipped** - [ ] **接口契约发群**(login + 平台读 API + simulate/trade;强调重复功能以平台路径为准) - [ ] 前端 React 多 Agent 入口(HashRouter,`web/` init) - [ ] 同步 `MEMORY/REQUIREMENTS/FRAMEWORK` 与各 Agent 负责人联调节奏 diff --git a/docs/项目框架设计/接口契约-代销平台API-v0.1.md b/docs/项目框架设计/接口契约-代销平台API-v0.1.md index c439f6c..13bfaec 100644 --- a/docs/项目框架设计/接口契约-代销平台API-v0.1.md +++ b/docs/项目框架设计/接口契约-代销平台API-v0.1.md @@ -1,6 +1,6 @@ # 接口契约 · 代销平台 API v0.1 -> 状态:**已定口径(2026-09-08)** · 实现进行中 +> 状态:**v0.1 已实现(2026-09-08)** · 519 pytest 绿 > 范围:**未接 Agent 前的 Core 代销平台 REST**(客户 App / 理财师工作台 / 内勤只读) > 关联:[04-从需求到公共API开发方法.md](../项目管理/04-从需求到公共API开发方法.md) · [02-JWT-RBAC鉴权手册.md](./技术选型和版本/02-JWT-RBAC鉴权手册.md) @@ -62,13 +62,38 @@ - 失败:手册 §10 结构(`ApiError`);403 归属/角色、404 资源不存在。 - 列表:`{ "items": [...], "total": n, "limit": ..., "offset": ... }`(与 chat sessions 对齐)。 -### 2.4 Service / 代码包(实现侧) +**脱敏(2026-09-08 拍板):** -| 层 | 约定 | -| --- | --- | -| 路由 | `app/api/customers.py` `products.py` `advisors.py` `compliance.py` | -| 平台 Service | `app/service/platform/` 封装 `core_ro`,REST 与日后 Agent 适配 **共用一个 Service** | -| Repository | 仍只 `core_ro.py` SELECT;缺方法先补 RO 再暴露 API | +| 项 | v0.1 | 真库阶段 | +| --- | --- | --- | +| 开关 | `settings.platform_response_desensitize`(env:`PLATFORM_RESPONSE_DESENSITIZE`,**默认 `false`**) | 生产/真 Core 切 `true` | +| 行为 | **关闭**:L0 字段原样返回(模拟库联调) | **开启**:出参经 `app/utils/desensitize.py`(姓名/手机/证件/银行卡;`customer_id` 不脱敏) | +| 实现位置 | **Platform Service 层**统一处理(REST 与日后 Agent 适配共用同一出口) | 同上 | + +### 2.4 Service / 代码包(实现侧 · 分层说明) + +代销平台采用 **三层**,避免「REST 里写 SQL」或「Agent 合并时再抄一遍查数逻辑」: + +```text + HTTP 请求 + ↓ + app/api/customers.py 等 ← 薄路由:鉴权、参数校验、ok() 包装、HTTP 状态码 + ↓ + app/service/platform/*.py ← Platform Service:归属无关的业务组装、脱敏开关、分页口径 + ↓ + app/repository/core_ro.py ← 只读 SQL,不感知 HTTP/Agent +``` + +| 层 | 职责 | 谁调用 | +| --- | --- | --- | +| **api/** | FastAPI 路由;`Depends(get_platform_auth_context)`;调 Service | 前端、Postman | +| **service/platform/** | 封装 `core_ro`;统一脱敏、列表分页、字段映射 | **REST 只调这一层**;合并 Agent 时也调这一层(或同进程 import) | +| **repository/core_ro** | `SELECT` Core 模拟库 | 仅 Service / 风控等特殊路径 | + +> **「REST 只调 Service」** = `customers.py` 里不出现 `CoreReadOnlyRepository()` 直调,而是 `platform_service.get_customer_l0(...)`。这样 Agent Tool 日后改调 Service 时,与 App 仪表盘 **同一套逻辑**,不会两套口径。 + +路由文件:`app/api/customers.py` `products.py` `advisors.py` `compliance.py` `staff.py` +Service 文件:`app/service/platform/customer_service.py` `product_service.py` …(按域拆分,可合并为一个 `platform_service.py` 若保持简单) --- @@ -108,14 +133,25 @@ --- -## 5. 归属规则摘要(G-01) +## 5. 归属规则摘要(G-01 · 平台 API) -| 角色 | 读 customer 维度数据 | +> **2026-09-08 统筹拍板:** 各角色权限不同;平台读接口 **不要求** `X-Agent-Type`(实现侧用 `get_platform_auth_context`)。 + +| 角色 | 读 `customers/{id}` 及子资源 | | --- | --- | | `customer` | 仅 `auth.customer_id == customer_id` | -| `advisor` | `customer_advisor_rel` active 归属 | -| `risk_officer` / 演示 | 全量(只读) | -| 其他 staff | 403 + audit | +| `advisor` | 仅 `core_customer_advisor` active 归属名下客户 | +| `analyst` | **全量只读**(支撑 D-01 内勤查数) | +| `risk_officer` | 全量只读(与现有 G-01 一致) | +| `risk_manager` | v0.1 **全量只读**(与台账只读定位一致;无写接口) | +| `risk_demo` | **全量只读**(与 risk_officer 一致,支撑 simulate/演示联调) | +| `compliance` / 其他 staff | **403**(客户 L0 不在其业务范围) | + +**产品/净值**(`/api/products*`):任意已登录 staff 或 customer 可读(公开产品事实,无客户归属语义)。 + +**理财师客户列表**(`/api/advisors/{advisor_id}/customers`):`advisor` 仅 `advisor_id == auth.actor_id`;`analyst` / `risk_officer` / `risk_manager` 可查任意 advisor。 + +**`/api/staff/me`:** 仅 `token_type=staff`;customer token → 403。 --- @@ -124,3 +160,5 @@ | 日期 | 说明 | | --- | --- | | 2026-09-08 | v0.1 口径:路由 A · 平台 API 优先 · 命名规范 · 端点清单 · 迁移表 | +| 2026-09-08 | 实现前拍板:整包交付 · 适当性新旧并存 · analyst 全量只读 · 平台 JWT 免 X-Agent-Type | +| 2026-09-08 | 脱敏:`PLATFORM_RESPONSE_DESENSITIZE` 默认 false;真库再开 · risk_demo 全量只读 | diff --git a/docs/项目管理/04-从需求到公共API开发方法.md b/docs/项目管理/04-从需求到公共API开发方法.md index 0a65d6f..fcbda3e 100644 --- a/docs/项目管理/04-从需求到公共API开发方法.md +++ b/docs/项目管理/04-从需求到公共API开发方法.md @@ -59,6 +59,7 @@ | Agent 隔离 | 四 Agent **不互调 LLM**;跨 Agent 走画像表与预警表 | | 审计 | `audit_log` 等 **只 INSERT** | | 合规 | 不代客交易、不营销式推荐、代理人草稿不自动外发 | +| 平台出参脱敏 | `PLATFORM_RESPONSE_DESENSITIZE`(默认 **关**);v0.1 模拟库原样返回;接真 Core 再开(Service 层统一出口) | ### 门闸 B · Wave / P0 裁剪 diff --git a/tests/_ddl.py b/tests/_ddl.py index fa137bc..c24bedf 100644 --- a/tests/_ddl.py +++ b/tests/_ddl.py @@ -45,7 +45,16 @@ SQLITE_TABLES: dict[str, str] = { product_id VARCHAR(64) PRIMARY KEY, product_name VARCHAR(128), min_risk_code VARCHAR(8), product_type VARCHAR(32), min_subscribe_amount DECIMAL DEFAULT 1.00, term_days INTEGER, - requires_disclosure TINYINT DEFAULT 0) + requires_disclosure TINYINT DEFAULT 0, is_open TINYINT DEFAULT 1) + """, + "core_product_nav": """ + CREATE TABLE core_product_nav ( + product_id VARCHAR(64), nav_date DATE, nav DECIMAL, daily_chg_pct REAL) + """, + "core_staff": """ + CREATE TABLE core_staff ( + staff_id VARCHAR(64) PRIMARY KEY, display_name VARCHAR(128), + staff_type VARCHAR(32), roles TEXT, is_active TINYINT DEFAULT 1) """, "core_holding": """ CREATE TABLE core_holding ( diff --git a/tests/test_main.py b/tests/test_main.py index fe84379..b2c7129 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -62,6 +62,16 @@ def test_all_routers_mounted(client): assert set(paths) == { "/health", "/api/auth/login", + "/api/customers/{customer_id}", + "/api/customers/{customer_id}/holdings", + "/api/customers/{customer_id}/trades", + "/api/customers/{customer_id}/products", + "/api/products", + "/api/products/{product_id}", + "/api/products/{product_id}/nav", + "/api/advisors/{advisor_id}/customers", + "/api/staff/me", + "/api/compliance/suitability-check", "/api/risk/alerts", "/api/risk/alerts/{alert_id}/handle", "/api/risk/suitability/check", diff --git a/tests/test_platform_api.py b/tests/test_platform_api.py new file mode 100644 index 0000000..d295927 --- /dev/null +++ b/tests/test_platform_api.py @@ -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**"