diff --git a/.env.example b/.env.example index 8027a56..e872f9b 100644 --- a/.env.example +++ b/.env.example @@ -34,3 +34,10 @@ # NAV_SESSION_COOKIE_SECURE=auto # CSRF 校验仍失败时,可填前端访问的完整 Origin,多个用英文逗号分隔,例如: # NAV_CSRF_TRUSTED_ORIGINS=https://nav.example.com + +# ---------- 云端复盘中控 iframe 嵌入(与 manual_trading_hub 配合)---------- +# 本地导航代登录中控(服务端请求云端 /api/auth/login,再打开 /embed-auth) +# NAV_HUB_USERNAME=admin +# NAV_HUB_PASSWORD=你的中控密码 +# 打开标记为「复盘中控」的服务时自动代登录(1=开启) +# NAV_HUB_AUTO_LOGIN=1 diff --git a/app.py b/app.py index ffd3512..53048b7 100644 --- a/app.py +++ b/app.py @@ -1,9 +1,13 @@ +import json import os import secrets +import urllib.error +import urllib.request from pathlib import Path from typing import Optional +from urllib.parse import urlencode -from flask import Flask, flash, redirect, render_template, request, url_for +from flask import Flask, flash, jsonify, redirect, render_template, request, url_for from flask_login import LoginManager, current_user, login_required, login_user, logout_user from flask_wtf.csrf import CSRFProtect from werkzeug.middleware.proxy_fix import ProxyFix @@ -159,7 +163,79 @@ def create_app() -> Flask: .all() ) grouped.append((g, svcs)) - return render_template("index.html", grouped=grouped) + return render_template( + "index.html", + grouped=grouped, + hub_auto_login=os.environ.get("NAV_HUB_AUTO_LOGIN", "").strip() == "1", + ) + + @app.route("/api/embed/hub-login", methods=["POST"]) + @csrf.exempt + @login_required + def api_embed_hub_login(): + """ + 本地导航代登录云端中控:服务端请求 hub /api/auth/login,返回 embed-auth URL。 + 避免浏览器在跨站 iframe 里丢弃 Set-Cookie。 + """ + body = request.get_json(silent=True) or {} + sid = body.get("service_id") + base = (body.get("base_url") or "").strip().rstrip("/") + next_path = (body.get("next") or "/monitor").strip() or "/monitor" + if not next_path.startswith("/"): + next_path = "/" + next_path + username = (body.get("username") or os.environ.get("NAV_HUB_USERNAME") or "").strip() + password = body.get("password") + if password is None: + password = os.environ.get("NAV_HUB_PASSWORD") or "" + password = str(password) + + if sid: + svc = db.session.get(Service, int(sid)) + if not svc or not svc.is_hub_embed(): + return jsonify({"ok": False, "detail": "服务不存在或未标记为中控"}), 400 + base = svc.build_origin() + if not next_path or next_path == "/monitor": + p = (svc.path or "/monitor").strip() or "/monitor" + next_path = p if p.startswith("/") else "/" + p + + if not base: + return jsonify({"ok": False, "detail": "缺少 base_url"}), 400 + if not username or not password: + return jsonify({"ok": False, "detail": "缺少中控用户名或密码(可配置 NAV_HUB_USERNAME / NAV_HUB_PASSWORD)"}), 400 + + payload = json.dumps({"username": username, "password": password}).encode("utf-8") + req = urllib.request.Request( + f"{base}/api/auth/login", + data=payload, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=15) as resp: + raw = resp.read().decode("utf-8", errors="replace") + except urllib.error.HTTPError as e: + try: + err_body = e.read().decode("utf-8", errors="replace") + detail = json.loads(err_body).get("detail", err_body) + except Exception: + detail = str(e) + return jsonify({"ok": False, "detail": detail or "中控登录失败"}), 401 + except Exception as e: + return jsonify({"ok": False, "detail": f"无法连接中控: {e}"}), 502 + + try: + data = json.loads(raw) + except json.JSONDecodeError: + return jsonify({"ok": False, "detail": "中控返回非 JSON"}), 502 + if not data.get("ok"): + return jsonify({"ok": False, "detail": data.get("detail") or "登录失败"}), 401 + token = data.get("session_token") + if not token: + return jsonify({"ok": False, "detail": "中控未返回 session_token,请升级云端 hub 后重试"}), 502 + + q = urlencode({"token": token, "next": next_path}) + embed_url = f"{base}/embed-auth?{q}" + return jsonify({"ok": True, "embed_auth_url": embed_url, "next": next_path}) # ---------- 分组管理 ---------- @app.route("/admin/groups") @@ -261,6 +337,7 @@ def create_app() -> Flask: path=path, sort_order=form.sort_order.data or 0, group_id=form.group_id.data, + embed_kind=(form.embed_kind.data or "").strip(), ) db.session.add(s) db.session.commit() @@ -296,6 +373,7 @@ def create_app() -> Flask: s.path = (form.path.data or "").strip() or "/" s.sort_order = form.sort_order.data or 0 s.group_id = form.group_id.data + s.embed_kind = (form.embed_kind.data or "").strip() db.session.commit() flash("服务已更新", "success") return redirect(url_for("admin_services")) @@ -352,6 +430,16 @@ def _migrate_schema() -> None: ) ) print("[nav] 已为 services 表添加 scheme 列(默认 http)。", flush=True) + cols = {c["name"] for c in insp.get_columns("services")} + if "embed_kind" not in cols: + with db.engine.begin() as conn: + conn.execute( + text( + "ALTER TABLE services ADD COLUMN embed_kind VARCHAR(16) " + "NOT NULL DEFAULT ''" + ) + ) + print("[nav] 已为 services 表添加 embed_kind 列。", flush=True) except Exception as exc: print(f"[nav] 数据库结构迁移跳过或失败: {exc}", flush=True) diff --git a/forms.py b/forms.py index 3f6bd57..db109bb 100644 --- a/forms.py +++ b/forms.py @@ -45,6 +45,15 @@ class ServiceForm(FlaskForm): validators=[Optional(), NumberRange(min=-10**6, max=10**6)], default=0, ) + embed_kind = SelectField( + "嵌入类型", + choices=[ + ("", "普通(直接打开路径)"), + ("hub", "复盘中控(云端 hub,需 embed-auth 登录)"), + ], + default="", + validators=[Optional()], + ) submit = SubmitField("保存") def validate_path(self, field): diff --git a/instance/nav_local.db b/instance/nav_local.db index 2a87929..932131d 100644 Binary files a/instance/nav_local.db and b/instance/nav_local.db differ diff --git a/models.py b/models.py index 0ab57c4..47a3ddc 100644 --- a/models.py +++ b/models.py @@ -47,12 +47,33 @@ class Service(db.Model): group_id = db.Column( db.Integer, db.ForeignKey("service_groups.id"), nullable=False, index=True ) + # hub=复盘中控(iframe 嵌入需 /embed-auth);留空=普通内嵌 + embed_kind = db.Column(db.String(16), nullable=False, default="", server_default="") + + def build_origin(self) -> str: + proto = (self.scheme or "http").strip().lower() + if proto not in ("http", "https"): + proto = "http" + return f"{proto}://{self.host}:{self.port}" def build_url(self) -> str: p = (self.path or "/").strip() if not p.startswith("/"): p = "/" + p - proto = (self.scheme or "http").strip().lower() - if proto not in ("http", "https"): - proto = "http" - return f"{proto}://{self.host}:{self.port}{p}" + return f"{self.build_origin()}{p}" + + def build_open_url(self) -> str: + """导航 iframe 首次打开的地址(中控走 login?embed=1 以便写入会话)。""" + kind = (self.embed_kind or "").strip().lower() + next_path = (self.path or "/monitor").strip() or "/monitor" + if not next_path.startswith("/"): + next_path = "/" + next_path + if kind == "hub": + from urllib.parse import urlencode + + q = urlencode({"embed": "1", "next": next_path}) + return f"{self.build_origin()}/login?{q}" + return self.build_url() + + def is_hub_embed(self) -> bool: + return (self.embed_kind or "").strip().lower() == "hub" diff --git a/templates/admin_service_form.html b/templates/admin_service_form.html index 0317fea..2fb6cca 100644 --- a/templates/admin_service_form.html +++ b/templates/admin_service_form.html @@ -70,6 +70,16 @@
{{ form.sort_order.errors[0] }}
{% endif %} +
+ {{ form.embed_kind.label }} + {{ form.embed_kind() }} +

