Restructure into modules/ with single-process CTP and config/ layout.
Move business code under modules/, env template to config/, PM2 single qihuo process, and _legacy shims for old imports. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
|
||||
from modules.settings.routes import register
|
||||
|
||||
__all__ = ["register"]
|
||||
@@ -0,0 +1,86 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""Web 登录账号:settings 表 + .env 同步。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Callable
|
||||
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
from modules.core.env_file import update_env_vars
|
||||
|
||||
ADMIN_USERNAME_KEY = "ADMIN_USERNAME"
|
||||
ADMIN_PASSWORD_KEY = "ADMIN_PASSWORD"
|
||||
|
||||
|
||||
def save_admin_credentials(
|
||||
*,
|
||||
username: str,
|
||||
old_password: str,
|
||||
new_password: str,
|
||||
new_password2: str,
|
||||
get_setting: Callable[[str, str], str],
|
||||
set_setting: Callable[[str, str], None],
|
||||
) -> tuple[bool, str, dict[str, str]]:
|
||||
"""
|
||||
校验原密码后更新用户名/密码,写入 settings 与 .env。
|
||||
返回 (成功, 提示, env_updates)。
|
||||
"""
|
||||
username = (username or "").strip()
|
||||
old_password = old_password or ""
|
||||
new_password = new_password or ""
|
||||
new_password2 = new_password2 or ""
|
||||
|
||||
if not username:
|
||||
return False, "用户名不能为空", {}
|
||||
if len(username) > 64:
|
||||
return False, "用户名过长(最多 64 字符)", {}
|
||||
if not re.match(r"^[A-Za-z0-9_.@-]+$", username):
|
||||
return False, "用户名仅支持字母、数字及 _ . @ -", {}
|
||||
|
||||
admin_hash = get_setting("admin_password_hash")
|
||||
if not admin_hash or not check_password_hash(admin_hash, old_password):
|
||||
return False, "原密码错误", {}
|
||||
|
||||
current_username = (get_setting("admin_username") or "").strip()
|
||||
password_change = bool(new_password or new_password2)
|
||||
|
||||
if password_change:
|
||||
if not new_password or not new_password2:
|
||||
return False, "请同时填写新密码与确认密码", {}
|
||||
if len(new_password) < 6:
|
||||
return False, "新密码至少 6 位", {}
|
||||
if new_password != new_password2:
|
||||
return False, "两次新密码不一致", {}
|
||||
|
||||
username_changed = username != current_username
|
||||
if not username_changed and not password_change:
|
||||
return False, "未修改任何内容", {}
|
||||
|
||||
set_setting("admin_username", username)
|
||||
env_updates: dict[str, str] = {ADMIN_USERNAME_KEY: username}
|
||||
|
||||
if password_change:
|
||||
set_setting("admin_password_hash", generate_password_hash(new_password))
|
||||
env_updates[ADMIN_PASSWORD_KEY] = new_password
|
||||
|
||||
try:
|
||||
update_env_vars(env_updates)
|
||||
except OSError as exc:
|
||||
return False, f"数据库已更新,但写入 .env 失败:{exc}", env_updates
|
||||
|
||||
for key, val in env_updates.items():
|
||||
os.environ[key] = val
|
||||
|
||||
parts: list[str] = []
|
||||
if username_changed:
|
||||
parts.append("用户名已更新")
|
||||
if password_change:
|
||||
parts.append("密码已更新")
|
||||
parts.append("已同步至 .env")
|
||||
return True, ";".join(parts), env_updates
|
||||
@@ -0,0 +1,53 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""顶栏导航项显示开关(系统设置)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Callable
|
||||
|
||||
# 可在系统设置中开关的导航项
|
||||
NAV_TOGGLES: dict[str, str] = {
|
||||
"dashboard": "数据看板",
|
||||
"risk_guide": "风控说明",
|
||||
"fees": "手续费配置",
|
||||
"plans": "开单计划",
|
||||
"market": "行情K线",
|
||||
"strategy": "策略交易",
|
||||
"ai": "AI 分析",
|
||||
}
|
||||
|
||||
DEFAULT_NAV: dict[str, bool] = {k: True for k in NAV_TOGGLES}
|
||||
|
||||
|
||||
def get_nav_items(get_setting: Callable[[str, str], str]) -> dict[str, bool]:
|
||||
raw = (get_setting("nav_items", "") or "").strip()
|
||||
out = dict(DEFAULT_NAV)
|
||||
if not raw:
|
||||
return out
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
if isinstance(data, dict):
|
||||
for k in NAV_TOGGLES:
|
||||
if k in data:
|
||||
out[k] = bool(data[k])
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def save_nav_items(set_setting: Callable[[str, str], None], items: dict[str, bool]) -> None:
|
||||
merged = dict(DEFAULT_NAV)
|
||||
for k in NAV_TOGGLES:
|
||||
if k in items:
|
||||
merged[k] = bool(items[k])
|
||||
set_setting("nav_items", json.dumps(merged, ensure_ascii=False))
|
||||
|
||||
|
||||
def nav_enabled(get_setting: Callable[[str, str], str], key: str) -> bool:
|
||||
if key not in NAV_TOGGLES:
|
||||
return True
|
||||
return get_nav_items(get_setting).get(key, True)
|
||||
@@ -0,0 +1,314 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
"""HTTP routes for settings module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from flask import (
|
||||
Response,
|
||||
flash,
|
||||
jsonify,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
send_file,
|
||||
session,
|
||||
stream_with_context,
|
||||
url_for,
|
||||
)
|
||||
|
||||
|
||||
def register(deps) -> None:
|
||||
app = deps.app
|
||||
login_required = deps.login_required
|
||||
require_nav = deps.require_nav
|
||||
get_db = deps.get_db
|
||||
get_setting = deps.get_setting
|
||||
set_setting = deps.set_setting
|
||||
fetch_price = deps.fetch_price
|
||||
send_wechat_msg = deps.send_wechat_msg
|
||||
touch_stats_cache = deps.touch_stats_cache
|
||||
get_stats_data = deps.get_stats_data
|
||||
build_market_quote_payload = deps.build_market_quote_payload
|
||||
today_str = deps.today_str
|
||||
expire_old_plans = deps.expire_old_plans
|
||||
TZ = deps.tz
|
||||
DB_PATH = deps.db_path
|
||||
UPLOAD_DIR = deps.upload_dir
|
||||
OPEN_TYPES = deps.open_types
|
||||
EXIT_TRIGGERS = deps.exit_triggers
|
||||
BEHAVIOR_TAGS = deps.behavior_tags
|
||||
KLINE_PERIODS = deps.kline_periods
|
||||
KLINE_CUTOFFS = deps.kline_cutoffs
|
||||
calc_holding_duration = deps.calc_holding_duration
|
||||
holding_to_minutes = deps.holding_to_minutes
|
||||
classify_close_result = deps.classify_close_result
|
||||
calc_rr_ratio = deps.calc_rr_ratio
|
||||
calc_theoretical_pnl = deps.calc_theoretical_pnl
|
||||
parse_review_date_filter = deps.parse_review_date_filter
|
||||
_trading_mode = deps.trading_mode
|
||||
_ua_is_phone = deps.ua_is_phone
|
||||
_static_asset_v = deps.static_asset_v
|
||||
|
||||
from modules.settings.nav_settings import NAV_TOGGLES, get_nav_items, save_nav_items
|
||||
from modules.settings.admin_settings import save_admin_credentials
|
||||
from modules.backup.db_backup import (
|
||||
backup_dir,
|
||||
backup_in_progress,
|
||||
default_restore_dir,
|
||||
get_backup_last_at,
|
||||
list_backups,
|
||||
schedule_backup,
|
||||
)
|
||||
from modules.market.market import get_quote_source_label
|
||||
from modules.trading.product_recommend import small_account_margin_recommendations
|
||||
|
||||
@app.route("/settings", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def settings():
|
||||
if request.method == "POST":
|
||||
action = request.form.get("action")
|
||||
if action == "backup_now":
|
||||
ok, msg = schedule_backup(
|
||||
get_setting=get_setting,
|
||||
set_setting=set_setting,
|
||||
include_uploads=True,
|
||||
)
|
||||
flash(msg if ok else msg)
|
||||
elif action == "backup_config":
|
||||
auto = request.form.get("backup_auto_enabled") == "1"
|
||||
set_setting("backup_auto_enabled", "1" if auto else "0")
|
||||
try:
|
||||
hour = int(request.form.get("backup_auto_hour", "3") or 3)
|
||||
set_setting("backup_auto_hour", str(max(0, min(23, hour))))
|
||||
except ValueError:
|
||||
flash("自动备份小时无效")
|
||||
return redirect(url_for("settings"))
|
||||
try:
|
||||
keep = int(request.form.get("backup_keep_count", "30") or 30)
|
||||
set_setting("backup_keep_count", str(max(5, min(200, keep))))
|
||||
except ValueError:
|
||||
flash("保留份数无效")
|
||||
return redirect(url_for("settings"))
|
||||
flash("备份策略已保存")
|
||||
elif action == "wechat":
|
||||
webhook = request.form.get("wechat_webhook", "").strip()
|
||||
set_setting("wechat_webhook", webhook)
|
||||
flash("企业微信配置已保存")
|
||||
elif action == "ai":
|
||||
set_setting("ai_enabled", "1" if request.form.get("ai_enabled") else "0")
|
||||
provider = (request.form.get("ai_provider") or "ollama").strip().lower()
|
||||
if provider not in ("ollama", "openai"):
|
||||
provider = "ollama"
|
||||
set_setting("ai_provider", provider)
|
||||
set_setting("ai_ollama_base_url", (request.form.get("ai_ollama_base_url") or "").strip())
|
||||
set_setting("ai_ollama_model", (request.form.get("ai_ollama_model") or "").strip())
|
||||
set_setting("ai_openai_base_url", (request.form.get("ai_openai_base_url") or "").strip())
|
||||
key = (request.form.get("ai_openai_api_key") or "").strip()
|
||||
if key:
|
||||
set_setting("ai_openai_api_key", key)
|
||||
set_setting("ai_openai_model", (request.form.get("ai_openai_model") or "").strip())
|
||||
set_setting("ai_daily_report_enabled", "1" if request.form.get("ai_daily_report_enabled") else "0")
|
||||
try:
|
||||
set_setting("ai_daily_report_hour", str(max(0, min(23, int(request.form.get("ai_daily_report_hour", "15") or 15)))))
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
set_setting("ai_daily_report_minute", str(max(0, min(59, int(request.form.get("ai_daily_report_minute", "5") or 5)))))
|
||||
except ValueError:
|
||||
pass
|
||||
flash("AI 配置已保存")
|
||||
elif action == "trading":
|
||||
mode = request.form.get("trading_mode", "simulation").strip()
|
||||
if mode not in ("simulation", "live"):
|
||||
mode = "simulation"
|
||||
sizing = request.form.get("position_sizing_mode", "fixed").strip()
|
||||
if sizing == "risk":
|
||||
sizing = "amount"
|
||||
if sizing not in ("fixed", "amount"):
|
||||
sizing = "fixed"
|
||||
set_setting("trading_mode", mode)
|
||||
set_setting("position_sizing_mode", sizing)
|
||||
try:
|
||||
fl = int(float(request.form.get("fixed_lots", "1") or 1))
|
||||
set_setting("fixed_lots", str(max(1, fl)))
|
||||
except ValueError:
|
||||
flash("固定手数无效")
|
||||
return redirect(url_for("settings"))
|
||||
try:
|
||||
fa = float(request.form.get("fixed_amount", "5000") or 5000)
|
||||
set_setting("fixed_amount", str(max(1.0, fa)))
|
||||
except ValueError:
|
||||
flash("固定金额无效")
|
||||
return redirect(url_for("settings"))
|
||||
try:
|
||||
rp = float(request.form.get("risk_percent", "1") or 1)
|
||||
set_setting("risk_percent", str(max(0.1, min(100.0, rp))))
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
mp = float(request.form.get("max_margin_pct", "30") or 30)
|
||||
set_setting("max_margin_pct", str(max(1.0, min(100.0, mp))))
|
||||
except ValueError:
|
||||
flash("保证金比例无效")
|
||||
return redirect(url_for("settings"))
|
||||
try:
|
||||
rmp = float(request.form.get("roll_max_margin_pct", "50") or 50)
|
||||
set_setting("roll_max_margin_pct", str(max(1.0, min(100.0, rmp))))
|
||||
except ValueError:
|
||||
flash("滚仓保证金比例无效")
|
||||
return redirect(url_for("settings"))
|
||||
try:
|
||||
tb = int(float(request.form.get("trailing_be_tick_buffer", "2") or 2))
|
||||
set_setting("trailing_be_tick_buffer", str(max(1, min(20, tb))))
|
||||
except ValueError:
|
||||
flash("移动保本缓冲无效")
|
||||
return redirect(url_for("settings"))
|
||||
try:
|
||||
pt = int(float(request.form.get("pending_order_timeout_min", "5") or 5))
|
||||
set_setting("pending_order_timeout_min", str(max(1, min(60, pt))))
|
||||
except ValueError:
|
||||
flash("挂单超时无效")
|
||||
return redirect(url_for("settings"))
|
||||
flash("交易模式已保存")
|
||||
elif action == "ctp":
|
||||
from modules.ctp.ctp_settings import save_ctp_auto_connect, is_ctp_auto_connect_enabled
|
||||
from modules.ctp.ctp_settings import save_ctp_settings_from_form
|
||||
from modules.ctp.vnpy_bridge import ctp_disconnect
|
||||
|
||||
was_enabled = is_ctp_auto_connect_enabled(get_setting)
|
||||
auto_enabled = save_ctp_auto_connect(request.form, set_setting)
|
||||
save_result = save_ctp_settings_from_form(request.form, set_setting)
|
||||
if not auto_enabled:
|
||||
ctp_disconnect(set_disabled_hint=True)
|
||||
elif not was_enabled and auto_enabled:
|
||||
try:
|
||||
from modules.ctp.vnpy_bridge import get_bridge
|
||||
from modules.core.trading_context import get_trading_mode
|
||||
|
||||
mode = get_trading_mode(get_setting)
|
||||
get_bridge().reconnect_after_settings_saved(mode)
|
||||
except Exception as exc:
|
||||
app.logger.debug("CTP connect after enable auto: %s", exc)
|
||||
pwd_updated = save_result.get("passwords_updated") or []
|
||||
pwd_empty = save_result.get("passwords_submitted_empty") or []
|
||||
simnow_pwd_len = len((request.form.get("simnow_password") or "").strip())
|
||||
live_pwd_len = len((request.form.get("ctp_live_password") or "").strip())
|
||||
print(
|
||||
f"CTP settings save: simnow_password_len={simnow_pwd_len} "
|
||||
f"live_password_len={live_pwd_len} updated={pwd_updated}",
|
||||
flush=True,
|
||||
)
|
||||
app.logger.info(
|
||||
"CTP settings save: simnow_password_len=%s live_password_len=%s updated=%s",
|
||||
simnow_pwd_len,
|
||||
live_pwd_len,
|
||||
pwd_updated,
|
||||
)
|
||||
if "simnow_password" in pwd_updated:
|
||||
pwd_note = f"SimNow 交易密码已更新({simnow_pwd_len} 位)"
|
||||
elif "simnow_password" in pwd_empty:
|
||||
pwd_note = "SimNow 交易密码未改:提交为空,请在「交易密码」框手打后再保存"
|
||||
elif "ctp_live_password" in pwd_updated:
|
||||
pwd_note = "实盘交易密码已更新"
|
||||
elif "ctp_live_password" in pwd_empty:
|
||||
pwd_note = "实盘交易密码未改(提交为空)"
|
||||
else:
|
||||
pwd_note = ""
|
||||
if not auto_enabled:
|
||||
flash("CTP 配置已保存;自动连接已关闭,所有 CTP 连接已断开")
|
||||
return redirect(url_for("settings"))
|
||||
if not was_enabled:
|
||||
flash("CTP 配置已保存;自动连接已开启,正在连接…")
|
||||
return redirect(url_for("settings"))
|
||||
flash_msg = "CTP 配置已保存,正在使用新地址重连…"
|
||||
if pwd_note:
|
||||
flash_msg = f"CTP 配置已保存;{pwd_note},正在重连…"
|
||||
try:
|
||||
from modules.ctp.vnpy_bridge import get_bridge
|
||||
from modules.core.trading_context import get_trading_mode
|
||||
|
||||
b = get_bridge()
|
||||
if pwd_updated:
|
||||
b._clear_login_cooldown()
|
||||
mode = get_trading_mode(get_setting)
|
||||
info = b.reconnect_after_settings_saved(mode)
|
||||
if info.get("cooldown"):
|
||||
flash_msg = f"CTP 配置已保存;{pwd_note or '请稍后再连'}"
|
||||
elif not info.get("started") and info.get("connected"):
|
||||
flash_msg = f"CTP 配置已保存;{pwd_note or '当前连接正常'}"
|
||||
except Exception as exc:
|
||||
app.logger.warning("CTP reconnect after settings save: %s", exc)
|
||||
flash_msg = f"CTP 配置已保存;{pwd_note or '请稍后在持仓监控页重连'}"
|
||||
flash(flash_msg)
|
||||
elif action == "nav":
|
||||
items = {k: request.form.get(f"nav_{k}") == "on" for k in NAV_TOGGLES}
|
||||
save_nav_items(set_setting, items)
|
||||
flash("导航显示已保存")
|
||||
elif action == "password":
|
||||
ok, msg, _ = save_admin_credentials(
|
||||
username=request.form.get("admin_username", ""),
|
||||
old_password=request.form.get("old_password", ""),
|
||||
new_password=request.form.get("new_password", ""),
|
||||
new_password2=request.form.get("new_password2", ""),
|
||||
get_setting=get_setting,
|
||||
set_setting=set_setting,
|
||||
)
|
||||
if ok and session.get("logged_in"):
|
||||
session["username"] = (request.form.get("admin_username") or "").strip()
|
||||
flash(msg)
|
||||
return redirect(url_for("settings"))
|
||||
|
||||
webhook = get_setting("wechat_webhook")
|
||||
username = get_setting("admin_username")
|
||||
ctp_st = {}
|
||||
try:
|
||||
from modules.ctp.vnpy_bridge import ctp_status
|
||||
from modules.core.trading_context import get_trading_mode
|
||||
|
||||
ctp_st = ctp_status(get_trading_mode(get_setting))
|
||||
except Exception:
|
||||
pass
|
||||
from modules.ctp.ctp_settings import get_ctp_settings_for_ui, is_ctp_auto_connect_enabled
|
||||
from modules.trading.product_recommend import small_account_margin_recommendations
|
||||
|
||||
return render_template(
|
||||
"settings.html",
|
||||
webhook=webhook,
|
||||
username=username,
|
||||
quote_label=get_quote_source_label(ctp_connected=bool(ctp_st.get("connected"))),
|
||||
ctp_status=ctp_st,
|
||||
ctp_cfg=get_ctp_settings_for_ui(),
|
||||
ctp_auto_connect=is_ctp_auto_connect_enabled(get_setting),
|
||||
trading_mode=get_setting("trading_mode", "simulation"),
|
||||
position_sizing_mode=get_setting("position_sizing_mode", "fixed"),
|
||||
fixed_lots=get_setting("fixed_lots", "1"),
|
||||
fixed_amount=get_setting("fixed_amount", "5000"),
|
||||
risk_percent=get_setting("risk_percent", "1"),
|
||||
max_margin_pct=get_setting("max_margin_pct", "30"),
|
||||
roll_max_margin_pct=get_setting("roll_max_margin_pct", "50"),
|
||||
small_account_margin_rec=small_account_margin_recommendations(),
|
||||
trailing_be_tick_buffer=get_setting("trailing_be_tick_buffer", "2"),
|
||||
pending_order_timeout_min=get_setting("pending_order_timeout_min", "5"),
|
||||
nav_items=get_nav_items(get_setting),
|
||||
nav_toggles=NAV_TOGGLES,
|
||||
backup_dir=str(backup_dir()),
|
||||
backup_last_at=get_backup_last_at(get_setting),
|
||||
backup_running=backup_in_progress(),
|
||||
backup_items=list_backups(),
|
||||
backup_auto_enabled=get_setting("backup_auto_enabled", "1") == "1",
|
||||
backup_auto_hour=get_setting("backup_auto_hour", "3"),
|
||||
backup_keep_count=get_setting("backup_keep_count", "30"),
|
||||
backup_restore_dir=default_restore_dir(),
|
||||
ai_enabled=get_setting("ai_enabled", "0") == "1",
|
||||
ai_provider=get_setting("ai_provider", "ollama"),
|
||||
ai_ollama_base_url=get_setting("ai_ollama_base_url", "http://127.0.0.1:11434"),
|
||||
ai_ollama_model=get_setting("ai_ollama_model", "qwen2.5:7b"),
|
||||
ai_openai_base_url=get_setting("ai_openai_base_url", "https://api.openai.com/v1"),
|
||||
ai_openai_api_key=get_setting("ai_openai_api_key", ""),
|
||||
ai_openai_model=get_setting("ai_openai_model", "gpt-4o-mini"),
|
||||
ai_daily_report_enabled=get_setting("ai_daily_report_enabled", "1") == "1",
|
||||
ai_daily_report_hour=get_setting("ai_daily_report_hour", "15"),
|
||||
ai_daily_report_minute=get_setting("ai_daily_report_minute", "5"),
|
||||
)
|
||||
Reference in New Issue
Block a user