diff --git a/AGENTS.md b/AGENTS.md index 7157893..8c06edb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,6 @@ app/repository/core_ro.py # Core 只读 + check_suitability(R-02) scripts/core/reset.ps1 # 本地灌 Core 模拟库 ``` -**当前分支:** `merger` · **测试基线:** `python -m pytest` → **827 passed**, 1 skipped · **Redis:** Docker `6380` · **前端:** `cd web && npm run build/test` +**当前分支:** `merger` · **测试基线:** `python -m pytest` → **833 passed**, 1 skipped · **Redis:** Docker `6380` · **前端:** `cd web && npm run build/test` 技术选型硬阀门见 MEMORY 第 3、7 节。Cursor 以 `.cursor/rules/project-memory.mdc` 为准。 diff --git a/app/api/deps.py b/app/api/deps.py index b82d0eb..f62ca9b 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -356,6 +356,18 @@ def require_staff_token(auth: AuthContext, risk_repo: RiskRepository) -> None: deny(auth, "AUTH_403_SCOPE", risk_repo, message="staff token required", agent_type="platform") +def assert_platform_product_read(auth: AuthContext, risk_repo: RiskRepository) -> None: + """产品货架/净值:客户、理财师、分析员、风控演示角色可读;纯 compliance 不可。""" + if auth.has_role( + "customer", + "advisor", + "analyst", + *PLATFORM_FULL_READ_ROLES, + ): + return + deny(auth, "AUTH_403_SCOPE", risk_repo, message="product catalog forbidden", agent_type="platform") + + def assert_customer_access( auth: AuthContext, customer_id: str, diff --git a/app/api/products.py b/app/api/products.py index a7ec0c8..b8ad0ce 100644 --- a/app/api/products.py +++ b/app/api/products.py @@ -4,7 +4,7 @@ from __future__ import annotations from fastapi import APIRouter, Depends, Query, Request -from app.api.deps import AuthContext, get_platform_auth_context +from app.api.deps import AuthContext, assert_platform_product_read, get_platform_auth_context from app.repository.core_ro import CoreReadOnlyRepository from app.service.platform import product_service from app.utils.exceptions import ApiError @@ -24,6 +24,9 @@ def list_products_api( offset: int = Query(0, ge=0), auth: AuthContext = Depends(get_platform_auth_context), ) -> dict: + from app.api.deps import RiskRepository as RR + + assert_platform_product_read(auth, RR()) data = product_service.list_products(limit=limit, offset=offset) return ok(data, _trace_id(request)).model_dump() @@ -34,18 +37,42 @@ def get_product_api( request: Request, auth: AuthContext = Depends(get_platform_auth_context), ) -> dict: + from app.api.deps import RiskRepository as RR + + assert_platform_product_read(auth, RR()) 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/history") +def get_product_nav_history_api( + product_id: str, + request: Request, + days: int = Query(365, ge=1, le=730), + auth: AuthContext = Depends(get_platform_auth_context), +) -> dict: + from app.api.deps import RiskRepository as RR + + repo = RR() + assert_platform_product_read(auth, repo) + core = CoreReadOnlyRepository() + data = product_service.get_nav_history(product_id, days=days, core_ro=core) + if data is None: + raise ApiError(404, "NOT_FOUND", f"product not found: {product_id}") + return ok(data, _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: + from app.api.deps import RiskRepository as RR + + assert_platform_product_read(auth, RR()) core = CoreReadOnlyRepository() if core.get_product(product_id) is None: raise ApiError(404, "NOT_FOUND", f"product not found: {product_id}") diff --git a/app/api/ready.py b/app/api/ready.py index c604db2..452b13b 100644 --- a/app/api/ready.py +++ b/app/api/ready.py @@ -1,41 +1,45 @@ """启动自检:Redis + 关键路由是否挂载(前端 Topbar 条用)。""" from __future__ import annotations -from fastapi import APIRouter, Request +from fastapi import APIRouter, FastAPI, Request from app.config.settings import settings router = APIRouter(tags=["platform"]) -@router.get("/api/ready") -def ready(request: Request): - trace_id = getattr(request.state, "trace_id", "unknown") +def build_ready_payload(app: FastAPI, trace_id: str) -> dict: checks: dict[str, bool | str] = {} try: from app.service.risk import redis_gateway gw = redis_gateway.get_gateway() - if gw is None: - checks["redis"] = False - else: - gw.exists("__ready_ping__") - checks["redis"] = True + gw.ping() + checks["redis"] = True except Exception as exc: # noqa: BLE001 checks["redis"] = False checks["redis_error"] = str(exc)[:120] - paths = set(request.app.openapi().get("paths", {}).keys()) + paths = set(app.openapi().get("paths", {}).keys()) checks["chat_close_all"] = "/api/chat/sessions/close-all" in paths checks["chat_sessions"] = "/api/chat/sessions" in paths checks["analyst_chat"] = "/api/analyst/chat" in paths critical = [checks["chat_close_all"], checks["chat_sessions"], checks["analyst_chat"]] - ok = all(critical) and checks.get("redis") is True + routes_ok = all(critical) + redis_ok = checks.get("redis") is True + ok = routes_ok return { "ok": ok, + "degraded": routes_ok and not redis_ok, "env": settings.app_env, "trace_id": trace_id, "checks": checks, } + + +@router.get("/api/ready") +def ready(request: Request): + trace_id = getattr(request.state, "trace_id", "unknown") + return build_ready_payload(request.app, trace_id) diff --git a/app/main.py b/app/main.py index caaa290..78289b8 100644 --- a/app/main.py +++ b/app/main.py @@ -23,7 +23,7 @@ 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.api.analyst import router as analyst_router -from app.api.ready import router as ready_router +from app.api.ready import build_ready_payload, router as ready_router from app.api.visitor import router as visitor_router from app.config.settings import settings from app.middleware.trace import TraceMiddleware @@ -97,6 +97,13 @@ app.include_router(analyst_router) app.include_router(ready_router) +@app.get("/api/ready", tags=["platform"], include_in_schema=True) +def api_ready_probe(request: Request): + """与 ready_router 同逻辑;在 app 上再挂一份,避免旧进程/路由表遗漏时 404。""" + trace_id = getattr(request.state, "trace_id", "unknown") + return build_ready_payload(app, trace_id) + + @app.middleware("http") async def audit_middleware_entry(request: Request, call_next): return await audit_middleware(request, call_next) @@ -131,8 +138,10 @@ async def trace_middleware(request: Request, call_next): @app.get("/health") -def health(request: Request): +def health(request: Request, probe: bool = False): trace_id = getattr(request.state, "trace_id", "unknown") + if probe: + return build_ready_payload(app, trace_id) return {"status": "ok", "env": settings.app_env, "trace_id": trace_id} diff --git a/app/repository/core_ro.py b/app/repository/core_ro.py index 023ab90..8e81643 100644 --- a/app/repository/core_ro.py +++ b/app/repository/core_ro.py @@ -496,6 +496,31 @@ class CoreReadOnlyRepository: row = conn.execute(sql, {"pid": product_id}).mappings().first() return dict(row) if row else None + def list_nav_history( + self, product_id: str, *, days: int = 365, end_date: date | None = None + ) -> list[dict[str, Any]]: + """按 nav_date 升序返回最近 days 条(含 end_date 当日若存在)。""" + end = end_date or date.today() + sql = text( + """ + SELECT nav_date, nav, daily_chg_pct + FROM core_product_nav + WHERE product_id = :pid + AND nav_date <= :end + ORDER BY nav_date DESC + LIMIT :lim + """ + ) + with self._engine.connect() as conn: + rows = [ + dict(r) + for r in conn.execute( + sql, {"pid": product_id, "end": end.isoformat(), "lim": days} + ).mappings() + ] + rows.reverse() + return rows + def list_active_customers(self) -> list[dict[str, Any]]: """全量在册客户(id + display_name;AML scan_all 全量扫描用,仅 SELECT)。""" sql = text( diff --git a/app/service/platform/product_service.py b/app/service/platform/product_service.py index a54a6a0..bb9afc0 100644 --- a/app/service/platform/product_service.py +++ b/app/service/platform/product_service.py @@ -31,3 +31,28 @@ def get_latest_nav( ) -> dict[str, Any] | None: repo = core_ro or CoreReadOnlyRepository() return to_jsonable(repo.get_latest_nav(product_id)) + + +def get_nav_history( + product_id: str, + *, + days: int = 365, + core_ro: CoreReadOnlyRepository | None = None, +) -> dict[str, Any] | None: + """返回产品历史净值序列;产品不存在时 None。""" + repo = core_ro or CoreReadOnlyRepository() + product = repo.get_product(product_id) + if product is None: + return None + items = [to_jsonable(r) for r in repo.list_nav_history(product_id, days=days)] + latest = repo.get_latest_nav(product_id) + return { + "product_id": product_id, + "product_name": product.get("product_name"), + "min_risk_code": product.get("min_risk_code"), + "items": items, + "days_requested": days, + "count": len(items), + "latest_nav_date": latest.get("nav_date") if latest else None, + "quote_mode": "simulated", + } diff --git a/app/service/risk/redis_gateway.py b/app/service/risk/redis_gateway.py index e55e1ae..338dfbe 100644 --- a/app/service/risk/redis_gateway.py +++ b/app/service/risk/redis_gateway.py @@ -40,6 +40,9 @@ class RedisGateway: def exists(self, key: str) -> bool: return bool(self._ensure().exists(key)) + def ping(self) -> bool: + return bool(self._ensure().ping()) + def get(self, key: str) -> str | None: val = self._ensure().get(key) if val is None: diff --git a/docs/course/index.html b/docs/course/index.html index 8cf7c84..763ab9c 100644 --- a/docs/course/index.html +++ b/docs/course/index.html @@ -137,6 +137,12 @@

