diff --git a/app/ARCHITECTURE.md b/app/ARCHITECTURE.md index ec3f267..33cdec8 100644 --- a/app/ARCHITECTURE.md +++ b/app/ARCHITECTURE.md @@ -30,6 +30,9 @@ background scheduler `backend/application.py` and `backend/bootstrap/`. - `backend/http/` owns common authentication, request IDs, responses, static delivery, and error normalization. Feature-specific transport handlers live beside their feature. + Exact POST endpoints that only delegate to one of those handlers use the explicit maps in + `backend/application.py`; endpoints with path parameters, body handling, or special error + semantics remain visible control flow in `RequestHandler`. - `backend/features//` owns the mechanically moved service, repository, HTTP, agent, or deterministic calculation code for that product area. - `backend/data/` owns provider construction, source policy, provenance, units, freshness, diff --git a/app/backend/application.py b/app/backend/application.py index e4e6b91..efea17b 100644 --- a/app/backend/application.py +++ b/app/backend/application.py @@ -507,6 +507,40 @@ class DashboardService( SERVICE = DashboardService() +PUBLIC_POST_HANDLERS = { + "/api/auth/register": "auth_register", + "/api/auth/login": "auth_login", +} + +AUTHENTICATED_POST_HANDLERS = { + "/api/auth/logout": "auth_logout", + "/api/account/birth-profile": "save_birth_profile", + "/api/account/password": "change_password", + "/api/alerts": "save_alert", + "/api/trades": "save_trade_entry", + "/api/assistant/chat": "stream_assistant_chat", + "/api/admin/settings": "save_system_settings", + "/api/admin/settings/test": "test_system_llm_settings", + "/api/admin/membership": "save_membership", + "/api/admin/refresh": "start_background_refresh", + "/api/watchlist": "save_watchlist", + "/api/notes": "save_note", + "/api/reasons": "save_reason", + "/api/seat-aliases": "save_seat_alias", + "/api/heaven/sector-phases": "save_sector_phase_override", + "/api/backfill": "backfill_data", + "/api/screener/sync": "sync_screener_data", + "/api/screener/compile": "compile_screener_strategy", + "/api/screener/strategies": "save_screener_strategy", + "/api/screener/run": "run_screener", + "/api/screener/tracking/refresh": "refresh_screener_tracking", + "/api/mentors/chat": "stream_mentor_chat", + "/api/heaven/hexagram": "heaven_hexagram", + "/api/heaven/personal": "heaven_personal", + "/api/heaven/interpret": "heaven_interpret", +} + + class RequestHandler( AccountHttpMixin, SystemHttpMixin, @@ -522,6 +556,13 @@ class RequestHandler( application_service = SERVICE route_registry = ROUTES + def _dispatch_named_handler(self, path: str, handlers: dict[str, str]) -> bool: + handler_name = handlers.get(path) + if handler_name is None: + return False + getattr(self, handler_name)() + return True + def do_GET(self) -> None: parsed = urlparse(self.path) if parsed.path == "/api/health": @@ -864,24 +905,13 @@ class RequestHandler( def do_POST(self) -> None: parsed = urlparse(self.path) - if parsed.path == "/api/auth/register": - self.auth_register() - return - if parsed.path == "/api/auth/login": - self.auth_login() + if self._dispatch_named_handler(parsed.path, PUBLIC_POST_HANDLERS): return if not self.require_auth() or not self.require_csrf(): return if not self.require_access("POST", parsed.path): return - if parsed.path == "/api/auth/logout": - self.auth_logout() - return - if parsed.path == "/api/account/birth-profile": - self.save_birth_profile() - return - if parsed.path == "/api/account/password": - self.change_password() + if self._dispatch_named_handler(parsed.path, AUTHENTICATED_POST_HANDLERS): return alert_read_match = re.fullmatch(r"/api/alerts/(\d+)/read", parsed.path) if alert_read_match: @@ -895,57 +925,6 @@ class RequestHandler( {"ok": True, **SERVICE.mark_all_alerts_read(str(body.get("as_of") or ""))} ) return - if parsed.path == "/api/alerts": - self.save_alert() - return - if parsed.path == "/api/trades": - self.save_trade_entry() - return - if parsed.path == "/api/assistant/chat": - self.stream_assistant_chat() - return - if parsed.path == "/api/admin/settings": - self.save_system_settings() - return - if parsed.path == "/api/admin/settings/test": - self.test_system_llm_settings() - return - if parsed.path == "/api/admin/membership": - self.save_membership() - return - if parsed.path == "/api/admin/refresh": - self.start_background_refresh() - return - if parsed.path == "/api/watchlist": - self.save_watchlist() - return - if parsed.path == "/api/notes": - self.save_note() - return - if parsed.path == "/api/reasons": - self.save_reason() - return - if parsed.path == "/api/seat-aliases": - self.save_seat_alias() - return - if parsed.path == "/api/heaven/sector-phases": - self.save_sector_phase_override() - return - if parsed.path == "/api/backfill": - self.backfill_data() - return - if parsed.path == "/api/screener/sync": - self.sync_screener_data() - return - if parsed.path == "/api/screener/compile": - self.compile_screener_strategy() - return - if parsed.path == "/api/screener/strategies": - self.save_screener_strategy() - return - if parsed.path == "/api/screener/run": - self.run_screener() - return if parsed.path == "/api/screener/tracking": try: result = SERVICE.add_screener_tracking(self.read_json_body()) @@ -953,9 +932,6 @@ class RequestHandler( except (ValueError, json.JSONDecodeError) as exc: self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) return - if parsed.path == "/api/screener/tracking/refresh": - self.refresh_screener_tracking() - return if parsed.path == "/api/mentors/preferences": try: result = SERVICE.save_mentor_preferences(self.read_json_body()) @@ -963,18 +939,6 @@ class RequestHandler( except (ValueError, json.JSONDecodeError) as exc: self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) return - if parsed.path == "/api/mentors/chat": - self.stream_mentor_chat() - return - if parsed.path == "/api/heaven/hexagram": - self.heaven_hexagram() - return - if parsed.path == "/api/heaven/personal": - self.heaven_personal() - return - if parsed.path == "/api/heaven/interpret": - self.heaven_interpret() - return self.send_json({"error": "Not found"}, HTTPStatus.NOT_FOUND) def do_DELETE(self) -> None: diff --git a/app/config/architecture-inventory.json b/app/config/architecture-inventory.json index 0bb2538..fa818a2 100644 --- a/app/config/architecture-inventory.json +++ b/app/config/architecture-inventory.json @@ -329,8 +329,8 @@ }, { "path": "backend/application.py", - "bytes": 49784, - "lines": 1165 + "bytes": 48749, + "lines": 1129 }, { "path": "frontend/styles/theme.css", diff --git a/app/tests/test_http_dispatch.py b/app/tests/test_http_dispatch.py new file mode 100644 index 0000000..4b7852c --- /dev/null +++ b/app/tests/test_http_dispatch.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import unittest + +from backend.application import ( + AUTHENTICATED_POST_HANDLERS, + PUBLIC_POST_HANDLERS, + RequestHandler, +) + + +class HttpDispatchContractTests(unittest.TestCase): + @staticmethod + def handler(path: str, calls: list[str]) -> RequestHandler: + handler = RequestHandler.__new__(RequestHandler) + handler.path = path + handler.require_auth = lambda: calls.append("auth") or True + handler.require_csrf = lambda: calls.append("csrf") or True + handler.require_access = lambda method, route: ( + calls.append(f"access:{method}:{route}") or True + ) + return handler + + def test_named_handlers_are_real_registered_post_routes(self) -> None: + all_handlers = {**PUBLIC_POST_HANDLERS, **AUTHENTICATED_POST_HANDLERS} + self.assertEqual( + set(PUBLIC_POST_HANDLERS) & set(AUTHENTICATED_POST_HANDLERS), set() + ) + for path, handler_name in all_handlers.items(): + with self.subTest(path=path): + route = RequestHandler.route_registry.resolve("POST", path) + self.assertIsNotNone(route) + self.assertTrue(callable(getattr(RequestHandler, handler_name))) + expected_access = "public" if path in PUBLIC_POST_HANDLERS else None + if expected_access: + self.assertEqual(route.access, expected_access) + else: + self.assertNotEqual(route.access, "public") + + def test_public_post_dispatches_without_authentication(self) -> None: + for path, handler_name in PUBLIC_POST_HANDLERS.items(): + with self.subTest(path=path): + calls: list[str] = [] + handler = self.handler(path, calls) + handler.require_auth = lambda: (_ for _ in ()).throw( + AssertionError("public route required authentication") + ) + setattr(handler, handler_name, lambda: calls.append(handler_name)) + + RequestHandler.do_POST(handler) + + self.assertEqual(calls, [handler_name]) + + def test_authenticated_post_preserves_guard_order(self) -> None: + for path, handler_name in AUTHENTICATED_POST_HANDLERS.items(): + with self.subTest(path=path): + calls: list[str] = [] + handler = self.handler(path, calls) + setattr(handler, handler_name, lambda: calls.append(handler_name)) + + RequestHandler.do_POST(handler) + + self.assertEqual( + calls, + ["auth", "csrf", f"access:POST:{path}", handler_name], + ) + + def test_failed_access_never_dispatches_protected_handler(self) -> None: + path, handler_name = next(iter(AUTHENTICATED_POST_HANDLERS.items())) + calls: list[str] = [] + handler = self.handler(path, calls) + handler.require_access = lambda method, route: ( + calls.append(f"access:{method}:{route}") or False + ) + setattr(handler, handler_name, lambda: calls.append(handler_name)) + + RequestHandler.do_POST(handler) + + self.assertEqual(calls, ["auth", "csrf", f"access:POST:{path}"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/app/tools/build_api_registry.py b/app/tools/build_api_registry.py index e0011fc..ab286a4 100644 --- a/app/tools/build_api_registry.py +++ b/app/tools/build_api_registry.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +import ast import json import re import sys @@ -59,8 +60,32 @@ def _role(method: str, path: str) -> str: return required_role(method, sample) +def _mapped_paths(text: str) -> dict[str, set[str]]: + paths = {method: set() for method in ("GET", "POST", "DELETE")} + tree = ast.parse(text) + for node in tree.body: + if not isinstance(node, ast.Assign) or len(node.targets) != 1: + continue + target = node.targets[0] + if not isinstance(target, ast.Name): + continue + match = re.fullmatch( + r"(?:PUBLIC_|AUTHENTICATED_)?(GET|POST|DELETE)_HANDLERS", target.id + ) + if not match: + continue + mapping = ast.literal_eval(node.value) + if not isinstance(mapping, dict) or not all( + isinstance(path, str) and path.startswith("/api/") for path in mapping + ): + raise ValueError(f"Invalid route handler map: {target.id}") + paths[match.group(1)].update(mapping) + return paths + + def build() -> dict: text = (ROOT / "backend" / "application.py").read_text(encoding="utf-8") + mapped_paths = _mapped_paths(text) method_matches = list(re.finditer(r"^ def do_(GET|POST|DELETE)\(", text, re.MULTILINE)) routes = [] for index, match in enumerate(method_matches): @@ -68,6 +93,7 @@ def build() -> dict: end = method_matches[index + 1].start() if index + 1 < len(method_matches) else len(text) block = text[match.start():end] exact_paths = set(re.findall(r'parsed\.path\s*==\s*"(/api/[^"]+)"', block)) + exact_paths.update(mapped_paths[method]) patterns = set( re.findall( r're\.(?:fullmatch|match)\(\s*r?["\']([^"\']*?/api/[^"\']+)["\']\s*,\s*parsed\.path', diff --git a/app/tools/build_architecture_inventory.py b/app/tools/build_architecture_inventory.py index 4dad8e9..5a1a172 100644 --- a/app/tools/build_architecture_inventory.py +++ b/app/tools/build_architecture_inventory.py @@ -37,16 +37,15 @@ def page_inventory(html: str) -> list[dict[str, str]]: def api_inventory(server: str) -> dict[str, list[str]]: - exact = sorted(set(re.findall(r'parsed\.path\s*==\s*"(/api/[^"]+)"', server))) + routes = json.loads(source("config/api.config.json"))["routes"] + exact = sorted( + {item["path"] for item in routes if item["match"] == "exact"} + ) prefixes = sorted( set(re.findall(r'parsed\.path\.startswith\(\s*"(/api/[^"]+)"', server)) ) patterns = sorted( - set( - item - for item in re.findall(r'r?["\']([^"\']*?/api/[^"\']+)["\']', server) - if "\\d" in item or ".+" in item or "(?P" in item - ) + {item["path"] for item in routes if item["match"] == "regex"} ) return {"exact": exact, "prefixes": prefixes, "patterns": patterns} diff --git a/docs/governance/code-reduction.md b/docs/governance/code-reduction.md index 28a2c87..b8b7869 100644 --- a/docs/governance/code-reduction.md +++ b/docs/governance/code-reduction.md @@ -19,6 +19,7 @@ | 批次 | 边界 | 基线问题 | 目标 | 状态 | |---|---|---|---|---| | CR-01 | LLM供应商传输 | 问师、问天、复盘助手和策略编译各自构造HTTP请求、解析响应和读取错误 | 只保留`backend/llm/transport.py`一个网络出口 | 已完成 | +| CR-02 | HTTP精确POST委托 | 27个端点重复使用“比较路径、调用无参数处理器、返回”三行分支 | 用公开/受保护两张显式映射统一委托,同时保留复杂路由的原控制流 | 已完成 | ## CR-01验收口径 @@ -43,3 +44,25 @@ 回档基线为`xiaobai-preservation-complete-20260801`;本批检查点为 `xiaobai-reduction-01-llm-transport-20260801`。 + +## CR-02验收口径 + +- 仅纳入没有路径参数、请求体解析或专属异常分支的精确POST端点;其余路由保持原样。 +- 公开注册/登录端点继续在鉴权前分发;受保护端点继续严格执行登录、CSRF、注册表权限、处理器。 +- 27个映射路径必须都由权威API注册表解析,处理方法必须真实存在,公开与受保护集合不得重叠。 +- API路径、功能归属、访问角色、状态码、错误正文和静态页面绕过鉴权行为保持不变。 +- API清单生成器必须结构化读取显式映射;架构清查复用API清单,不再维护第二套路由发现规则。 + +## CR-02结果 + +- 2个公开端点和25个受保护端点改为显式委托映射,原来的79行重复分支被43行映射、分发与调用替代; + `backend/application.py`净减少36行,规范化源码约减少1.0 KB。 +- 带正则路径参数、请求体读取、查询参数转换或特殊异常语义的GET、POST、DELETE端点未改动。 +- 架构清查删除了自行扫描精确/正则API路径的第二套规则,改为消费权威`api.config.json`;API注册表的 + 53个精确路径、11个正则路径、功能归属和权限均未变化。 +- 原版231项、候选315项、纯`app/`导出252项、45项Playwright通过;24个JavaScript文件、 + API/架构注册表、Git空白检查和SQLite完整性检查通过。 +- 本批不修改前端、CSS、业务计算、数据源、数据库结构、LLM、会员规则或部署。 + +本批基线为`xiaobai-reduction-01-llm-transport-20260801`;检查点为 +`xiaobai-reduction-02-http-dispatch-20260801`。