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
+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