总架构、数据流、分 Agent 亮点、Demo 动线、诚实边界、Q&A 锚点——与交互课同步。

docs/答辩 · 可打印提纲 + + +

答辩 Demo 全流程 SOP

+

给人看的逐步演示:6~25 分钟三档路线、四角色账号、每步亮点+口述句;链问数/风控分册。

+ Markdown · 答辩主脚本 +

架构治理 · ADR 与不变量

diff --git a/docs/course/jinrong-architecture-governance/index.html b/docs/course/jinrong-architecture-governance/index.html index f038bce..b16fe07 100644 --- a/docs/course/jinrong-architecture-governance/index.html +++ b/docs/course/jinrong-architecture-governance/index.html @@ -533,7 +533,7 @@

鉴权 deps ∥ gateway

决策:一套 JWT;模块禁止 import gateway。Fitness:auth pytest。

平台 API

决策:与 Agent 重复时 platform 为准。Dashboard 每次打 MySQL(性能 trade-off)。

-

顾问 Agent

不变量:草稿不直发 C 端。骨架已通,enforcement 随功能增。

+

顾问 Agent

未完整接入:无独立深编排 · T-20 未做 · Chat 骨架在 · 答辩勿作主 Demo。

前端 web/

决策:四角色路由;TODO 路由守卫。Vitest 22;E2E 未进库。

