Add roles/ACL so admin and users can issue activation codes; exe opens review login.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-17 18:07:23 +08:00
parent 1d4d5b06df
commit a4474c28ea
13 changed files with 517 additions and 145 deletions
+8 -7
View File
@@ -39,14 +39,15 @@ bash deploy/manage.sh
| 功能 | 说明 | | 功能 | 说明 |
|------|------| |------|------|
| **登录** | 网页账号(默认 admin) | | **登录** | 管理员 / 普通用户(一人一账户,账户仅管理员创建) |
| **系统设置** | 前端修改登录密码 | | **用户管理** | 管理员创建用户并绑定唯一交易账户 |
| **下载中心** | 通用辅助包 + 生成激活码 | | **系统设置** | 修改当前登录密码 |
| **账户 / 机器** | 标注名称、备注、类型 | | **下载中心** | 通用辅助包;**管理员与用户均可**为自己权限内账户发激活码 |
| **首次激活** | exe 调用 API 绑定指纹与 token | | **账户 / 机器** | 账户仅管理员创建;机器可预登记或激活时自动创建 |
| **首次激活** | exe 调用 API 绑定指纹与 token(账户须已存在) |
| **成交上报** | 已激活客户端 POST 成交与四周期 K 线 | | **成交上报** | 已激活客户端 POST 成交与四周期 K 线 |
| **交易复盘** | 列表 + 详情四周期(M1/M5/M15/H1)+ 入场形式标注 | | **交易复盘** | 按角色过滤;详情四周期 + 入场形式标注 |
| **desktop/** | 已迁入下单辅助,支持激活与上报 | | **desktop/** | 下单辅助:激活、上报、**打开复盘**(浏览器登录页) |
--- ---
+189 -56
View File
@@ -106,6 +106,17 @@ def init_db() -> None:
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT NOT NULL 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()] 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) code = secrets.token_urlsafe(12)
conn.execute( # created_by 存在 activations 可选列:兼容旧库无该列时忽略
"INSERT INTO activations(code, account_id, machine_id, status, created_at) VALUES(?,?,?,?,?)", cols = {r["name"] for r in conn.execute("PRAGMA table_info(activations)").fetchall()}
(code, account_id, machine_id, "pending", now()), 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 return code
@@ -145,6 +171,10 @@ def activate_device(conn: sqlite3.Connection, code: str, fingerprint: str, machi
raise ValueError("激活码无效") raise ValueError("激活码无效")
if row["status"] == "revoked": if row["status"] == "revoked":
raise ValueError("激活码已作废") 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) token = secrets.token_urlsafe(32)
machine_id = row["machine_id"] machine_id = row["machine_id"]
@@ -156,7 +186,7 @@ def activate_device(conn: sqlite3.Connection, code: str, fingerprint: str, machi
else: else:
cur = conn.execute( cur = conn.execute(
"INSERT INTO machines(name, device_fingerprint, note, enabled, last_seen_at, created_at) VALUES(?,?,?,?,?,?)", "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 machine_id = cur.lastrowid
conn.execute("UPDATE activations SET machine_id=? WHERE id=?", (machine_id, row["id"])) 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) return row_to_dict(row)
def list_trades(conn: sqlite3.Connection, limit: int = 200) -> list[dict]: def list_trades(
cur = conn.execute( conn: sqlite3.Connection,
""" limit: int = 200,
SELECT t.*, a.name AS account_name, m.name AS machine_name account_id: Optional[int] = None,
FROM trades t ) -> list[dict]:
LEFT JOIN accounts a ON a.id = t.account_id if account_id is None:
LEFT JOIN machines m ON m.id = t.machine_id cur = conn.execute(
ORDER BY COALESCE(t.close_time, t.open_time, t.created_at) DESC """
LIMIT ? SELECT t.*, a.name AS account_name, m.name AS machine_name
""", FROM trades t
(limit,), 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 = [] rows = []
for r in cur.fetchall(): for r in cur.fetchall():
d = row_to_dict(r) d = row_to_dict(r)
@@ -226,6 +274,28 @@ def get_trade(conn: sqlite3.Connection, trade_id: int) -> Optional[dict]:
return d 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: def upsert_bars(conn: sqlite3.Connection, symbol: str, timeframe: str, bars: list[dict]) -> int:
n = 0 n = 0
for b in bars: for b in bars:
@@ -277,61 +347,124 @@ def set_setting(conn: sqlite3.Connection, key: str, value: str) -> None:
def seed_if_empty() -> None: def seed_if_empty() -> None:
"""仅确保管理员用户存在;交易账户不自动创建,须管理员手工建。"""
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
from app.config import Config from app.config import Config
with get_db() as conn: with get_db() as conn:
n = conn.execute("SELECT COUNT(*) AS c FROM accounts").fetchone()["c"] # 兼容旧库:补 activations.created_by
if n == 0: 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( conn.execute(
"INSERT INTO accounts(name, broker, login_id, account_type, note, enabled, created_at) VALUES(?,?,?,?,?,?,?)", """INSERT INTO users(username, password_hash, role, account_id, enabled, created_at)
("模拟盘-主", "MT5", "", "demo", "默认账户,可改标注", 1, now()), VALUES(?,?,?,?,?,?)""",
) (username, pwd_hash, "admin", None, 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),
) )
def verify_admin(username: str, password: str) -> bool: def verify_login(username: str, password: str) -> Optional[dict]:
from werkzeug.security import check_password_hash from werkzeug.security import check_password_hash
with get_db() as conn: with get_db() as conn:
user = get_setting(conn, "admin_user", "admin") user = get_user_by_username(conn, username)
pwd_hash = get_setting(conn, "admin_password_hash") if not user:
if username != user: return None
return False if not check_password_hash(user["password_hash"], password):
if not pwd_hash: return None
from app.config import Config return user
return password == Config.ADMIN_PASSWORD
return check_password_hash(pwd_hash, password)
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 from werkzeug.security import check_password_hash, generate_password_hash
if len(new_password) < 6: if len(new_password) < 6:
raise ValueError("新密码至少 6 位") raise ValueError("新密码至少 6 位")
with get_db() as conn: with get_db() as conn:
pwd_hash = get_setting(conn, "admin_password_hash") user = get_user(conn, user_id)
if pwd_hash: if not user:
if not check_password_hash(pwd_hash, old_password): raise ValueError("用户不存在")
raise ValueError("当前密码不正确") if not check_password_hash(user["password_hash"], old_password):
else: raise ValueError("当前密码不正确")
from app.config import Config conn.execute(
"UPDATE users SET password_hash=? WHERE id=?",
(generate_password_hash(new_password), user_id),
)
if old_password != Config.ADMIN_PASSWORD:
raise ValueError("当前密码不正确") def create_user(
set_setting(conn, "admin_password_hash", generate_password_hash(new_password)) 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)
+167 -44
View File
@@ -4,11 +4,11 @@ from __future__ import annotations
import json import json
import secrets import secrets
from functools import wraps from functools import wraps
from pathlib import Path
from flask import ( from flask import (
Flask, Flask,
flash, flash,
g,
jsonify, jsonify,
redirect, redirect,
render_template, render_template,
@@ -25,24 +25,48 @@ from app import db as database
def login_required(view): def login_required(view):
@wraps(view) @wraps(view)
def wrapped(*args, **kwargs): def wrapped(*args, **kwargs):
if not session.get("user"): if not session.get("user_id"):
return redirect(url_for("login", next=request.path)) return redirect(url_for("login", next=request.path))
return view(*args, **kwargs) return view(*args, **kwargs)
return wrapped 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: def register_routes(app: Flask) -> None:
@app.context_processor @app.context_processor
def inject_globals(): def inject_globals():
return { return {
"app_name": "Gold Monitor", "app_name": "Gold Monitor",
"public_base": Config.PUBLIC_BASE_URL, "public_base": Config.PUBLIC_BASE_URL,
"current_user": current_user(),
"is_admin": session.get("role") == "admin",
} }
@app.get("/login") @app.get("/login")
def login(): def login():
if session.get("user"): if session.get("user_id"):
return redirect(url_for("dashboard")) return redirect(url_for("dashboard"))
return render_template("login.html") return render_template("login.html")
@@ -50,11 +74,16 @@ def register_routes(app: Flask) -> None:
def login_post(): def login_post():
user = (request.form.get("username") or "").strip() user = (request.form.get("username") or "").strip()
pwd = request.form.get("password") or "" pwd = request.form.get("password") or ""
if database.verify_admin(user, pwd): u = database.verify_login(user, pwd)
session["user"] = user if not u:
return redirect(request.args.get("next") or url_for("dashboard")) flash("用户名或密码错误", "error")
flash("用户名或密码错误", "error") return render_template("login.html"), 401
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") @app.get("/logout")
def logout(): def logout():
@@ -76,19 +105,62 @@ def register_routes(app: Flask) -> None:
flash("两次输入的新密码不一致", "error") flash("两次输入的新密码不一致", "error")
return redirect(url_for("settings_page")) return redirect(url_for("settings_page"))
try: try:
database.change_admin_password(old_pwd, new_pwd) database.change_user_password(int(session["user_id"]), old_pwd, new_pwd)
flash("密码已更新,请牢记新密码", "ok") flash("密码已更新,请牢记新密码", "ok")
except ValueError as exc: except ValueError as exc:
flash(str(exc), "error") flash(str(exc), "error")
return redirect(url_for("settings_page")) 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("/") @app.get("/")
@login_required @login_required
def dashboard(): def dashboard():
u = current_user()
with database.get_db() as conn: with database.get_db() as conn:
accounts = database.list_accounts(conn) if u["role"] == "admin":
machines = database.list_machines(conn) accounts = database.list_accounts(conn)
trades = database.list_trades(conn, 20) 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( return render_template(
"dashboard.html", "dashboard.html",
accounts=accounts, accounts=accounts,
@@ -96,16 +168,19 @@ def register_routes(app: Flask) -> None:
trades=trades, trades=trades,
) )
# ---------- 账户 / 机器 ---------- # ---------- 账户(仅管理员创建)----------
@app.get("/accounts") @app.get("/accounts")
@login_required @login_required
def accounts_page(): def accounts_page():
u = current_user()
with database.get_db() as conn: with database.get_db() as conn:
items = database.list_accounts(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") @app.post("/accounts")
@login_required @admin_required
def accounts_create(): def accounts_create():
name = (request.form.get("name") or "").strip() name = (request.form.get("name") or "").strip()
if not name: if not name:
@@ -125,12 +200,16 @@ def register_routes(app: Flask) -> None:
database.now(), database.now(),
), ),
) )
flash("账户已添加", "ok") flash("账户已创建(仅管理员可创建)", "ok")
return redirect(url_for("accounts_page")) return redirect(url_for("accounts_page"))
@app.post("/accounts/<int:aid>/note") @app.post("/accounts/<int:aid>/note")
@login_required @login_required
def accounts_note(aid: int): 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() note = (request.form.get("note") or "").strip()
with database.get_db() as conn: with database.get_db() as conn:
conn.execute("UPDATE accounts SET note=? WHERE id=?", (note, aid)) conn.execute("UPDATE accounts SET note=? WHERE id=?", (note, aid))
@@ -140,12 +219,26 @@ def register_routes(app: Flask) -> None:
@app.get("/machines") @app.get("/machines")
@login_required @login_required
def machines_page(): def machines_page():
# 机器列表:管理员全看;普通用户只看与自己账户激活相关的机器
u = current_user()
with database.get_db() as conn: with database.get_db() as conn:
items = database.list_machines(conn) if u["role"] == "admin":
return render_template("machines.html", items=items) 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") @app.post("/machines")
@login_required @admin_required
def machines_create(): def machines_create():
name = (request.form.get("name") or "").strip() or "新机器" name = (request.form.get("name") or "").strip() or "新机器"
with database.get_db() as conn: with database.get_db() as conn:
@@ -170,22 +263,40 @@ def register_routes(app: Flask) -> None:
flash("机器标注已保存", "ok") flash("机器标注已保存", "ok")
return redirect(url_for("machines_page")) return redirect(url_for("machines_page"))
# ---------- 下载 / 激活 ---------- # ---------- 下载 / 激活(管理员与用户都可发码;用户仅自己账户)----------
@app.get("/download") @app.get("/download")
@login_required @login_required
def download_page(): def download_page():
u = current_user()
with database.get_db() as conn: 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) machines = database.list_machines(conn)
acts = conn.execute( if u["role"] == "admin":
""" acts = conn.execute(
SELECT act.*, a.name AS account_name, m.name AS machine_name """
FROM activations act SELECT act.*, a.name AS account_name, m.name AS machine_name
LEFT JOIN accounts a ON a.id = act.account_id FROM activations act
LEFT JOIN machines m ON m.id = act.machine_id LEFT JOIN accounts a ON a.id = act.account_id
ORDER BY act.id DESC LIMIT 50 LEFT JOIN machines m ON m.id = act.machine_id
""" ORDER BY act.id DESC LIMIT 50
).fetchall() """
).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] activations = [database.row_to_dict(r) for r in acts]
exe_name = "黄金市价辅助.exe" exe_name = "黄金市价辅助.exe"
exe_path = RELEASES_DIR / exe_name exe_path = RELEASES_DIR / exe_name
@@ -201,15 +312,19 @@ def register_routes(app: Flask) -> None:
@app.post("/download/activation") @app.post("/download/activation")
@login_required @login_required
def download_create_activation(): def download_create_activation():
u = current_user()
account_id = int(request.form.get("account_id") or 0) account_id = int(request.form.get("account_id") or 0)
machine_raw = request.form.get("machine_id") or "" machine_raw = request.form.get("machine_id") or ""
machine_id = int(machine_raw) if machine_raw.strip().isdigit() else None machine_id = int(machine_raw) if machine_raw.strip().isdigit() else None
with database.get_db() as conn: if not database.user_can_access_account(u, account_id):
if not conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone(): flash("只能为自己的账户发放激活码", "error")
flash("账户不存在", "error") return redirect(url_for("download_page"))
return redirect(url_for("download_page")) try:
code = database.create_activation(conn, account_id, machine_id) with database.get_db() as conn:
flash(f"已生成激活码:{code}", "ok") 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")) return redirect(url_for("download_page"))
@app.get("/download/file") @app.get("/download/file")
@@ -218,12 +333,10 @@ def register_routes(app: Flask) -> None:
name = "黄金市价辅助.exe" name = "黄金市价辅助.exe"
path = RELEASES_DIR / name path = RELEASES_DIR / name
if not path.is_file(): if not path.is_file():
# 提供占位说明文件,避免空目录无下载
readme = RELEASES_DIR / "README_PLACE_EXE_HERE.txt" readme = RELEASES_DIR / "README_PLACE_EXE_HERE.txt"
if not readme.is_file(): if not readme.is_file():
readme.write_text( readme.write_text(
"请将打包好的通用辅助 exe 放到本目录,命名为:黄金市价辅助.exe\n" "请将打包好的通用辅助 exe 放到本目录,命名为:黄金市价辅助.exe\n",
"客户端首次启动使用网页生成的激活码绑定机器与账户。\n",
encoding="utf-8", encoding="utf-8",
) )
return send_from_directory(RELEASES_DIR, readme.name, as_attachment=True) 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()}) return jsonify({"ok": True, "server_time": database.now()})
# ---------- 成交上报 / 复盘 ----------
@app.post("/api/v1/trades") @app.post("/api/v1/trades")
def api_trades_upsert(): def api_trades_upsert():
token = request.headers.get("X-Device-Token") or "" token = request.headers.get("X-Device-Token") or ""
@@ -267,6 +379,8 @@ def register_routes(app: Flask) -> None:
act = database.auth_token(conn, token) act = database.auth_token(conn, token)
if not act: if not act:
return jsonify({"ok": False, "error": "未激活"}), 401 return jsonify({"ok": False, "error": "未激活"}), 401
if not act.get("account_id"):
return jsonify({"ok": False, "error": "激活码未绑定账户"}), 400
snapshot = data.get("snapshot") or {} snapshot = data.get("snapshot") or {}
conn.execute( conn.execute(
""" """
@@ -326,19 +440,23 @@ def register_routes(app: Flask) -> None:
@app.get("/records") @app.get("/records")
@login_required @login_required
def records_page(): def records_page():
u = current_user()
with database.get_db() as conn: 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) return render_template("records.html", trades=trades)
@app.get("/records/<int:tid>") @app.get("/records/<int:tid>")
@login_required @login_required
def record_detail(tid: int): def record_detail(tid: int):
u = current_user()
with database.get_db() as conn: with database.get_db() as conn:
trade = database.get_trade(conn, tid) trade = database.get_trade(conn, tid)
if not trade: if not trade or not database.user_can_access_account(u, trade.get("account_id")):
flash("记录不存在", "error") flash("记录不存在或无权查看", "error")
return redirect(url_for("records_page")) return redirect(url_for("records_page"))
# 四周期:以开仓时间为中心取前后窗口
center = trade.get("open_time") or trade.get("created_at") or database.now() center = trade.get("open_time") or trade.get("created_at") or database.now()
window = 3600 * 6 window = 3600 * 6
charts = {} charts = {}
@@ -351,9 +469,14 @@ def register_routes(app: Flask) -> None:
@app.post("/records/<int:tid>/annotate") @app.post("/records/<int:tid>/annotate")
@login_required @login_required
def record_annotate(tid: int): def record_annotate(tid: int):
u = current_user()
entry_style = (request.form.get("entry_style") or "").strip() entry_style = (request.form.get("entry_style") or "").strip()
note = (request.form.get("review_note") or "").strip() note = (request.form.get("review_note") or "").strip()
with database.get_db() as conn: 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( conn.execute(
"UPDATE trades SET entry_style=?, review_note=? WHERE id=?", "UPDATE trades SET entry_style=?, review_note=? WHERE id=?",
(entry_style, note, tid), (entry_style, note, tid),
+5 -2
View File
@@ -1,10 +1,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}账户 · {{ app_name }}{% endblock %} {% block title %}账户 · {{ app_name }}{% endblock %}
{% block content %} {% block content %}
<h2 style="margin:0 0 14px">账户标注</h2> <h2 style="margin:0 0 14px">账户{% if can_create %}(仅管理员可创建){% else %}(只读自己的账户){% endif %}</h2>
<div class="grid cols-2"> <div class="grid cols-2">
{% if can_create %}
<div class="card"> <div class="card">
<h3>新增账户</h3> <h3>新增账户</h3>
<p class="muted">账户禁止自助注册,创建后在「用户」中绑定给唯一操作员。</p>
<form method="post" action="{{ url_for('accounts_create') }}"> <form method="post" action="{{ url_for('accounts_create') }}">
<div class="form-row"><label>名称</label><input name="name" required placeholder="如:模拟盘-A"></div> <div class="form-row"><label>名称</label><input name="name" required placeholder="如:模拟盘-A"></div>
<div class="form-row"><label>券商</label><input name="broker" placeholder="MT5 券商名"></div> <div class="form-row"><label>券商</label><input name="broker" placeholder="MT5 券商名"></div>
@@ -21,8 +23,9 @@
<button class="btn" type="submit">添加</button> <button class="btn" type="submit">添加</button>
</form> </form>
</div> </div>
{% endif %}
<div class="card"> <div class="card">
<h3>已有账户</h3> <h3>账户列表</h3>
<table> <table>
<thead><tr><th>名称</th><th>类型</th><th>标注</th><th></th></tr></thead> <thead><tr><th>名称</th><th>类型</th><th>标注</th><th></th></tr></thead>
<tbody> <tbody>
+6 -1
View File
@@ -14,11 +14,16 @@
<a href="{{ url_for('dashboard') }}" class="{% if request.endpoint=='dashboard' %}active{% endif %}">总览</a> <a href="{{ url_for('dashboard') }}" class="{% if request.endpoint=='dashboard' %}active{% endif %}">总览</a>
<a href="{{ url_for('records_page') }}" class="{% if request.endpoint and request.endpoint.startswith('record') %}active{% endif %}">交易复盘</a> <a href="{{ url_for('records_page') }}" class="{% if request.endpoint and request.endpoint.startswith('record') %}active{% endif %}">交易复盘</a>
<a href="{{ url_for('download_page') }}" class="{% if request.endpoint and 'download' in request.endpoint %}active{% endif %}">下载中心</a> <a href="{{ url_for('download_page') }}" class="{% if request.endpoint and 'download' in request.endpoint %}active{% endif %}">下载中心</a>
{% if is_admin %}
<a href="{{ url_for('accounts_page') }}" class="{% if request.endpoint and 'account' in request.endpoint %}active{% endif %}">账户</a> <a href="{{ url_for('accounts_page') }}" class="{% if request.endpoint and 'account' in request.endpoint %}active{% endif %}">账户</a>
<a href="{{ url_for('users_page') }}" class="{% if request.endpoint and 'users' in request.endpoint %}active{% endif %}">用户</a>
<a href="{{ url_for('machines_page') }}" class="{% if request.endpoint and 'machine' in request.endpoint %}active{% endif %}">机器</a> <a href="{{ url_for('machines_page') }}" class="{% if request.endpoint and 'machine' in request.endpoint %}active{% endif %}">机器</a>
{% else %}
<a href="{{ url_for('accounts_page') }}" class="{% if request.endpoint and 'account' in request.endpoint %}active{% endif %}">我的账户</a>
{% endif %}
<a href="{{ url_for('settings_page') }}" class="{% if request.endpoint and 'settings' in request.endpoint %}active{% endif %}">系统设置</a> <a href="{{ url_for('settings_page') }}" class="{% if request.endpoint and 'settings' in request.endpoint %}active{% endif %}">系统设置</a>
</nav> </nav>
<div class="right">{{ session.get('user') }} · <a href="{{ url_for('logout') }}">退出</a></div> <div class="right">{{ current_user.username }}({{ current_user.role }}) · <a href="{{ url_for('logout') }}">退出</a></div>
</header> </header>
{% endif %} {% endif %}
<main class="wrap"> <main class="wrap">
+1
View File
@@ -17,6 +17,7 @@
</div> </div>
<div class="card"> <div class="card">
<h3>生成激活码</h3> <h3>生成激活码</h3>
<p class="muted">管理员与普通用户均可发放;普通用户只能发给自己的账户。</p>
<form method="post" action="{{ url_for('download_create_activation') }}"> <form method="post" action="{{ url_for('download_create_activation') }}">
<div class="form-row"> <div class="form-row">
<label>绑定账户</label> <label>绑定账户</label>
+9 -1
View File
@@ -1,8 +1,9 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}机器 · {{ app_name }}{% endblock %} {% block title %}机器 · {{ app_name }}{% endblock %}
{% block content %} {% block content %}
<h2 style="margin:0 0 14px">机器标注</h2> <h2 style="margin:0 0 14px">机器{% if is_admin %}(管理员可预登记){% else %}(与本账户相关){% endif %}</h2>
<div class="grid cols-2"> <div class="grid cols-2">
{% if is_admin %}
<div class="card"> <div class="card">
<h3>预登记机器</h3> <h3>预登记机器</h3>
<p class="muted">也可不预登记:激活时自动创建机器并绑定指纹。</p> <p class="muted">也可不预登记:激活时自动创建机器并绑定指纹。</p>
@@ -12,6 +13,7 @@
<button class="btn" type="submit">添加</button> <button class="btn" type="submit">添加</button>
</form> </form>
</div> </div>
{% endif %}
<div class="card"> <div class="card">
<h3>已有机器</h3> <h3>已有机器</h3>
<table> <table>
@@ -20,12 +22,18 @@
{% for m in items %} {% for m in items %}
<tr> <tr>
<td> <td>
{% if is_admin %}
<form method="post" action="{{ url_for('machines_note', mid=m.id) }}"> <form method="post" action="{{ url_for('machines_note', mid=m.id) }}">
<input name="name" value="{{ m.name }}" style="margin-bottom:6px"> <input name="name" value="{{ m.name }}" style="margin-bottom:6px">
<div class="muted">{{ m.device_fingerprint or '未绑定' }}</div> <div class="muted">{{ m.device_fingerprint or '未绑定' }}</div>
<textarea name="note" rows="2" style="margin-top:6px">{{ m.note or '' }}</textarea> <textarea name="note" rows="2" style="margin-top:6px">{{ m.note or '' }}</textarea>
<button class="btn secondary" style="margin-top:6px" type="submit">保存</button> <button class="btn secondary" style="margin-top:6px" type="submit">保存</button>
</form> </form>
{% else %}
<div>{{ m.name }}</div>
<div class="muted">{{ m.device_fingerprint or '未绑定' }}</div>
{% if m.note %}<div class="muted" style="margin-top:6px">{{ m.note }}</div>{% endif %}
{% endif %}
</td> </td>
<td class="muted">{{ m.last_seen_at or '—' }}</td> <td class="muted">{{ m.last_seen_at or '—' }}</td>
<td>#{{ m.id }}</td> <td>#{{ m.id }}</td>
+1 -1
View File
@@ -5,7 +5,7 @@
<div class="grid cols-2"> <div class="grid cols-2">
<div class="card"> <div class="card">
<h3>修改登录密码</h3> <h3>修改登录密码</h3>
<p class="muted">修改后立即生效,无需重启服务。请使用足够强度的密码。</p> <p class="muted">修改当前登录用户密码,立即生效。管理员请在「用户」页创建普通用户。</p>
<form method="post" action="{{ url_for('settings_password') }}" style="margin-top:12px"> <form method="post" action="{{ url_for('settings_password') }}" style="margin-top:12px">
<div class="form-row"> <div class="form-row">
<label>当前密码</label> <label>当前密码</label>
+56
View File
@@ -0,0 +1,56 @@
{% extends "base.html" %}
{% block title %}用户管理 · {{ app_name }}{% endblock %}
{% block content %}
<h2 style="margin:0 0 6px">用户管理</h2>
<p class="muted" style="margin:0 0 14px">仅管理员可创建。普通用户必须绑定唯一交易账户(一人一账户,账户不可自助注册)。</p>
<div class="grid cols-2">
<div class="card">
<h3>创建用户</h3>
<form method="post" action="{{ url_for('users_create') }}">
<div class="form-row"><label>用户名</label><input name="username" required></div>
<div class="form-row"><label>初始密码</label><input name="password" type="password" required minlength="6"></div>
<div class="form-row">
<label>角色</label>
<select name="role" id="roleSel">
<option value="user">普通用户</option>
<option value="admin">管理员</option>
</select>
</div>
<div class="form-row" id="accountRow">
<label>绑定交易账户(普通用户必选,且未被占用)</label>
<select name="account_id">
<option value="">—</option>
{% for a in free_accounts %}
<option value="{{ a.id }}">#{{ a.id }} {{ a.name }}</option>
{% endfor %}
</select>
</div>
<button class="btn" type="submit">创建</button>
</form>
</div>
<div class="card">
<h3>已有用户</h3>
<table>
<thead><tr><th>用户名</th><th>角色</th><th>账户</th><th>状态</th></tr></thead>
<tbody>
{% for u in users %}
<tr>
<td>{{ u.username }}</td>
<td><span class="tag {% if u.role=='admin' %}green{% else %}gray{% endif %}">{{ u.role }}</span></td>
<td>{{ u.account_name or (u.account_id or '—') }}</td>
<td>{{ '启用' if u.enabled else '停用' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const sel = document.getElementById('roleSel');
const row = document.getElementById('accountRow');
function sync(){ row.style.display = sel.value === 'admin' ? 'none' : 'block'; }
sel.addEventListener('change', sync); sync();
</script>
{% endblock %}
+1
View File
@@ -4,6 +4,7 @@
- 连接本机已登录 MT5,市价开平、ATR 止损止盈、保本、满仓灰显等(由原 `MT5黄金方案/Desktop` 迁入) - 连接本机已登录 MT5,市价开平、ATR 止损止盈、保本、满仓灰显等(由原 `MT5黄金方案/Desktop` 迁入)
- **通用包 + 首次激活**:启动时填写中控地址与激活码 - **通用包 + 首次激活**:启动时填写中控地址与激活码
- **打开复盘**:主界面按钮用浏览器打开中控 `/login`,用网页账号直接登录复盘
- **自动上报**:开仓/平仓成功后 `POST /api/v1/trades`,并上传 M1/M5/M15/H1 K 线供网页复盘 - **自动上报**:开仓/平仓成功后 `POST /api/v1/trades`,并上传 M1/M5/M15/H1 K 线供网页复盘
## 本地配置 ## 本地配置
+34 -1
View File
@@ -7,9 +7,10 @@ import math
import sys import sys
import threading import threading
import time import time
import webbrowser
import tkinter as tk import tkinter as tk
from datetime import datetime, timedelta from datetime import datetime, timedelta
from tkinter import messagebox, ttk from tkinter import messagebox, simpledialog, ttk
from typing import Any, Optional, Tuple, Union from typing import Any, Optional, Tuple, Union
# 顶层导入,避免打包 exe 漏依赖 # 顶层导入,避免打包 exe 漏依赖
@@ -66,6 +67,17 @@ BE_MIN_MOVE_USD = 3.0 # 价格向有利方向走出该美金距离才可保本
BE_OFFSET_USD = 1.0 # 保本止损 = 开仓价 ± 1$ 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: def enable_dpi_awareness() -> None:
"""避免高分屏下 Tk 窗口被系统拉伸发虚。""" """避免高分屏下 Tk 窗口被系统拉伸发虚。"""
if sys.platform != "win32": 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) 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 = 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.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) self._set_be_button_enabled(False)
# 持仓列表:无竖线;列宽按权重拉满右侧;平仓红色边框按钮 # 持仓列表:无竖线;列宽按权重拉满右侧;平仓红色边框按钮
@@ -1430,6 +1445,24 @@ class OrderApp(tk.Tk):
self._run_async(work) 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: def destroy(self) -> None:
try: try:
self.bridge.shutdown() self.bridge.shutdown()
+10 -7
View File
@@ -27,12 +27,15 @@ Windows exe (通用包+激活) ──API───────────┘
| 表 | 用途 | | 表 | 用途 |
|----|------| |----|------|
| `accounts` | 交易账户与标注 | | `users` | 登录用户:`role`=admin/user,普通用户必绑唯一 `account_id` |
| `accounts` | 交易账户与标注(仅管理员创建,禁止自助注册) |
| `machines` | 机器、指纹、在线时间 | | `machines` | 机器、指纹、在线时间 |
| `activations` | 激活码、token、状态 | | `activations` | 激活码、token、状态、`created_by` 发放人 |
| `trades` | 成交与复盘字段(entry_style / review_note / snapshot_json) | | `trades` | 成交与复盘字段(entry_style / review_note / snapshot_json) |
| `bars` | K 线缓存(symbol+timeframe+ts 唯一) | | `bars` | K 线缓存(symbol+timeframe+ts 唯一) |
**权限**:管理员看全部;普通用户复盘/激活码仅限自己的 `account_id`。激活码可由 admin 与 user 发放(user 仅自己账户)。
## 4. HTTP API ## 4. HTTP API
### 健康检查 ### 健康检查
@@ -115,17 +118,17 @@ Header:`X-Device-Token`
| `deploy/ecosystem.config.cjs` | PM2 | | `deploy/ecosystem.config.cjs` | PM2 |
| `deploy/manage.sh` | 交互部署 | | `deploy/manage.sh` | 交互部署 |
## 7. 管理员密码 ## 7. 用户与密码
- 表 `settings`:`admin_user` / `admin_password_hash`(Werkzeug hash) - 表 `users`:密码为 Werkzeug hash;首次启动用 `.env` 的 `ADMIN_USER` / `ADMIN_PASSWORD` 种子管理员
- 首次启动用 `.env` 的 `ADMIN_PASSWORD` 写入 hash - 网页「系统设置」改当前用户密码,**不写回 .env**
- 网页「系统设置」可改密,**不写回 .env**,以数据库为准 - 普通用户由管理员在 `/users` 创建并绑定账户
## 8. 客户端(desktop/) ## 8. 客户端(desktop/)
| 文件 | 说明 | | 文件 | 说明 |
|------|------| |------|------|
| `gold_order_app.py` | 下单主程序(已迁入) | | `gold_order_app.py` | 下单主程序;含「打开复盘」→ 浏览器 `/login` |
| `activation_dialog.py` | 首次激活 UI | | `activation_dialog.py` | 首次激活 UI |
| `hub_client.py` | HTTP 激活/上报 | | `hub_client.py` | HTTP 激活/上报 |
| `device_store.py` | `~/.gold_monitor/device.json` | | `device_store.py` | `~/.gold_monitor/device.json` |
+30 -25
View File
@@ -3,15 +3,24 @@
## 1. 登录网页 ## 1. 登录网页
浏览器打开:`http://<服务器IP>:5800` 浏览器打开:`http://<服务器IP>:5800`
默认:`admin` / `admin123`(部署后请修改)。 或在已激活的 exe 上点 **打开复盘**,会用浏览器打开登录页。
默认管理员:`admin` / `admin123`(部署后请立即修改)。
普通用户由管理员在 **用户** 页创建,**一人绑定一个交易账户**,不可自助注册账户。
### 角色说明
| 角色 | 权限 |
|------|------|
| **管理员** | 创建账户、创建用户、看全部复盘、给任意账户发激活码、管理机器 |
| **普通用户** | 只看自己账户的复盘与下载中心;可为**自己的账户**发放激活码;改自己的登录密码 |
顶栏: 顶栏:
- **总览**:账户/机器数量、最近成交 - **总览** / **交易复盘**:按角色过滤数据
- **交易复盘**:列表与详情 - **下载中心**:下载通用 exe + 生成激活码(用户和管理员都可发)
- **下载中心**:通用 exe + 激活码 - **账户 / 用户 / 机器**:管理员可见;普通用户仅「我的账户」
- **账户 / 机器**:标注管理 - **系统设置**:修改当前登录用户密码
- **系统设置**:修改网页登录密码(立即生效,无需改 `.env` / 重启)
### 修改密码 ### 修改密码
@@ -19,23 +28,18 @@
2. 输入当前密码与新密码(至少 6 位)→ 保存 2. 输入当前密码与新密码(至少 6 位)→ 保存
3. 下次登录使用新密码 3. 下次登录使用新密码
> 初始密码来自部署时的 `.env`(默认 admin123);修改后以系统设置中的密码为准。 > 初始管理员密码来自部署时的 `.env`(默认 admin123);修改后以系统设置为准。
## 2. 标注账户与机器 ## 2. 管理员:建账户与用户
### 账户 1. **账户** 页新增交易账户(模拟/美分/实盘),禁止用户自助注册
2. **用户** 页创建普通用户,并绑定**唯一、尚未占用**的账户
「账户」页新增:名称、券商、登录号、类型(模拟/美分/实盘)、备注。 3. 把登录账号发给该操作员
用于激活码绑定与复盘归属。
### 机器
可预登记机器名称与备注;也可不登记,**激活时自动创建**并写入设备指纹。
## 3. 下载通用包并首次激活 ## 3. 下载通用包并首次激活
1. 打开 **下载中心** 1. 打开 **下载中心**(管理员或普通用户均可)
2. 选择要绑定的 **账户**(可选预绑定机器)→ **生成激活码** 2. 选择账户 → **生成激活码**(普通用户只能选自己的账户)
3. 下载 `黄金市价辅助.exe`(需管理员已放入 `releases/`) 3. 下载 `黄金市价辅助.exe`(需管理员已放入 `releases/`)
4. 在 Windows 上启动 exe: 4. 在 Windows 上启动 exe:
- 填写服务器地址,例如 `http://192.168.1.10:5800` - 填写服务器地址,例如 `http://192.168.1.10:5800`
@@ -48,20 +52,21 @@
1. 先登录本机 MT5 1. 先登录本机 MT5
2. 打开已激活的辅助 exe 下单 / 保本 / 平仓 2. 打开已激活的辅助 exe 下单 / 保本 / 平仓
3. 客户端在成交或平仓时自动上报(接口见技术文档) 3. 需要复盘时点 exe 上的 **打开复盘**,浏览器登录网页账号即可
4. 客户端在成交或平仓时自动上报(接口见技术文档)
## 5. 网页复盘 ## 5. 网页复盘
1. 打开 **交易复盘** 1. 打开 **交易复盘**(普通用户只看到自己账户的成交)
2. 点某笔 **打开复盘** 2. 点某笔 **打开复盘**
3. 查看成交信息与 **M1 / M5 / M15 / H1** 四图 3. 查看成交信息与 **M1 / M5 / M15 / H1** 四图
4. 选择 **入场形式**(如「M15关键位+M1强吞没」)并填写备注 → 保存 4. 选择 **入场形式** 并填写备注 → 保存
> K 线依赖客户端上报缓存。若四图为空,请确认 exe 已上报 bars,或稍后在客户端补传。 > K 线依赖客户端上报缓存。若四图为空,请确认 exe 已上报 bars。
## 6. 推荐练手流程 ## 6. 推荐练手流程
1. 美分/模拟账户在「账户」中建好并标注 1. 管理员建美分/模拟账户,并创建对应用户
2. 每台电脑生成激活码并激活 2. 用户登录 → 下载中心为自己账户生成激活码 → 本机激活
3. 按纪律交易,自动上报 3. 按纪律交易,自动上报
4. 每日网页复盘:标注亏单共性(位不对 / 形不对 / 违纪) 4. 每日网页复盘(exe「打开复盘」或浏览器)