diff --git a/README.md b/README.md index 33c7ca2..aea5c0f 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,10 @@ YRTV 是面向固定 CS2 战队的私人数据站。它不是公共玩家排行 - 203 次职业纪录刷新事件 - 2 个战队赛季 - 111 个日/周/月/季/年度奖项 -- 31 项自动化测试通过 -- 39 项数据完整性检查通过 +- 19 条正面/负面/趣味数据发现 +- 36 枚地图与 ELO 分段勋章 +- 36 项自动化测试通过 +- 43 项数据完整性检查通过 数据规模会随导入变化,Admin 数据完整性中心显示的结果是运行时事实。 @@ -77,6 +79,16 @@ YRTV 是面向固定 CS2 战队的私人数据站。它不是公共玩家排行 - 个人主页展示荣誉数量和近期奖章 - 每次职业纪录刷新保留时间和对应比赛 +### 发现与勋章 + +- 独立 `/discover/` 发现模块 +- 高光、低谷和趣味数据同时展示 +- 爆种、尽力局、躺赢、低谷、稳定性、夜间比赛和连胜等发现 +- 每张达到门槛的地图评选金银铜 +- 每个达到门槛的 ELO 分段评选金银铜 +- 勋章最低 5 场,展示 Rating、K/D、ADR、胜率和样本数 +- 个人职业主页同步展示已获得勋章 + ### 数据运营 - Admin 上传 `iframe_network.json` @@ -85,6 +97,10 @@ YRTV 是面向固定 CS2 战队的私人数据站。它不是公共玩家排行 - 后台执行 L1 → L2 → L3 - 实时查看作业阶段、进度、日志和耗时 - 数据完整性中心与 JSON 报告 +- 独立运营总览、比赛导入、作业中心、数据质量、查询控制台和系统信息 +- 数据库对象目录与只读 SQL 执行耗时 +- L1/L2/L3/Web 文件状态和回滚快照可视化 +- 后台写操作使用 CSRF 防护 ## 快速开始 @@ -213,6 +229,8 @@ Flask services and player profiles - `dm_player_record_events` - `dm_team_season_stats` - `dm_player_awards` + - `dm_discovery_insights` + - `dm_performance_medals` ### Web:应用状态 diff --git a/database/L2/L2.db b/database/L2/L2.db index ed7a033..3e31336 100644 Binary files a/database/L2/L2.db and b/database/L2/L2.db differ diff --git a/database/L3/L3.db b/database/L3/L3.db index a0d2e32..8521f1f 100644 Binary files a/database/L3/L3.db and b/database/L3/L3.db differ diff --git a/database/L3/L3_Builder.py b/database/L3/L3_Builder.py index 9be460a..f89a1fa 100644 --- a/database/L3/L3_Builder.py +++ b/database/L3/L3_Builder.py @@ -335,6 +335,13 @@ def main(force_all: bool = False, workers: int = 1, create_backup: bool = True): processed_ids, ) logger.info("Narrative marts rebuilt: %s", narrative_counts) + from database.L3.processors.discovery_processor import DiscoveryProcessor + discovery_counts = DiscoveryProcessor.rebuild( + conn_l2, + conn_l3, + processed_ids, + ) + logger.info("Discovery marts rebuilt: %s", discovery_counts) quick_check = conn_l3.execute("PRAGMA quick_check").fetchone()[0] if quick_check != 'ok': diff --git a/database/L3/processors/discovery_processor.py b/database/L3/processors/discovery_processor.py new file mode 100644 index 0000000..b46357a --- /dev/null +++ b/database/L3/processors/discovery_processor.py @@ -0,0 +1,399 @@ +from collections import defaultdict +from datetime import datetime, timezone +import json +from statistics import pstdev + + +class DiscoveryProcessor: + ELO_SEGMENTS = ( + (0, 1200, '<1200', '<1200 ELO'), + (1200, 1400, '1200-1399', '1200-1399 ELO'), + (1400, 1600, '1400-1599', '1400-1599 ELO'), + (1600, 1800, '1600-1799', '1600-1799 ELO'), + (1800, 2000, '1800-1999', '1800-1999 ELO'), + (2000, float('inf'), '2000+', '2000+ ELO'), + ) + + MEDAL_TIERS = {1: 'gold', 2: 'silver', 3: 'bronze'} + + @staticmethod + def rebuild(conn_l2, conn_l3, roster_ids): + conn_l3.execute('DELETE FROM dm_discovery_insights') + conn_l3.execute('DELETE FROM dm_performance_medals') + rows = DiscoveryProcessor._load_rows(conn_l2, roster_ids) + insights = DiscoveryProcessor._build_insights(rows) + medals = DiscoveryProcessor._build_medals(rows) + DiscoveryProcessor._insert_insights(conn_l3, insights) + DiscoveryProcessor._insert_medals(conn_l3, medals) + return { + 'insights': len(insights), + 'medals': len(medals), + } + + @staticmethod + def _load_rows(conn_l2, roster_ids): + if not roster_ids: + return [] + placeholders = ','.join('?' for _ in roster_ids) + rows = conn_l2.execute( + f""" + SELECT + p.match_id, + p.steam_id_64, + p.rating, + p.kd_ratio, + p.adr, + p.kills, + p.deaths, + p.headshot_count, + p.first_kill, + p.first_death, + p.throw_harm, + p.flash_enemy, + p.is_win, + p.origin_elo, + m.map_name, + m.start_time + FROM fact_match_players p + JOIN fact_matches m ON m.match_id = p.match_id + WHERE p.steam_id_64 IN ({placeholders}) + ORDER BY m.start_time, p.match_id + """, + roster_ids, + ).fetchall() + return [dict(row) for row in rows] + + @staticmethod + def _elo_segment(value): + value = float(value or 0) + for minimum, maximum, key, label in DiscoveryProcessor.ELO_SEGMENTS: + if minimum <= value < maximum: + return key, label + return None, None + + @staticmethod + def _aggregate(rows): + matches = len(rows) + wins = sum(int(row['is_win'] or 0) for row in rows) + kills = sum(int(row['kills'] or 0) for row in rows) + deaths = sum(int(row['deaths'] or 0) for row in rows) + return { + 'matches': matches, + 'wins': wins, + 'win_rate': wins / matches if matches else 0, + 'avg_rating': ( + sum(float(row['rating'] or 0) for row in rows) / matches + if matches else 0 + ), + 'avg_kd': kills / deaths if deaths else float(kills), + 'avg_adr': ( + sum(float(row['adr'] or 0) for row in rows) / matches + if matches else 0 + ), + } + + @staticmethod + def _build_medals(rows): + dimensions = defaultdict(lambda: defaultdict(list)) + labels = {} + for row in rows: + steam_id = str(row['steam_id_64']) + map_name = row['map_name'] or 'Unknown' + dimensions[('map', map_name)][steam_id].append(row) + labels[('map', map_name)] = map_name + + segment_key, segment_label = DiscoveryProcessor._elo_segment( + row['origin_elo'] + ) + if segment_key: + dimensions[('elo', segment_key)][steam_id].append(row) + labels[('elo', segment_key)] = segment_label + + medals = [] + for (dimension_type, dimension_key), player_rows in dimensions.items(): + candidates = [] + for steam_id, matches in player_rows.items(): + if len(matches) < 5: + continue + stats = DiscoveryProcessor._aggregate(matches) + candidates.append((steam_id, stats)) + candidates.sort( + key=lambda item: ( + item[1]['avg_rating'], + item[1]['avg_adr'], + item[1]['matches'], + ), + reverse=True, + ) + for rank, (steam_id, stats) in enumerate(candidates[:3], 1): + medals.append({ + 'dimension_type': dimension_type, + 'dimension_key': dimension_key, + 'dimension_label': labels[(dimension_type, dimension_key)], + 'medal_rank': rank, + 'medal_tier': DiscoveryProcessor.MEDAL_TIERS[rank], + 'steam_id_64': steam_id, + **stats, + 'sample_reliable': int(stats['matches'] >= 10), + }) + return medals + + @staticmethod + def _streaks(rows): + win_best = loss_best = win_current = loss_current = 0 + for row in rows: + if row['is_win']: + win_current += 1 + loss_current = 0 + else: + loss_current += 1 + win_current = 0 + win_best = max(win_best, win_current) + loss_best = max(loss_best, loss_current) + return win_best, loss_best + + @staticmethod + def _player_stats(rows): + grouped = defaultdict(list) + for row in rows: + grouped[str(row['steam_id_64'])].append(row) + + result = {} + for steam_id, matches in grouped.items(): + ratings = [float(row['rating'] or 0) for row in matches] + night_matches = [ + row for row in matches + if datetime.fromtimestamp( + int(row['start_time']), + timezone.utc, + ).hour in {23, 0, 1, 2, 3, 4, 5} + ] + win_streak, loss_streak = DiscoveryProcessor._streaks(matches) + map_rows = defaultdict(list) + for row in matches: + map_rows[row['map_name'] or 'Unknown'].append(row) + eligible_maps = [ + (map_name, DiscoveryProcessor._aggregate(map_matches)) + for map_name, map_matches in map_rows.items() + if len(map_matches) >= 5 + ] + best_map = ( + max(eligible_maps, key=lambda item: item[1]['avg_rating']) + if eligible_maps else None + ) + result[steam_id] = { + 'matches': len(matches), + 'carry_losses': sum( + 1 for row in matches + if not row['is_win'] and float(row['rating'] or 0) >= 1.2 + ), + 'monster_games': sum( + 1 for row in matches + if float(row['rating'] or 0) >= 1.5 + ), + 'rough_games': sum( + 1 for row in matches + if float(row['rating'] or 0) < 0.7 + ), + 'lucky_wins': sum( + 1 for row in matches + if row['is_win'] and float(row['rating'] or 0) < 0.8 + ), + 'opening_balance': sum( + int(row['first_kill'] or 0) + - int(row['first_death'] or 0) + for row in matches + ), + 'rating_volatility': pstdev(ratings) if len(ratings) > 1 else 0, + 'night_matches': len(night_matches), + 'night_share': len(night_matches) / len(matches), + 'win_streak': win_streak, + 'loss_streak': loss_streak, + 'best_map': best_map, + 'best_game': max( + matches, + key=lambda row: float(row['rating'] or 0), + ), + } + return result + + @staticmethod + def _insight( + key, + steam_id, + tone, + category, + title, + description, + metric_label, + metric_value, + metric_unit='', + match_id=None, + evidence=None, + order=0, + ): + return { + 'insight_key': key, + 'steam_id_64': steam_id, + 'tone': tone, + 'category': category, + 'title': title, + 'description': description, + 'metric_label': metric_label, + 'metric_value': float(metric_value), + 'metric_unit': metric_unit, + 'match_id': match_id, + 'evidence_json': json.dumps(evidence or {}, ensure_ascii=False), + 'display_order': order, + } + + @staticmethod + def _build_insights(rows): + stats = DiscoveryProcessor._player_stats(rows) + if not stats: + return [] + insights = [] + + definitions = ( + ( + 'monster_games', max, 'positive', '爆发', + '爆种制造机', 'Rating ≥ 1.50 的比赛次数全队最多。', + '爆种局', '场', 10, + ), + ( + 'carry_losses', max, 'positive', '抗压', + '逆风尽力王', '失利时 Rating ≥ 1.20 的比赛次数全队最多。', + '尽力局', '场', 20, + ), + ( + 'opening_balance', max, 'positive', '突破', + '开门红专家', '生涯首杀减首死净值全队最高。', + 'FK-FD', '', 30, + ), + ( + 'rough_games', max, 'negative', '低谷', + '低谷收藏家', 'Rating < 0.70 的比赛次数全队最多。', + '低迷局', '场', 40, + ), + ( + 'lucky_wins', max, 'fun', '趣味', + '躺赢许可证', '赢球但个人 Rating < 0.80 的次数全队最多。', + '幸运胜场', '场', 50, + ), + ( + 'rating_volatility', max, 'fun', '稳定性', + '过山车选手', 'Rating 标准差全队最高,状态最有悬念。', + '波动', '', 60, + ), + ( + 'rating_volatility', min, 'positive', '稳定性', + '定海神针', 'Rating 标准差全队最低,发挥最稳定。', + '波动', '', 70, + ), + ( + 'night_share', max, 'fun', '时段', + '夜猫子', '23:00-05:59 UTC 比赛占比全队最高。', + '夜间占比', '%', 80, + ), + ( + 'win_streak', max, 'positive', '连胜', + '连胜发动机', '个人最长连续胜场全队最高。', + '最长连胜', '场', 90, + ), + ( + 'loss_streak', max, 'negative', '连败', + '逆境耐受测试', '个人最长连续败场全队最高。', + '最长连败', '场', 100, + ), + ) + + for metric, selector, tone, category, title, description, label, unit, order in definitions: + steam_id, player = selector( + stats.items(), + key=lambda item: item[1][metric], + ) + value = player[metric] + display_value = value * 100 if unit == '%' else value + if display_value <= 0: + continue + insights.append(DiscoveryProcessor._insight( + f'global:{metric}:{selector.__name__}', + steam_id, + tone, + category, + title, + description, + label, + display_value, + unit, + evidence={'matches': player['matches']}, + order=order, + )) + + for index, (steam_id, player) in enumerate(stats.items(), 1): + if not player['best_map']: + continue + map_name, map_stats = player['best_map'] + insights.append(DiscoveryProcessor._insight( + f'player:{steam_id}:map_specialist', + steam_id, + 'positive', + '地图', + f'{map_name} 地头蛇', + '个人至少 5 场地图中,平均 Rating 最高的一张。', + '地图 Rating', + map_stats['avg_rating'], + '', + evidence={ + 'map': map_name, + 'matches': map_stats['matches'], + 'win_rate': map_stats['win_rate'], + }, + order=120 + index, + )) + return insights + + @staticmethod + def _insert_insights(conn_l3, rows): + conn_l3.executemany( + """ + INSERT INTO dm_discovery_insights ( + insight_key, steam_id_64, tone, category, title, + description, metric_label, metric_value, metric_unit, + match_id, evidence_json, display_order + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + [ + ( + row['insight_key'], row['steam_id_64'], row['tone'], + row['category'], row['title'], row['description'], + row['metric_label'], row['metric_value'], + row['metric_unit'], row['match_id'], + row['evidence_json'], row['display_order'], + ) + for row in rows + ], + ) + + @staticmethod + def _insert_medals(conn_l3, rows): + conn_l3.executemany( + """ + INSERT INTO dm_performance_medals ( + dimension_type, dimension_key, dimension_label, + medal_rank, medal_tier, steam_id_64, matches, wins, + win_rate, avg_rating, avg_kd, avg_adr, sample_reliable + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + [ + ( + row['dimension_type'], row['dimension_key'], + row['dimension_label'], row['medal_rank'], + row['medal_tier'], row['steam_id_64'], row['matches'], + row['wins'], row['win_rate'], row['avg_rating'], + row['avg_kd'], row['avg_adr'], row['sample_reliable'], + ) + for row in rows + ], + ) + diff --git a/database/L3/schema.sql b/database/L3/schema.sql index 8b1c572..c430a45 100644 --- a/database/L3/schema.sql +++ b/database/L3/schema.sql @@ -587,6 +587,61 @@ ON dm_player_awards(steam_id_64, period_start DESC); CREATE INDEX IF NOT EXISTS idx_player_awards_period ON dm_player_awards(award_type, period_start DESC); +-- ============================================================================ +-- Discovery Mart: Data-driven fun facts, both positive and negative +-- ============================================================================ +CREATE TABLE IF NOT EXISTS dm_discovery_insights ( + insight_key TEXT PRIMARY KEY, + steam_id_64 TEXT, + tone TEXT NOT NULL + CHECK (tone IN ('positive', 'negative', 'fun')), + category TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL, + metric_label TEXT, + metric_value REAL, + metric_unit TEXT, + match_id TEXT, + evidence_json TEXT NOT NULL DEFAULT '{}', + display_order INTEGER NOT NULL DEFAULT 0, + last_updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_discovery_tone_order +ON dm_discovery_insights(tone, display_order, insight_key); + +CREATE INDEX IF NOT EXISTS idx_discovery_player +ON dm_discovery_insights(steam_id_64, tone); + +-- ============================================================================ +-- Medal Mart: Gold/silver/bronze by map and ELO segment +-- ============================================================================ +CREATE TABLE IF NOT EXISTS dm_performance_medals ( + dimension_type TEXT NOT NULL + CHECK (dimension_type IN ('map', 'elo')), + dimension_key TEXT NOT NULL, + dimension_label TEXT NOT NULL, + medal_rank INTEGER NOT NULL CHECK (medal_rank BETWEEN 1 AND 3), + medal_tier TEXT NOT NULL + CHECK (medal_tier IN ('gold', 'silver', 'bronze')), + steam_id_64 TEXT NOT NULL, + matches INTEGER NOT NULL, + wins INTEGER NOT NULL, + win_rate REAL, + avg_rating REAL, + avg_kd REAL, + avg_adr REAL, + sample_reliable BOOLEAN NOT NULL DEFAULT 0, + last_updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (dimension_type, dimension_key, medal_rank) +); + +CREATE INDEX IF NOT EXISTS idx_performance_medals_player +ON dm_performance_medals(steam_id_64, dimension_type, medal_rank); + +CREATE INDEX IF NOT EXISTS idx_performance_medals_dimension +ON dm_performance_medals(dimension_type, dimension_key, medal_rank); + -- ============================================================================ -- Schema Summary -- ============================================================================ @@ -609,4 +664,6 @@ ON dm_player_awards(award_type, period_start DESC); -- dm_player_record_events: Historical record-breaking moments -- dm_team_season_stats: Calendar season team summaries -- dm_player_awards: Daily/weekly/monthly/quarterly/yearly awards +-- dm_discovery_insights: Positive, negative and quirky data discoveries +-- dm_performance_medals: Map and ELO-segment gold/silver/bronze medals -- ============================================================================ diff --git a/database/Web/Web_App.sqlite b/database/Web/Web_App.sqlite index abee742..dca395d 100644 Binary files a/database/Web/Web_App.sqlite and b/database/Web/Web_App.sqlite differ diff --git a/database/job_store.py b/database/job_store.py index 4203da4..f92b505 100644 --- a/database/job_store.py +++ b/database/job_store.py @@ -48,19 +48,63 @@ class JobStore: finally: db.close() - def list_jobs(self, limit: int = 20) -> List[Dict[str, Any]]: + def list_jobs( + self, + limit: int = 20, + status: Optional[str] = None, + job_type: Optional[str] = None, + offset: int = 0, + ) -> List[Dict[str, Any]]: + db = self._connect() + try: + where = [] + args = [] + if status: + where.append('status = ?') + args.append(status) + if job_type: + where.append('job_type = ?') + args.append(job_type) + where_sql = f"WHERE {' AND '.join(where)}" if where else '' + args.extend([ + max(1, min(int(limit), 100)), + max(0, int(offset)), + ]) + rows = db.execute( + f""" + SELECT * + FROM etl_jobs + {where_sql} + ORDER BY created_at DESC, id DESC + LIMIT ? OFFSET ? + """, + args, + ).fetchall() + return [dict(row) for row in rows] + finally: + db.close() + + def get_summary(self) -> Dict[str, int]: db = self._connect() try: rows = db.execute( """ - SELECT * + SELECT status, COUNT(*) AS count FROM etl_jobs - ORDER BY created_at DESC, id DESC - LIMIT ? - """, - [max(1, min(int(limit), 100))], + GROUP BY status + """ ).fetchall() - return [dict(row) for row in rows] + summary = { + 'total': 0, + 'queued': 0, + 'running': 0, + 'succeeded': 0, + 'failed': 0, + } + for row in rows: + summary[row['status']] = int(row['count']) + summary['total'] += int(row['count']) + return summary finally: db.close() @@ -195,4 +239,3 @@ class JobStore: db.commit() finally: db.close() - diff --git a/tests/test_integration.py b/tests/test_integration.py index 4016372..d9ceb77 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -46,6 +46,7 @@ class ApplicationIntegrationTests(unittest.TestCase): '/matches/', '/reports/', '/awards/', + '/discover/', '/players/', f'/players/{self.roster_ids[0]}', '/teams/', @@ -59,6 +60,35 @@ class ApplicationIntegrationTests(unittest.TestCase): self.assertEqual(response.status_code, 200) self.assertGreater(len(response.data), 100) + def test_discovery_filters_and_player_medals_render(self): + response = self.client.get('/discover/') + self.assertEqual(response.status_code, 200) + for label in ('趣味发现', '表现勋章墙', '勋章总榜'): + self.assertIn(label.encode('utf-8'), response.data) + + response = self.client.get('/discover/?tone=negative') + self.assertEqual(response.status_code, 200) + self.assertIn('低谷收藏家'.encode('utf-8'), response.data) + + response = self.client.get('/discover/?dimension=elo') + self.assertEqual(response.status_code, 200) + self.assertIn(b'ELO Medal', response.data) + + l3 = sqlite3.connect(Config.DB_L3_PATH) + try: + medal_player = l3.execute( + """ + SELECT steam_id_64 + FROM dm_performance_medals + ORDER BY medal_rank + LIMIT 1 + """ + ).fetchone()[0] + finally: + l3.close() + response = self.client.get(f'/players/{medal_player}') + self.assertIn(b'Map & ELO Medals', response.data) + def test_match_detail_contains_post_match_report(self): l3 = sqlite3.connect(Config.DB_L3_PATH) try: @@ -103,10 +133,11 @@ class ApplicationIntegrationTests(unittest.TestCase): def test_admin_integrity_page_and_json_render(self): with self.client.session_transaction() as session: session['is_admin'] = True + session['_csrf_token'] = 'test-csrf' response = self.client.get('/admin/data-integrity') self.assertEqual(response.status_code, 200) - self.assertIn('数据完整性中心'.encode('utf-8'), response.data) + self.assertIn('数据质量中心'.encode('utf-8'), response.data) response = self.client.get('/admin/data-integrity?format=json') self.assertEqual(response.status_code, 200) @@ -120,13 +151,66 @@ class ApplicationIntegrationTests(unittest.TestCase): response = self.client.get('/admin/import-match') self.assertEqual(response.status_code, 200) - self.assertIn('比赛数据导入'.encode('utf-8'), response.data) + self.assertIn('比赛导入'.encode('utf-8'), response.data) + + def test_admin_console_pages_share_navigation_and_real_data(self): + with self.client.session_transaction() as session: + session['is_admin'] = True + session['_csrf_token'] = 'test-csrf' + pages = { + '/admin/': ('运营总览', '完整数据流水线'), + '/admin/jobs': ('作业中心', '执行记录'), + '/admin/sql': ('只读查询控制台', 'Database Objects'), + '/admin/system': ('系统与存储', '回滚快照'), + } + for path, labels in pages.items(): + with self.subTest(path=path): + response = self.client.get(path) + self.assertEqual(response.status_code, 200) + self.assertIn('YRTV'.encode('utf-8'), response.data) + for label in labels: + self.assertIn(label.encode('utf-8'), response.data) + + def test_admin_sql_is_read_only_and_csrf_protected(self): + with self.client.session_transaction() as session: + session['is_admin'] = True + session['_csrf_token'] = 'test-csrf' + + response = self.client.post( + '/admin/sql', + data={ + '_csrf_token': 'test-csrf', + 'db_name': 'l2', + 'query': 'SELECT COUNT(*) AS matches FROM fact_matches', + }, + ) + self.assertEqual(response.status_code, 200) + self.assertIn(b'matches', response.data) + self.assertIn(b'208', response.data) + + response = self.client.post( + '/admin/sql', + data={ + '_csrf_token': 'test-csrf', + 'db_name': 'l2', + 'query': 'DELETE FROM fact_matches', + }, + ) + self.assertEqual(response.status_code, 200) + self.assertIn(b'Only SELECT queries are allowed', response.data) + + response = self.client.post( + '/admin/trigger_etl', + headers={'Accept': 'application/json'}, + ) + self.assertEqual(response.status_code, 400) def test_duplicate_match_upload_is_rejected_without_starting_job(self): from database.paths import L1_DB with self.client.session_transaction() as session: session['is_admin'] = True + session['_csrf_token'] = 'test-csrf' with sqlite3.connect(str(L1_DB)) as db: raw = db.execute( """ @@ -142,6 +226,7 @@ class ApplicationIntegrationTests(unittest.TestCase): response = self.client.post( '/admin/import-match', data={ + '_csrf_token': 'test-csrf', 'capture': ( upload, 'iframe_network.json', @@ -178,6 +263,8 @@ class ApplicationIntegrationTests(unittest.TestCase): ): with self.subTest(label=label): self.assertIn(label.encode('utf-8'), response.data) + self.assertIn(b'x-data="{ selected: \'last_20\' }"', response.data) + self.assertNotIn(b'periods: [{', response.data) def test_profile_period_api_and_trend_window(self): steam_id = self.roster_ids[0] @@ -699,6 +786,80 @@ class DatabaseGovernanceTests(unittest.TestCase): self.assertNotIn((award_type, period_key), seen) seen.add((award_type, period_key)) + def test_discovery_tones_and_medal_rules(self): + l3 = sqlite3.connect(Config.DB_L3_PATH) + try: + tones = { + row[0] for row in l3.execute( + 'SELECT DISTINCT tone FROM dm_discovery_insights' + ) + } + medals = l3.execute( + """ + SELECT dimension_type, dimension_key, medal_rank, + medal_tier, matches + FROM dm_performance_medals + """ + ).fetchall() + finally: + l3.close() + + self.assertEqual(tones, {'positive', 'negative', 'fun'}) + self.assertGreater(len(medals), 0) + seen = set() + expected_tiers = {1: 'gold', 2: 'silver', 3: 'bronze'} + for dimension_type, dimension_key, rank, tier, matches in medals: + key = (dimension_type, dimension_key, rank) + self.assertNotIn(key, seen) + seen.add(key) + self.assertIn(dimension_type, {'map', 'elo'}) + self.assertEqual(tier, expected_tiers[rank]) + self.assertGreaterEqual(matches, 5) + + def test_map_gold_medal_matches_highest_eligible_rating(self): + l3 = sqlite3.connect(Config.DB_L3_PATH) + l2 = sqlite3.connect(Config.DB_L2_PATH) + web = sqlite3.connect(Config.DB_WEB_PATH) + try: + medal = l3.execute( + """ + SELECT dimension_key, steam_id_64, avg_rating + FROM dm_performance_medals + WHERE dimension_type = 'map' AND medal_rank = 1 + ORDER BY dimension_key + LIMIT 1 + """ + ).fetchone() + roster_ids = json.loads( + web.execute( + """ + SELECT player_ids_json FROM team_lineups + WHERE is_active = 1 + """ + ).fetchone()[0] + ) + placeholders = ','.join('?' for _ in roster_ids) + expected = l2.execute( + f""" + SELECT p.steam_id_64, AVG(p.rating) AS avg_rating + FROM fact_match_players p + JOIN fact_matches m ON m.match_id = p.match_id + WHERE p.steam_id_64 IN ({placeholders}) + AND m.map_name = ? + GROUP BY p.steam_id_64 + HAVING COUNT(*) >= 5 + ORDER BY avg_rating DESC, AVG(p.adr) DESC, COUNT(*) DESC + LIMIT 1 + """, + roster_ids + [medal[0]], + ).fetchone() + finally: + l3.close() + l2.close() + web.close() + self.assertEqual(medal[1], expected[0]) + self.assertAlmostEqual(medal[2], expected[1], places=8) + def test_seasons_only_include_team_matches(self): l2 = sqlite3.connect(Config.DB_L2_PATH) l3 = sqlite3.connect(Config.DB_L3_PATH) diff --git a/web/app.py b/web/app.py index 068e320..e845157 100644 --- a/web/app.py +++ b/web/app.py @@ -6,6 +6,7 @@ sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) from flask import Flask from web.config import Config from web.database import close_dbs, initialize_web_db +from web.auth import get_csrf_token def create_app(config_object=Config): @@ -16,10 +17,12 @@ def create_app(config_object=Config): from web.services.roster_version_service import RosterVersionService RosterVersionService.ensure_initial_version() app.teardown_appcontext(close_dbs) + app.jinja_env.globals['csrf_token'] = get_csrf_token from web.routes import ( admin, awards, + discover, main, matches, opponents, @@ -39,6 +42,7 @@ def create_app(config_object=Config): app.register_blueprint(opponents.bp) app.register_blueprint(reports.bp) app.register_blueprint(awards.bp) + app.register_blueprint(discover.bp) return app diff --git a/web/auth.py b/web/auth.py index f30a982..ea9d8b2 100644 --- a/web/auth.py +++ b/web/auth.py @@ -1,5 +1,8 @@ from functools import wraps -from flask import session, redirect, url_for, flash +import hmac +import secrets + +from flask import abort, flash, request, session, redirect, url_for def admin_required(f): @wraps(f) @@ -9,3 +12,32 @@ def admin_required(f): flash('Admin access required', 'warning') return redirect(url_for('admin.login')) return decorated_function + + +def get_csrf_token(): + token = session.get('_csrf_token') + if not token: + token = secrets.token_urlsafe(32) + session['_csrf_token'] = token + return token + + +def validate_csrf(): + expected = session.get('_csrf_token') + received = ( + request.headers.get('X-CSRF-Token') + or request.form.get('_csrf_token') + ) + if not expected or not received or not hmac.compare_digest( + str(expected), + str(received), + ): + abort(400, description='Invalid CSRF token') + + +def csrf_protected(f): + @wraps(f) + def decorated_function(*args, **kwargs): + validate_csrf() + return f(*args, **kwargs) + return decorated_function diff --git a/web/routes/admin.py b/web/routes/admin.py index e8edcf1..25507e7 100644 --- a/web/routes/admin.py +++ b/web/routes/admin.py @@ -1,15 +1,27 @@ -from flask import Blueprint, render_template, request, redirect, url_for, session, flash, jsonify +from flask import ( + Blueprint, + flash, + jsonify, + redirect, + render_template, + request, + session, + url_for, +) from web.config import Config -from web.auth import admin_required +from web.auth import admin_required, csrf_protected, validate_csrf from web.database import query_db +from web.services.admin_service import AdminService from web.services.etl_service import EtlService import hmac +import time bp = Blueprint('admin', __name__, url_prefix='/admin') @bp.route('/login', methods=['GET', 'POST']) def login(): if request.method == 'POST': + validate_csrf() token = request.form.get('token') or '' if hmac.compare_digest(token, Config.ADMIN_TOKEN): session['is_admin'] = True @@ -26,7 +38,10 @@ def logout(): @bp.route('/') @admin_required def dashboard(): - return render_template('admin/dashboard.html') + return render_template( + 'admin/dashboard.html', + **AdminService.get_overview(), + ) @bp.route('/data-integrity') @admin_required @@ -40,6 +55,7 @@ def data_integrity(): @bp.route('/trigger_etl', methods=['POST']) @admin_required +@csrf_protected def trigger_etl(): from database.job_store import JobStore @@ -68,6 +84,7 @@ def import_match(): store = JobStore(Config.DB_WEB_PATH) if request.method == 'POST': + validate_csrf() upload = request.files.get('capture') if not upload or not upload.filename: flash('请选择 iframe_network.json 文件。', 'error') @@ -91,7 +108,7 @@ def import_match(): 'success', ) return redirect(url_for( - 'admin.import_match', + 'admin.jobs', job_id=prepared['job_id'], )) except (DuplicateMatchError, ImportValidationError) as exc: @@ -106,14 +123,34 @@ def import_match(): ) 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, + jobs=store.list_jobs(5), + ) + + +@bp.route('/jobs') +@admin_required +def jobs(): + from database.job_store import JobStore + + status = request.args.get('status') or None + job_type = request.args.get('type') or None + selected_job_id = request.args.get('job_id', type=int) + data = AdminService.get_jobs(status, job_type) + data['selected_job'] = ( + JobStore(Config.DB_WEB_PATH).get_job(selected_job_id) + if selected_job_id else None + ) + return render_template('admin/jobs.html', **data) + + +@bp.route('/system') +@admin_required +def system(): + return render_template( + 'admin/system.html', + **AdminService.get_system_status(), ) @@ -133,9 +170,14 @@ def sql_runner(): result = None error = None query = "" - db_name = "l2" + db_name = request.args.get('db_name', 'l2') + if db_name not in {'l2', 'l3', 'web'}: + db_name = 'l2' + duration_ms = None + row_count = None if request.method == 'POST': + validate_csrf() query = (request.form.get('query') or '').strip() db_name = request.form.get('db_name', 'l2') @@ -151,8 +193,10 @@ def sql_runner(): query = statement if 'LIMIT' not in statement.upper(): query = f"{statement} LIMIT 50" - + started = time.perf_counter() rows = query_db(db_name, query) + duration_ms = (time.perf_counter() - started) * 1000 + row_count = len(rows) if rows: columns = rows[0].keys() result = {'columns': columns, 'rows': rows} @@ -160,5 +204,18 @@ def sql_runner(): result = {'columns': [], 'rows': []} except Exception as e: error = str(e) - - return render_template('admin/sql.html', result=result, error=error, query=query, db_name=db_name) + + try: + catalog = AdminService.get_database_catalog(db_name) + except ValueError: + catalog = [] + return render_template( + 'admin/sql.html', + result=result, + error=error, + query=query, + db_name=db_name, + catalog=catalog, + duration_ms=duration_ms, + row_count=row_count, + ) diff --git a/web/routes/discover.py b/web/routes/discover.py new file mode 100644 index 0000000..ac41d7f --- /dev/null +++ b/web/routes/discover.py @@ -0,0 +1,24 @@ +from flask import Blueprint, render_template, request + +from web.services.discovery_service import DiscoveryService + + +bp = Blueprint('discover', __name__, url_prefix='/discover') + + +@bp.route('/') +def index(): + tone = request.args.get('tone') + dimension = request.args.get('dimension') + if dimension not in {'map', 'elo'}: + dimension = None + return render_template( + 'discover/index.html', + insights=DiscoveryService.get_insights(tone), + medals=DiscoveryService.get_medals(dimension), + leaderboard=DiscoveryService.get_medal_leaderboard(), + tone=tone, + dimension=dimension, + tone_labels=DiscoveryService.TONE_LABELS, + ) + diff --git a/web/routes/players.py b/web/routes/players.py index 6d73281..c4aa026 100644 --- a/web/routes/players.py +++ b/web/routes/players.py @@ -3,6 +3,7 @@ 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.narrative_service import NarrativeService +from web.services.discovery_service import DiscoveryService from web.services.web_service import WebService from web.database import execute_db, query_db from web.config import Config @@ -205,6 +206,7 @@ def detail(steam_id): records=records, professional_identity=NarrativeService.get_player_identity(steam_id), honors=NarrativeService.get_player_honors(steam_id), + performance_medals=DiscoveryService.get_player_medals(steam_id), l2_stats=l2_stats, side_stats=side_stats) diff --git a/web/services/admin_service.py b/web/services/admin_service.py new file mode 100644 index 0000000..c858d36 --- /dev/null +++ b/web/services/admin_service.py @@ -0,0 +1,175 @@ +from datetime import datetime +import json +from pathlib import Path +import sqlite3 + +from database.job_store import JobStore +from database.maintenance import ( + backup_storage_status, + check_managed_databases, +) +from database.paths import BACKUP_ROOT +from web.config import Config +from web.database import query_db +from web.services.integrity_service import IntegrityService +from web.services.roster_version_service import RosterVersionService +from web.services.team_context_service import TeamContextService + + +class AdminService: + DATABASE_LABELS = { + 'l2': 'L2 Facts', + 'l3': 'L3 Marts', + 'web': 'Web App', + } + + @staticmethod + def _format_bytes(value): + value = float(value or 0) + for unit in ('B', 'KB', 'MB', 'GB'): + if value < 1024 or unit == 'GB': + return f'{value:.1f} {unit}' + value /= 1024 + + @staticmethod + def get_overview(): + integrity = IntegrityService.build_report() + store = JobStore(Config.DB_WEB_PATH) + latest_report = query_db( + 'l3', + """ + SELECT match_id, match_date, map_name, is_win, + team_avg_rating, summary_text + FROM dm_match_reports + ORDER BY match_date DESC + LIMIT 1 + """, + one=True, + ) + latest_match = dict(latest_report) if latest_report else None + if latest_match: + latest_match['date_text'] = datetime.fromtimestamp( + latest_match['match_date'] + ).strftime('%Y-%m-%d %H:%M') + + counts = integrity['counts'] + return { + 'integrity': integrity, + 'jobs': store.list_jobs(8), + 'job_summary': store.get_summary(), + 'latest_match': latest_match, + 'roster_size': len(TeamContextService.get_active_roster_ids()), + 'roster_versions': len(RosterVersionService.list_versions()), + 'metrics': { + 'matches': counts.get('matches', 0), + 'roster_players': counts.get('active_roster', 0), + 'reports': counts.get('l3_match_reports', 0), + 'awards': counts.get('l3_awards', 0), + 'backup_size': AdminService._format_bytes( + counts.get('backup_bytes', 0) + ), + }, + } + + @staticmethod + def get_jobs(status=None, job_type=None, limit=50): + store = JobStore(Config.DB_WEB_PATH) + return { + 'jobs': store.list_jobs( + limit=limit, + status=status, + job_type=job_type, + ), + 'summary': store.get_summary(), + 'status': status, + 'job_type': job_type, + } + + @staticmethod + def get_database_catalog(db_name): + if db_name not in {'l2', 'l3', 'web'}: + raise ValueError('Unknown database') + rows = query_db( + db_name, + """ + SELECT name, type + FROM sqlite_master + WHERE type IN ('table', 'view') + AND name NOT LIKE 'sqlite_%' + ORDER BY type, name + """, + ) + return [dict(row) for row in rows] + + @staticmethod + def get_system_status(): + managed = check_managed_databases() + web_status = { + 'path': Config.DB_WEB_PATH, + 'exists': Path(Config.DB_WEB_PATH).exists(), + 'size_bytes': ( + Path(Config.DB_WEB_PATH).stat().st_size + if Path(Config.DB_WEB_PATH).exists() else 0 + ), + } + databases = [] + for name, item in managed.items(): + entry = dict(item) + entry['name'] = name.upper() + entry['size_text'] = AdminService._format_bytes( + entry['size_bytes'] + ) + databases.append(entry) + web_status.update({ + 'name': 'WEB', + 'quick_check': 'managed by app', + 'size_text': AdminService._format_bytes(web_status['size_bytes']), + }) + databases.append(web_status) + + backup_dirs = sorted( + [path for path in BACKUP_ROOT.glob('*') if path.is_dir()], + key=lambda path: path.stat().st_mtime, + reverse=True, + ) + backups = [] + for directory in backup_dirs: + manifest_path = directory / 'manifest.json' + manifest = {} + if manifest_path.exists(): + try: + manifest = json.loads( + manifest_path.read_text(encoding='utf-8') + ) + except (OSError, json.JSONDecodeError): + manifest = {} + size = sum( + file.stat().st_size + for file in directory.rglob('*') + if file.is_file() + ) + backups.append({ + 'name': directory.name, + 'created_at': manifest.get('created_at'), + 'size_text': AdminService._format_bytes(size), + 'databases': manifest.get('databases', {}), + }) + + return { + 'databases': databases, + 'backups': backups, + 'backup_summary': backup_storage_status(), + 'config': { + 'web_schema_version': Config.WEB_SCHEMA_VERSION, + 'sqlite_timeout': Config.SQLITE_TIMEOUT_SECONDS, + 'slow_query_threshold': Config.SLOW_QUERY_THRESHOLD_SECONDS, + 'max_upload_mb': Config.MAX_CONTENT_LENGTH / 1024 / 1024, + 'secret_key_configured': ( + Config.SECRET_KEY != 'yrtv-dev-only-change-me' + ), + 'admin_token_configured': ( + Config.ADMIN_TOKEN != 'yrtv-admin-dev' + ), + }, + } + diff --git a/web/services/discovery_service.py b/web/services/discovery_service.py new file mode 100644 index 0000000..28b3ead --- /dev/null +++ b/web/services/discovery_service.py @@ -0,0 +1,163 @@ +import json + +from web.database import query_db + + +class DiscoveryService: + TONE_LABELS = { + 'positive': '高光', + 'negative': '低谷', + 'fun': '趣味', + } + MEDAL_LABELS = { + 'gold': '金牌', + 'silver': '银牌', + 'bronze': '铜牌', + } + + @staticmethod + def _identity_map(steam_ids): + steam_ids = sorted({str(value) for value in steam_ids if value}) + if not steam_ids: + return {} + placeholders = ','.join('?' for _ in steam_ids) + rows = query_db( + 'l2', + f""" + SELECT steam_id_64, username, avatar_url + FROM dim_players + WHERE steam_id_64 IN ({placeholders}) + """, + steam_ids, + ) + return {str(row['steam_id_64']): dict(row) for row in rows} + + @staticmethod + def get_insights(tone=None): + args = [] + where = '' + if tone in DiscoveryService.TONE_LABELS: + where = 'WHERE tone = ?' + args.append(tone) + rows = query_db( + 'l3', + f""" + SELECT * + FROM dm_discovery_insights + {where} + ORDER BY display_order, insight_key + """, + args, + ) + identities = DiscoveryService._identity_map( + [row['steam_id_64'] for row in rows] + ) + result = [] + for row in rows: + item = dict(row) + item['identity'] = identities.get(str(item['steam_id_64']), {}) + item['tone_label'] = DiscoveryService.TONE_LABELS.get( + item['tone'], + item['tone'], + ) + try: + item['evidence'] = json.loads(item['evidence_json'] or '{}') + except json.JSONDecodeError: + item['evidence'] = {} + result.append(item) + return result + + @staticmethod + def get_medals(dimension_type=None): + args = [] + where = '' + if dimension_type in {'map', 'elo'}: + where = 'WHERE dimension_type = ?' + args.append(dimension_type) + rows = query_db( + 'l3', + f""" + SELECT * + FROM dm_performance_medals + {where} + ORDER BY + CASE dimension_type WHEN 'map' THEN 1 ELSE 2 END, + dimension_key, + medal_rank + """, + args, + ) + identities = DiscoveryService._identity_map( + [row['steam_id_64'] for row in rows] + ) + grouped = [] + current_key = None + current = None + for row in rows: + item = dict(row) + item['identity'] = identities.get(str(item['steam_id_64']), {}) + item['medal_label'] = DiscoveryService.MEDAL_LABELS.get( + item['medal_tier'], + item['medal_tier'], + ) + key = (item['dimension_type'], item['dimension_key']) + if key != current_key: + current = { + 'dimension_type': item['dimension_type'], + 'dimension_key': item['dimension_key'], + 'dimension_label': item['dimension_label'], + 'medals': [], + } + grouped.append(current) + current_key = key + current['medals'].append(item) + return grouped + + @staticmethod + def get_medal_leaderboard(): + rows = query_db( + 'l3', + """ + SELECT + steam_id_64, + COUNT(*) AS medals, + SUM(CASE WHEN medal_tier = 'gold' THEN 1 ELSE 0 END) AS gold, + SUM(CASE WHEN medal_tier = 'silver' THEN 1 ELSE 0 END) AS silver, + SUM(CASE WHEN medal_tier = 'bronze' THEN 1 ELSE 0 END) AS bronze + FROM dm_performance_medals + GROUP BY steam_id_64 + ORDER BY gold DESC, silver DESC, bronze DESC + """, + ) + identities = DiscoveryService._identity_map( + [row['steam_id_64'] for row in rows] + ) + result = [] + for row in rows: + item = dict(row) + item['identity'] = identities.get(str(item['steam_id_64']), {}) + result.append(item) + return result + + @staticmethod + def get_player_medals(steam_id): + rows = query_db( + 'l3', + """ + SELECT * + FROM dm_performance_medals + WHERE steam_id_64 = ? + ORDER BY medal_rank, dimension_type, dimension_key + """, + [steam_id], + ) + result = [] + for row in rows: + item = dict(row) + item['medal_label'] = DiscoveryService.MEDAL_LABELS.get( + item['medal_tier'], + item['medal_tier'], + ) + result.append(item) + return result + diff --git a/web/services/integrity_service.py b/web/services/integrity_service.py index b26c77f..7c7acf7 100644 --- a/web/services/integrity_service.py +++ b/web/services/integrity_service.py @@ -246,6 +246,7 @@ class IntegrityService: @staticmethod def _check_l3(db, roster_ids, checks, counts): required_tables = { + 'dm_discovery_insights', 'dm_duo_stats', 'dm_match_player_reports', 'dm_match_reports', @@ -257,6 +258,7 @@ class IntegrityService: 'dm_player_record_events', 'dm_player_records', 'dm_player_weapon_stats', + 'dm_performance_medals', 'dm_lineup_stats', 'dm_team_season_stats', } @@ -309,6 +311,12 @@ class IntegrityService: counts['l3_awards'] = db.execute( 'SELECT COUNT(*) FROM dm_player_awards' ).fetchone()[0] + counts['l3_discoveries'] = db.execute( + 'SELECT COUNT(*) FROM dm_discovery_insights' + ).fetchone()[0] + counts['l3_medals'] = db.execute( + 'SELECT COUNT(*) FROM dm_performance_medals' + ).fetchone()[0] expected_matches = counts.get('matches', 0) IntegrityService._check( checks, @@ -443,6 +451,8 @@ class IntegrityService: ('l3_record_events', 'Record event mart'), ('l3_seasons', 'Team season mart'), ('l3_awards', 'Player award mart'), + ('l3_discoveries', 'Discovery insight mart'), + ('l3_medals', 'Map and ELO medal mart'), ): value = counts[key] IntegrityService._check( @@ -453,6 +463,53 @@ class IntegrityService: value, ) + discovery_tones = { + row[0] for row in db.execute( + 'SELECT DISTINCT tone FROM dm_discovery_insights' + ) + } + IntegrityService._check( + checks, + 'Discovery tone coverage', + ( + 'pass' + if discovery_tones == {'positive', 'negative', 'fun'} + else 'fail' + ), + f"tones: {', '.join(sorted(discovery_tones))}", + len(discovery_tones), + ) + + invalid_medals = db.execute( + """ + SELECT COUNT(*) + FROM dm_performance_medals + WHERE matches < 5 + OR medal_rank NOT BETWEEN 1 AND 3 + """ + ).fetchone()[0] + duplicate_medals = db.execute( + """ + SELECT COUNT(*) + FROM ( + SELECT dimension_type, dimension_key, medal_rank, COUNT(*) AS n + FROM dm_performance_medals + GROUP BY dimension_type, dimension_key, medal_rank + HAVING n > 1 + ) + """ + ).fetchone()[0] + IntegrityService._check( + checks, + 'Performance medal validity', + 'pass' if invalid_medals == 0 and duplicate_medals == 0 else 'fail', + ( + f'{invalid_medals} invalid samples, ' + f'{duplicate_medals} duplicate ranks' + ), + invalid_medals + duplicate_medals, + ) + if roster_ids: placeholders = ','.join('?' for _ in roster_ids) scope_sql = f"AND steam_id_64 IN ({placeholders})" diff --git a/web/templates/admin/base.html b/web/templates/admin/base.html new file mode 100644 index 0000000..e92cfd1 --- /dev/null +++ b/web/templates/admin/base.html @@ -0,0 +1,86 @@ +{% extends "base.html" %} + +{% block title %}{% block admin_title %}Admin{% endblock %} - YRTV{% endblock %} + +{% block content %} +{% set admin_nav = [ + ('admin.dashboard', '总览', 'Overview'), + ('admin.import_match', '比赛导入', 'Import'), + ('admin.jobs', '作业中心', 'Jobs'), + ('admin.data_integrity', '数据质量', 'Integrity'), + ('admin.sql_runner', '查询控制台', 'Query'), + ('admin.system', '系统信息', 'System') +] %} +
运行前自动备份 L1/L2/L3,串行构建并验证 {{ integrity.checks|length }} 项数据质量规则。失败时自动恢复。
+成功 {{ job_summary.succeeded }} · 失败 {{ job_summary.failed }}
+{{ latest_match.summary_text }}
+ 打开赛后报告 → + {% else %} +- 校验时间:{{ report.generated_at }} -
失败优先,其次警告,最后通过项
系统自动提取比赛 ID、计算 SHA256、检查必要接口并拒绝重复数据。
+- 上传完整的 iframe_network.json,系统会自动识别比赛 ID,并执行 L1 → L2 → L3。 -
-