Files
stock/stock-html/routes/admin.py
T
freedakgmail c5df676af6 fix: 修复 market.py _put_db 拼写错误和多处连接泄漏
- market.py:308: _put_db(conn) → put_db(_conn) 修复函数名和变量名拼写错误
- market.py: get_fundamental put_db 移到 finally,初始化 conn=None 防 NameError
- admin.py: kline_sync_status 和 admin_scan_status put_db 移到 finally
2026-07-22 07:35:35 +08:00

1355 lines
51 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
管理后台 API 路由
"""
from flask import Blueprint, request, jsonify, session, render_template
from db import get_db, put_db
from psycopg2.extras import RealDictCursor
from werkzeug.security import generate_password_hash
import functools
import subprocess
import os
import socket
import platform
bp = Blueprint('admin', __name__)
def _calc_win_rate(trades_rows, sim_pos_rows, sim_trades_rows, realtime_prices=None):
if realtime_prices is None:
realtime_prices = {}
from decimal import Decimal
def _pair_trades(rows):
holdings = {} # code -> list of (price, qty)
completed = []
for r in rows:
code = r['stock_code']
name = r['stock_name']
ttype = r['trade_type']
price = float(r['price'] or 0)
qty = int(r['quantity'] or 0)
if ttype == 'buy':
if code not in holdings:
holdings[code] = {'name': name, 'buys': []}
holdings[code]['buys'].append({'price': price, 'qty': qty, 'date': r['trade_date']})
elif ttype == 'sell':
if code in holdings and holdings[code]['buys']:
remaining = qty
total_cost = 0
total_qty = 0
while remaining > 0 and holdings[code]['buys']:
buy = holdings[code]['buys'][0]
take = min(remaining, buy['qty'])
total_cost += buy['price'] * take
total_qty += take
remaining -= take
buy['qty'] -= take
if buy['qty'] <= 0:
holdings[code]['buys'].pop(0)
if total_qty > 0:
avg_buy = total_cost / total_qty
profit = (price - avg_buy) * total_qty
pct = (price - avg_buy) / avg_buy * 100 if avg_buy else 0
completed.append({
'code': code, 'name': name,
'buy_price': round(avg_buy, 4), 'sell_price': price,
'quantity': total_qty, 'profit': round(profit, 2),
'pct': round(pct, 2), 'date': r['trade_date'],
})
open_positions = []
for code, data in holdings.items():
if data['buys']:
total_cost = sum(b['price'] * b['qty'] for b in data['buys'])
total_qty = sum(b['qty'] for b in data['buys'])
if total_qty > 0:
open_positions.append({
'code': code, 'name': data['name'],
'avg_cost': round(total_cost / total_qty, 4),
'quantity': total_qty,
})
return completed, open_positions
# 实盘交易分析
real_completed, real_open = _pair_trades(trades_rows)
real_wins = [t for t in real_completed if t['profit'] > 0]
real_losses = [t for t in real_completed if t['profit'] <= 0]
real_total_profit = sum(t['profit'] for t in real_completed)
# 实盘持仓浮盈(用实时价格计算)
real_open_detail = []
for pos in real_open:
code = pos['code']
avg_cost = pos['avg_cost']
qty = pos['quantity']
current = float(realtime_prices.get(code, 0))
if current > 0 and avg_cost > 0:
profit = (current - avg_cost) * qty
pct = (current - avg_cost) / avg_cost * 100
real_open_detail.append({
'code': code, 'name': pos['name'],
'avg_cost': avg_cost, 'current_price': round(current, 4),
'quantity': qty, 'profit': round(profit, 2), 'pct': round(pct, 2),
})
else:
real_open_detail.append({
'code': code, 'name': pos['name'],
'avg_cost': avg_cost, 'current_price': current,
'quantity': qty, 'profit': 0, 'pct': 0,
})
real_open_wins = [p for p in real_open_detail if p['profit'] > 0]
real_open_losses = [p for p in real_open_detail if p['profit'] <= 0 and p['current_price'] > 0]
real_open_profit = sum(p['profit'] for p in real_open_detail)
# 模拟交易分析
sim_completed, _ = _pair_trades(sim_trades_rows)
sim_wins = [t for t in sim_completed if t['profit'] > 0]
sim_losses = [t for t in sim_completed if t['profit'] <= 0]
sim_completed_profit = sum(t['profit'] for t in sim_completed)
# 模拟持仓浮盈
sim_unrealized = []
for p in sim_pos_rows:
avg_cost = float(p['avg_cost'] or 0)
current = float(p.get('realtime_price') or p.get('current_price') or 0)
qty = int(p['quantity'] or 0)
if qty > 0 and avg_cost > 0:
profit = (current - avg_cost) * qty
pct = (current - avg_cost) / avg_cost * 100
sim_unrealized.append({
'code': p['stock_code'], 'name': p['stock_name'],
'avg_cost': round(avg_cost, 4), 'current_price': round(current, 4),
'quantity': qty, 'profit': round(profit, 2), 'pct': round(pct, 2),
})
sim_unrealized_wins = [p for p in sim_unrealized if p['profit'] > 0]
sim_unrealized_losses = [p for p in sim_unrealized if p['profit'] <= 0 and p['current_price'] > 0]
sim_unrealized_profit = sum(p['profit'] for p in sim_unrealized)
sim_unrealized_with_price = [p for p in sim_unrealized if p['current_price'] > 0]
sim_total_count = len(sim_completed) + len(sim_unrealized_with_price)
sim_total_wins = len(sim_wins) + len(sim_unrealized_wins)
sim_total_losses = len(sim_losses) + len(sim_unrealized_losses)
sim_total_profit = sim_completed_profit + sim_unrealized_profit
# 总胜率:实盘已完成 + 实盘持仓 + 模拟已完成 + 模拟持仓
real_open_with_price = [p for p in real_open_detail if p['current_price'] > 0]
total_count = len(real_completed) + len(real_open_with_price) + sim_total_count
total_wins = len(real_wins) + len(real_open_wins) + sim_total_wins
total_profit = real_total_profit + real_open_profit + sim_total_profit
return {
'real': {
'completed': real_completed,
'open': real_open_detail,
'win_count': len(real_wins) + len(real_open_wins),
'loss_count': len(real_losses) + len(real_open_losses),
'total_profit': round(real_total_profit + real_open_profit, 2),
'completed_profit': round(real_total_profit, 2),
'open_profit': round(real_open_profit, 2),
'win_rate': round((len(real_wins) + len(real_open_wins)) / (len(real_completed) + len(real_open_with_price)) * 100, 1) if (real_completed or real_open_with_price) else 0,
},
'sim': {
'completed': sim_completed,
'unrealized': sim_unrealized,
'win_count': sim_total_wins,
'loss_count': sim_total_losses,
'total': sim_total_count,
'total_profit': round(sim_total_profit, 2),
'completed_profit': round(sim_completed_profit, 2),
'unrealized_profit': round(sim_unrealized_profit, 2),
'win_rate': round(sim_total_wins / sim_total_count * 100, 1) if sim_total_count else 0,
},
'overall_win_rate': round(total_wins / total_count * 100, 1) if total_count else 0,
'total_completed': total_count,
'total_profit': round(total_profit, 2),
}
def admin_required(f):
@functools.wraps(f)
def decorated(*args, **kwargs):
if 'user_id' not in session:
return jsonify({'success': False, 'error': '请先登录'}), 401
conn = get_db()
if not conn:
return jsonify({'success': False, 'error': '数据库连接失败'}), 500
try:
cur = conn.cursor()
cur.execute("SELECT is_admin FROM users WHERE id = %s", (session['user_id'],))
row = cur.fetchone()
if not row or not row[0]:
return jsonify({'success': False, 'error': '无管理员权限'}), 403
finally:
put_db(conn)
return f(*args, **kwargs)
return decorated
@bp.route('/admin')
def admin_page():
if 'user_id' not in session:
return render_template('admin.html')
conn = get_db()
if not conn:
return render_template('admin.html')
try:
cur = conn.cursor()
cur.execute("SELECT is_admin FROM users WHERE id = %s", (session['user_id'],))
row = cur.fetchone()
if not row or not row[0]:
return '无权访问', 403
finally:
put_db(conn)
return render_template('admin.html')
# ========== 系统概览 ==========
@bp.route('/api/admin/dashboard', methods=['GET'])
@admin_required
def dashboard():
conn = get_db()
if not conn:
return jsonify({'success': False}), 500
try:
cur = conn.cursor()
stats = {}
cur.execute("SELECT count(*) FROM users")
stats['user_count'] = cur.fetchone()[0]
cur.execute("SELECT count(*) FROM stock_realtime_price")
stats['stock_count'] = cur.fetchone()[0]
cur.execute("SELECT count(*), max(scan_date)::text FROM stock_signal_scan")
row = cur.fetchone()
stats['scan_count'] = row[0]
stats['last_scan_date'] = row[1]
cur.execute("SELECT count(*) FROM stock_fundamental")
stats['fundamental_count'] = cur.fetchone()[0]
cur.execute("SELECT count(*) FROM trades")
stats['trade_count'] = cur.fetchone()[0]
cur.execute("SELECT count(*) FROM watchlist")
stats['watchlist_count'] = cur.fetchone()[0]
cur.execute("SELECT count(*) FROM sim_trades")
stats['sim_trade_count'] = cur.fetchone()[0]
cur.execute("SELECT count(DISTINCT code) FROM stock_signal_scan WHERE triggered_count > 0 AND scan_date = (SELECT max(scan_date) FROM stock_signal_scan)")
stats['signal_stock_count'] = cur.fetchone()[0]
# 服务器信息
try:
from config import Config
hostname = socket.gethostname()
# 获取服务器外网IP
try:
import urllib.request
external_ip = urllib.request.urlopen(
'http://metadata.tencentyun.com/latest/meta-data/public-ipv4', timeout=2
).read().decode().strip()
except Exception:
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
external_ip = s.getsockname()[0]
s.close()
except Exception:
external_ip = '未知'
# 内网IP
try:
internal_ip = socket.gethostbyname(hostname)
except Exception:
internal_ip = '未知'
stats['server_info'] = {
'hostname': hostname,
'external_ip': external_ip,
'internal_ip': internal_ip,
'app_port': getattr(Config, 'PORT', 3333),
'os_info': f"{platform.system()} {platform.release()}",
'python_version': platform.python_version(),
'domain': request.host,
'scheme': request.scheme,
}
except Exception as e:
stats['server_info'] = {'error': str(e)}
return jsonify({'success': True, 'data': stats})
finally:
put_db(conn)
# ========== 用户管理 ==========
@bp.route('/api/admin/users', methods=['GET'])
@admin_required
def list_users():
conn = get_db()
if not conn:
return jsonify({'success': False}), 500
try:
cur = conn.cursor(cursor_factory=RealDictCursor)
cur.execute("""
SELECT u.id, u.username, u.email, u.is_admin, u.created_at::text,
(SELECT count(*) FROM watchlist w WHERE w.user_id = u.id) as watchlist_count,
(SELECT count(*) FROM trades t WHERE t.user_id = u.id) as trade_count,
(SELECT count(*) FROM sim_trades st WHERE st.user_id = u.id) as sim_trade_count,
(SELECT count(*) FROM ai_call_log al WHERE al.user_id = u.id) as ai_call_count,
(SELECT updated_at::text FROM alerts_cache ac WHERE ac.user_id = u.id) as last_active
FROM users u
ORDER BY u.id
""")
users = [dict(u) for u in cur.fetchall()]
for u in users:
uid = u['id']
cur.execute("SELECT stock_code, stock_name, trade_type, price, quantity, trade_date::text FROM trades WHERE user_id = %s ORDER BY trade_date, created_at", (uid,))
all_trades = cur.fetchall()
cur.execute("""
SELECT p.stock_code, p.stock_name, p.quantity, p.avg_cost, p.current_price,
r.price as realtime_price
FROM sim_positions p LEFT JOIN stock_realtime_price r ON p.stock_code = r.code
WHERE p.user_id = %s AND p.quantity > 0
""", (uid,))
sim_pos = cur.fetchall()
cur.execute("SELECT stock_code, stock_name, trade_type, price, quantity, trade_date::text FROM sim_trades WHERE user_id = %s ORDER BY trade_date, created_at", (uid,))
all_sim = cur.fetchall()
real_codes_qty = {}
for t in all_trades:
c = t['stock_code']
q = int(t['quantity'] or 0)
real_codes_qty[c] = real_codes_qty.get(c, 0) + (q if t['trade_type'] == 'buy' else -q)
open_codes = {c for c, q in real_codes_qty.items() if q > 0}
rt_prices = {}
if open_codes:
cl = list(open_codes)
cur.execute(f"SELECT code, price FROM stock_realtime_price WHERE code IN ({','.join(['%s']*len(cl))})", cl)
for row in cur.fetchall():
rt_prices[row['code']] = float(row['price'] or 0)
wa = _calc_win_rate(all_trades, sim_pos, all_sim, rt_prices)
u['win_rate'] = wa['overall_win_rate']
u['total_profit'] = wa.get('total_profit', 0)
return jsonify({'success': True, 'data': users})
finally:
put_db(conn)
@bp.route('/api/admin/users/<int:user_id>', methods=['GET'])
@admin_required
def get_user_detail(user_id):
conn = get_db()
if not conn:
return jsonify({'success': False}), 500
try:
cur = conn.cursor(cursor_factory=RealDictCursor)
cur.execute("SELECT id, username, email, is_admin, created_at::text FROM users WHERE id = %s", (user_id,))
user = cur.fetchone()
if not user:
return jsonify({'success': False, 'error': '用户不存在'}), 404
cur.execute("SELECT stock_code, stock_name, added_time::text FROM watchlist WHERE user_id = %s ORDER BY added_time DESC", (user_id,))
watchlist = cur.fetchall()
cur.execute("""
SELECT id, stock_code, stock_name, trade_type, price, quantity,
trade_date::text, reason, result, profit_amount, notes, created_at::text
FROM trades WHERE user_id = %s ORDER BY trade_date DESC LIMIT 50
""", (user_id,))
trades = cur.fetchall()
cur.execute("""
SELECT id, stock_code, stock_name, trade_type, price, quantity,
trade_date::text, signal_reason, created_at::text
FROM sim_trades WHERE user_id = %s ORDER BY trade_date DESC LIMIT 50
""", (user_id,))
sim_trades = cur.fetchall()
cur.execute("""
SELECT id, stock_code, stock_name, call_type, created_at::text
FROM ai_call_log WHERE user_id = %s ORDER BY created_at DESC LIMIT 50
""", (user_id,))
ai_calls = cur.fetchall()
# 胜率分析 - 配对买卖交易
cur.execute("""
SELECT stock_code, stock_name, trade_type, price, quantity, trade_date::text
FROM trades WHERE user_id = %s ORDER BY trade_date, created_at
""", (user_id,))
all_trades = cur.fetchall()
# 模拟持仓和收益
cur.execute("""
SELECT p.stock_code, p.stock_name, p.quantity, p.avg_cost, p.current_price,
r.price as realtime_price
FROM sim_positions p
LEFT JOIN stock_realtime_price r ON p.stock_code = r.code
WHERE p.user_id = %s AND p.quantity > 0
""", (user_id,))
sim_positions = cur.fetchall()
# 模拟交易历史中的已卖出记录
cur.execute("""
SELECT stock_code, stock_name, trade_type, price, quantity, trade_date::text
FROM sim_trades WHERE user_id = %s ORDER BY trade_date, created_at
""", (user_id,))
all_sim = cur.fetchall()
# 获取实盘持仓股票的实时价格
real_codes = set()
buys = {}
for t in all_trades:
if t['trade_type'] == 'buy':
buys.setdefault(t['stock_code'], 0)
buys[t['stock_code']] += int(t['quantity'] or 0)
elif t['trade_type'] == 'sell':
buys.setdefault(t['stock_code'], 0)
buys[t['stock_code']] -= int(t['quantity'] or 0)
real_codes = {c for c, q in buys.items() if q > 0}
realtime_prices = {}
if real_codes:
codes_list = list(real_codes)
placeholders = ','.join(['%s'] * len(codes_list))
cur.execute(f"SELECT code, price FROM stock_realtime_price WHERE code IN ({placeholders})", codes_list)
for row in cur.fetchall():
realtime_prices[row['code']] = float(row['price'] or 0)
win_analysis = _calc_win_rate(all_trades, sim_positions, all_sim, realtime_prices)
return jsonify({
'success': True,
'data': {
'user': dict(user),
'watchlist': [dict(w) for w in watchlist],
'trades': [dict(t) for t in trades],
'sim_trades': [dict(s) for s in sim_trades],
'ai_calls': [dict(a) for a in ai_calls],
'win_analysis': win_analysis,
}
})
finally:
put_db(conn)
@bp.route('/api/admin/users/<int:user_id>/reset_password', methods=['POST'])
@admin_required
def reset_user_password(user_id):
data = request.get_json() or {}
new_password = data.get('new_password', '')
if len(new_password) < 6:
return jsonify({'success': False, 'error': '密码至少6位'}), 400
conn = get_db()
if not conn:
return jsonify({'success': False}), 500
try:
cur = conn.cursor()
pw_hash = generate_password_hash(new_password)
cur.execute("UPDATE users SET password_hash = %s WHERE id = %s", (pw_hash, user_id))
conn.commit()
return jsonify({'success': True})
except Exception as e:
conn.rollback()
return jsonify({'success': False, 'error': str(e)}), 500
finally:
put_db(conn)
@bp.route('/api/admin/users/<int:user_id>/toggle_admin', methods=['POST'])
@admin_required
def toggle_admin(user_id):
if user_id == session.get('user_id'):
return jsonify({'success': False, 'error': '不能修改自己的管理员状态'}), 400
conn = get_db()
if not conn:
return jsonify({'success': False}), 500
try:
cur = conn.cursor()
cur.execute("UPDATE users SET is_admin = NOT is_admin WHERE id = %s RETURNING is_admin", (user_id,))
row = cur.fetchone()
conn.commit()
return jsonify({'success': True, 'is_admin': row[0] if row else False})
except Exception as e:
conn.rollback()
return jsonify({'success': False, 'error': str(e)}), 500
finally:
put_db(conn)
@bp.route('/api/admin/users/<int:user_id>', methods=['DELETE'])
@admin_required
def delete_user(user_id):
if user_id == session.get('user_id'):
return jsonify({'success': False, 'error': '不能删除自己'}), 400
conn = get_db()
if not conn:
return jsonify({'success': False}), 500
try:
cur = conn.cursor()
for tbl in ['alerts_cache', 'watchlist', 'trades', 'sim_trades']:
cur.execute(f"DELETE FROM {tbl} WHERE user_id = %s", (user_id,))
cur.execute("DELETE FROM sim_positions WHERE user_id = %s", (user_id,))
cur.execute("DELETE FROM sim_config WHERE user_id = %s", (user_id,))
cur.execute("DELETE FROM sim_daily_stats WHERE user_id = %s", (user_id,))
cur.execute("DELETE FROM users WHERE id = %s", (user_id,))
conn.commit()
return jsonify({'success': True})
except Exception as e:
conn.rollback()
return jsonify({'success': False, 'error': str(e)}), 500
finally:
put_db(conn)
# ========== 用户数据管理 ==========
@bp.route('/api/admin/users/<int:user_id>/watchlist/<code>', methods=['DELETE'])
@admin_required
def delete_user_watchlist(user_id, code):
conn = get_db()
if not conn:
return jsonify({'success': False}), 500
try:
cur = conn.cursor()
cur.execute("DELETE FROM watchlist WHERE user_id = %s AND stock_code = %s", (user_id, code))
conn.commit()
return jsonify({'success': True})
finally:
put_db(conn)
@bp.route('/api/admin/users/<int:user_id>/trades/<int:trade_id>', methods=['DELETE'])
@admin_required
def delete_user_trade(user_id, trade_id):
conn = get_db()
if not conn:
return jsonify({'success': False}), 500
try:
cur = conn.cursor()
cur.execute("DELETE FROM trades WHERE id = %s AND user_id = %s", (trade_id, user_id))
conn.commit()
return jsonify({'success': True})
finally:
put_db(conn)
# ========== 系统数据 ==========
@bp.route('/api/admin/scan_history', methods=['GET'])
@admin_required
def scan_history():
conn = get_db()
if not conn:
return jsonify({'success': False}), 500
try:
cur = conn.cursor(cursor_factory=RealDictCursor)
cur.execute("""
SELECT scan_date::text, count(*) as total,
sum(CASE WHEN triggered_count > 0 THEN 1 ELSE 0 END) as with_signal,
max(triggered_count) as max_triggered,
to_char(min(created_at), 'HH24:MI:SS') as scan_start,
to_char(max(created_at), 'HH24:MI:SS') as scan_end,
EXTRACT(EPOCH FROM (max(created_at) - min(created_at)))::int as duration_sec
FROM stock_signal_scan
GROUP BY scan_date
ORDER BY scan_date DESC
LIMIT 30
""")
return jsonify({'success': True, 'data': [dict(r) for r in cur.fetchall()]})
finally:
put_db(conn)
@bp.route('/api/admin/data_stats', methods=['GET'])
@admin_required
def data_stats():
conn = get_db()
if not conn:
return jsonify({'success': False}), 500
try:
cur = conn.cursor(cursor_factory=RealDictCursor)
tables = [
('stock_realtime_price', '实时价格'),
('stock_signal_scan', '信号扫描'),
('stock_fundamental', '基本面'),
('stock_fund_flow_history', '资金流向'),
('stock_fund_flow_today', '今日资金'),
]
result = []
for tbl, label in tables:
try:
cur.execute(f"SELECT count(*) as cnt FROM {tbl}")
cnt = cur.fetchone()['cnt']
result.append({'table': tbl, 'label': label, 'count': cnt})
except Exception:
result.append({'table': tbl, 'label': label, 'count': 0})
cur.execute("""
SELECT data_type, status, started_at::text, finished_at::text as completed_at, records_count, error_message
FROM data_update_log ORDER BY started_at DESC LIMIT 20
""")
logs = [dict(r) for r in cur.fetchall()]
return jsonify({'success': True, 'data': {'tables': result, 'logs': logs}})
finally:
put_db(conn)
# ========== 全景扫描管理 ==========
_scan_proc = None # 全景扫描 Popen 对象
def _is_scan_running():
"""检查扫描脚本是否正在运行"""
global _scan_proc
# 方法1:检查保存的 Popen 对象
if _scan_proc is not None:
rc = _scan_proc.poll()
if rc is None:
return True
else:
_scan_proc = None
# 方法2pgrep 兜底
try:
result = subprocess.run(
['/usr/bin/pgrep', '-f', 'full_signal_scan\\.py'],
capture_output=True, timeout=5,
)
return result.returncode == 0
except Exception:
return False
@bp.route('/api/admin/trigger_scan', methods=['POST'])
@admin_required
def trigger_scan():
"""管理员触发全景扫描"""
try:
if _is_scan_running():
return jsonify({'success': False, 'error': '扫描正在进行中,请稍后再试'}), 409
script_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'full_signal_scan.py',
)
if not os.path.exists(script_path):
return jsonify({'success': False, 'error': '扫描脚本不存在'}), 404
force = True # 管理员触发始终强制重新扫描
env = os.environ.copy()
env['PATH'] = '/opt/stock-app/venv/bin:/usr/local/bin:/usr/bin:/bin'
env['FORCE_RESCAN'] = '1'
log_path = os.path.join(os.path.dirname(script_path), 'scan.log')
proc = subprocess.Popen(
['python', script_path],
cwd=os.path.dirname(script_path),
stdout=open(log_path, 'w'),
stderr=subprocess.STDOUT,
env=env,
start_new_session=True,
)
global _scan_proc
_scan_proc = proc
return jsonify({
'success': True,
'message': '全景扫描已在后台启动(强制重新扫描)',
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
# ========== K线数据同步 ==========
_kline_sync_proc = None # subprocess.Popen 对象
def _is_kline_sync_running():
"""检查K线同步脚本是否正在运行"""
global _kline_sync_proc
# 方法1:检查保存的 Popen 对象(最可靠,能正确处理僵尸进程)
if _kline_sync_proc is not None:
rc = _kline_sync_proc.poll() # None=运行中, 非None=已结束
if rc is None:
return True
else:
_kline_sync_proc = None # 进程已结束,清理引用
# 方法2pgrep 兜底(处理Flask重启后进程仍在的情况)
try:
result = subprocess.run(
['/usr/bin/pgrep', '-f', 'sync_kline\\.py'],
capture_output=True, timeout=5,
)
return result.returncode == 0
except Exception:
return False
@bp.route('/api/admin/trigger_kline_sync', methods=['POST'])
@admin_required
def trigger_kline_sync():
"""管理员触发K线数据同步"""
try:
if _is_kline_sync_running():
return jsonify({'success': False, 'error': 'K线同步正在进行中,请稍后再试'}), 409
data = request.get_json() or {}
sync_mode = data.get('mode', 'incremental') # 'full' 或 'incremental'
script_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'sync_kline.py',
)
if not os.path.exists(script_path):
return jsonify({'success': False, 'error': 'K线同步脚本不存在'}), 404
env = os.environ.copy()
env['PATH'] = '/opt/stock-app/venv/bin:/usr/local/bin:/usr/bin:/bin'
cmd = ['python', script_path]
if sync_mode == 'full':
cmd.append('--full')
log_path = os.path.join(os.path.dirname(script_path), 'kline_sync.log')
proc = subprocess.Popen(
cmd,
cwd=os.path.dirname(script_path),
stdout=open(log_path, 'w'),
stderr=subprocess.STDOUT,
env=env,
start_new_session=True,
)
global _kline_sync_proc
_kline_sync_proc = proc
mode_text = '全量同步(180天历史)' if sync_mode == 'full' else '增量同步(最近5天)'
return jsonify({
'success': True,
'message': f'K线同步已启动:{mode_text}',
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/api/admin/kline_sync_status', methods=['GET'])
@admin_required
def kline_sync_status():
"""管理员查询K线同步状态"""
try:
conn = get_db()
if not conn:
return jsonify({'success': False, 'error': '数据库连接失败'}), 500
cur = conn.cursor()
# K线数据统计
cur.execute("""
SELECT count(*) as total_rows,
count(DISTINCT code) as stock_count,
min(trade_date)::text as min_date,
max(trade_date)::text as max_date
FROM stock_kline_daily
""")
row = cur.fetchone()
total_rows, stock_count, min_date, max_date = row
# 最近更新统计
cur.execute("""
SELECT count(DISTINCT code)
FROM stock_kline_daily
WHERE updated_at >= CURRENT_TIMESTAMP - INTERVAL '1 hour'
""")
recent_updated = cur.fetchone()[0]
# 总股票数
cur.execute("SELECT count(*) FROM stock_realtime_price")
total_stocks = cur.fetchone()[0]
cur.close()
is_running = _is_kline_sync_running()
# 从日志中解析同步进度(优先读取较新的日志文件)
sync_progress = {}
log_tail = ''
try:
import re
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
log_candidates = [
os.path.join(base_dir, 'kline_scan.log'), # trigger_task 触发时写入
os.path.join(base_dir, 'kline_sync.log'), # cron/direct触发时写入
]
# 选择最新的日志文件
log_path = None
latest_mtime = 0
for lp in log_candidates:
if os.path.exists(lp):
mt = os.path.getmtime(lp)
if mt > latest_mtime:
latest_mtime = mt
log_path = lp
if log_path and os.path.exists(log_path):
with open(log_path, 'r', errors='replace') as f:
lines = f.readlines()
log_tail = ''.join(lines[-5:]).strip()
# 解析进度行: [ 83.9%] 4872/5810 | 速度: 28.5只/秒 | 剩余: 0.5分钟 | 成功: 4034 | 失败: 838 | 已保存: 4000条
for line in reversed(lines):
m = re.search(
r'\[\s*([\d.]+)%\]\s+(\d+)/(\d+)\s+\|.*?速度:\s*([\d.]+).*?\|.*?剩余:\s*([\d.]+).*?\|.*?成功:\s*(\d+).*?\|.*?失败:\s*(\d+)',
line,
)
if m:
sync_progress = {
'percent': float(m.group(1)),
'done': int(m.group(2)),
'total': int(m.group(3)),
'speed': float(m.group(4)),
'remaining_min': float(m.group(5)),
'success': int(m.group(6)),
'failed': int(m.group(7)),
}
break
# 检查是否已完成
if not is_running and any('✅ 同步完成' in l or '✅ 同步中断' in l for l in lines[-10:]):
for line in reversed(lines[-15:]):
m2 = re.search(r'成功:\s*(\d+)\s*\|\s*失败:\s*(\d+)', line)
if m2:
sync_progress['completed'] = True
sync_progress['success'] = int(m2.group(1))
sync_progress['failed'] = int(m2.group(2))
break
m3 = re.search(r'耗时:\s*([\d.]+)分钟', line)
if m3:
sync_progress['elapsed_min'] = float(m3.group(1))
except Exception:
pass
return jsonify({
'success': True,
'is_running': is_running,
'total_rows': total_rows,
'stock_count': stock_count,
'total_stocks': total_stocks,
'coverage': round(stock_count / total_stocks * 100, 1) if total_stocks > 0 else 0,
'min_date': min_date,
'max_date': max_date,
'recent_updated': recent_updated,
'sync_progress': sync_progress,
'log_tail': log_tail,
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
finally:
put_db(conn)
# ========== 定时任务监控 ==========
@bp.route('/api/admin/scheduled_tasks', methods=['GET'])
@admin_required
def scheduled_tasks():
"""获取全部定时任务执行状态"""
import re
from datetime import datetime
# 定义所有定时任务
tasks = [
{
'id': 'kline_scan',
'name': 'K线同步+全景扫描',
'schedule': '工作日 11:45, 16:25',
'script': 'auto_kline_then_scan.sh',
'log_file': '/opt/stock-app/kline_scan.log',
'extra_log_files': ['/opt/stock-app/scan.log', '/opt/stock-app/kline_sync.log'],
'type': 'cron',
'pgrep_patterns': ['auto_kline_then_scan', 'sync_kline\\.py', 'full_signal_scan'],
},
{
'id': 'kline_5min',
'name': '5分钟K线采集',
'schedule': '工作日 17:30',
'script': 'auto_sync_kline_5min.sh',
'log_file': '/opt/stock-app/sync_kline_5min.log',
'type': 'cron',
'pgrep_patterns': ['auto_sync_kline_5min', 'sync_kline_5min'],
},
{
'id': 'fund_flow',
'name': '资金流向计算',
'schedule': '工作日 18:00',
'script': 'auto_sync_fund_flow.sh',
'log_file': '/opt/stock-app/sync_fund_flow.log',
'type': 'cron',
'pgrep_patterns': ['auto_sync_fund_flow', 'sync_fund_flow'],
},
{
'id': 'vacuum',
'name': '数据库维护(VACUUM)',
'schedule': '周日 03:00',
'script': 'cleanup_data.py',
'log_file': '/opt/stock-app/cleanup.log',
'type': 'cron',
'pgrep_patterns': ['cleanup_data'],
},
{
'id': 'stock_data_service',
'name': '实时数据采集服务',
'schedule': '常驻服务',
'script': 'stock_data_service.py',
'log_file': '/opt/stock-app/stock_data_service.log',
'type': 'systemd',
'service_name': 'stock-data-service',
},
{
'id': 'stock_app',
'name': 'Web应用服务',
'schedule': '常驻服务',
'script': 'app.py',
'log_file': None,
'type': 'systemd',
'service_name': 'stock-app',
},
]
result = []
for task in tasks:
info = {
'id': task['id'],
'name': task['name'],
'schedule': task['schedule'],
'script': task['script'],
'type': task['type'],
'status': 'unknown',
'last_run': None,
'last_log': '',
'is_running': False,
}
# 检查 systemd 服务状态
if task['type'] == 'systemd' and task.get('service_name'):
try:
r = subprocess.run(
['systemctl', 'is-active', task['service_name']],
capture_output=True, text=True, timeout=5,
)
active = r.stdout.strip()
info['is_running'] = (active == 'active')
info['status'] = 'running' if active == 'active' else 'stopped'
# 获取服务运行时间
r2 = subprocess.run(
['systemctl', 'show', task['service_name'], '--property=ActiveEnterTimestamp'],
capture_output=True, text=True, timeout=5,
)
ts = r2.stdout.strip().replace('ActiveEnterTimestamp=', '')
if ts and ts != 'n/a':
info['last_run'] = ts
except Exception:
pass
# 检查进程是否在运行 (cron 任务)
if task['type'] == 'cron':
patterns = task.get('pgrep_patterns', [task['script'].replace('.sh', '').replace('.py', '')])
for pat in patterns:
try:
r = subprocess.run(
['/usr/bin/pgrep', '-f', pat],
capture_output=True, timeout=5,
)
if r.returncode == 0:
info['is_running'] = True
info['status'] = 'running'
break
except Exception:
pass
# 读取日志文件(支持多日志源,选择最新的)
log_candidates = [task.get('log_file')]
log_candidates.extend(task.get('extra_log_files', []))
log_candidates = [lf for lf in log_candidates if lf and os.path.exists(lf)]
# 选择最新修改的日志文件
log_file = None
if log_candidates:
log_file = max(log_candidates, key=lambda f: os.path.getmtime(f))
if log_file:
try:
stat = os.stat(log_file)
file_size = stat.st_size
info['log_size'] = file_size
# 读取最后8000字节(确保能覆盖完成标记和汇总信息)
with open(log_file, 'r', errors='replace') as f:
if file_size > 8000:
f.seek(file_size - 8000)
f.readline() # 跳过可能的不完整行
lines = f.readlines()
# 获取最后执行的日志
info['last_log'] = ''.join(lines[-15:]).strip()
# 解析最后执行时间和状态
for line in reversed(lines):
# 匹配 "2026-02-26 19:17:33" 格式的时间戳
m = re.search(r'(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})', line)
if m and not info['last_run']:
info['last_run'] = m.group(1)
# 解析状态 (优先检查退出码和成功标记)
if info['status'] == 'unknown' or info['status'] == 'running':
low = line.lower()
stripped = line.strip()
# 1. 最高优先: 明确的退出码
exit_m = re.search(r'退出码:\s*(\d+)', line)
if exit_m:
exit_code = int(exit_m.group(1))
if exit_code == 0 and not info['is_running']:
info['status'] = 'success'
elif exit_code != 0 and not info['is_running']:
info['status'] = 'error'
# 2. 成功标记(扫描完成、同步完成等)
elif ('' in line and ('完成' in line or '成功' in line or 'success' in low)):
if not info['is_running']:
info['status'] = 'success'
elif re.search(r'(扫描|同步|采集)(完成|成功)', line) and 'Traceback' not in line:
if not info['is_running']:
info['status'] = 'success'
# 3. 致命错误(Traceback、连接失败等,排除统计行中的"失败")
elif 'Traceback' in line or 'psycopg2.' in line:
if not info['is_running']:
info['status'] = 'error'
elif 'fe_sendauth' in line or ('password' in low and 'connection' in low):
info['status'] = 'error'
info['error_msg'] = '数据库连接失败'
# 4. 只有行首是错误标记才认为是真正的错误(排除"失败: 2只"这类统计行)
elif stripped.startswith('') or (stripped.startswith('失败') and '' not in line):
if not info['is_running']:
info['status'] = 'error'
if info['last_run'] and info['status'] != 'unknown':
break
if info['status'] == 'unknown' and not info['is_running']:
info['status'] = 'idle'
except Exception as e:
info['last_log'] = f'读取日志失败: {str(e)}'
elif not log_file:
pass
else:
info['status'] = 'no_log'
info['last_log'] = '日志文件不存在'
# 读取 crontab
result.append(info)
crontab_content = ''
try:
r = subprocess.run(['crontab', '-l'], capture_output=True, text=True, timeout=5)
if r.returncode == 0:
crontab_content = r.stdout.strip()
# 也检查 root crontab
r2 = subprocess.run(['sudo', 'crontab', '-l'], capture_output=True, text=True, timeout=5)
if r2.returncode == 0 and r2.stdout.strip():
crontab_content = r2.stdout.strip()
except Exception:
pass
return jsonify({
'success': True,
'tasks': result,
'crontab': crontab_content,
})
@bp.route('/api/admin/scheduled_tasks/<task_id>/log', methods=['GET'])
@admin_required
def scheduled_task_log(task_id):
"""获取指定任务的完整日志"""
log_map = {
'kline_scan': '/opt/stock-app/kline_scan.log',
'kline_5min': '/opt/stock-app/sync_kline_5min.log',
'fund_flow': '/opt/stock-app/sync_fund_flow.log',
'vacuum': '/opt/stock-app/cleanup.log',
'stock_data_service': '/opt/stock-app/stock_data_service.log',
}
log_file = log_map.get(task_id)
if not log_file:
return jsonify({'success': False, 'error': '无效的任务ID'}), 404
if not os.path.exists(log_file):
return jsonify({'success': True, 'log': '日志文件不存在', 'lines': 0})
try:
lines_param = request.args.get('lines', 100, type=int)
lines_param = min(lines_param, 500)
with open(log_file, 'r', errors='replace') as f:
all_lines = f.readlines()
total_lines = len(all_lines)
content = ''.join(all_lines[-lines_param:])
return jsonify({
'success': True,
'log': content,
'total_lines': total_lines,
'showing': min(lines_param, total_lines),
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/api/admin/trigger_task/<task_id>', methods=['POST'])
@admin_required
def trigger_task(task_id):
"""手动触发指定定时任务"""
import threading
TASK_COMMANDS = {
'kline_scan': {
'cmd': ['/bin/bash', '/opt/stock-app/auto_kline_then_scan.sh'],
'log': '/opt/stock-app/kline_scan.log',
'name': 'K线同步+全景扫描',
},
'kline_5min': {
'cmd': ['/bin/bash', '/opt/stock-app/auto_sync_kline_5min.sh'],
'log': '/opt/stock-app/sync_kline_5min.log',
'name': '5分钟K线采集',
},
'fund_flow': {
'cmd': ['/bin/bash', '/opt/stock-app/auto_sync_fund_flow.sh'],
'log': '/opt/stock-app/sync_fund_flow.log',
'name': '资金流向计算',
},
'vacuum': {
'cmd': ['/opt/stock-app/venv/bin/python', '/opt/stock-app/cleanup_data.py'],
'log': '/opt/stock-app/cleanup.log',
'name': '数据库维护(VACUUM)',
'env_extra': {'DB_PASSWORD': 'stock_password_2025'},
},
}
task_cfg = TASK_COMMANDS.get(task_id)
if not task_cfg:
return jsonify({'success': False, 'error': f'不支持手动触发的任务: {task_id}'}), 400
# 检查是否已在运行
script_base = task_cfg['cmd'][-1].split('/')[-1].replace('.sh', '').replace('.py', '')
try:
r = subprocess.run(['/usr/bin/pgrep', '-f', script_base], capture_output=True, timeout=5)
if r.returncode == 0:
return jsonify({'success': False, 'error': f'{task_cfg["name"]} 正在运行中,请等待完成'}), 409
except Exception:
pass
def _run_task():
env = dict(os.environ)
env['DB_PASSWORD'] = env.get('DB_PASSWORD', 'stock_password_2025')
if 'env_extra' in task_cfg:
env.update(task_cfg['env_extra'])
log_file = task_cfg['log']
with open(log_file, 'a') as lf:
lf.write(f"\n{'='*50}\n")
lf.write(f"手动触发 by admin ({__import__('datetime').datetime.now().strftime('%Y-%m-%d %H:%M:%S')})\n")
lf.write(f"{'='*50}\n")
lf.flush()
subprocess.run(
task_cfg['cmd'],
stdout=lf, stderr=lf,
env=env,
cwd='/opt/stock-app',
)
t = threading.Thread(target=_run_task, daemon=True)
t.start()
return jsonify({'success': True, 'message': f'{task_cfg["name"]} 已启动'})
@bp.route('/api/admin/kline_5min_progress', methods=['GET'])
@admin_required
def kline_5min_progress():
"""获取5分钟K线采集实时进度(从日志文件解析)"""
import re
log_path = '/opt/stock-app/sync_kline_5min.log'
progress = {}
# 检查进程是否在运行
is_running = False
try:
r = subprocess.run(['/usr/bin/pgrep', '-f', 'sync_kline_5min'], capture_output=True, timeout=5)
is_running = (r.returncode == 0)
except Exception:
pass
if not os.path.exists(log_path):
return jsonify({'success': True, 'is_running': is_running, 'progress': progress})
try:
stat = os.stat(log_path)
# 读取最后8KB
with open(log_path, 'r', errors='replace') as f:
if stat.st_size > 8192:
f.seek(stat.st_size - 8192)
f.readline()
lines = f.readlines()
# 解析进度行: [ 5.1%] 300/5810 | 3.3只/秒 | 剩余 27.8分钟 | ✅200 ❌5 ⏭️95 | 已保存 15000条
for line in reversed(lines):
m = re.search(
r'\[\s*([\d.]+)%\]\s+(\d+)/(\d+)\s+\|.*?([\d.]+)只/秒.*?剩余\s*([\d.]+)分钟',
line,
)
if m:
progress = {
'percent': float(m.group(1)),
'done': int(m.group(2)),
'total': int(m.group(3)),
'speed': float(m.group(4)),
'remaining_min': float(m.group(5)),
}
# 解析成功/失败/跳过
m2 = re.search(r'✅(\d+)\s*❌(\d+)\s*⏭️(\d+)', line)
if m2:
progress['success'] = int(m2.group(1))
progress['failed'] = int(m2.group(2))
progress['skipped'] = int(m2.group(3))
# 解析已保存条数
m3 = re.search(r'已保存\s*(\d+)条', line)
if m3:
progress['saved_rows'] = int(m3.group(1))
break
# 检查是否已完成(日志尾部的总结行)
if not is_running and progress:
for line in reversed(lines[-20:]):
if '✅ 完成' in line or '⏹️ 中断' in line:
progress['completed'] = True
break
m_done = re.search(r'成功:\s*(\d+)\s*\|\s*失败:\s*(\d+)\s*\|\s*跳过:\s*(\d+)', line)
if m_done:
progress['completed'] = True
progress['success'] = int(m_done.group(1))
progress['failed'] = int(m_done.group(2))
progress['skipped'] = int(m_done.group(3))
m_elapsed = re.search(r'耗时:\s*([\d.]+)\s*分钟', line)
if m_elapsed:
progress['elapsed_min'] = float(m_elapsed.group(1))
# 取最后3行日志供展示
log_tail = ''.join(lines[-3:]).strip()
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
return jsonify({
'success': True,
'is_running': is_running,
'progress': progress,
'log_tail': log_tail,
})
@bp.route('/api/admin/scan_status', methods=['GET'])
@admin_required
def admin_scan_status():
"""管理员查询扫描进度"""
try:
from datetime import datetime
conn = get_db()
if not conn:
return jsonify({'success': False, 'error': '数据库连接失败'}), 500
cur = conn.cursor()
scan_date = datetime.now().strftime('%Y-%m-%d')
cur.execute(
"SELECT count(*) FROM stock_signal_scan WHERE scan_date = %s",
(scan_date,),
)
scanned = cur.fetchone()[0]
cur.execute("SELECT count(*) FROM stock_realtime_price")
total = cur.fetchone()[0]
cur.execute(
"SELECT count(*) FROM stock_signal_scan WHERE scan_date = %s AND triggered_count > 0",
(scan_date,),
)
triggered = cur.fetchone()[0]
cur.close()
is_running = _is_scan_running()
# 从日志中解析实时进度(速度、剩余时间等)
scan_progress = {}
try:
import re
log_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'scan.log',
)
if os.path.exists(log_path):
with open(log_path, 'r') as f:
lines = f.readlines()
# 解析进度行: [ 83.5%] 4872/5810 | 速度: 28.5只/秒 | 剩余: 0.5分钟 | 触发: 1312 | 失败: 838
for line in reversed(lines):
m = re.search(
r'\[\s*([\d.]+)%\]\s+(\d+)/(\d+)\s+\|.*?速度:\s*([\d.]+).*?\|.*?剩余:\s*([\d.]+).*?\|.*?触发:\s*(\d+).*?\|.*?失败:\s*(\d+)',
line,
)
if m:
scan_progress = {
'percent': float(m.group(1)),
'done': int(m.group(2)),
'total': int(m.group(3)),
'speed': float(m.group(4)),
'remaining_min': float(m.group(5)),
'triggered': int(m.group(6)),
'failed': int(m.group(7)),
}
break
except Exception:
pass
return jsonify({
'success': True,
'scan_date': scan_date,
'total': total,
'scanned': scanned,
'triggered': triggered,
'progress': round(scanned / total * 100, 1) if total > 0 else 0,
'is_complete': scanned >= total and not is_running,
'is_running': is_running,
'scan_progress': scan_progress,
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
finally:
put_db(conn)