2.0.0-rc2: Achievements Refactored & Admin improved.

This commit is contained in:
2026-08-09 01:34:20 +08:00
parent 3874bf57a9
commit 63a0751aba
29 changed files with 2143 additions and 355 deletions
+175
View File
@@ -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'
),
},
}
+163
View File
@@ -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
+57
View File
@@ -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})"