+ 选「复盘中控」时:路径填 /monitor;本地 iframe 打开会先走中控登录页或代登录。 +

+ {% if form.embed_kind.errors %} +
{{ form.embed_kind.errors[0] }}
+ {% endif %} +
{{ form.submit(class="btn btn-primary", style="width: auto") }} 取消 diff --git a/templates/admin_services.html b/templates/admin_services.html index 87c2452..1b53f35 100644 --- a/templates/admin_services.html +++ b/templates/admin_services.html @@ -49,7 +49,7 @@ {% for s in services %} - {{ s.name }} + {{ s.name }}{% if s.embed_kind == 'hub' %} (中控){% endif %} {{ s.build_url() }} {{ s.group.name if s.group else '—' }} {{ s.sort_order }} diff --git a/templates/index.html b/templates/index.html index f0ab3f0..edccf63 100644 --- a/templates/index.html +++ b/templates/index.html @@ -41,7 +41,12 @@ href="#" class="nav-link" role="button" - data-url="{{ svc.build_url()|e }}" + data-url="{{ svc.build_open_url()|e }}" + data-base-url="{{ svc.build_url()|e }}" + data-origin="{{ svc.build_origin()|e }}" + data-next-path="{{ (svc.path or '/monitor')|e }}" + data-embed-kind="{{ (svc.embed_kind or '')|e }}" + data-service-id="{{ svc.id }}" data-name="{{ svc.name | e }}" >{{ svc.name }} @@ -75,7 +80,12 @@
+ @@ -121,6 +140,7 @@