feat: settings password change, migrate desktop client with hub activation/report
This commit is contained in:
@@ -40,11 +40,13 @@ bash deploy/manage.sh
|
|||||||
| 功能 | 说明 |
|
| 功能 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| **登录** | 网页账号(默认 admin) |
|
| **登录** | 网页账号(默认 admin) |
|
||||||
|
| **系统设置** | 前端修改登录密码 |
|
||||||
| **下载中心** | 通用辅助包 + 生成激活码 |
|
| **下载中心** | 通用辅助包 + 生成激活码 |
|
||||||
| **账户 / 机器** | 标注名称、备注、类型 |
|
| **账户 / 机器** | 标注名称、备注、类型 |
|
||||||
| **首次激活** | exe 调用 API 绑定指纹与 token |
|
| **首次激活** | exe 调用 API 绑定指纹与 token |
|
||||||
| **成交上报** | 已激活客户端 POST 成交与可选 K 线 |
|
| **成交上报** | 已激活客户端 POST 成交与四周期 K 线 |
|
||||||
| **交易复盘** | 列表 + 详情四周期(M1/M5/M15/H1)+ 入场形式标注 |
|
| **交易复盘** | 列表 + 详情四周期(M1/M5/M15/H1)+ 入场形式标注 |
|
||||||
|
| **desktop/** | 已迁入下单辅助,支持激活与上报 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,11 @@ def init_db() -> None:
|
|||||||
open REAL, high REAL, low REAL, close REAL, volume REAL,
|
open REAL, high REAL, low REAL, close REAL, volume REAL,
|
||||||
UNIQUE(symbol, timeframe, ts)
|
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()]
|
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:
|
def seed_if_empty() -> None:
|
||||||
|
from werkzeug.security import generate_password_hash
|
||||||
|
|
||||||
|
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"]
|
n = conn.execute("SELECT COUNT(*) AS c FROM accounts").fetchone()["c"]
|
||||||
if n == 0:
|
if n == 0:
|
||||||
@@ -273,3 +294,44 @@ def seed_if_empty() -> None:
|
|||||||
"INSERT INTO machines(name, device_fingerprint, note, enabled, created_at) VALUES(?,?,?,?,?)",
|
"INSERT INTO machines(name, device_fingerprint, note, enabled, created_at) VALUES(?,?,?,?,?)",
|
||||||
("未绑定机器", "", "下载通用包后用激活码绑定", 1, now()),
|
("未绑定机器", "", "下载通用包后用激活码绑定", 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
@@ -50,7 +50,7 @@ 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 user == Config.ADMIN_USER and pwd == Config.ADMIN_PASSWORD:
|
if database.verify_admin(user, pwd):
|
||||||
session["user"] = user
|
session["user"] = user
|
||||||
return redirect(request.args.get("next") or url_for("dashboard"))
|
return redirect(request.args.get("next") or url_for("dashboard"))
|
||||||
flash("用户名或密码错误", "error")
|
flash("用户名或密码错误", "error")
|
||||||
@@ -61,6 +61,27 @@ def register_routes(app: Flask) -> None:
|
|||||||
session.clear()
|
session.clear()
|
||||||
return redirect(url_for("login"))
|
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("/")
|
@app.get("/")
|
||||||
@login_required
|
@login_required
|
||||||
def dashboard():
|
def dashboard():
|
||||||
|
|||||||
@@ -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('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('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('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>
|
</nav>
|
||||||
<div class="right">{{ session.get('user') }} · <a href="{{ url_for('logout') }}">退出</a></div>
|
<div class="right">{{ session.get('user') }} · <a href="{{ url_for('logout') }}">退出</a></div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -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
@@ -1,31 +1,32 @@
|
|||||||
# desktop · MT5 黄金辅助客户端
|
# desktop · MT5 黄金辅助客户端
|
||||||
|
|
||||||
## 定位
|
## 功能
|
||||||
|
|
||||||
- **通用包**:所有机器下载同一个 exe(由网页「下载中心」分发)。
|
- 连接本机已登录 MT5,市价开平、ATR 止损止盈、保本、满仓灰显等(由原 `MT5黄金方案/Desktop` 迁入)
|
||||||
- **首次激活**:启动后输入网页生成的激活码,调用 `POST /api/v1/activate`,本地保存 `token`。
|
- **通用包 + 首次激活**:启动时填写中控地址与激活码
|
||||||
- **运行期**:本机连接已登录的 MT5;成交/快照 `POST /api/v1/trades`;可选上报 K 线 `POST /api/v1/bars`。
|
- **自动上报**:开仓/平仓成功后 `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 示例
|
```bat
|
||||||
|
cd desktop
|
||||||
```http
|
py -3 gold_order_app.py
|
||||||
POST /api/v1/activate
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"code": "激活码",
|
|
||||||
"fingerprint": "机器唯一指纹",
|
|
||||||
"machine_name": "书房PC"
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
成功后请求头携带:`X-Device-Token: <token>`
|
需本机已安装 `MetaTrader5`、`numpy`,并打开登录 MT5。
|
||||||
|
|
||||||
|
## 打包
|
||||||
|
|
||||||
|
```bat
|
||||||
|
build_exe.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
产物:`dist\黄金市价辅助.exe`,复制到仓库 `releases\黄金市价辅助.exe` 后由网页下载中心分发。
|
||||||
|
|
||||||
|
## 离线模式
|
||||||
|
|
||||||
|
激活窗可点「离线使用」:正常下单,但不上报复盘中心。
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
MetaTrader5>=5.0.45
|
||||||
|
numpy>=1.24
|
||||||
|
pyinstaller>=6.0
|
||||||
+18
-2
@@ -115,9 +115,25 @@ 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)
|
||||||
|
- 首次启动用 `.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)
|
- 复盘筛选、统计面板、AI 点评(可对齐 crypto_monitor)
|
||||||
- PostgreSQL 替换 SQLite(多写高并发时)
|
- PostgreSQL 替换 SQLite(多写高并发时)
|
||||||
- Nginx 反代与 HTTPS
|
- Nginx 反代与 HTTPS
|
||||||
|
|||||||
@@ -11,6 +11,15 @@
|
|||||||
- **交易复盘**:列表与详情
|
- **交易复盘**:列表与详情
|
||||||
- **下载中心**:通用 exe + 激活码
|
- **下载中心**:通用 exe + 激活码
|
||||||
- **账户 / 机器**:标注管理
|
- **账户 / 机器**:标注管理
|
||||||
|
- **系统设置**:修改网页登录密码(立即生效,无需改 `.env` / 重启)
|
||||||
|
|
||||||
|
### 修改密码
|
||||||
|
|
||||||
|
1. 登录后打开 **系统设置**
|
||||||
|
2. 输入当前密码与新密码(至少 6 位)→ 保存
|
||||||
|
3. 下次登录使用新密码
|
||||||
|
|
||||||
|
> 初始密码来自部署时的 `.env`(默认 admin123);修改后以系统设置中的密码为准。
|
||||||
|
|
||||||
## 2. 标注账户与机器
|
## 2. 标注账户与机器
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user