diff --git a/README.md b/README.md index b9bddd6..5e779d0 100644 --- a/README.md +++ b/README.md @@ -39,14 +39,15 @@ bash deploy/manage.sh | 功能 | 说明 | |------|------| -| **登录** | 网页账号(默认 admin) | -| **系统设置** | 前端修改登录密码 | -| **下载中心** | 通用辅助包 + 生成激活码 | -| **账户 / 机器** | 标注名称、备注、类型 | -| **首次激活** | exe 调用 API 绑定指纹与 token | +| **登录** | 管理员 / 普通用户(一人一账户,账户仅管理员创建) | +| **用户管理** | 管理员创建用户并绑定唯一交易账户 | +| **系统设置** | 修改当前登录密码 | +| **下载中心** | 通用辅助包;**管理员与用户均可**为自己权限内账户发激活码 | +| **账户 / 机器** | 账户仅管理员创建;机器可预登记或激活时自动创建 | +| **首次激活** | exe 调用 API 绑定指纹与 token(账户须已存在) | | **成交上报** | 已激活客户端 POST 成交与四周期 K 线 | -| **交易复盘** | 列表 + 详情四周期(M1/M5/M15/H1)+ 入场形式标注 | -| **desktop/** | 已迁入下单辅助,支持激活与上报 | +| **交易复盘** | 按角色过滤;详情四周期 + 入场形式标注 | +| **desktop/** | 下单辅助:激活、上报、**打开复盘**(浏览器登录页) | --- diff --git a/app/db.py b/app/db.py index 1edef12..292f052 100644 --- a/app/db.py +++ b/app/db.py @@ -106,6 +106,17 @@ def init_db() -> None: key TEXT PRIMARY KEY, value TEXT NOT NULL ); + + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + account_id INTEGER UNIQUE, + enabled INTEGER DEFAULT 1, + created_at REAL NOT NULL, + FOREIGN KEY(account_id) REFERENCES accounts(id) + ); """ ) @@ -130,12 +141,27 @@ def list_machines(conn: sqlite3.Connection) -> list[dict]: return [row_to_dict(r) for r in cur.fetchall()] -def create_activation(conn: sqlite3.Connection, account_id: int, machine_id: Optional[int]) -> str: +def create_activation( + conn: sqlite3.Connection, + account_id: int, + machine_id: Optional[int], + created_by_user_id: Optional[int] = None, +) -> str: + if not conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone(): + raise ValueError("账户不存在(须由管理员预先创建)") code = secrets.token_urlsafe(12) - conn.execute( - "INSERT INTO activations(code, account_id, machine_id, status, created_at) VALUES(?,?,?,?,?)", - (code, account_id, machine_id, "pending", now()), - ) + # created_by 存在 activations 可选列:兼容旧库无该列时忽略 + cols = {r["name"] for r in conn.execute("PRAGMA table_info(activations)").fetchall()} + if "created_by" in cols: + conn.execute( + "INSERT INTO activations(code, account_id, machine_id, status, created_at, created_by) VALUES(?,?,?,?,?,?)", + (code, account_id, machine_id, "pending", now(), created_by_user_id), + ) + else: + conn.execute( + "INSERT INTO activations(code, account_id, machine_id, status, created_at) VALUES(?,?,?,?,?)", + (code, account_id, machine_id, "pending", now()), + ) return code @@ -145,6 +171,10 @@ def activate_device(conn: sqlite3.Connection, code: str, fingerprint: str, machi raise ValueError("激活码无效") if row["status"] == "revoked": raise ValueError("激活码已作废") + if not row["account_id"]: + raise ValueError("激活码未绑定账户,请联系管理员重新发放") + if not conn.execute("SELECT id FROM accounts WHERE id=? AND enabled=1", (row["account_id"],)).fetchone(): + raise ValueError("绑定账户不存在或已停用") token = secrets.token_urlsafe(32) machine_id = row["machine_id"] @@ -156,7 +186,7 @@ def activate_device(conn: sqlite3.Connection, code: str, fingerprint: str, machi else: cur = conn.execute( "INSERT INTO machines(name, device_fingerprint, note, enabled, last_seen_at, created_at) VALUES(?,?,?,?,?,?)", - (machine_name or f"PC-{fingerprint[:6]}", fingerprint, "激活自动创建", 1, now(), now()), + (machine_name or f"PC-{fingerprint[:6]}", fingerprint, "激活绑定", 1, now(), now()), ) machine_id = cur.lastrowid conn.execute("UPDATE activations SET machine_id=? WHERE id=?", (machine_id, row["id"])) @@ -183,18 +213,36 @@ def auth_token(conn: sqlite3.Connection, token: str) -> Optional[dict]: return row_to_dict(row) -def list_trades(conn: sqlite3.Connection, limit: int = 200) -> list[dict]: - cur = conn.execute( - """ - SELECT t.*, a.name AS account_name, m.name AS machine_name - FROM trades t - LEFT JOIN accounts a ON a.id = t.account_id - LEFT JOIN machines m ON m.id = t.machine_id - ORDER BY COALESCE(t.close_time, t.open_time, t.created_at) DESC - LIMIT ? - """, - (limit,), - ) +def list_trades( + conn: sqlite3.Connection, + limit: int = 200, + account_id: Optional[int] = None, +) -> list[dict]: + if account_id is None: + cur = conn.execute( + """ + SELECT t.*, a.name AS account_name, m.name AS machine_name + FROM trades t + LEFT JOIN accounts a ON a.id = t.account_id + LEFT JOIN machines m ON m.id = t.machine_id + ORDER BY COALESCE(t.close_time, t.open_time, t.created_at) DESC + LIMIT ? + """, + (limit,), + ) + else: + cur = conn.execute( + """ + SELECT t.*, a.name AS account_name, m.name AS machine_name + FROM trades t + LEFT JOIN accounts a ON a.id = t.account_id + LEFT JOIN machines m ON m.id = t.machine_id + WHERE t.account_id=? + ORDER BY COALESCE(t.close_time, t.open_time, t.created_at) DESC + LIMIT ? + """, + (account_id, limit), + ) rows = [] for r in cur.fetchall(): d = row_to_dict(r) @@ -226,6 +274,28 @@ def get_trade(conn: sqlite3.Connection, trade_id: int) -> Optional[dict]: return d +def list_users(conn: sqlite3.Connection) -> list[dict]: + cur = conn.execute( + """ + SELECT u.*, a.name AS account_name + FROM users u + LEFT JOIN accounts a ON a.id = u.account_id + ORDER BY u.id ASC + """ + ) + return [row_to_dict(r) for r in cur.fetchall()] + + +def get_user_by_username(conn: sqlite3.Connection, username: str) -> Optional[dict]: + return row_to_dict( + conn.execute("SELECT * FROM users WHERE username=? AND enabled=1", (username,)).fetchone() + ) + + +def get_user(conn: sqlite3.Connection, user_id: int) -> Optional[dict]: + return row_to_dict(conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()) + + def upsert_bars(conn: sqlite3.Connection, symbol: str, timeframe: str, bars: list[dict]) -> int: n = 0 for b in bars: @@ -277,61 +347,124 @@ def set_setting(conn: sqlite3.Connection, key: str, value: str) -> None: def seed_if_empty() -> None: + """仅确保管理员用户存在;交易账户不自动创建,须管理员手工建。""" from werkzeug.security import generate_password_hash from app.config import Config with get_db() as conn: - n = conn.execute("SELECT COUNT(*) AS c FROM accounts").fetchone()["c"] - if n == 0: + # 兼容旧库:补 activations.created_by + cols = {r["name"] for r in conn.execute("PRAGMA table_info(activations)").fetchall()} + if "created_by" not in cols: + try: + conn.execute("ALTER TABLE activations ADD COLUMN created_by INTEGER") + except sqlite3.OperationalError: + pass + + n_users = conn.execute("SELECT COUNT(*) AS c FROM users").fetchone()["c"] + if n_users == 0: + # 迁移旧 settings 密码,或使用 .env 默认 + old_hash = get_setting(conn, "admin_password_hash") + pwd_hash = old_hash or generate_password_hash(Config.ADMIN_PASSWORD) + username = get_setting(conn, "admin_user") or Config.ADMIN_USER conn.execute( - "INSERT INTO accounts(name, broker, login_id, account_type, note, enabled, created_at) VALUES(?,?,?,?,?,?,?)", - ("模拟盘-主", "MT5", "", "demo", "默认账户,可改标注", 1, now()), - ) - n2 = conn.execute("SELECT COUNT(*) AS c FROM machines").fetchone()["c"] - if n2 == 0: - conn.execute( - "INSERT INTO machines(name, device_fingerprint, note, enabled, created_at) VALUES(?,?,?,?,?)", - ("未绑定机器", "", "下载通用包后用激活码绑定", 1, now()), - ) - if not get_setting(conn, "admin_user"): - set_setting(conn, "admin_user", Config.ADMIN_USER) - if not get_setting(conn, "admin_password_hash"): - set_setting( - conn, - "admin_password_hash", - generate_password_hash(Config.ADMIN_PASSWORD), + """INSERT INTO users(username, password_hash, role, account_id, enabled, created_at) + VALUES(?,?,?,?,?,?)""", + (username, pwd_hash, "admin", None, 1, now()), ) -def verify_admin(username: str, password: str) -> bool: +def verify_login(username: str, password: str) -> Optional[dict]: from werkzeug.security import check_password_hash with get_db() as conn: - user = get_setting(conn, "admin_user", "admin") - pwd_hash = get_setting(conn, "admin_password_hash") - if username != user: - return False - if not pwd_hash: - from app.config import Config - - return password == Config.ADMIN_PASSWORD - return check_password_hash(pwd_hash, password) + user = get_user_by_username(conn, username) + if not user: + return None + if not check_password_hash(user["password_hash"], password): + return None + return user -def change_admin_password(old_password: str, new_password: str) -> None: +def change_user_password(user_id: int, old_password: str, new_password: str) -> None: from werkzeug.security import check_password_hash, generate_password_hash if len(new_password) < 6: raise ValueError("新密码至少 6 位") with get_db() as conn: - pwd_hash = get_setting(conn, "admin_password_hash") - if pwd_hash: - if not check_password_hash(pwd_hash, old_password): - raise ValueError("当前密码不正确") - else: - from app.config import Config + user = get_user(conn, user_id) + if not user: + raise ValueError("用户不存在") + if not check_password_hash(user["password_hash"], old_password): + raise ValueError("当前密码不正确") + conn.execute( + "UPDATE users SET password_hash=? WHERE id=?", + (generate_password_hash(new_password), user_id), + ) - if old_password != Config.ADMIN_PASSWORD: - raise ValueError("当前密码不正确") - set_setting(conn, "admin_password_hash", generate_password_hash(new_password)) + +def create_user( + conn: sqlite3.Connection, + username: str, + password: str, + role: str, + account_id: Optional[int], +) -> int: + from werkzeug.security import generate_password_hash + + username = username.strip() + if not username: + raise ValueError("用户名不能为空") + if len(password) < 6: + raise ValueError("密码至少 6 位") + if role not in ("admin", "user"): + raise ValueError("角色无效") + if role == "user": + if not account_id: + raise ValueError("普通用户必须绑定一个交易账户") + if not conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone(): + raise ValueError("交易账户不存在") + taken = conn.execute( + "SELECT id FROM users WHERE account_id=? AND role='user'", + (account_id,), + ).fetchone() + if taken: + raise ValueError("该交易账户已绑定其他用户(一人一账户)") + else: + account_id = None + + try: + cur = conn.execute( + """INSERT INTO users(username, password_hash, role, account_id, enabled, created_at) + VALUES(?,?,?,?,?,?)""", + (username, generate_password_hash(password), role, account_id, 1, now()), + ) + except sqlite3.IntegrityError as exc: + raise ValueError("用户名已存在或账户已被占用") from exc + return int(cur.lastrowid) + + +def user_can_access_account(user: dict, account_id: Optional[int]) -> bool: + if not user: + return False + if user.get("role") == "admin": + return True + return account_id is not None and int(user.get("account_id") or 0) == int(account_id) + + +# 兼容旧调用名 +def verify_admin(username: str, password: str) -> bool: + u = verify_login(username, password) + return bool(u and u.get("role") == "admin") + + +def change_admin_password(old_password: str, new_password: str) -> None: + """兼容:修改名为 admin 的管理员密码。""" + with get_db() as conn: + u = get_user_by_username(conn, "admin") or conn.execute( + "SELECT * FROM users WHERE role='admin' ORDER BY id LIMIT 1" + ).fetchone() + if not u: + raise ValueError("未找到管理员用户") + uid = u["id"] if isinstance(u, sqlite3.Row) else u["id"] + change_user_password(uid, old_password, new_password) diff --git a/app/routes.py b/app/routes.py index 8adf5a7..baf2700 100644 --- a/app/routes.py +++ b/app/routes.py @@ -4,11 +4,11 @@ from __future__ import annotations import json import secrets from functools import wraps -from pathlib import Path from flask import ( Flask, flash, + g, jsonify, redirect, render_template, @@ -25,24 +25,48 @@ from app import db as database def login_required(view): @wraps(view) def wrapped(*args, **kwargs): - if not session.get("user"): + 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"): + if session.get("user_id"): return redirect(url_for("dashboard")) return render_template("login.html") @@ -50,11 +74,16 @@ def register_routes(app: Flask) -> None: def login_post(): user = (request.form.get("username") or "").strip() pwd = request.form.get("password") or "" - if database.verify_admin(user, pwd): - session["user"] = user - return redirect(request.args.get("next") or url_for("dashboard")) - flash("用户名或密码错误", "error") - return render_template("login.html"), 401 + 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(): @@ -76,19 +105,62 @@ def register_routes(app: Flask) -> None: flash("两次输入的新密码不一致", "error") return redirect(url_for("settings_page")) try: - database.change_admin_password(old_pwd, new_pwd) + 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")) + @app.get("/") @login_required def dashboard(): + u = current_user() with database.get_db() as conn: - accounts = database.list_accounts(conn) - machines = database.list_machines(conn) - trades = database.list_trades(conn, 20) + if u["role"] == "admin": + accounts = database.list_accounts(conn) + machines = database.list_machines(conn) + trades = database.list_trades(conn, 20) + else: + accounts = [ + a + for a in database.list_accounts(conn) + if a["id"] == u.get("account_id") + ] + machines = database.list_machines(conn) + trades = database.list_trades(conn, 20, account_id=u.get("account_id")) return render_template( "dashboard.html", accounts=accounts, @@ -96,16 +168,19 @@ def register_routes(app: Flask) -> None: trades=trades, ) - # ---------- 账户 / 机器 ---------- + # ---------- 账户(仅管理员创建)---------- @app.get("/accounts") @login_required def accounts_page(): + u = current_user() with database.get_db() as conn: items = database.list_accounts(conn) - return render_template("accounts.html", items=items) + 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") - @login_required + @admin_required def accounts_create(): name = (request.form.get("name") or "").strip() if not name: @@ -125,12 +200,16 @@ def register_routes(app: Flask) -> None: database.now(), ), ) - flash("账户已添加", "ok") + 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)) @@ -140,12 +219,26 @@ def register_routes(app: Flask) -> None: @app.get("/machines") @login_required def machines_page(): + # 机器列表:管理员全看;普通用户只看与自己账户激活相关的机器 + u = current_user() with database.get_db() as conn: - items = database.list_machines(conn) - return render_template("machines.html", items=items) + if u["role"] == "admin": + items = database.list_machines(conn) + else: + cur = conn.execute( + """ + SELECT DISTINCT m.* FROM machines m + JOIN activations act ON act.machine_id = m.id + WHERE act.account_id=? + ORDER BY m.id DESC + """, + (u.get("account_id"),), + ) + items = [database.row_to_dict(r) for r in cur.fetchall()] + return render_template("machines.html", items=items, is_admin=(u["role"] == "admin")) @app.post("/machines") - @login_required + @admin_required def machines_create(): name = (request.form.get("name") or "").strip() or "新机器" with database.get_db() as conn: @@ -170,22 +263,40 @@ def register_routes(app: Flask) -> None: flash("机器标注已保存", "ok") return redirect(url_for("machines_page")) - # ---------- 下载 / 激活 ---------- + # ---------- 下载 / 激活(管理员与用户都可发码;用户仅自己账户)---------- @app.get("/download") @login_required def download_page(): + u = current_user() with database.get_db() as conn: - accounts = database.list_accounts(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) - acts = conn.execute( - """ - SELECT act.*, a.name AS account_name, m.name AS machine_name - 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() + if u["role"] == "admin": + acts = conn.execute( + """ + SELECT act.*, a.name AS account_name, m.name AS machine_name + 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 + 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 = [database.row_to_dict(r) for r in acts] exe_name = "黄金市价辅助.exe" exe_path = RELEASES_DIR / exe_name @@ -201,15 +312,19 @@ def register_routes(app: Flask) -> None: @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 - with database.get_db() as conn: - if not conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone(): - flash("账户不存在", "error") - return redirect(url_for("download_page")) - code = database.create_activation(conn, account_id, machine_id) - flash(f"已生成激活码:{code}", "ok") + 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") @@ -218,12 +333,10 @@ def register_routes(app: Flask) -> None: 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" - "客户端首次启动使用网页生成的激活码绑定机器与账户。\n", + "请将打包好的通用辅助 exe 放到本目录,命名为:黄金市价辅助.exe\n", encoding="utf-8", ) return send_from_directory(RELEASES_DIR, readme.name, as_attachment=True) @@ -258,7 +371,6 @@ def register_routes(app: Flask) -> None: ) 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 "" @@ -267,6 +379,8 @@ def register_routes(app: Flask) -> None: 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( """ @@ -326,19 +440,23 @@ def register_routes(app: Flask) -> None: @app.get("/records") @login_required def records_page(): + u = current_user() with database.get_db() as conn: - trades = database.list_trades(conn, 300) + 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: - flash("记录不存在", "error") + 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 = {} @@ -351,9 +469,14 @@ def register_routes(app: Flask) -> None: @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), diff --git a/app/templates/accounts.html b/app/templates/accounts.html index 99f0060..6ec1959 100644 --- a/app/templates/accounts.html +++ b/app/templates/accounts.html @@ -1,10 +1,12 @@ {% extends "base.html" %} {% block title %}账户 · {{ app_name }}{% endblock %} {% block content %} -

账户标注

+

账户{% if can_create %}(仅管理员可创建){% else %}(只读自己的账户){% endif %}

+ {% if can_create %}

新增账户

+

账户禁止自助注册,创建后在「用户」中绑定给唯一操作员。

@@ -21,8 +23,9 @@
+ {% endif %}
-

已有账户

+

账户列表

diff --git a/app/templates/base.html b/app/templates/base.html index 86549f3..a871fdc 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -14,11 +14,16 @@ 总览交易复盘下载中心 + {% if is_admin %} 账户 + 用户机器 + {% else %} + 我的账户 + {% endif %} 系统设置 -
{{ session.get('user') }} · 退出
+
{{ current_user.username }}({{ current_user.role }}) · 退出
{% endif %}
diff --git a/app/templates/download.html b/app/templates/download.html index 9e32207..d44b19f 100644 --- a/app/templates/download.html +++ b/app/templates/download.html @@ -17,6 +17,7 @@

生成激活码

+

管理员与普通用户均可发放;普通用户只能发给自己的账户。

diff --git a/app/templates/machines.html b/app/templates/machines.html index 39bcd16..fe2c710 100644 --- a/app/templates/machines.html +++ b/app/templates/machines.html @@ -1,8 +1,9 @@ {% extends "base.html" %} {% block title %}机器 · {{ app_name }}{% endblock %} {% block content %} -

机器标注

+

机器{% if is_admin %}(管理员可预登记){% else %}(与本账户相关){% endif %}

+ {% if is_admin %}

预登记机器

也可不预登记:激活时自动创建机器并绑定指纹。

@@ -12,6 +13,7 @@
+ {% endif %}

已有机器

名称类型标注
@@ -20,12 +22,18 @@ {% for m in items %} diff --git a/app/templates/settings.html b/app/templates/settings.html index 77f691d..4b57882 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -5,7 +5,7 @@

修改登录密码

-

修改后立即生效,无需重启服务。请使用足够强度的密码。

+

修改当前登录用户密码,立即生效。管理员请在「用户」页创建普通用户。

diff --git a/app/templates/users.html b/app/templates/users.html new file mode 100644 index 0000000..74bb220 --- /dev/null +++ b/app/templates/users.html @@ -0,0 +1,56 @@ +{% extends "base.html" %} +{% block title %}用户管理 · {{ app_name }}{% endblock %} +{% block content %} +

用户管理

+

仅管理员可创建。普通用户必须绑定唯一交易账户(一人一账户,账户不可自助注册)。

+
+
+

创建用户

+ +
+
+
+ + +
+
+ + +
+ + +
+
+

已有用户

+
+ {% if is_admin %}
{{ m.device_fingerprint or '未绑定' }}
+ {% else %} +
{{ m.name }}
+
{{ m.device_fingerprint or '未绑定' }}
+ {% if m.note %}
{{ m.note }}
{% endif %} + {% endif %}
{{ m.last_seen_at or '—' }} #{{ m.id }}
+ + + {% for u in users %} + + + + + + + {% endfor %} + +
用户名角色账户状态
{{ u.username }}{{ u.role }}{{ u.account_name or (u.account_id or '—') }}{{ '启用' if u.enabled else '停用' }}
+
+
+{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/desktop/README.md b/desktop/README.md index 729fc2f..5494f16 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -4,6 +4,7 @@ - 连接本机已登录 MT5,市价开平、ATR 止损止盈、保本、满仓灰显等(由原 `MT5黄金方案/Desktop` 迁入) - **通用包 + 首次激活**:启动时填写中控地址与激活码 +- **打开复盘**:主界面按钮用浏览器打开中控 `/login`,用网页账号直接登录复盘 - **自动上报**:开仓/平仓成功后 `POST /api/v1/trades`,并上传 M1/M5/M15/H1 K 线供网页复盘 ## 本地配置 diff --git a/desktop/gold_order_app.py b/desktop/gold_order_app.py index 0b380bb..1cc0bff 100644 --- a/desktop/gold_order_app.py +++ b/desktop/gold_order_app.py @@ -7,9 +7,10 @@ import math import sys import threading import time +import webbrowser import tkinter as tk from datetime import datetime, timedelta -from tkinter import messagebox, ttk +from tkinter import messagebox, simpledialog, ttk from typing import Any, Optional, Tuple, Union # 顶层导入,避免打包 exe 漏依赖 @@ -66,6 +67,17 @@ BE_MIN_MOVE_USD = 3.0 # 价格向有利方向走出该美金距离才可保本 BE_OFFSET_USD = 1.0 # 保本止损 = 开仓价 ± 1$ +def simpledialog_ask_server(parent: tk.Misc) -> str: + """未激活或本地无中控地址时,让用户输入复盘服务器 URL。""" + value = simpledialog.askstring( + "打开复盘", + "请输入中控地址(例如 http://192.168.1.10:5800)", + parent=parent, + initialvalue="http://127.0.0.1:5800", + ) + return (value or "").strip() + + def enable_dpi_awareness() -> None: """避免高分屏下 Tk 窗口被系统拉伸发虚。""" if sys.platform != "win32": @@ -743,6 +755,9 @@ class OrderApp(tk.Tk): ttk.Label(pos_bar, text="持仓记录", font=("Microsoft YaHei UI", 13, "bold")).pack(side=tk.LEFT) self.btn_break_even = self._mk_btn(pos_bar, "保本", COLOR_BE, self.on_break_even, h=34, w=90) self.btn_break_even.pack(side=tk.RIGHT, padx=4) + self._mk_btn(pos_bar, "打开复盘", "#3a5a80", self.on_open_review, h=34, w=100).pack( + side=tk.RIGHT, padx=4 + ) self._set_be_button_enabled(False) # 持仓列表:无竖线;列宽按权重拉满右侧;平仓红色边框按钮 @@ -1430,6 +1445,24 @@ class OrderApp(tk.Tk): self._run_async(work) + def on_open_review(self) -> None: + """打开中控复盘登录页(浏览器)。""" + server = "" + try: + server = (load_device().get("server") or "").rstrip("/") + except Exception: + server = "" + if not server: + server = simpledialog_ask_server(self) + if not server: + return + url = server.rstrip("/") + "/login" + try: + webbrowser.open(url) + self.set_status(f"已打开复盘登录页:{url}", ok=True) + except Exception as exc: + self.set_status(f"无法打开浏览器:{exc}", ok=False) + def destroy(self) -> None: try: self.bridge.shutdown() diff --git a/docs/technical.md b/docs/technical.md index f47aa38..04d1fba 100644 --- a/docs/technical.md +++ b/docs/technical.md @@ -27,12 +27,15 @@ Windows exe (通用包+激活) ──API───────────┘ | 表 | 用途 | |----|------| -| `accounts` | 交易账户与标注 | +| `users` | 登录用户:`role`=admin/user,普通用户必绑唯一 `account_id` | +| `accounts` | 交易账户与标注(仅管理员创建,禁止自助注册) | | `machines` | 机器、指纹、在线时间 | -| `activations` | 激活码、token、状态 | +| `activations` | 激活码、token、状态、`created_by` 发放人 | | `trades` | 成交与复盘字段(entry_style / review_note / snapshot_json) | | `bars` | K 线缓存(symbol+timeframe+ts 唯一) | +**权限**:管理员看全部;普通用户复盘/激活码仅限自己的 `account_id`。激活码可由 admin 与 user 发放(user 仅自己账户)。 + ## 4. HTTP API ### 健康检查 @@ -115,17 +118,17 @@ Header:`X-Device-Token` | `deploy/ecosystem.config.cjs` | PM2 | | `deploy/manage.sh` | 交互部署 | -## 7. 管理员密码 +## 7. 用户与密码 -- 表 `settings`:`admin_user` / `admin_password_hash`(Werkzeug hash) -- 首次启动用 `.env` 的 `ADMIN_PASSWORD` 写入 hash -- 网页「系统设置」可改密,**不写回 .env**,以数据库为准 +- 表 `users`:密码为 Werkzeug hash;首次启动用 `.env` 的 `ADMIN_USER` / `ADMIN_PASSWORD` 种子管理员 +- 网页「系统设置」改当前用户密码,**不写回 .env** +- 普通用户由管理员在 `/users` 创建并绑定账户 ## 8. 客户端(desktop/) | 文件 | 说明 | |------|------| -| `gold_order_app.py` | 下单主程序(已迁入) | +| `gold_order_app.py` | 下单主程序;含「打开复盘」→ 浏览器 `/login` | | `activation_dialog.py` | 首次激活 UI | | `hub_client.py` | HTTP 激活/上报 | | `device_store.py` | `~/.gold_monitor/device.json` | diff --git a/docs/usage.md b/docs/usage.md index cec6f87..b0b3c56 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -3,15 +3,24 @@ ## 1. 登录网页 浏览器打开:`http://<服务器IP>:5800` -默认:`admin` / `admin123`(部署后请修改)。 +或在已激活的 exe 上点 **打开复盘**,会用浏览器打开登录页。 + +默认管理员:`admin` / `admin123`(部署后请立即修改)。 +普通用户由管理员在 **用户** 页创建,**一人绑定一个交易账户**,不可自助注册账户。 + +### 角色说明 + +| 角色 | 权限 | +|------|------| +| **管理员** | 创建账户、创建用户、看全部复盘、给任意账户发激活码、管理机器 | +| **普通用户** | 只看自己账户的复盘与下载中心;可为**自己的账户**发放激活码;改自己的登录密码 | 顶栏: -- **总览**:账户/机器数量、最近成交 -- **交易复盘**:列表与详情 -- **下载中心**:通用 exe + 激活码 -- **账户 / 机器**:标注管理 -- **系统设置**:修改网页登录密码(立即生效,无需改 `.env` / 重启) +- **总览** / **交易复盘**:按角色过滤数据 +- **下载中心**:下载通用 exe + 生成激活码(用户和管理员都可发) +- **账户 / 用户 / 机器**:管理员可见;普通用户仅「我的账户」 +- **系统设置**:修改当前登录用户密码 ### 修改密码 @@ -19,23 +28,18 @@ 2. 输入当前密码与新密码(至少 6 位)→ 保存 3. 下次登录使用新密码 -> 初始密码来自部署时的 `.env`(默认 admin123);修改后以系统设置中的密码为准。 +> 初始管理员密码来自部署时的 `.env`(默认 admin123);修改后以系统设置为准。 -## 2. 标注账户与机器 +## 2. 管理员:建账户与用户 -### 账户 - -「账户」页新增:名称、券商、登录号、类型(模拟/美分/实盘)、备注。 -用于激活码绑定与复盘归属。 - -### 机器 - -可预登记机器名称与备注;也可不登记,**激活时自动创建**并写入设备指纹。 +1. **账户** 页新增交易账户(模拟/美分/实盘),禁止用户自助注册 +2. **用户** 页创建普通用户,并绑定**唯一、尚未占用**的账户 +3. 把登录账号发给该操作员 ## 3. 下载通用包并首次激活 -1. 打开 **下载中心** -2. 选择要绑定的 **账户**(可选预绑定机器)→ **生成激活码** +1. 打开 **下载中心**(管理员或普通用户均可) +2. 选择账户 → **生成激活码**(普通用户只能选自己的账户) 3. 下载 `黄金市价辅助.exe`(需管理员已放入 `releases/`) 4. 在 Windows 上启动 exe: - 填写服务器地址,例如 `http://192.168.1.10:5800` @@ -48,20 +52,21 @@ 1. 先登录本机 MT5 2. 打开已激活的辅助 exe 下单 / 保本 / 平仓 -3. 客户端在成交或平仓时自动上报(接口见技术文档) +3. 需要复盘时点 exe 上的 **打开复盘**,浏览器登录网页账号即可 +4. 客户端在成交或平仓时自动上报(接口见技术文档) ## 5. 网页复盘 -1. 打开 **交易复盘** +1. 打开 **交易复盘**(普通用户只看到自己账户的成交) 2. 点某笔 **打开复盘** 3. 查看成交信息与 **M1 / M5 / M15 / H1** 四图 -4. 选择 **入场形式**(如「M15关键位+M1强吞没」)并填写备注 → 保存 +4. 选择 **入场形式** 并填写备注 → 保存 -> K 线依赖客户端上报缓存。若四图为空,请确认 exe 已上报 bars,或稍后在客户端补传。 +> K 线依赖客户端上报缓存。若四图为空,请确认 exe 已上报 bars。 ## 6. 推荐练手流程 -1. 美分/模拟账户在「账户」中建好并标注 -2. 每台电脑生成激活码并激活 +1. 管理员建美分/模拟账户,并创建对应用户 +2. 用户登录 → 下载中心为自己账户生成激活码 → 本机激活 3. 按纪律交易,自动上报 -4. 每日网页复盘:标注亏单共性(位不对 / 形不对 / 违纪) +4. 每日网页复盘(exe「打开复盘」或浏览器)