#!/usr/bin/env python3 # -*- coding: utf-8 -*- """观澜中文系统 · 本机离线版网关 (唯一用户入口 http://127.0.0.1:28084/; 部署包 CLAUDE_CODE_端口与交接重构任务.md §1, 用户令 2026-09-06 "从网关开始, 做离线版 18084"). 只做四件事, 零依赖 (stdlib): 1. 统一路径 → 组件端口 (用户看不到 8033/8020/8791/8792/54292): / 门户 · /detail/ 工作台(8033 /v2) · /cms/ (8020) · /sim/ (8791) · /sim/sys/ (8792) · /viewer/ (54292) · /local-ai/ (11434) 2. 回包改写: 组件页里的根相对 URL (href/src/fetch('/api/…')) 与绝对 http://127.0.0.1:端口 → 统一前缀; 因此组件源码零改动, 8033 共享脏文件不碰. 3. 离线守卫 (绝不静默转云): /detail/api/ask_models 只留本机 Ollama 实有模型; /detail/api/ask|rpt_compose 带非本机模型 → 400 明确拒绝; /local-ai/ 上游拒连 → 503 "本机模型未启动". 4. /healthz (全部模块状态, 逐项探活) · /api/version (版本/构建/内容清单/模式). 规则表见 ROUTES; 改路由改表不改逻辑. 启动: scripts/guanlan_offline_up.sh (一条命令). """ import sys as _sys, pathlib as _plb _sys.path.insert(0, str(_plb.Path(__file__).resolve().parents[1])) from src import paths as P from src import proc as _proc # 无窗口子进程 (2026-09-16 用户令: 不弹命令窗口) import argparse, datetime as dt, hashlib, http.client, json, os, pathlib, re, socket, subprocess, sys, time, urllib.parse from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from concurrent.futures import ThreadPoolExecutor ROOT = pathlib.Path(__file__).resolve().parents[1] PORTAL = pathlib.Path(os.environ.get("GUANLAN_PORTAL", str(ROOT / "release/portal.html"))) # v2: 门户 = 本仓 release/, 不再指桌面 STATIC_DIR = pathlib.Path(os.environ.get("GUANLAN_STATIC", str(ROOT / "release"))) # 旧 18084 = Desktop 静态目录, 保留兜底 (如东治理清单等) MANIFEST = P.store() / "release-manifest.json" CONTRACT = P.guanlan() / "facts_contract_v0.json" OLLAMA = ("127.0.0.1", int(os.environ.get("GUANLAN_OLLAMA_PORT", "11434"))) # 验收用: 指向死端口即可模拟 "本机模型未启动" (不必真停 Ollama) # prefix → (upstream port, 上游路径映射: 前缀去掉后剩 "" 时用的默认路径, 探活路径, 名称) # ★ 2026-09-17 (用户令 2「端口单一真源」+ 开箱验证不再打扰运行中的实例): # 上游端口默认取下面这张表, 但**可被 `configs/serve.json` 覆盖** —— 于是"整套服务换个端口跑" # 成了配置能表达的事(以前是硬编码, `check_transferable.run_and_probe` 因此**必须**要求默认端口空闲, # 也就必须先把用户正在跑的服务停掉; 现场实测后果: 用户在验证窗口里点门户「登录」→ /detail/ 报"模块未启动")。 _DEFAULT_ROUTES = [ ("/detail", 18033, "/v2", "/v2", "综合详细分析工作台"), ("/cms", 18020, "/?theme=light", "/", "CMS 振动诊断"), ("/sim/sys", 18792, "/0_四系统合页.html", "/0_四系统合页.html", "仿真·四系统合页"), ("/sim", 18791, "/sc1_sim_demo.html", "/sc1_sim_demo.html", "仿真与回放"), ("/viewer", 64292, "/unit-workbench.html", "/unit-workbench.html", "三维拆装工作台"), # ★2026-09-29 用户令:算法服务随 serve 默认起,并经网关可达(算法 = Python + FastAPI,P10)。 # 前缀 `/algorithm` → 上游 18050;前端与后端(各栈)都从这一个源站调算法,免去直连端口。 ("/algorithm", 18050, "/healthz", "/healthz", "算法服务 (FastAPI)"), # ★2026-09-29 用户令(P11):Vue 前端经网关同一源站伺服 —— 页面里的相对取数才能直接打到 /api 与 /algorithm。 ("/web", 28110, "/index.html", "/index.html", "观澜前端 (Vue 3)"), ("/local-ai", OLLAMA[1], "/api/tags", "/api/tags", "本机模型 Ollama"), # ★2026-09-30 用户令(P12 灰度):Java 后端与 Python **同一源站并存** —— `/java/**` 直通 28120。 # 迁移期用 `scripts/java_contract_parity.py` 按冻契约逐条比对;42 条全绿后再切换入口并删 Python 侧。 ("/java", 28120, "/api/facts", "/api/facts", "观澜后端 (Java/Spring Boot)"), # ★2026-09-30 用户令(P12 灰度切换):Vue 前端经 `/web` 伺服、页面打相对 `/api/*` ⇒ # 把**同源** `/api/*` 指到 Java 后端(旧页走 `/detail/api/*`,仍由 Python 承担)。 ("/api", 28120, "/api/fleet", "/api/fleet", "观澜后端 (Java) · 灰度主入口"), ] _PORT_KEY = {'/detail': 'detail', '/cms': 'cms', '/sim/sys': 'sim_sys', '/sim': 'sim', '/viewer': 'viewer', '/algorithm': 'algorithm', '/web': 'web', '/local-ai': 'ollama', '/java': 'java', '/api': 'java'} def _upstream_ports() -> dict: """上游端口: `configs/serve.json` 里同名键覆盖内置默认 (文件读不到就用默认)。""" import json as _json import pathlib as _pl got = {} try: from app_common.app_common_guanlan.api import paths as _P cfg_p = _P.config('serve.json') # P9: 走配置取用口(多根查找),不再手拼路径 got = _json.loads(cfg_p.read_text(encoding='utf-8-sig')) except Exception: got = {} return {pre: int(got.get(_PORT_KEY[pre]) or port) for pre, port, *_ in _DEFAULT_ROUTES} ROUTES = [(pre, _upstream_ports()[pre], a, b, nm) for pre, _, a, b, nm in _DEFAULT_ROUTES] PORT2PREFIX = {p: pre for pre, p, *_ in ROUTES} PORT2PREFIX[28084] = "" # v2 入口绝对链接 → 根 for _old, _new in ((8033, 18033), (8020, 18020), (8792, 18792), (8791, 18791), (54292, 64292), (18084, 28084)): PORT2PREFIX[_old] = PORT2PREFIX.get(_new, "") # 旧端口绝对链接同样归到 v2 前缀 HOP = {"connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailers", "transfer-encoding", "upgrade", "content-length", "host"} TEXT_RE = re.compile(r"text/html|javascript|text/css|application/json") MODEL_PARAM_PATHS = ("/api/ask", "/api/rpt_compose") def rewrite(body: str, prefix: str) -> str: """组件页根相对 URL → 统一前缀. 只改 href/src/action/content 属性、url()、fetch/open 调用; 不动 // 开头的协议相对与 data:。 ★2026-09-22 用户测试 4: 作者可在标签上标 `data-abs="1"` 声明**这条链接不要被前缀改写** (组件页要指回**网关根**——如"返回观澜门户"——根相对写法 `/` 会被本函数改成 `/detail/`, 于是永远回不到门户)。做法: 先把这类 href 加哨兵前缀跳过改写,收尾再摘掉哨兵。 """ if prefix: keep: list[str] = [] def _protect(url: str) -> str: keep.append(url) return f'href="@@KEEP{len(keep) - 1}@@"' # 同一标签里 href 与 data-abs="1" 两种书写顺序都认(哨兵不含 '/',天然不被下面的前缀正则命中) body = re.sub(r'href="([^"]*)"([^>]*?data-abs="1")', lambda m: _protect(m.group(1)) + m.group(2), body) body = re.sub(r'(<[^>]*?data-abs="1"[^>]*?)href="([^"]*)"', lambda m: m.group(1) + _protect(m.group(2)), body) body = re.sub(r'((?:href|src|action|content)=["\'])/(?![/])', r"\1" + prefix + "/", body) body = re.sub(r"(url\(['\"]?)/(?![/])", r"\1" + prefix + "/", body) body = re.sub(r"((?:fetch|open|EventSource|WebSocket|assign|replace)\(\s*(?:['\"](?:GET|POST)['\"]\s*,\s*)?[`'\"])/(?![/])", r"\1" + prefix + "/", body) body = re.sub(r"((?模块不可用 · 观澜' '
' f'

