feat: settings password change, migrate desktop client with hub activation/report

This commit is contained in:
dekun
2026-10-17 17:50:42 +08:00
parent fb35968a1f
commit 1d4d5b06df
14 changed files with 1933 additions and 26 deletions
+3 -1
View File
@@ -40,11 +40,13 @@ bash deploy/manage.sh
| 功能 | 说明 |
|------|------|
| **登录** | 网页账号(默认 admin) |
| **系统设置** | 前端修改登录密码 |
| **下载中心** | 通用辅助包 + 生成激活码 |
| **账户 / 机器** | 标注名称、备注、类型 |
| **首次激活** | exe 调用 API 绑定指纹与 token |
| **成交上报** | 已激活客户端 POST 成交与可选 K 线 |
| **成交上报** | 已激活客户端 POST 成交与四周期 K 线 |
| **交易复盘** | 列表 + 详情四周期(M1/M5/M15/H1)+ 入场形式标注 |
| **desktop/** | 已迁入下单辅助,支持激活与上报 |
---
+62
View File
@@ -101,6 +101,11 @@ def init_db() -> None:
open REAL, high REAL, low REAL, close REAL, volume REAL,
UNIQUE(symbol, timeframe, ts)
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
"""
)
@@ -259,7 +264,23 @@ def get_bars(conn: sqlite3.Connection, symbol: str, timeframe: str, start_ts: fl
return [row_to_dict(r) for r in cur.fetchall()]
def get_setting(conn: sqlite3.Connection, key: str, default: str = "") -> str:
row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
return row["value"] if row else default
def set_setting(conn: sqlite3.Connection, key: str, value: str) -> None:
conn.execute(
"INSERT INTO settings(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(key, value),
)
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:
@@ -273,3 +294,44 @@ def seed_if_empty() -> None:
"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:
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)
def change_admin_password(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
if old_password != Config.ADMIN_PASSWORD:
raise ValueError("当前密码不正确")
set_setting(conn, "admin_password_hash", generate_password_hash(new_password))
+22 -1
View File
@@ -50,7 +50,7 @@ def register_routes(app: Flask) -> None:
def login_post():
user = (request.form.get("username") or "").strip()
pwd = request.form.get("password") or ""
if user == Config.ADMIN_USER and pwd == Config.ADMIN_PASSWORD:
if database.verify_admin(user, pwd):
session["user"] = user
return redirect(request.args.get("next") or url_for("dashboard"))
flash("用户名或密码错误", "error")
@@ -61,6 +61,27 @@ def register_routes(app: Flask) -> None:
session.clear()
return redirect(url_for("login"))
@app.get("/settings")
@login_required
def settings_page():
return render_template("settings.html")
@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_admin_password(old_pwd, new_pwd)
flash("密码已更新,请牢记新密码", "ok")
except ValueError as exc:
flash(str(exc), "error")
return redirect(url_for("settings_page"))
@app.get("/")
@login_required
def dashboard():
+1
View File
@@ -16,6 +16,7 @@
<a href="{{ url_for('download_page') }}" class="{% if request.endpoint and 'download' 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('machines_page') }}" class="{% if request.endpoint and 'machine' 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>
<div class="right">{{ session.get('user') }} · <a href="{{ url_for('logout') }}">退出</a></div>
</header>
+33
View File
@@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block title %}系统设置 · {{ app_name }}{% endblock %}
{% block content %}
<h2 style="margin:0 0 14px">系统设置</h2>
<div class="grid cols-2">
<div class="card">
<h3>修改登录密码</h3>
<p class="muted">修改后立即生效,无需重启服务。请使用足够强度的密码。</p>
<form method="post" action="{{ url_for('settings_password') }}" style="margin-top:12px">
<div class="form-row">
<label>当前密码</label>
<input name="old_password" type="password" autocomplete="current-password" required>
</div>
<div class="form-row">
<label>新密码(至少 6 位)</label>
<input name="new_password" type="password" autocomplete="new-password" required minlength="6">
</div>
<div class="form-row">
<label>确认新密码</label>
<input name="new_password2" type="password" autocomplete="new-password" required minlength="6">
</div>
<button class="btn" type="submit">保存密码</button>
</form>
</div>
<div class="card">
<h3>服务信息</h3>
<p class="muted">对外地址:{{ public_base }}</p>
<p class="muted">端口:5800</p>
<p class="muted">当前登录用户:{{ session.get('user') }}</p>
<p class="muted" style="margin-top:12px">通用 exe 放置目录:<code>releases/黄金市价辅助.exe</code></p>
</div>
</div>
{% endblock %}
+23 -22
View File
@@ -1,31 +1,32 @@
# desktop · MT5 黄金辅助客户端
## 定位
## 功能
- **通用包**:所有机器下载同一个 exe(由网页「下载中心」分发)。
- **首次激活**:启动后输入网页生成的激活码,调用 `POST /api/v1/activate`,本地保存 `token`。
- **运行期**:本机连接已登录的 MT5;成交/快照 `POST /api/v1/trades`;可选上报 K 线 `POST /api/v1/bars`。
- 连接本机已登录 MT5,市价开平、ATR 止损止盈、保本、满仓灰显等(由原 `MT5黄金方案/Desktop` 迁入)
- **通用包 + 首次激活**:启动时填写中控地址与激活码
- **自动上报**:开仓/平仓成功后 `POST /api/v1/trades`,并上传 M1/M5/M15/H1 K 线供网页复盘
## 与现有代码
## 本地配置
当前可从 `E:\仓库\MT5黄金方案\Desktop\gold_order_app.py` 迁移下单逻辑。
本目录后续接入:
激活信息保存在:`%USERPROFILE%\.gold_monitor\device.json`
1. 激活窗 / 本地 `device.json`(token、account_id、machine_id、server)
2. 配置服务器地址(默认 `http://<ubuntu>:5800`)
3. 成交与四周期 bars 上报
## 开发运行
## 激活 API 示例
```http
POST /api/v1/activate
Content-Type: application/json
{
"code": "激活码",
"fingerprint": "机器唯一指纹",
"machine_name": "书房PC"
}
```bat
cd desktop
py -3 gold_order_app.py
```
成功后请求头携带:`X-Device-Token: <token>`
需本机已安装 `MetaTrader5`、`numpy`,并打开登录 MT5。
## 打包
```bat
build_exe.bat
```
产物:`dist\黄金市价辅助.exe`,复制到仓库 `releases\黄金市价辅助.exe` 后由网页下载中心分发。
## 离线模式
激活窗可点「离线使用」:正常下单,但不上报复盘中心。
+77
View File
@@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
"""首次激活对话框:服务器地址 + 激活码。"""
from __future__ import annotations
import tkinter as tk
from tkinter import messagebox, ttk
import hub_client
from device_store import is_activated, load_device, machine_fingerprint, save_device
def ensure_activated(root: tk.Tk) -> bool:
"""已激活且能 ping 通则通过;否则弹出激活窗。返回是否可继续。"""
device = load_device()
if device.get("token") and device.get("server"):
if hub_client.ping():
return True
# token 失效时允许重新激活
if not messagebox.askyesno(
"激活校验",
"无法连接中控或令牌失效,是否重新激活?\n选「否」将以离线模式继续(不上报复盘)。",
parent=root,
):
return True
win = tk.Toplevel(root)
win.title("激活黄金辅助")
win.geometry("460x280")
win.transient(root)
win.grab_set()
result = {"ok": False}
frm = ttk.Frame(win, padding=16)
frm.pack(fill=tk.BOTH, expand=True)
ttk.Label(frm, text="首次使用请输入中控地址与激活码(网页下载中心生成)").pack(anchor="w")
ttk.Label(frm, text="服务器地址(例:http://192.168.1.10:5800)").pack(anchor="w", pady=(12, 4))
var_server = tk.StringVar(value=device.get("server") or "http://127.0.0.1:5800")
ttk.Entry(frm, textvariable=var_server, width=48).pack(fill=tk.X)
ttk.Label(frm, text="激活码").pack(anchor="w", pady=(12, 4))
var_code = tk.StringVar()
ttk.Entry(frm, textvariable=var_code, width=48).pack(fill=tk.X)
ttk.Label(frm, text="本机名称(可改)").pack(anchor="w", pady=(12, 4))
var_name = tk.StringVar(value=device.get("machine_name") or "")
ttk.Entry(frm, textvariable=var_name, width=48).pack(fill=tk.X)
status = ttk.Label(frm, text="", foreground="#888")
status.pack(anchor="w", pady=(10, 0))
def do_skip():
result["ok"] = True
win.destroy()
def do_ok():
server = var_server.get().strip()
code = var_code.get().strip()
if not server or not code:
status.configure(text="请填写服务器与激活码", foreground="#f6465d")
return
try:
hub_client.activate(server, code, machine_fingerprint(), var_name.get().strip() or "PC")
result["ok"] = True
messagebox.showinfo("激活成功", "已绑定账户与机器,成交将上报复盘中心。", parent=win)
win.destroy()
except hub_client.HubError as exc:
status.configure(text=str(exc), foreground="#f6465d")
btns = ttk.Frame(frm)
btns.pack(fill=tk.X, pady=(16, 0))
ttk.Button(btns, text="离线使用", command=do_skip).pack(side=tk.LEFT)
ttk.Button(btns, text="激活", command=do_ok).pack(side=tk.RIGHT)
win.protocol("WM_DELETE_WINDOW", do_skip)
root.wait_window(win)
return result["ok"]
+30
View File
@@ -0,0 +1,30 @@
@echo off
chcp 65001 >nul
cd /d "%~dp0"
set "HTTP_PROXY="
set "HTTPS_PROXY="
set "http_proxy="
set "https_proxy="
set "ALL_PROXY="
set "all_proxy="
set "NO_PROXY=*"
set "no_proxy=*"
echo [1/2] 打包通用辅助 exe ...
py -3 -m PyInstaller --noconfirm --clean --onefile --windowed --name "黄金市价辅助" --hidden-import=MetaTrader5 --hidden-import=numpy --collect-all MetaTrader5 --collect-all numpy gold_order_app.py
if errorlevel 1 goto fail
echo [2/2] 复制到 releases(若存在上级目录)
if exist "..\releases" copy /Y "dist\黄金市价辅助.exe" "..\releases\黄金市价辅助.exe" >nul
echo.
echo 完成: %cd%\dist\黄金市价辅助.exe
echo 请放到服务器 /opt/gold_monitor/releases/ 供网页下载。
pause
exit /b 0
:fail
echo 打包失败
pause
exit /b 1
+64
View File
@@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
"""本机激活信息:通用包首次激活后写入 device.json。"""
from __future__ import annotations
import json
import os
import platform
import uuid
from pathlib import Path
from typing import Any, Optional
APP_DIR = Path(os.environ.get("GOLD_MONITOR_DATA", Path.home() / ".gold_monitor"))
DEVICE_FILE = APP_DIR / "device.json"
def ensure_dir() -> None:
APP_DIR.mkdir(parents=True, exist_ok=True)
def machine_fingerprint() -> str:
"""相对稳定的本机指纹(非绝对防伪,够用区分机器)。"""
node = uuid.getnode()
name = platform.node() or "pc"
return f"{name}-{node:012x}"
def load_device() -> dict[str, Any]:
ensure_dir()
if not DEVICE_FILE.is_file():
return {
"server": "",
"token": "",
"account_id": None,
"machine_id": None,
"fingerprint": machine_fingerprint(),
"machine_name": platform.node() or "PC",
}
try:
data = json.loads(DEVICE_FILE.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
data = {}
data.setdefault("fingerprint", machine_fingerprint())
data.setdefault("machine_name", platform.node() or "PC")
data.setdefault("server", "")
data.setdefault("token", "")
return data
def save_device(data: dict[str, Any]) -> None:
ensure_dir()
DEVICE_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
def is_activated() -> bool:
d = load_device()
return bool(d.get("token") and d.get("server"))
def clear_activation() -> None:
d = load_device()
d["token"] = ""
d["account_id"] = None
d["machine_id"] = None
save_device(d)
File diff suppressed because it is too large Load Diff
+123
View File
@@ -0,0 +1,123 @@
# -*- coding: utf-8 -*-
"""与 gold_monitor 中控通信:激活、成交上报、K 线上报。"""
from __future__ import annotations
import json
import urllib.error
import urllib.request
from typing import Any, Optional
from device_store import load_device, save_device
class HubError(Exception):
pass
def _url(server: str, path: str) -> str:
base = (server or "").rstrip("/")
if not base:
raise HubError("未配置服务器地址")
if not path.startswith("/"):
path = "/" + path
return base + path
def _request(
method: str,
server: str,
path: str,
body: Optional[dict] = None,
token: str = "",
timeout: float = 15.0,
) -> dict[str, Any]:
data = None
headers = {"Accept": "application/json"}
if body is not None:
raw = json.dumps(body, ensure_ascii=False).encode("utf-8")
data = raw
headers["Content-Type"] = "application/json"
if token:
headers["X-Device-Token"] = token
req = urllib.request.Request(_url(server, path), data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
text = resp.read().decode("utf-8", errors="replace")
return json.loads(text) if text else {}
except urllib.error.HTTPError as exc:
try:
detail = exc.read().decode("utf-8", errors="replace")
payload = json.loads(detail) if detail else {}
raise HubError(payload.get("error") or detail or str(exc)) from exc
except HubError:
raise
except Exception:
raise HubError(f"HTTP {exc.code}") from exc
except urllib.error.URLError as exc:
raise HubError(f"无法连接服务器:{exc.reason}") from exc
def activate(server: str, code: str, fingerprint: str, machine_name: str) -> dict[str, Any]:
result = _request(
"POST",
server,
"/api/v1/activate",
{
"code": code.strip(),
"fingerprint": fingerprint,
"machine_name": machine_name,
},
)
if not result.get("ok"):
raise HubError(result.get("error") or "激活失败")
device = load_device()
device["server"] = server.rstrip("/")
device["token"] = result["token"]
device["account_id"] = result.get("account_id")
device["machine_id"] = result.get("machine_id")
device["fingerprint"] = fingerprint
device["machine_name"] = machine_name
save_device(device)
return result
def ping() -> bool:
d = load_device()
if not d.get("token") or not d.get("server"):
return False
try:
r = _request("GET", d["server"], "/api/v1/ping", token=d["token"], timeout=8)
return bool(r.get("ok"))
except HubError:
return False
def report_trade(payload: dict[str, Any]) -> Optional[int]:
d = load_device()
if not d.get("token") or not d.get("server"):
return None
try:
r = _request("POST", d["server"], "/api/v1/trades", body=payload, token=d["token"])
if r.get("ok"):
return int(r.get("id") or 0)
except HubError:
return None
return None
def report_bars(symbol: str, timeframe: str, bars: list[dict]) -> int:
d = load_device()
if not d.get("token") or not d.get("server") or not bars:
return 0
try:
r = _request(
"POST",
d["server"],
"/api/v1/bars",
body={"symbol": symbol, "timeframe": timeframe, "bars": bars},
token=d["token"],
timeout=30,
)
return int(r.get("count") or 0)
except HubError:
return 0
+3
View File
@@ -0,0 +1,3 @@
MetaTrader5>=5.0.45
numpy>=1.24
pyinstaller>=6.0
+18 -2
View File
@@ -115,9 +115,25 @@ 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**,以数据库为准
## 8. 客户端(desktop/)
| 文件 | 说明 |
|------|------|
| `gold_order_app.py` | 下单主程序(已迁入) |
| `activation_dialog.py` | 首次激活 UI |
| `hub_client.py` | HTTP 激活/上报 |
| `device_store.py` | `~/.gold_monitor/device.json` |
开仓/平仓成功返回 `(消息, report_dict)`,后台线程上报 trades + 四周期 bars。
## 9. 后续扩展(规划)
- 客户端完整激活窗与成交/Bars 自动上报(对接 `desktop/`)
- 复盘筛选、统计面板、AI 点评(可对齐 crypto_monitor)
- PostgreSQL 替换 SQLite(多写高并发时)
- Nginx 反代与 HTTPS
+9
View File
@@ -11,6 +11,15 @@
- **交易复盘**:列表与详情
- **下载中心**:通用 exe + 激活码
- **账户 / 机器**:标注管理
- **系统设置**:修改网页登录密码(立即生效,无需改 `.env` / 重启)
### 修改密码
1. 登录后打开 **系统设置**
2. 输入当前密码与新密码(至少 6 位)→ 保存
3. 下次登录使用新密码
> 初始密码来自部署时的 `.env`(默认 admin123);修改后以系统设置中的密码为准。
## 2. 标注账户与机器