2.0.0 Beta : Self-hosted Release

This commit is contained in:
2026-08-09 02:03:29 +08:00
parent 63a0751aba
commit 37cb05eb70
33 changed files with 810 additions and 31 deletions
+32 -1
View File
@@ -3,7 +3,7 @@ import os
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from flask import Flask
from flask import Flask, redirect, request, session, url_for
from web.config import Config
from web.database import close_dbs, initialize_web_db
from web.auth import get_csrf_token
@@ -19,8 +19,38 @@ def create_app(config_object=Config):
app.teardown_appcontext(close_dbs)
app.jinja_env.globals['csrf_token'] = get_csrf_token
@app.context_processor
def inject_brand():
return {
'brand': {
'primary': app.config['BRAND_PRIMARY'],
'aliases': app.config['BRAND_ALIASES'],
'signature': app.config['BRAND_SIGNATURE'],
}
}
@app.before_request
def require_private_access():
if app.config['SITE_VISIBILITY'] != 'private':
return None
endpoint = request.endpoint or ''
if (
endpoint == 'static'
or endpoint == 'main.healthz'
or endpoint.startswith('access.')
or endpoint.startswith('admin.')
or session.get('viewer_access')
or session.get('is_admin')
):
return None
return redirect(url_for(
'access.login',
next=request.full_path if request.method == 'GET' else None,
))
from web.routes import (
admin,
access,
awards,
discover,
main,
@@ -33,6 +63,7 @@ def create_app(config_object=Config):
wiki,
)
app.register_blueprint(main.bp)
app.register_blueprint(access.bp)
app.register_blueprint(matches.bp)
app.register_blueprint(players.bp)
app.register_blueprint(teams.bp)
+30 -1
View File
@@ -1,13 +1,37 @@
import os
from database.paths import L2_DB, L3_DB, WEB_DB, WEB_SCHEMA
from database.paths import (
L2_DB,
L3_DB,
OUTPUT_ARENA,
RUNTIME_DATA_ROOT,
WEB_DB,
WEB_SCHEMA,
)
class Config:
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
DATA_ROOT = str(RUNTIME_DATA_ROOT)
IMPORT_DIR = str(OUTPUT_ARENA)
SECRET_KEY = os.environ.get('SECRET_KEY', 'yrtv-dev-only-change-me')
ADMIN_TOKEN = os.environ.get('ADMIN_TOKEN', 'yrtv-admin-dev')
SITE_VISIBILITY = os.environ.get('SITE_VISIBILITY', 'public').lower()
VIEWER_TOKEN = os.environ.get('VIEWER_TOKEN', '')
BRAND_PRIMARY = os.environ.get('BRAND_PRIMARY', 'Superjacky6')
BRAND_ALIASES = [
value.strip()
for value in os.environ.get(
'BRAND_ALIASES',
'jacky,jk,yr,jacky0987',
).split(',')
if value.strip()
]
BRAND_SIGNATURE = (
f"{BRAND_PRIMARY} / {' / '.join(BRAND_ALIASES)}"
)
DB_L2_PATH = str(L2_DB)
DB_L3_PATH = str(L3_DB)
@@ -20,6 +44,11 @@ class Config:
SLOW_QUERY_THRESHOLD_SECONDS = float(
os.environ.get('SLOW_QUERY_THRESHOLD_SECONDS', '0.25')
)
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'
SESSION_COOKIE_SECURE = (
os.environ.get('SESSION_COOKIE_SECURE', 'false').lower() == 'true'
)
# Pagination
ITEMS_PER_PAGE = 20
+42
View File
@@ -0,0 +1,42 @@
import hmac
from flask import (
Blueprint,
current_app,
flash,
redirect,
render_template,
request,
session,
url_for,
)
from web.auth import validate_csrf
bp = Blueprint('access', __name__, url_prefix='/access')
@bp.route('/', methods=['GET', 'POST'])
def login():
if current_app.config['SITE_VISIBILITY'] != 'private':
return redirect(url_for('main.index'))
if request.method == 'POST':
validate_csrf()
token = request.form.get('token') or ''
viewer_token = current_app.config['VIEWER_TOKEN']
if viewer_token and hmac.compare_digest(
token,
viewer_token,
):
session['viewer_access'] = True
target = request.args.get('next')
if not target or not target.startswith('/') or target.startswith('//'):
target = url_for('main.index')
return redirect(target)
flash('访问密码错误', 'error')
return render_template('access/login.html')
@bp.route('/logout')
def logout():
session.pop('viewer_access', None)
return redirect(url_for('access.login'))
+39
View File
@@ -13,6 +13,9 @@ 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
from web.services.team_context_service import TeamContextService
from web.services.web_service import WebService
from web.services.roster_version_service import RosterVersionService
import hmac
import time
@@ -38,11 +41,47 @@ def logout():
@bp.route('/')
@admin_required
def dashboard():
if not TeamContextService.get_active_roster_ids():
return redirect(url_for('admin.setup'))
return render_template(
'admin/dashboard.html',
**AdminService.get_overview(),
)
@bp.route('/setup', methods=['GET', 'POST'])
@admin_required
def setup():
if request.method == 'POST':
validate_csrf()
name = (request.form.get('name') or '').strip()
description = (request.form.get('description') or '').strip()
raw_ids = request.form.get('steam_ids') or ''
player_ids = []
for value in raw_ids.replace(',', '\n').splitlines():
steam_id = value.strip()
if steam_id and steam_id not in player_ids:
player_ids.append(steam_id)
if not name:
flash('请输入战队名称', 'error')
elif not player_ids:
flash('至少添加一名队员 Steam ID', 'error')
elif any(not value.isdigit() for value in player_ids):
flash('Steam ID 只能包含数字', 'error')
else:
lineup_id = WebService.save_lineup(
name,
description,
player_ids,
)
RosterVersionService.snapshot_roster(
player_ids,
name=f'{name} Initial Roster',
)
flash('战队初始化完成', 'success')
return redirect(url_for('admin.dashboard'))
return render_template('admin/setup.html')
@bp.route('/data-integrity')
@admin_required
def data_integrity():
+20
View File
@@ -1,8 +1,28 @@
from flask import Blueprint, render_template, request, jsonify
from web.database import query_db
from web.config import Config
from web.services.stats_service import StatsService
bp = Blueprint('main', __name__)
@bp.route('/healthz')
def healthz():
try:
for db_name in ('l2', 'l3', 'web'):
query_db(db_name, 'SELECT 1', one=True)
except Exception as exc:
return jsonify({
'status': 'unhealthy',
'error': str(exc),
'brand': Config.BRAND_PRIMARY,
}), 503
return jsonify({
'status': 'ok',
'version': '2.0.0-beta',
'brand': Config.BRAND_PRIMARY,
})
@bp.route('/')
def index():
recent_matches = StatsService.get_recent_matches(limit=5)
+4 -1
View File
@@ -164,6 +164,10 @@ class AdminService:
'sqlite_timeout': Config.SQLITE_TIMEOUT_SECONDS,
'slow_query_threshold': Config.SLOW_QUERY_THRESHOLD_SECONDS,
'max_upload_mb': Config.MAX_CONTENT_LENGTH / 1024 / 1024,
'data_root': Config.DATA_ROOT,
'import_dir': Config.IMPORT_DIR,
'site_visibility': Config.SITE_VISIBILITY,
'brand_primary': Config.BRAND_PRIMARY,
'secret_key_configured': (
Config.SECRET_KEY != 'yrtv-dev-only-change-me'
),
@@ -172,4 +176,3 @@ class AdminService:
),
},
}
+31
View File
@@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block title %}Private Access - YRTV{% endblock %}
{% block content %}
<div class="mx-auto flex min-h-[620px] max-w-3xl items-center justify-center px-4">
<div class="w-full overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-2xl dark:border-slate-700 dark:bg-slate-900">
<div class="bg-gradient-to-br from-slate-950 via-yrtv-950 to-slate-900 px-8 py-10 text-white">
<div class="text-xs font-black uppercase tracking-[0.3em] text-yrtv-300">{{ brand.primary }} Private Analytics</div>
<h1 class="mt-4 text-3xl font-black">这是一座私人战队数据站</h1>
<p class="mt-3 text-sm leading-7 text-slate-300">输入战队提供的访问密码后,可以查看比赛、职业主页、发现、荣誉和战队履历。</p>
</div>
<div class="p-8">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="mb-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ message }}</div>
{% endfor %}
{% endwith %}
<form method="POST" class="space-y-4">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div>
<label class="text-xs font-black uppercase tracking-wider text-slate-500">Viewer Token</label>
<input type="password" name="token" required class="mt-2 block w-full rounded-xl border-slate-200 bg-slate-50 px-4 py-3 focus:border-yrtv-500 focus:ring-yrtv-500 dark:border-slate-700 dark:bg-slate-800" placeholder="输入访问密码">
</div>
<button class="w-full rounded-xl bg-yrtv-600 px-5 py-3 font-black text-white hover:bg-yrtv-500">进入 YRTV</button>
</form>
<div class="mt-6 text-center text-xs text-slate-400">Protected by {{ brand.signature }}</div>
</div>
</div>
</div>
{% endblock %}
+1 -1
View File
@@ -19,6 +19,7 @@
<div>
<div class="text-xs font-black uppercase tracking-[0.25em] text-yrtv-400">YRTV</div>
<div class="mt-1 text-lg font-black text-white">Operations</div>
<div class="text-[9px] font-bold uppercase tracking-wider text-slate-500">{{ brand.primary }} Control Plane</div>
</div>
<span class="rounded-full bg-amber-400/10 px-2 py-1 text-[10px] font-bold uppercase text-amber-300">Beta</span>
</div>
@@ -83,4 +84,3 @@
</div>
</div>
{% endblock %}
+39
View File
@@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block title %}First Setup - YRTV{% endblock %}
{% block content %}
<div class="mx-auto max-w-3xl px-4 py-8">
<div class="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-2xl dark:border-slate-700 dark:bg-slate-900">
<div class="bg-gradient-to-r from-yrtv-700 to-slate-900 p-8 text-white">
<div class="text-xs font-black uppercase tracking-[0.3em] text-yrtv-200">{{ brand.primary }} Self-hosted</div>
<h1 class="mt-3 text-3xl font-black">创建你的私人战队 HLTV</h1>
<p class="mt-3 text-sm text-slate-200">这一步只建立战队与 roster,不会生成或修改比赛数据。之后从 Admin 上传自己的 iframe_network.json。</p>
</div>
<div class="p-8">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="mb-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ message }}</div>
{% endfor %}
{% endwith %}
<form method="POST" class="space-y-5">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div>
<label class="text-xs font-black uppercase tracking-wider text-slate-500">战队名称</label>
<input name="name" required class="mt-2 block w-full rounded-xl border-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-700 dark:bg-slate-800" placeholder="My Team">
</div>
<div>
<label class="text-xs font-black uppercase tracking-wider text-slate-500">说明</label>
<input name="description" class="mt-2 block w-full rounded-xl border-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-700 dark:bg-slate-800" placeholder="Private CS2 roster">
</div>
<div>
<label class="text-xs font-black uppercase tracking-wider text-slate-500">Roster Steam ID</label>
<textarea name="steam_ids" rows="8" required class="mt-2 block w-full rounded-xl border-slate-200 bg-slate-50 px-4 py-3 font-mono text-sm dark:border-slate-700 dark:bg-slate-800" placeholder="每行一个 Steam ID"></textarea>
<p class="mt-2 text-xs text-slate-400">支持换行或逗号分隔。后续可在 Clubhouse 修改。</p>
</div>
<button class="w-full rounded-xl bg-yrtv-600 px-5 py-3 font-black text-white hover:bg-yrtv-500">创建战队并进入后台</button>
</form>
</div>
</div>
</div>
{% endblock %}
+7 -1
View File
@@ -62,11 +62,17 @@
('Web schema', 'v' + config.web_schema_version|string),
('SQLite timeout', config.sqlite_timeout|string + 's'),
('Slow query', config.slow_query_threshold|string + 's'),
('Max upload', '%.0f MB'|format(config.max_upload_mb))
('Max upload', '%.0f MB'|format(config.max_upload_mb)),
('Visibility', config.site_visibility),
('Brand', config.brand_primary)
] %}
<div class="flex items-center justify-between"><dt class="text-slate-500">{{ label }}</dt><dd class="font-mono font-black text-slate-800 dark:text-slate-200">{{ value }}</dd></div>
{% endfor %}
</dl>
<div class="mt-4 space-y-2 border-t border-slate-100 pt-4 text-[10px] dark:border-slate-800">
<div><span class="font-bold uppercase text-slate-400">Data root</span><div class="mt-1 break-all font-mono text-slate-500">{{ config.data_root }}</div></div>
<div><span class="font-bold uppercase text-slate-400">Import dir</span><div class="mt-1 break-all font-mono text-slate-500">{{ config.import_dir }}</div></div>
</div>
</section>
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
+1 -1
View File
@@ -5,7 +5,7 @@
{% block content %}
<div class="space-y-8 px-4 sm:px-0">
<div>
<p class="text-xs font-bold uppercase tracking-widest text-amber-600">YRTV Awards</p>
<p class="text-xs font-bold uppercase tracking-widest text-amber-600">YRTV Awards · {{ brand.primary }}</p>
<h1 class="mt-1 text-3xl font-black text-gray-900 dark:text-white">周期最佳与荣誉榜</h1>
<p class="mt-2 text-sm text-gray-500">完全基于已导入比赛。月、季度和年度奖项设有最低场次门槛。</p>
</div>
+16 -2
View File
@@ -3,6 +3,11 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="author" content="{{ brand.signature }}">
<meta name="creator" content="{{ brand.primary }}">
<meta name="generator" content="YRTV 2.0.0 Beta by {{ brand.primary }}">
<meta property="og:site_name" content="YRTV · {{ brand.primary }}">
<meta property="og:title" content="{% block og_title %}YRTV Private CS2 Analytics{% endblock %}">
<title>{% block title %}YRTV - CS2 Data Platform{% endblock %}</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/hammer.js/2.0.8/hammer.min.js"></script>
@@ -40,7 +45,10 @@
<div class="flex justify-between h-16">
<div class="flex">
<div class="flex-shrink-0 flex items-center">
<a href="{{ url_for('main.index') }}" class="text-2xl font-bold text-yrtv-600">YRTV</a>
<a href="{{ url_for('main.index') }}" class="group">
<span class="block text-2xl font-bold leading-none text-yrtv-600">YRTV</span>
<span class="mt-0.5 block text-[8px] font-black uppercase tracking-[0.18em] text-slate-400 group-hover:text-yrtv-500">by {{ brand.primary }}</span>
</a>
</div>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<a href="{{ url_for('main.index') }}" class="{% if request.endpoint == 'main.index' %}border-yrtv-500 text-gray-900 dark:text-white{% else %}border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 hover:text-gray-700 dark:hover:text-white{% endif %} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">首页</a>
@@ -111,10 +119,16 @@
<!-- Footer -->
<footer class="bg-white dark:bg-slate-800 border-t border-slate-200 dark:border-slate-700 mt-auto">
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<p class="text-center text-sm text-gray-500">&copy; 2026 YRTV Data Platform. All rights reserved. 赣ICP备2026001600号</p>
<p class="text-center text-sm font-bold text-gray-500">&copy; 2026 YRTV · {{ brand.primary }} Data Lab</p>
<p class="mt-1 text-center text-[10px] uppercase tracking-[0.2em] text-gray-400">{{ brand.aliases|join(' / ') }} · 赣ICP备2026001600号</p>
</div>
</footer>
<div class="pointer-events-none fixed bottom-3 right-4 z-[90] select-none text-right opacity-20">
<div class="text-xs font-black uppercase tracking-[0.2em] text-yrtv-600">{{ brand.primary }}</div>
<div class="text-[8px] font-bold text-slate-500">{{ brand.aliases|join(' · ') }}</div>
</div>
{% block scripts %}{% endblock %}
<script>
+1 -1
View File
@@ -22,7 +22,7 @@
<section class="overflow-hidden rounded-3xl bg-gradient-to-br from-slate-950 via-cyan-950 to-yrtv-950 p-8 text-white shadow-2xl">
<div class="flex flex-col gap-8 lg:flex-row lg:items-end lg:justify-between">
<div>
<div class="text-xs font-black uppercase tracking-[0.3em] text-cyan-300">Data Discovery</div>
<div class="text-xs font-black uppercase tracking-[0.3em] text-cyan-300">{{ brand.primary }} · Data Discovery</div>
<h1 class="mt-3 text-4xl font-black">有趣的数据,不只展示好的一面</h1>
<p class="mt-3 max-w-2xl text-sm leading-7 text-slate-300">从 885 条 roster 比赛记录中自动发现高光、低谷、反差和个人习惯。每张卡片都有统计证据。</p>
</div>
+1
View File
@@ -5,6 +5,7 @@
<!-- Hero Section -->
<div class="bg-gradient-to-r from-yrtv-900 to-yrtv-600 rounded-2xl shadow-xl overflow-hidden">
<div class="px-6 py-12 sm:px-12 sm:py-16 lg:py-20 text-center">
<div class="mb-4 text-xs font-black uppercase tracking-[0.3em] text-yrtv-200">{{ brand.signature }}</div>
<h1 class="text-4xl font-extrabold tracking-tight text-white sm:text-5xl lg:text-6xl">
JKTV CS2 队伍数据洞察平台
</h1>
@@ -6,7 +6,7 @@
<span class="rounded-lg px-3 py-1 text-xs font-black {% if post_match_report.is_win %}bg-green-100 text-green-700{% else %}bg-red-100 text-red-600{% endif %}">
{{ 'VICTORY' if post_match_report.is_win else 'DEFEAT' }}
</span>
<h2 class="text-xl font-black text-gray-900 dark:text-white">赛后报告</h2>
<h2 class="text-xl font-black text-gray-900 dark:text-white">赛后报告 <span class="ml-2 text-[10px] uppercase tracking-wider text-slate-400">by {{ brand.primary }}</span></h2>
</div>
<p class="mt-3 max-w-3xl text-sm text-gray-600 dark:text-gray-300">{{ post_match_report.summary_text }}</p>
</div>
@@ -1,7 +1,7 @@
<section class="overflow-hidden rounded-2xl border border-gray-100 bg-gradient-to-r from-slate-900 via-slate-800 to-yrtv-900 p-6 text-white shadow-xl dark:border-slate-700">
<div class="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div>
<div class="text-xs font-bold uppercase tracking-[0.25em] text-yrtv-300">Professional Identity</div>
<div class="text-xs font-bold uppercase tracking-[0.25em] text-yrtv-300">{{ brand.primary }} · Professional Identity</div>
<div class="mt-2 flex flex-wrap items-center gap-3">
<span class="rounded-full bg-white/10 px-3 py-1 text-sm font-bold uppercase">{{ professional_identity.member_role or 'member' }}</span>
<span class="text-sm text-slate-300">{{ professional_identity.roster_version or 'Active Roster' }}</span>
+1 -1
View File
@@ -5,7 +5,7 @@
{% block content %}
<div class="space-y-6 px-4 sm:px-0">
<div>
<p class="text-xs font-bold uppercase tracking-widest text-yrtv-600">Post Match</p>
<p class="text-xs font-bold uppercase tracking-widest text-yrtv-600">{{ brand.primary }} · Post Match</p>
<h1 class="mt-1 text-3xl font-black text-gray-900 dark:text-white">赛后报告中心</h1>
<p class="mt-2 text-sm text-gray-500">表现变化只与该队员比赛发生前的最近 20 场比较。</p>
</div>
+1 -1
View File
@@ -6,7 +6,7 @@
<div class="space-y-8 px-4 sm:px-0">
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<p class="text-xs font-bold uppercase tracking-widest text-yrtv-600">Team Career</p>
<p class="text-xs font-bold uppercase tracking-widest text-yrtv-600">{{ brand.primary }} · Team Career</p>
<h1 class="mt-1 text-3xl font-black text-gray-900 dark:text-white">战队履历与阵容表现</h1>
<p class="mt-2 text-sm text-gray-500">只统计 roster 成员在同一队伍实际共同出场的比赛。</p>
</div>