diff --git a/docs/course/jinrong-architecture-governance/modules/08-ops.html b/docs/course/jinrong-architecture-governance/modules/08-ops.html index 11207a1..1bd9239 100644 --- a/docs/course/jinrong-architecture-governance/modules/08-ops.html +++ b/docs/course/jinrong-architecture-governance/modules/08-ops.html @@ -12,7 +12,7 @@

鉴权 deps ∥ gateway

决策:一套 JWT;模块禁止 import gateway。Fitness:auth pytest。

平台 API

决策:与 Agent 重复时 platform 为准。Dashboard 每次打 MySQL(性能 trade-off)。

-

顾问 Agent

不变量:草稿不直发 C 端。骨架已通,enforcement 随功能增。

+

顾问 Agent

未完整接入:无独立深编排 · T-20 未做 · Chat 骨架在 · 答辩勿作主 Demo。

前端 web/

决策:四角色路由;TODO 路由守卫。Vitest 22;E2E 未进库。

diff --git a/docs/course/jinrong-overview/index.html b/docs/course/jinrong-overview/index.html index b08893c..76e6265 100644 --- a/docs/course/jinrong-overview/index.html +++ b/docs/course/jinrong-overview/index.html @@ -673,8 +673,8 @@

模块 8 · 答辩动线

5~8 分钟 Demo
+ 必背铁律

- 对照 docs/答辩/答辩知识点清单.md 的滚动版:先讲清四角色不互调 LLM,再按动线演示,最后主动说边界。 - 基线 825 pytest · 22 Vitest · 分支 merger。 + 对照 docs/答辩/DEMO-SOP-答辩全流程.md 与 答辩知识点清单.md:先讲清四角色不互调 LLM,再按动线演示,最后主动说边界。 + 基线 827 pytest · 22 Vitest · 分支 merger。

@@ -710,7 +710,7 @@
🛡风控线
📊问数线
-

灌库:scripts/demo/prepare_all.ps1(含 Core reset + 问数模板种子)

+

灌库:scripts/demo/prepare_all.ps1 · 逐步脚本:docs/答辩/DEMO-SOP-答辩全流程.md

@@ -758,6 +758,7 @@

诚实边界(主动说加分)