c5df676af6
- 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
1355 lines
51 KiB
Python
1355 lines
51 KiB
Python
"""
|
||
管理后台 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
|
||
|
||
# 方法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:
|
||
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)
|