| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403 |
- #!/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"((?<![\w.])location\s*=\s*[`'\"])/(?![/])", r"\1" + prefix + "/", body) # location.href= 已被 href= 规则覆盖, 此处只管裸 location=
- body = re.sub(r"([`'\"])/api/", r"\1" + prefix + "/api/", body) # 字符串常量拼 URL (如 '/api/ask_status' + qs) 不经 fetch( 直接出现 → 逮 22:21 ask_status 404
- for i, url in enumerate(keep): # 摘哨兵: 恢复原样(不做任何前缀改写)
- body = body.replace(f'href="@@KEEP{i}@@"', f'href="{url}"')
- for port, pre in PORT2PREFIX.items():
- body = re.sub(r"https?://(?:127\.0\.0\.1|localhost):%d(?=[/\"'` )<&?#]|$)" % port, pre or "", body)
- body = body.replace('href=""', 'href="/"') # 18084 根裸链
- return body
- def _q(path): # http.client 只吃 ASCII 请求行; 中文路径 (仿真合页) 必须百分号编码
- return urllib.parse.quote(path, safe="/%?=&:+@,;$!*'()~")
- _PORTAL_CACHE = {}
- def portal_bytes():
- """门户 20MB 单文件: 按 (mtime, size) 缓存改写结果, 文件一变即失效; 不缓存过期内容."""
- st = PORTAL.stat(); key = (st.st_mtime_ns, st.st_size)
- if _PORTAL_CACHE.get("key") != key:
- _PORTAL_CACHE.update(key=key, body=rewrite(PORTAL.read_text(encoding="utf-8"), "").encode("utf-8"))
- return _PORTAL_CACHE["body"]
- def _probe(host, port, path, timeout=None):
- t0 = time.time()
- try:
- c = http.client.HTTPConnection(host, port, timeout=timeout or PROBE_TIMEOUT); c.request("GET", _q(path)); r = c.getresponse(); r.read(65536); c.close()
- return dict(ok=r.status < 400, http=r.status, ms=int((time.time() - t0) * 1000))
- except OSError as e:
- return dict(ok=False, http=None, ms=int((time.time() - t0) * 1000), err=str(e)[:80])
- # 探活参数 (2026-09-11 调): 原先 6 个上游**串行**探、每个默认 3 s, 而两个掉线的模块
- # (CMS 无产物未起 / Ollama 无模型未起) 各卡满超时 → /healthz 实测 6.5 s; 再加上每次重读
- # 20 MB 门户算 sha256 (磁盘忙时这一项能到几十秒), 一个探活页慢得没法看。
- # 现在: ① 全部探针并发 → 总耗时 = 最慢那个; ② 单探针 1.5 s; ③ 门户 sha 按 (mtime,size) 缓存;
- # ④ 整个结果缓存 HEALTH_TTL 秒 → 连续调用近乎零成本。实测: 冷探 6.5 s → 1.53 s, 命中缓存 15 ms;
- # 全部模块健康时冷探 ≈ 最慢的健康探针 (实测 sim_sys 345 ms, 其余 <25 ms)。
- #
- # ★为什么"掉线模块"必须等满超时: 本机实测 **连任何已关闭的 127.0.0.1 端口都不回 RST**,
- # 而是把 SYN 丢掉等超时 (对照用的随机闭端口 49996/49997 同样 1.5 s 超时, 不是我们端口的问题,
- # 是这台机器的防火墙/安全软件行为)。所以"有模块掉线"时的冷探下限就是 PROBE_TIMEOUT,
- # 想更快只能靠缓存 —— 别为此把超时压到健康模块也可能被误判的程度。
- PROBE_TIMEOUT = 1.5
- HEALTH_TTL = 5.0
- _HEALTH_CACHE = {"at": 0.0, "payload": None}
- _PORTAL_SHA = {"key": None, "sha": None}
- def _portal_sha():
- """门户文件 sha256, 按 (mtime_ns, size) 缓存。原实现每次 /healthz 与 /api/version 都
- `PORTAL.read_bytes()` 重读 20 MB 再哈希 —— 值没变, 代价却按请求量重复付。"""
- st = PORTAL.stat()
- key = (st.st_mtime_ns, st.st_size)
- if _PORTAL_SHA["key"] != key:
- _PORTAL_SHA.update(key=key, sha=hashlib.sha256(PORTAL.read_bytes()).hexdigest())
- return _PORTAL_SHA["sha"]
- def ollama_models(timeout=3.0):
- """返回 (running, [模型名]). 拒连 = 未启动, 不猜."""
- try:
- c = http.client.HTTPConnection(*OLLAMA, timeout=timeout); c.request("GET", "/api/tags"); r = c.getresponse(); d = json.loads(r.read()); c.close()
- return True, sorted(m["name"] for m in d.get("models", []))
- except (OSError, ValueError) as e:
- return False, []
- _OPS = {"mod": None}
- def _ops_module():
- """按需加载运维控制台模块 (scripts/guanlan_ops.py)。
- 放在网关里而不是独立服务: 控制台必须**在"停组件服务"之后继续可用**, 而网关本来就要留着,
- 于是没有第二个进程要管。加载失败要响亮 —— 不能让 /ops 悄悄 404 让人以为页面没做。
- """
- if _OPS["mod"] is None:
- import importlib.util
- # ★P4(2026-09-22): 控制台实现已随网关迁入本包并改名 ops.py —— 先找同包件,
- # 再回落到旧位置 scripts/guanlan_ops.py(兼容壳),两条都不在才响亮报错。
- _ops_path = pathlib.Path(__file__).with_name("ops.py")
- if not _ops_path.is_file():
- _ops_path = pathlib.Path(__file__).resolve().parents[3] / "scripts" / "guanlan_ops.py"
- spec = importlib.util.spec_from_file_location("guanlan_ops", _ops_path)
- mod = importlib.util.module_from_spec(spec)
- spec.loader.exec_module(mod)
- _OPS["mod"] = mod
- return _OPS["mod"]
- def healthz():
- """全部模块状态 (逐项探活)。结果缓存 HEALTH_TTL 秒 (并发探活 + 缓存见 PROBE_TIMEOUT 处注释);
- 返回结构一字未改 (path/name/upstream_port/ok/http/ms/err · 门户那项带 file_sha256/portal)。"""
- if _HEALTH_CACHE["payload"] is not None and time.time() - _HEALTH_CACHE["at"] < HEALTH_TTL:
- return _HEALTH_CACHE["payload"]
- with ThreadPoolExecutor(max_workers=len(ROUTES) + 2) as ex:
- f_mods = [ex.submit(_probe, "127.0.0.1", port, hp, PROBE_TIMEOUT) for _, port, _, hp, _ in ROUTES]
- f_oll = ex.submit(ollama_models, PROBE_TIMEOUT) # 与路由探针并发, 不再串在后面多等一个超时
- f_sha = ex.submit(_portal_sha)
- mods = [dict(path=pre + "/", name=name, upstream_port=port, **f.result())
- for (pre, port, _, hp, name), f in zip(ROUTES, f_mods)]
- try:
- psha = f_sha.result()
- except OSError:
- psha = None
- run, models = f_oll.result()
- # 门户文件读失败 (如 Finder 启动的 app 无外接盘权限 EPERM) 必须报 ok=False 带原因, 不能让 /healthz 整个炸成空回复 (2026-09-07 app 冷启动实测)
- if not PORTAL.exists():
- mods.insert(0, dict(path="/", name="观澜中文系统门户", upstream_port=None, ok=False,
- file_sha256=None, portal=str(PORTAL)))
- elif psha is None:
- mods.insert(0, dict(path="/", name="观澜中文系统门户", upstream_port=None, ok=False,
- file_sha256=None, portal=str(PORTAL), error="OSError: 门户文件读失败 (权限/占用?)"))
- else:
- mods.insert(0, dict(path="/", name="观澜中文系统门户", upstream_port=None, ok=True,
- file_sha256=psha, portal=str(PORTAL)))
- out = dict(status="ok" if all(m["ok"] for m in mods) else "degraded", mode="offline", checked=dt.datetime.now().isoformat(timespec="seconds"), modules=mods,
- local_ai=dict(running=run, models=models, note=None if run else "本机模型未启动 (Ollama %s:%d 拒连); 不会转云" % OLLAMA))
- _HEALTH_CACHE.update(at=time.time(), payload=out)
- return out
- def version():
- man = json.loads(MANIFEST.read_text(encoding="utf-8")) if MANIFEST.exists() else {}
- b = man.get("detail_style_baseline", {}); c = man.get("content_contract", {})
- # src.proc.run_text: 网关无控制台, 裸 spawn git 会给它新建一个可见控制台窗口 ——
- # 而 /api/version 会被页面反复请求 ⇒ 反复闪窗 (2026-09-16 实测)。
- try: head = _proc.run_text(["git", "-C", str(ROOT), "rev-parse", "--short", "HEAD"], timeout=5).stdout.strip()
- except Exception: head = None
- con = json.loads(CONTRACT.read_text(encoding="utf-8")) if CONTRACT.exists() else {}
- try: psha = _portal_sha() # 缓存版: 同一份门户不重复读 20 MB
- except OSError: psha = None # 门户缺失/不可读 → 与原实现一致返回 None, 不抛
- return dict(mode="offline", entry="http://127.0.0.1:28084/", repo_head=head, detail_style_baseline=dict(commit=b.get("git_commit"), built=b.get("built"), fingerprint=b.get("fingerprint"), artifact_sha256=b.get("artifact_sha256")),
- content_contract=dict(version=con.get("contract_version"), sha256=con.get("contract_sha256"), n_claims=con.get("n_claims"), consumers=c.get("consumers_wired")),
- portal_sha256=psha, routes=[dict(path=p + "/", name=n) for p, _, _, _, n in ROUTES], gateway=pathlib.Path(__file__).name)
- def down_page(name, prefix, info):
- import html as _h
- return ('<!doctype html><html lang="zh"><head><meta charset="utf-8"><title>模块不可用 · 观澜</title>'
- '<style>body{margin:0;background:#F5F7F8;color:#1B2430;font:15px/1.7 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif}.box{max-width:640px;margin:12vh auto;padding:28px 32px;background:#fff;border:1px solid #DDE3E8;border-radius:12px}'
- 'h1{font-size:20px;margin:0 0 8px;color:#1F6F8B}code{background:#F5F7F8;padding:1px 6px;border-radius:4px}a{color:#1F6F8B}</style></head><body><div class="box">'
- f'<h1>模块不可用: {_h.escape(name)}</h1><p>路径 <code>{_h.escape(prefix)}/</code> 对应的内部组件未启动 (<code>{_h.escape(info["upstream"])}</code>), 网关未转向任何云端服务, 也不会伪造内容。</p>'
- f'<p>处理: {_h.escape(info["hint"])}; 全部模块状态见 <a href="/healthz">/healthz</a>。</p><p><a href="/">← 返回门户</a></p><p style="color:#6B7680;font-size:13px">{_h.escape(info["detail"])}</p></div></body></html>')
- 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/<r>/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)
|