# -*- coding: utf-8 -*- from __future__ import annotations import json import secrets from datetime import datetime from functools import wraps from flask import ( Flask, flash, g, jsonify, redirect, render_template, request, send_from_directory, session, url_for, ) from app.config import Config, RELEASES_DIR from app import db as database def login_required(view): @wraps(view) def wrapped(*args, **kwargs): if not session.get("user_id"): return redirect(url_for("login", next=request.path)) return view(*args, **kwargs) return wrapped def admin_required(view): @wraps(view) def wrapped(*args, **kwargs): if not session.get("user_id"): return redirect(url_for("login", next=request.path)) if session.get("role") != "admin": flash("需要管理员权限", "error") return redirect(url_for("dashboard")) return view(*args, **kwargs) return wrapped def current_user() -> dict: return { "id": session.get("user_id"), "username": session.get("username"), "role": session.get("role"), "account_id": session.get("account_id"), } def register_routes(app: Flask) -> None: @app.context_processor def inject_globals(): return { "app_name": "Gold Monitor", "public_base": Config.PUBLIC_BASE_URL, "current_user": current_user(), "is_admin": session.get("role") == "admin", } @app.get("/login") def login(): if session.get("user_id"): return redirect(url_for("dashboard")) return render_template("login.html") @app.post("/login") def login_post(): user = (request.form.get("username") or "").strip() pwd = request.form.get("password") or "" u = database.verify_login(user, pwd) if not u: flash("用户名或密码错误", "error") return render_template("login.html"), 401 session["user_id"] = u["id"] session["username"] = u["username"] session["role"] = u["role"] session["account_id"] = u.get("account_id") session["user"] = u["username"] # 兼容旧模板 return redirect(request.args.get("next") or url_for("dashboard")) @app.get("/logout") def logout(): session.clear() return redirect(url_for("login")) @app.get("/settings") @login_required def settings_page(): return render_template("settings.html") @app.post("/settings/profile") @login_required def settings_profile(): old_pwd = request.form.get("old_password") or "" new_username = (request.form.get("username") or "").strip() new_pwd = request.form.get("new_password") or "" new_pwd2 = request.form.get("new_password2") or "" if new_pwd or new_pwd2: if new_pwd != new_pwd2: flash("两次输入的新密码不一致", "error") return redirect(url_for("settings_page")) else: new_pwd = None try: prev = session.get("username") or "" updated = database.change_user_credentials( int(session["user_id"]), old_pwd, new_username=new_username, new_password=new_pwd, ) session["username"] = updated["username"] session["user"] = updated["username"] parts = [] if updated["username"] != prev: parts.append("用户名") if new_pwd: parts.append("密码") flash(("、".join(parts) or "资料") + "已更新", "ok") except ValueError as exc: flash(str(exc), "error") return redirect(url_for("settings_page")) @app.post("/settings/password") @login_required def settings_password(): """兼容旧表单:仅改密码。""" old_pwd = request.form.get("old_password") or "" new_pwd = request.form.get("new_password") or "" new_pwd2 = request.form.get("new_password2") or "" if new_pwd != new_pwd2: flash("两次输入的新密码不一致", "error") return redirect(url_for("settings_page")) try: database.change_user_password(int(session["user_id"]), old_pwd, new_pwd) flash("密码已更新,请牢记新密码", "ok") except ValueError as exc: flash(str(exc), "error") return redirect(url_for("settings_page")) # ---------- 用户管理(仅管理员)---------- @app.get("/users") @admin_required def users_page(): with database.get_db() as conn: users = database.list_users(conn) accounts = database.list_accounts(conn) # 未被占用的账户(用于新建普通用户) used = {u["account_id"] for u in users if u.get("account_id")} free_accounts = [a for a in accounts if a["id"] not in used] return render_template( "users.html", users=users, accounts=accounts, free_accounts=free_accounts, ) @app.post("/users") @admin_required def users_create(): username = (request.form.get("username") or "").strip() password = request.form.get("password") or "" role = (request.form.get("role") or "user").strip() account_raw = request.form.get("account_id") or "" account_id = int(account_raw) if account_raw.strip().isdigit() else None try: with database.get_db() as conn: database.create_user(conn, username, password, role, account_id) flash("用户已创建", "ok") except ValueError as exc: flash(str(exc), "error") return redirect(url_for("users_page")) def _fmt_ts(value) -> str: if value is None or value == "": return "—" try: return datetime.fromtimestamp(float(value)).strftime("%Y-%m-%d %H:%M:%S") except (TypeError, ValueError, OSError): return str(value) def _dashboard_payload(u: dict) -> dict: with database.get_db() as conn: if u["role"] == "admin": accounts = database.list_accounts(conn) trades = database.list_trades(conn, 40) else: accounts = [ a for a in database.list_accounts(conn) if a["id"] == u.get("account_id") ] trades = database.list_trades(conn, 40, account_id=u.get("account_id")) machines = database.list_machines_for_user(conn, u) online_machines = [m for m in machines if m.get("online")] for m in machines: m["last_seen_fmt"] = _fmt_ts(m.get("last_seen_at")) for t in trades: t["time_fmt"] = _fmt_ts( t.get("close_time") or t.get("open_time") or t.get("created_at") ) return { "accounts": accounts, "machines": machines, "online_machines": online_machines, "online_count": len(online_machines), "machine_count": len(machines), "trades": trades, } @app.get("/") @login_required def dashboard(): data = _dashboard_payload(current_user()) return render_template("dashboard.html", **data) @app.get("/api/dashboard/live") @login_required def dashboard_live(): """总览轮询:在线机器 + 实时成交。""" data = _dashboard_payload(current_user()) return jsonify( { "ok": True, "online_count": data["online_count"], "machine_count": data["machine_count"], "online_machines": [ { "id": m["id"], "name": m.get("name") or f"#{m['id']}", "last_seen_at": m.get("last_seen_at"), "last_seen_fmt": _fmt_ts(m.get("last_seen_at")), "online": True, } for m in data["online_machines"] ], "trades": [ { "id": t["id"], "time_fmt": t.get("time_fmt"), "symbol": t.get("symbol"), "side": t.get("side"), "volume": t.get("volume"), "profit": t.get("profit"), "account_name": t.get("account_name"), "machine_name": t.get("machine_name"), "ticket": t.get("ticket"), } for t in data["trades"] ], } ) # ---------- 账户(仅管理员创建)---------- @app.get("/accounts") @login_required def accounts_page(): u = current_user() with database.get_db() as conn: items = database.list_accounts(conn) if u["role"] != "admin": items = [a for a in items if a["id"] == u.get("account_id")] return render_template("accounts.html", items=items, can_create=(u["role"] == "admin")) @app.post("/accounts") @admin_required def accounts_create(): name = (request.form.get("name") or "").strip() if not name: flash("名称不能为空", "error") return redirect(url_for("accounts_page")) with database.get_db() as conn: conn.execute( """INSERT INTO accounts(name, broker, login_id, account_type, note, enabled, created_at) VALUES(?,?,?,?,?,?,?)""", ( name, (request.form.get("broker") or "").strip(), (request.form.get("login_id") or "").strip(), (request.form.get("account_type") or "demo").strip(), (request.form.get("note") or "").strip(), 1, database.now(), ), ) flash("账户已创建(仅管理员可创建)", "ok") return redirect(url_for("accounts_page")) @app.post("/accounts//note") @login_required def accounts_note(aid: int): u = current_user() if not database.user_can_access_account(u, aid): flash("无权修改该账户", "error") return redirect(url_for("accounts_page")) note = (request.form.get("note") or "").strip() with database.get_db() as conn: conn.execute("UPDATE accounts SET note=? WHERE id=?", (note, aid)) flash("标注已保存", "ok") return redirect(url_for("accounts_page")) @app.post("/accounts//delete") @admin_required def accounts_delete(aid: int): try: with database.get_db() as conn: database.delete_account(conn, aid) flash("账户已删除", "ok") except ValueError as exc: flash(str(exc), "error") return redirect(url_for("accounts_page")) @app.get("/machines") @login_required def machines_page(): u = current_user() with database.get_db() as conn: items = database.list_machines_for_user(conn, u) for m in items: m["last_seen_fmt"] = _fmt_ts(m.get("last_seen_at")) m["bound"] = bool(m.get("device_fingerprint")) return render_template("machines.html", items=items, is_admin=(u["role"] == "admin")) @app.post("/machines") @admin_required def machines_create(): name = (request.form.get("name") or "").strip() or "新机器" with database.get_db() as conn: conn.execute( """INSERT INTO machines(name, device_fingerprint, note, enabled, created_at) VALUES(?,?,?,?,?)""", (name, "", (request.form.get("note") or "").strip(), 1, database.now()), ) flash("机器已添加(预登记,激活后绑定指纹)", "ok") return redirect(url_for("machines_page")) @app.post("/machines//note") @login_required def machines_note(mid: int): note = (request.form.get("note") or "").strip() name = (request.form.get("name") or "").strip() with database.get_db() as conn: if name: conn.execute("UPDATE machines SET note=?, name=? WHERE id=?", (note, name, mid)) else: conn.execute("UPDATE machines SET note=? WHERE id=?", (note, mid)) flash("机器标注已保存", "ok") return redirect(url_for("machines_page")) @app.post("/machines//delete") @admin_required def machines_delete(mid: int): try: with database.get_db() as conn: database.delete_machine(conn, mid) flash("机器已删除", "ok") except ValueError as exc: flash(str(exc), "error") return redirect(url_for("machines_page")) # ---------- 下载 / 激活(管理员与用户都可发码;用户仅自己账户)---------- @app.get("/download") @login_required def download_page(): u = current_user() with database.get_db() as conn: all_accounts = database.list_accounts(conn) if u["role"] == "admin": accounts = all_accounts else: accounts = [a for a in all_accounts if a["id"] == u.get("account_id")] machines = database.list_machines(conn) if u["role"] == "admin": acts = conn.execute( """ SELECT act.*, a.name AS account_name, m.name AS machine_name, m.last_seen_at AS machine_last_seen FROM activations act LEFT JOIN accounts a ON a.id = act.account_id LEFT JOIN machines m ON m.id = act.machine_id ORDER BY act.id DESC LIMIT 50 """ ).fetchall() else: acts = conn.execute( """ SELECT act.*, a.name AS account_name, m.name AS machine_name, m.last_seen_at AS machine_last_seen FROM activations act LEFT JOIN accounts a ON a.id = act.account_id LEFT JOIN machines m ON m.id = act.machine_id WHERE act.account_id=? ORDER BY act.id DESC LIMIT 50 """, (u.get("account_id"),), ).fetchall() activations = [] now_ts = database.now() online_sec = 180 # 3 分钟内有心跳视为在线 for r in acts: item = database.row_to_dict(r) last = item.get("machine_last_seen") if item.get("status") == "active" and last and (now_ts - float(last)) <= online_sec: item["online_label"] = "在线" item["online"] = True else: item["online_label"] = "离线" item["online"] = False try: item["created_at_fmt"] = datetime.fromtimestamp(float(item["created_at"])).strftime( "%Y-%m-%d %H:%M" ) except (TypeError, ValueError, OSError): item["created_at_fmt"] = str(item.get("created_at") or "—") activations.append(item) exe_name = "黄金市价辅助.exe" exe_path = RELEASES_DIR / exe_name return render_template( "download.html", accounts=accounts, machines=machines, activations=activations, exe_ready=exe_path.is_file(), exe_name=exe_name, ) @app.post("/download/activation") @login_required def download_create_activation(): u = current_user() account_id = int(request.form.get("account_id") or 0) machine_raw = request.form.get("machine_id") or "" machine_id = int(machine_raw) if machine_raw.strip().isdigit() else None if not database.user_can_access_account(u, account_id): flash("只能为自己的账户发放激活码", "error") return redirect(url_for("download_page")) try: with database.get_db() as conn: code = database.create_activation(conn, account_id, machine_id, u.get("id")) flash(f"已生成激活码:{code}", "ok") except ValueError as exc: flash(str(exc), "error") return redirect(url_for("download_page")) @app.get("/download/file") @login_required def download_file(): name = "黄金市价辅助.exe" path = RELEASES_DIR / name if not path.is_file(): readme = RELEASES_DIR / "README_PLACE_EXE_HERE.txt" if not readme.is_file(): readme.write_text( "请将打包好的通用辅助 exe 放到本目录,命名为:黄金市价辅助.exe\n", encoding="utf-8", ) return send_from_directory(RELEASES_DIR, readme.name, as_attachment=True) return send_from_directory(RELEASES_DIR, name, as_attachment=True) @app.post("/api/v1/activate") def api_activate(): data = request.get_json(silent=True) or {} code = (data.get("code") or "").strip() fingerprint = (data.get("fingerprint") or secrets.token_hex(8)).strip() machine_name = (data.get("machine_name") or "").strip() if not code: return jsonify({"ok": False, "error": "缺少激活码"}), 400 try: with database.get_db() as conn: result = database.activate_device(conn, code, fingerprint, machine_name) return jsonify({"ok": True, **result}) except ValueError as exc: return jsonify({"ok": False, "error": str(exc)}), 400 @app.get("/api/v1/ping") def api_ping(): token = request.headers.get("X-Device-Token") or request.args.get("token") or "" with database.get_db() as conn: act = database.auth_token(conn, token) if not act: return jsonify({"ok": False, "error": "未激活"}), 401 if act.get("machine_id"): conn.execute( "UPDATE machines SET last_seen_at=? WHERE id=?", (database.now(), act["machine_id"]), ) return jsonify({"ok": True, "server_time": database.now()}) @app.post("/api/v1/trades") def api_trades_upsert(): token = request.headers.get("X-Device-Token") or "" data = request.get_json(silent=True) or {} with database.get_db() as conn: act = database.auth_token(conn, token) if not act: return jsonify({"ok": False, "error": "未激活"}), 401 if not act.get("account_id"): return jsonify({"ok": False, "error": "激活码未绑定账户"}), 400 snapshot = data.get("snapshot") or {} conn.execute( """ INSERT INTO trades( ticket, symbol, side, volume, open_price, close_price, open_time, close_time, profit, sl, tp, account_id, machine_id, entry_style, review_note, snapshot_json, created_at ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) """, ( str(data.get("ticket") or ""), (data.get("symbol") or "XAUUSD").upper(), (data.get("side") or "").lower(), float(data.get("volume") or 0), float(data.get("open_price") or 0), float(data.get("close_price") or 0) if data.get("close_price") is not None else None, data.get("open_time"), data.get("close_time"), float(data.get("profit") or 0) if data.get("profit") is not None else None, data.get("sl"), data.get("tp"), act["account_id"], act["machine_id"], (data.get("entry_style") or "").strip(), (data.get("review_note") or "").strip(), json.dumps(snapshot, ensure_ascii=False), database.now(), ), ) tid = conn.execute("SELECT last_insert_rowid()").fetchone()[0] return jsonify({"ok": True, "id": tid}) @app.post("/api/v1/bars") def api_bars_upload(): token = request.headers.get("X-Device-Token") or "" data = request.get_json(silent=True) or {} with database.get_db() as conn: if not database.auth_token(conn, token): return jsonify({"ok": False, "error": "未激活"}), 401 symbol = (data.get("symbol") or "XAUUSD").upper() timeframe = (data.get("timeframe") or "M1").upper() bars = data.get("bars") or [] n = database.upsert_bars(conn, symbol, timeframe, bars) return jsonify({"ok": True, "count": n}) @app.get("/api/v1/bars") @login_required def api_bars_get(): symbol = (request.args.get("symbol") or "XAUUSD").upper() timeframe = (request.args.get("timeframe") or "M15").upper() start_ts = float(request.args.get("start") or 0) end_ts = float(request.args.get("end") or database.now()) with database.get_db() as conn: bars = database.get_bars(conn, symbol, timeframe, start_ts, end_ts) return jsonify({"ok": True, "bars": bars}) @app.get("/records") @login_required def records_page(): u = current_user() with database.get_db() as conn: if u["role"] == "admin": trades = database.list_trades(conn, 300) else: trades = database.list_trades(conn, 300, account_id=u.get("account_id")) return render_template("records.html", trades=trades) @app.get("/records/") @login_required def record_detail(tid: int): u = current_user() with database.get_db() as conn: trade = database.get_trade(conn, tid) if not trade or not database.user_can_access_account(u, trade.get("account_id")): flash("记录不存在或无权查看", "error") return redirect(url_for("records_page")) center = trade.get("open_time") or trade.get("created_at") or database.now() window = 3600 * 6 charts = {} for tf in ("M1", "M5", "M15", "H1"): charts[tf] = database.get_bars( conn, trade["symbol"], tf, float(center) - window, float(center) + window ) return render_template("record_detail.html", trade=trade, charts=charts) @app.post("/records//annotate") @login_required def record_annotate(tid: int): u = current_user() entry_style = (request.form.get("entry_style") or "").strip() note = (request.form.get("review_note") or "").strip() with database.get_db() as conn: trade = database.get_trade(conn, tid) if not trade or not database.user_can_access_account(u, trade.get("account_id")): flash("无权标注该记录", "error") return redirect(url_for("records_page")) conn.execute( "UPDATE trades SET entry_style=?, review_note=? WHERE id=?", (entry_style, note, tid), ) flash("复盘标注已保存", "ok") return redirect(url_for("record_detail", tid=tid)) @app.get("/health") def health(): return jsonify({"ok": True, "service": "gold_monitor", "port": Config.PORT})