模块不可用: {_h.escape(name)}

路径 {_h.escape(prefix)}/ 对应的内部组件未启动 ({_h.escape(info["upstream"])}), 网关未转向任何云端服务, 也不会伪造内容。

' f'

处理: {_h.escape(info["hint"])}; 全部模块状态见 /healthz。

← 返回门户

{_h.escape(info["detail"])}

') class H(BaseHTTPRequestHandler): server_version = "guanlan-gateway/0.1" protocol_version = "HTTP/1.1" def log_message(self, fmt, *a): sys.stderr.write("%s %s\n" % (dt.datetime.now().strftime("%H:%M:%S"), fmt % a)) def _json(self, obj, code=200): b = json.dumps(obj, ensure_ascii=False, indent=1).encode("utf-8"); self.send_response(code); self.send_header("Content-Type", "application/json; charset=utf-8"); self.send_header("Content-Length", str(len(b))); self.send_header("Cache-Control", "no-store"); self.end_headers() if self.command != "HEAD": self.wfile.write(b) def _bytes(self, b, ctype, code=200, no_store=False): self.send_response(code); self.send_header("Content-Type", ctype); self.send_header("Content-Length", str(len(b))) if no_store: # 运维控制台/数据重算页**必须**不缓存 (2026-09-16 实逮): 页面 HTML/脚本是随包更新的 # (例如"内嵌版按钮点不动"这次修复), 没有 no-store 时浏览器会用旧页面 —— 用户看到的是 # "改了还是没反应", 排查方向会被带偏。数据面同样: 状态必须每次现取。 self.send_header("Cache-Control", "no-store, must-revalidate") self.send_header("Pragma", "no-cache") self.end_headers() if self.command != "HEAD": self.wfile.write(b) def _route(self, path): for pre, port, default, _, _ in ROUTES: if path == pre or path.startswith(pre + "/"): rest = path[len(pre):] return pre, port, (default if rest in ("", "/") else rest) return None def _read_body(self): n = int(self.headers.get("Content-Length") or 0); return self.rfile.read(n) if n else b"" def do_GET(self): self._handle() def do_POST(self): self._handle() def do_HEAD(self): self._handle() def _handle(self): u = urllib.parse.urlsplit(self.path); path = urllib.parse.unquote(u.path) if path == "/healthz": return self._json(healthz()) if path == "/api/version": return self._json(version()) if path == "/ops" or path.startswith("/ops/"): # 运维控制台 (停/启服务 · 重算 · 清产物): 页面 + JSON API 都在 scripts/guanlan_ops.py, # 网关只做转发 —— 页面由**网关自己**提供是刻意的: "停组件服务"要保留它, 否则按钮点完页面就没了。 ops = _ops_module() body = self._read_body() if self.command == "POST" else b"" code, mime, data = ops.handle(self.command, path, body) return self._bytes(data, mime, code, no_store=True) if path == "/local-ai/status": run, models = ollama_models(); return self._json(dict(running=run, models=models, endpoint="/local-ai/", note=None if run else "本机模型未启动"), 200 if run else 503) if path.startswith("/release/"): # E9/E10 裁定: Release 层不合并主库, 由网关只读暴露: /release/ 列表; /release/r1/… (E9 契约层) /release/r2/… (E10 分层覆盖) parts = path[len("/release/"):].split("/", 1); name = parts[0]; rest = parts[1] if len(parts) > 1 else "" dirs = {"r1": P.ont() / "release_r1", "r2": P.ont() / "release_r2"} if name not in dirs: out = {} for n, dd in dirs.items(): man = dd / "manifest.json"; out[n] = json.loads(man.read_text(encoding="utf-8")) if man.exists() else None return self._json(dict(layers=out, note="只读; 不合并主库 (E9/E10 裁定); 文件: /release//manifest.json · objects.json (· r2: layering.json)")) rel = dirs[name]; f = (rel / rest).resolve() if rest else None if not f or not str(f).startswith(str(rel.resolve())) or not f.is_file(): man = rel / "manifest.json"; mj = json.loads(man.read_text(encoding="utf-8")) if man.exists() else None; return self._json(dict(release=(mj or {}).get("release"), files=sorted(x.name for x in rel.glob("*.json")) if rel.exists() else [], manifest=mj), 200 if man.exists() else 503) return self._bytes(f.read_bytes(), "application/json; charset=utf-8") if path in ("/", "/index.html"): if not PORTAL.exists(): return self._json(dict(err="门户文件缺失: %s" % PORTAL), 503) return self._bytes(portal_bytes(), "text/html; charset=utf-8") # ★P12 灰度(2026-09-30):同源 `/api/*` **不剥前缀**直通 Java 后端 —— # Vue 前端经 `/web` 伺服、页面打相对 `/api/*`;旧页仍走 `/detail/api/*`(Python)。 # 注意:不能用 `_route()`,它会把命中前缀剥掉(`/api/fleet`→上游 `/fleet`)⇒ 404。 if path == '/api' or path.startswith('/api/'): port = _upstream_ports().get('/api', 28120) return self._proxy('/api', port, path, query=u.query) r = self._route(path) if r: return self._proxy(*r, query=u.query) return self._static(path) def _static(self, path): f = (STATIC_DIR / path.lstrip("/")).resolve() if not str(f).startswith(str(STATIC_DIR.resolve())) or not f.is_file(): return self._json(dict(err="not found", path=path), 404) import mimetypes; ct = mimetypes.guess_type(str(f))[0] or "application/octet-stream" if ct.startswith("text/"): ct += "; charset=utf-8" b = f.read_bytes() if "text/html" in ct: b = rewrite(b.decode("utf-8", "replace"), "").encode("utf-8") return self._bytes(b, ct) def _offline_guard(self, prefix, port, upath, query): """8033 问答/汇报带 model= 参数: 非本机 Ollama 模型一律拒, 不外发. 返回 None = 放行.""" if port != 8033 or not any(upath.startswith(p) for p in MODEL_PARAM_PATHS): return None q = urllib.parse.parse_qs(query); m = q.get("model", [""])[0] if not m or "probe" in q: return None run, models = ollama_models() if not run: return dict(err="本机模型未启动 (Ollama %s:%d 拒连); 离线版不转云" % OLLAMA + "", model=m, code=503) if m not in models: return dict(err="离线版只用本机模型, 拒绝外发: %s 不在本机 Ollama 列表" % m, local_models=models, code=400) return None def _proxy(self, prefix, port, upath, query): body = self._read_body() gq = query if body and port == 8033 and any(upath.startswith(p_) for p_ in MODEL_PARAM_PATHS) and "model=" not in (query or ""): # 2026-09-07 真停 Ollama 实测逮: /v2 用 JSON 体 POST {q, model} 问, 守卫只看 query 串 → 绕过, 8033 起问后回 "Connection refused" 原文. try: jb = json.loads(body.decode("utf-8")); m = jb.get("model") if isinstance(jb, dict) else None if m: gq = (query + "&" if query else "") + "model=" + urllib.parse.quote(str(m)) + ("&probe=1" if (isinstance(jb, dict) and jb.get("probe")) else "") except Exception: pass g = self._offline_guard(prefix, port, upath, gq) if g: code = g.pop("code"); return self._json(g, code) hdrs = {k: v for k, v in self.headers.items() if k.lower() not in HOP}; hdrs["Host"] = "127.0.0.1:%d" % port; hdrs["Accept-Encoding"] = "identity" target = _q(upath) + ("?" + query if query else "") try: c = http.client.HTTPConnection("127.0.0.1", port, timeout=900); c.request(self.command, target, body=body if body else None, headers=hdrs); r = c.getresponse() except OSError as e: name = next((n for p, pt, *_, n in ROUTES if pt == port), str(port)) info = dict(err="模块未启动: %s" % name, path=prefix + "/", upstream="127.0.0.1:%d" % port, detail=str(e)[:80], hint="本机模型未启动" if port == OLLAMA[1] else "运行 scripts/guanlan_offline_up.sh") # 故障演练 (部署包测试 4): 页面请求给一张明确的"模块不可用"页 (不白屏、不跳错页、带返回门户), API 请求给 JSON 503 if "text/html" in (self.headers.get("Accept") or "") and not upath.startswith("/api/"): return self._bytes(down_page(name, prefix, info).encode("utf-8"), "text/html; charset=utf-8", 503) return self._json(info, 503) data = r.read(); ctype = r.getheader("Content-Type", ""); enc = r.getheader("Content-Encoding", "") if TEXT_RE.search(ctype) and not enc: txt = data.decode("utf-8", "replace") if port == 8033 and upath.startswith("/api/ask_models") and "json" in ctype: txt = self._filter_models(txt) elif "json" not in ctype: txt = rewrite(txt, prefix) data = txt.encode("utf-8") self.send_response(r.status) for k, v in r.getheaders(): if k.lower() in HOP or k.lower() == "content-encoding": continue if k.lower() == "location": v = rewrite(v, prefix) self.send_header(k, v) self.send_header("Content-Length", str(len(data))); self.end_headers() try: if self.command != "HEAD": self.wfile.write(data) except (BrokenPipeError, ConnectionResetError): self.log_message("client dropped %s", self.path) # 浏览器/curl 中途断开 = 客户端行为, 记一行不抛栈 c.close() @staticmethod def _filter_models(txt): try: d = json.loads(txt) except ValueError: return txt run, local = ollama_models(); allm = d.get("models") or {}; keep = {k: v for k, v in allm.items() if k in local} d["models"] = keep; d["offline"] = dict(local_ai_running=run, dropped=sorted(set(allm) - set(keep)), note=None if run else "本机模型未启动 (Ollama 拒连), 本机档暂不可用; 不转云") return json.dumps(d, ensure_ascii=False) def main(): # 统一日志口径 (用户令 2): 之后本进程 stdout/stderr 的每一行都带 时间戳/级别/组件 from src import logfile as _logfile # noqa: E402 _logfile.prefix_stdout('gateway') ap = argparse.ArgumentParser(); ap.add_argument("--port", type=int, default=28084); ap.add_argument("--host", default="127.0.0.1"); ap.add_argument("--check", action="store_true", help="只打印 healthz 后退出"); a = ap.parse_args() if a.check: print(json.dumps(healthz(), ensure_ascii=False, indent=1)); return 0 srv = ThreadingHTTPServer((a.host, a.port), H); srv.daemon_threads = True sys.stderr.write("guanlan-gateway http://%s:%d/ (门户 %s)\n" % (a.host, a.port, PORTAL)); srv.serve_forever() if __name__ == "__main__": sys.exit(main() or 0)