2.0.0 Alpha: Data Refinery

This commit is contained in:
2026-08-08 21:31:56 +08:00
parent fa75081d4d
commit 562775e5db
48 changed files with 4172 additions and 661 deletions
+111 -22
View File
@@ -1,16 +1,17 @@
from flask import Blueprint, render_template, request, redirect, url_for, session, flash
from flask import Blueprint, render_template, request, redirect, url_for, session, flash, jsonify
from web.config import Config
from web.auth import admin_required
from web.database import query_db
import os
from web.services.etl_service import EtlService
import hmac
bp = Blueprint('admin', __name__, url_prefix='/admin')
@bp.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
token = request.form.get('token')
if token == Config.ADMIN_TOKEN:
token = request.form.get('token') or ''
if hmac.compare_digest(token, Config.ADMIN_TOKEN):
session['is_admin'] = True
return redirect(url_for('admin.dashboard'))
else:
@@ -27,19 +28,104 @@ def logout():
def dashboard():
return render_template('admin/dashboard.html')
from web.services.etl_service import EtlService
@bp.route('/data-integrity')
@admin_required
def data_integrity():
from web.services.integrity_service import IntegrityService
report = IntegrityService.build_report()
if request.args.get('format') == 'json':
return jsonify(report)
return render_template('admin/data_integrity.html', report=report)
@bp.route('/trigger_etl', methods=['POST'])
@admin_required
def trigger_etl():
script_name = request.form.get('script')
allowed = ['L1A.py', 'L2_Builder.py', 'L3_Builder.py']
if script_name not in allowed:
return "Invalid script", 400
success, message = EtlService.run_script(script_name)
status_code = 200 if success else 500
return message, status_code
from database.job_store import JobStore
store = JobStore(Config.DB_WEB_PATH)
job_id = store.create_job(
'manual_pipeline',
created_by='admin',
)
try:
pid = EtlService.start_pipeline(job_id)
except Exception as exc:
store.finish_job(job_id, False, str(exc), 0)
return jsonify({'success': False, 'error': str(exc)}), 500
return jsonify({'success': True, 'job_id': job_id, 'pid': pid}), 202
@bp.route('/import-match', methods=['GET', 'POST'])
@admin_required
def import_match():
from database.job_store import JobStore
from web.services.import_service import (
DuplicateMatchError,
ImportValidationError,
MatchImportService,
)
store = JobStore(Config.DB_WEB_PATH)
if request.method == 'POST':
upload = request.files.get('capture')
if not upload or not upload.filename:
flash('请选择 iframe_network.json 文件。', 'error')
return redirect(url_for('admin.import_match'))
prepared = None
try:
prepared = MatchImportService.prepare_import(
upload.read(),
upload.filename,
created_by='admin',
replace=request.form.get('replace') == '1',
)
EtlService.start_pipeline(
prepared['job_id'],
match_id=prepared['match_id'],
replace=prepared['replace'],
)
flash(
f"比赛 {prepared['match_id']} 已进入导入队列。",
'success',
)
return redirect(url_for(
'admin.import_match',
job_id=prepared['job_id'],
))
except (DuplicateMatchError, ImportValidationError) as exc:
flash(str(exc), 'warning')
except Exception as exc:
if prepared:
store.finish_job(
prepared['job_id'],
False,
f'Failed to start pipeline: {exc}',
0,
)
flash(f'启动导入失败:{exc}', 'error')
selected_job = None
selected_job_id = request.args.get('job_id', type=int)
if selected_job_id:
selected_job = store.get_job(selected_job_id)
return render_template(
'admin/import_match.html',
jobs=store.list_jobs(30),
selected_job=selected_job,
)
@bp.route('/api/jobs/<int:job_id>')
@admin_required
def api_job(job_id):
from database.job_store import JobStore
job = JobStore(Config.DB_WEB_PATH).get_job(job_id)
if not job:
return jsonify({'error': 'Job not found'}), 404
return jsonify(job)
@bp.route('/sql', methods=['GET', 'POST'])
@admin_required
@@ -50,18 +136,21 @@ def sql_runner():
db_name = "l2"
if request.method == 'POST':
query = request.form.get('query')
query = (request.form.get('query') or '').strip()
db_name = request.form.get('db_name', 'l2')
# Safety check
forbidden = ['DELETE', 'DROP', 'UPDATE', 'INSERT', 'ALTER', 'GRANT', 'REVOKE']
if any(x in query.upper() for x in forbidden):
error = "Only SELECT queries allowed in Web Runner."
statement = query.rstrip(';').strip()
if db_name not in {'l2', 'l3', 'web'}:
error = "Unknown database."
elif not statement.upper().startswith('SELECT '):
error = "Only SELECT queries are allowed."
elif ';' in statement:
error = "Only one SQL statement is allowed."
else:
try:
# Enforce limit if not present
if 'LIMIT' not in query.upper():
query += " LIMIT 50"
query = statement
if 'LIMIT' not in statement.upper():
query = f"{statement} LIMIT 50"
rows = query_db(db_name, query)
if rows:
+10 -12
View File
@@ -1,6 +1,5 @@
from flask import Blueprint, render_template, request, jsonify
from web.services.stats_service import StatsService
import time
bp = Blueprint('main', __name__)
@@ -18,18 +17,17 @@ def index():
return render_template('home/index.html', recent_matches=recent_matches, heatmap_data=heatmap_data, live_matches=live_matches)
from web.services.etl_service import EtlService
@bp.route('/parse_match', methods=['POST'])
def parse_match():
url = request.form.get('url')
if not url or '5eplay.com' not in url:
return jsonify({'success': False, 'message': 'Invalid 5EPlay URL'})
# Trigger L1A.py with URL argument
success, msg = EtlService.run_script('L1A.py', args=[url])
if success:
return jsonify({'success': True, 'message': 'Match parsing completed successfully!'})
else:
return jsonify({'success': False, 'message': f'Error: {msg}'})
return jsonify({'success': False, 'message': 'Invalid 5EPlay URL'}), 400
return jsonify({
'success': False,
'message': (
'URL downloader is not included in this repository. '
'Place iframe_network.json under output_arena/<match_id>/ '
'and run the L1/L2/L3 builders from Admin.'
),
}), 501
+3 -13
View File
@@ -33,19 +33,9 @@ def detail(match_id):
rounds = StatsService.get_match_rounds(match_id)
# --- Roster Identification ---
# Fetch active roster to identify "Our Team" players
from web.services.web_service import WebService
lineups = WebService.get_lineups()
# Assume we use the first/active lineup
active_roster_ids = []
if lineups:
try:
active_roster_ids = json.loads(lineups[0]['player_ids_json'])
except:
pass
# Mark roster players (Ensure strict string comparison)
from web.services.team_context_service import TeamContextService
active_roster_ids = TeamContextService.get_active_roster_ids()
roster_set = set(str(uid) for uid in active_roster_ids)
for p in players:
p['is_in_roster'] = str(p['steam_id_64']) in roster_set
+36 -44
View File
@@ -1,15 +1,16 @@
from flask import Blueprint, render_template, request, jsonify, redirect, url_for, flash, current_app, session
from web.services.stats_service import StatsService
from web.services.feature_service import FeatureService
from web.services.player_profile_service import PlayerProfileService
from web.services.web_service import WebService
from web.database import execute_db, query_db
from web.config import Config
from datetime import datetime
import os
import json
from werkzeug.utils import secure_filename
bp = Blueprint('players', __name__, url_prefix='/players')
ALLOWED_AVATAR_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.webp'}
@bp.route('/')
def index():
@@ -41,7 +42,12 @@ def detail(steam_id):
# Use steam_id as filename to ensure uniqueness per player
# Preserve extension
ext = os.path.splitext(file.filename)[1].lower()
if not ext: ext = '.jpg'
if (
ext not in ALLOWED_AVATAR_EXTENSIONS
or not (file.mimetype or '').startswith('image/')
):
flash('Avatar must be a JPG, PNG, or WebP image.', 'error')
return redirect(url_for('players.detail', steam_id=steam_id))
filename = f"{steam_id}{ext}"
upload_folder = os.path.join(current_app.root_path, 'static', 'avatars')
@@ -177,33 +183,9 @@ def detail(steam_id):
history_asc = StatsService.get_player_trend(steam_id, limit=1000)
history = history_asc[::-1] if history_asc else []
# Calculate Map Stats
map_stats = {}
for match in history:
m_name = match['map_name']
if m_name not in map_stats:
map_stats[m_name] = {'matches': 0, 'wins': 0, 'adr_sum': 0, 'rating_sum': 0}
map_stats[m_name]['matches'] += 1
if match['is_win']:
map_stats[m_name]['wins'] += 1
map_stats[m_name]['adr_sum'] += (match['adr'] or 0)
map_stats[m_name]['rating_sum'] += (match['rating'] or 0)
map_stats_list = []
for m_name, data in map_stats.items():
cnt = data['matches']
map_stats_list.append({
'map_name': m_name,
'matches': cnt,
'win_rate': data['wins'] / cnt,
'adr': data['adr_sum'] / cnt,
'rating': data['rating_sum'] / cnt
})
map_stats_list.sort(key=lambda x: x['matches'], reverse=True)
# --- New: Recent Performance Stats ---
# recent_stats = StatsService.get_recent_performance_stats(steam_id)
map_stats_list = PlayerProfileService.get_map_stats(steam_id)
period_stats = PlayerProfileService.get_period_stats(steam_id)
records = PlayerProfileService.get_records(steam_id)
return render_template('players/profile.html',
player=player,
@@ -213,6 +195,8 @@ def detail(steam_id):
history=history,
distribution=distribution,
map_stats=map_stats_list,
period_stats=period_stats,
records=records,
l2_stats=l2_stats,
side_stats=side_stats)
@@ -223,9 +207,13 @@ def like_comment(comment_id):
@bp.route('/<steam_id>/charts_data')
def charts_data(steam_id):
# ... (existing code) ...
# Trend Data
trends = StatsService.get_player_trend(steam_id, limit=1000)
period_key = request.args.get('period', 'last_20')
if period_key not in PlayerProfileService.PERIOD_KEYS:
period_key = 'last_20'
trends = PlayerProfileService.get_period_history(steam_id, period_key)
if not trends:
trends = StatsService.get_player_trend(steam_id, limit=20)
period_summary = PlayerProfileService.get_period(steam_id, period_key)
# Radar Data (Construct from features)
features = FeatureService.get_player_features(steam_id)
@@ -234,17 +222,11 @@ def charts_data(steam_id):
# Task 1: Strict Team Average Calculation
team_avg_radar = None
lineups = WebService.get_lineups()
if lineups:
target_lineup = None
try:
p_ids = [str(i) for i in json.loads(lineups[0].get("player_ids_json") or "[]")]
if str(steam_id) in p_ids:
target_lineup = p_ids
except:
target_lineup = None
if target_lineup:
from web.services.team_context_service import TeamContextService
active_roster_ids = TeamContextService.get_active_roster_ids()
target_lineup = active_roster_ids if str(steam_id) in active_roster_ids else None
if target_lineup:
# Calculate strict average for this lineup
team_sums = {
'score_aim': 0.0, 'score_defense': 0.0, 'score_utility': 0.0,
@@ -303,9 +285,19 @@ def charts_data(steam_id):
'trend': {'labels': trend_labels, 'values': trend_values},
'radar': radar_data,
'radar_dist': radar_dist,
'team_avg_radar': team_avg_radar
'team_avg_radar': team_avg_radar,
'period': period_summary,
})
@bp.route('/<steam_id>/period_stats')
def period_stats(steam_id):
period_key = request.args.get('period', 'last_20')
period = PlayerProfileService.get_period(steam_id, period_key)
if not period:
return jsonify({'error': 'Unknown period or player'}), 404
return jsonify(period)
# --- API for Comparison ---
@bp.route('/api/search')
def api_search():
+5 -7
View File
@@ -67,14 +67,12 @@ def api_search():
@bp.route('/api/roster', methods=['GET', 'POST'])
def api_roster():
# Assume single team mode, always operating on ID=1 or the first lineup
lineups = WebService.get_lineups()
if not lineups:
# Auto-create default team if none exists
target_team = WebService.get_active_lineup()
if not target_team:
WebService.save_lineup("My Team", "Default Roster", [])
lineups = WebService.get_lineups()
target_team = dict(lineups[0]) # Get the latest one
target_team = WebService.get_active_lineup()
target_team = dict(target_team)
if request.method == 'POST':
# Admin Check