""" 管理后台 API 路由 """ from flask import Blueprint, request, jsonify, session, render_template from db import get_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: conn.close() 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: conn.close() 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: conn.close() # ========== 用户管理 ========== @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: conn.close() @bp.route('/api/admin/users/', 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: conn.close() @bp.route('/api/admin/users//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: conn.close() @bp.route('/api/admin/users//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: conn.close() @bp.route('/api/admin/users/', 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: conn.close() # ========== 用户数据管理 ========== @bp.route('/api/admin/users//watchlist/', 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: conn.close() @bp.route('/api/admin/users//trades/', 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: conn.close() # ========== 系统数据 ========== @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: conn.close() @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: conn.close() # ========== 全景扫描管理 ========== _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 # 方法2:pgrep 兜底 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 # 进程已结束,清理引用 # 方法2:pgrep 兜底(处理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: import psycopg2 from config import Config conn = psycopg2.connect( host=Config.DB_HOST, port=Config.DB_PORT, dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD, ) 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() conn.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 # ========== 定时任务监控 ========== @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//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/', 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 import psycopg2 from config import Config conn = psycopg2.connect( host=Config.DB_HOST, port=Config.DB_PORT, dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD, ) 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() conn.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