Spaces:
Sleeping
Sleeping
| """ | |
| 短道速滑竞赛成绩 Dashboard 后端API服务 | |
| """ | |
| APP_VERSION = "1.1.96" | |
| import os | |
| import sys | |
| import json | |
| import time | |
| import hashlib | |
| import logging | |
| import functools | |
| from datetime import datetime, timedelta | |
| from functools import wraps | |
| from flask import Flask, request, jsonify, send_from_directory, send_file, g, session | |
| from flask_cors import CORS | |
| from flask_wtf.csrf import CSRFProtect | |
| from flask_limiter import Limiter | |
| from flask_limiter.util import get_remote_address | |
| from flask_caching import Cache | |
| import bcrypt | |
| import database | |
| import scraper | |
| sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) | |
| from database import ( | |
| init_db, get_chaoyang_athletes, get_athlete_results, get_athlete_events, | |
| get_event_timeseries, get_opponent_results, get_current_matches, | |
| get_all_matches, get_seasons, get_last_update, get_match_race_groups, | |
| get_race_group_results, get_athlete_categories, | |
| get_team_aliases, add_team_alias, delete_team_alias, | |
| get_athlete_overrides, set_athlete_override, delete_athlete_override, | |
| add_athlete, get_all_athletes, soft_delete_athlete, restore_athlete, delete_athlete, get_athlete_names, | |
| get_active_athlete_names, | |
| is_chaoyang_team, recalculate_chaoyang_flags, get_db, | |
| get_active_team, get_all_teams, set_active_team, get_team_config, | |
| get_athlete_photo, set_athlete_photo, delete_athlete_photo, | |
| get_training_types, add_training_type, delete_training_type, | |
| get_training_records, add_training_record, delete_training_record, | |
| add_training_records_batch, update_training_record, | |
| submit_training_change, get_pending_changes, approve_pending_change, reject_pending_change, | |
| get_athlete_attendance_detail, | |
| get_attendance_stats, get_attendance_detail, | |
| get_fee_config, set_fee_config, get_athlete_exemptions, | |
| set_athlete_exemption, delete_athlete_exemption, calculate_fees, | |
| create_user, get_user_by_username, get_all_users, update_user_role, update_user_password, delete_user, | |
| update_user_bio, get_user_public_profile, | |
| approve_user, reject_user, | |
| get_user_avatar, set_user_avatar, | |
| get_messages, add_message, delete_message, like_message, get_message_likes_by_user, | |
| log_visit, get_visit_stats, | |
| set_athlete_family, delete_athlete_family, get_athlete_family, | |
| get_user_family, get_all_families, is_family_of, | |
| get_athlete_honors, add_athlete_honor, update_athlete_honor, delete_athlete_honor, | |
| get_athlete_profile, update_athlete_profile, | |
| get_athlete_custom_results, add_athlete_custom_result, update_athlete_custom_result, delete_athlete_custom_result, | |
| get_relay_members, set_relay_members, delete_relay_member, auto_populate_relay_members, get_athlete_relay_results, | |
| get_weekly_schedule, set_weekly_schedule, delete_weekly_schedule, get_schedule_for_date, | |
| # New fee management functions | |
| get_athlete_fee_config, set_athlete_fee_config, delete_athlete_fee_config, | |
| get_prepaid_balances, set_prepaid_balance, delete_prepaid_balance, get_prepaid_transactions, | |
| get_fee_settlements, add_fee_settlement, delete_fee_settlement, | |
| get_accessible_athletes, | |
| # Nickname functions | |
| get_all_nicknames, add_nickname, delete_nickname, get_nickname_map, resolve_nickname, | |
| # 小程序微信登录 | |
| get_wx_binding_by_openid, create_wx_binding, approve_wx_binding, get_pending_wx_bindings, | |
| create_user_token, get_user_by_token, get_or_create_wx_user, | |
| ) | |
| from scraper import crawl_update as crawl_update_zhitikeji | |
| from scraper_chinacsa import crawl_update as crawl_update_chinacsa | |
| from database import close_db | |
| from config import get_config | |
| from structured_logger import StructuredLogger | |
| # 加载配置 | |
| config = get_config() | |
| app = Flask(__name__, static_folder="static", static_url_path="") | |
| app.secret_key = config.SECRET_KEY | |
| app.config['SESSION_COOKIE_SAMESITE'] = config.SESSION_COOKIE_SAMESITE | |
| app.config['SESSION_COOKIE_HTTPONLY'] = config.SESSION_COOKIE_HTTPONLY | |
| app.config['SESSION_COOKIE_SECURE'] = config.SESSION_COOKIE_SECURE | |
| app.config['PERMANENT_SESSION_LIFETIME'] = config.PERMANENT_SESSION_LIFETIME | |
| # 初始化结构化日志 | |
| StructuredLogger.configure(config) | |
| # 多服务器同步配置 | |
| SERVER_ROLE = os.environ.get("SERVER_ROLE", "master") # "master" or "slave" | |
| PEER_URL = os.environ.get("PEER_URL", "") | |
| SYNC_SECRET = os.environ.get("SYNC_SECRET", "st_sync_secret_2026") | |
| DB_PUSH_DEBOUNCE_SECONDS = 3.0 | |
| import threading | |
| import requests as http_requests | |
| _push_timer = None | |
| _push_lock = threading.Lock() | |
| # DB 重建信号文件:当 db_health_check 替换了 DB 文件后,通知所有 worker 重建连接 | |
| DB_REBUILD_SIGNAL = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", ".db_rebuild_signal") | |
| def push_db_to_peer(): | |
| """推送完整 short_track.db 到对端服务器(带安全校验)""" | |
| from database import get_db | |
| db_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", "short_track.db") | |
| if not os.path.exists(db_path): | |
| print(f"[sync] DB file not found at {db_path}, skipping push") | |
| return | |
| # 1. WAL checkpoint: 确保所有写入已合并到主文件 | |
| try: | |
| db = get_db() | |
| db.execute("PRAGMA wal_checkpoint(TRUNCATE)") | |
| except Exception as e: | |
| print(f"[sync] WAL checkpoint failed: {e}") | |
| # 2. 完整性校验:损坏的库坚决不推送 | |
| try: | |
| db = get_db() | |
| result = db.execute("PRAGMA integrity_check").fetchone() | |
| if result[0] != "ok": | |
| print(f"[sync] DB integrity check FAILED: {result[0]}, refusing to push corrupted DB") | |
| return | |
| except Exception as e: | |
| print(f"[sync] DB integrity check error: {e}, refusing to push") | |
| return | |
| # 3. 计算 hash(checkpoint 后文件一致) | |
| sha = hashlib.sha256() | |
| with open(db_path, 'rb') as f: | |
| while chunk := f.read(8192): | |
| sha.update(chunk) | |
| db_hash = sha.hexdigest() | |
| try: | |
| hash_resp = http_requests.post( | |
| f"{PEER_URL}/api/sync/check_hash", | |
| json={"hash": db_hash}, | |
| headers={"Authorization": f"Bearer {SYNC_SECRET}", "Content-Type": "application/json"}, | |
| timeout=10, | |
| ) | |
| if hash_resp.status_code == 200 and hash_resp.json().get("match"): | |
| print(f"[sync] Peer already has DB hash {db_hash[:12]}, skipping transfer") | |
| return | |
| except Exception as e: | |
| print(f"[sync] Hash check failed ({e}), proceeding with full push") | |
| try: | |
| with open(db_path, 'rb') as f: | |
| push_resp = http_requests.post( | |
| f"{PEER_URL}/api/sync/push_db", | |
| files={"db_file": ("short_track.db", f, "application/octet-stream")}, | |
| headers={"Authorization": f"Bearer {SYNC_SECRET}"}, | |
| timeout=120, | |
| ) | |
| if push_resp.status_code == 200: | |
| print(f"[sync] DB pushed to peer successfully ({os.path.getsize(db_path)} bytes)") | |
| else: | |
| print(f"[sync] Peer returned error: {push_resp.status_code} {push_resp.text[:200]}") | |
| except Exception as e: | |
| print(f"[sync] Failed to push DB to peer: {e}") | |
| def schedule_db_push(): | |
| """防抖:DB_PUSH_DEBOUNCE_SECONDS 秒内的多次写入只触发一次推送(仅 Master)""" | |
| global _push_timer | |
| if SERVER_ROLE != 'master' or not PEER_URL: | |
| return | |
| with _push_lock: | |
| if _push_timer is not None: | |
| _push_timer.cancel() | |
| _push_timer = threading.Timer(DB_PUSH_DEBOUNCE_SECONDS, push_db_to_peer) | |
| _push_timer.daemon = True | |
| _push_timer.start() | |
| def _get_db_integrity(): | |
| """返回 DB 完整性状态:ok / 错误信息(使用独立连接,避免受 thread-local 连接池污染)""" | |
| import sqlite3 as _sqlite3 | |
| db_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", "short_track.db") | |
| conn = None | |
| try: | |
| conn = _sqlite3.connect(db_path, timeout=5) | |
| result = conn.execute("PRAGMA integrity_check").fetchone() | |
| return result[0] if result else "unknown" | |
| except Exception as e: | |
| return str(e) | |
| finally: | |
| if conn: | |
| try: | |
| conn.close() | |
| except Exception: | |
| pass | |
| def _restore_from_local_backup(data_dir, db_path): | |
| """尝试从本地备份恢复数据库,返回 True 若成功""" | |
| backups = sorted( | |
| [f for f in os.listdir(data_dir) if f.startswith("short_track.db.bak_")], | |
| reverse=True | |
| ) | |
| import sqlite3 | |
| for bak_file in backups: | |
| bak_path = os.path.join(data_dir, bak_file) | |
| try: | |
| test_conn = sqlite3.connect(bak_path) | |
| result = test_conn.execute("PRAGMA integrity_check").fetchone() | |
| test_conn.close() | |
| if result[0] == "ok": | |
| if os.path.exists(db_path): | |
| os.rename(db_path, db_path + ".corrupted_" + datetime.now().strftime("%Y%m%d_%H%M%S")) | |
| with open(bak_path, 'rb') as src, open(db_path, 'wb') as dst: | |
| dst.write(src.read()) | |
| print(f"[health] Restored DB from local backup: {bak_file}") | |
| # 通知所有 worker/gunicorn 进程重建数据库连接 | |
| try: | |
| with open(DB_REBUILD_SIGNAL, 'w') as sf: | |
| sf.write(str(time.time())) | |
| except Exception: | |
| pass | |
| return True | |
| except Exception as e: | |
| print(f"[health] Local backup {bak_file} failed: {e}") | |
| return False | |
| def _pull_db_from_peer(): | |
| """从对端服务器拉取数据库,返回 True 若成功""" | |
| if not PEER_URL: | |
| return False | |
| try: | |
| resp = http_requests.get( | |
| f"{PEER_URL}/api/sync/pull_db", | |
| headers={"Authorization": f"Bearer {SYNC_SECRET}"}, | |
| timeout=120, | |
| ) | |
| if resp.status_code != 200: | |
| print(f"[health] Pull from peer failed: {resp.status_code} {resp.text[:100]}") | |
| return False | |
| db_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", "short_track.db") | |
| temp_path = db_path + ".incoming" | |
| with open(temp_path, 'wb') as f: | |
| f.write(resp.content) | |
| import sqlite3 | |
| test_conn = sqlite3.connect(temp_path) | |
| result = test_conn.execute("PRAGMA integrity_check").fetchone() | |
| test_conn.close() | |
| if result[0] != "ok": | |
| os.remove(temp_path) | |
| print(f"[health] Peer DB failed integrity check: {result[0]}") | |
| return False | |
| if os.path.exists(db_path): | |
| os.rename(db_path, db_path + ".corrupted_" + datetime.now().strftime("%Y%m%d_%H%M%S")) | |
| os.rename(temp_path, db_path) | |
| # 清理 WAL/SHM | |
| for suffix in ("-wal", "-shm"): | |
| wf = db_path + suffix | |
| if os.path.exists(wf): | |
| try: | |
| os.remove(wf) | |
| except Exception: | |
| pass | |
| print(f"[health] Successfully recovered DB from peer ({os.path.getsize(db_path)} bytes)") | |
| # 通知所有 worker/gunicorn 进程重建数据库连接 | |
| try: | |
| with open(DB_REBUILD_SIGNAL, 'w') as sf: | |
| sf.write(str(time.time())) | |
| except Exception: | |
| pass | |
| return True | |
| except Exception as e: | |
| print(f"[health] Pull from peer error: {e}") | |
| return False | |
| def db_health_check(): | |
| """DB 完整性检查 + 自动恢复(Master 和 Slave 均运行) | |
| 使用独立 sqlite3 连接检测,不与业务请求共享连接池。 | |
| 恢复成功后写入 DB_REBUILD_SIGNAL 文件,get_db() 检测到后自动重建连接。 | |
| 整体超时约 90 秒(本地备份扫描 + 对端拉取 60s timeout)。 | |
| """ | |
| import sqlite3 as _sqlite3 | |
| db_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", "short_track.db") | |
| data_dir = os.path.dirname(db_path) | |
| # 清理过期重建信号(如果 DB 完好,信号无意义) | |
| integrity = _get_db_integrity() | |
| if integrity == "ok": | |
| if os.path.exists(DB_REBUILD_SIGNAL): | |
| try: | |
| os.remove(DB_REBUILD_SIGNAL) | |
| except Exception: | |
| pass | |
| return | |
| print(f"[health] ALERT: DB integrity check FAILED: {integrity}") | |
| print(f"[health] Starting auto-recovery on {SERVER_ROLE}...") | |
| recovered = False | |
| # Step 1: 尝试本地备份恢复 | |
| if _restore_from_local_backup(data_dir, db_path): | |
| new_integrity = _get_db_integrity() | |
| if new_integrity == "ok": | |
| print(f"[health] Local backup restored successfully") | |
| recovered = True | |
| else: | |
| print(f"[health] Restored DB still fails integrity: {new_integrity}") | |
| # Step 2: 从对端拉取 | |
| if not recovered and _pull_db_from_peer(): | |
| new_integrity = _get_db_integrity() | |
| if new_integrity == "ok": | |
| print(f"[health] Peer recovery successful") | |
| recovered = True | |
| else: | |
| print(f"[health] Peer-recovered DB still fails integrity: {new_integrity}") | |
| if recovered: | |
| # 通知当前进程内 thread-local 连接重建 | |
| from database import close_db | |
| try: | |
| close_db() | |
| except Exception: | |
| pass | |
| # Master 恢复后推送到 Slave | |
| if SERVER_ROLE == 'master': | |
| push_db_to_peer() | |
| return | |
| print(f"[health] CRITICAL: All recovery attempts FAILED on {SERVER_ROLE}") | |
| # Initialize cache(生产环境使用 Redis,开发环境使用 simple) | |
| cache_config = { | |
| 'CACHE_TYPE': config.CACHE_TYPE, | |
| 'CACHE_DEFAULT_TIMEOUT': config.CACHE_DEFAULT_TIMEOUT, | |
| } | |
| if config.CACHE_TYPE == 'RedisCache': | |
| cache_config['CACHE_REDIS_URL'] = config.CACHE_REDIS_URL | |
| cache = Cache(app, config=cache_config) | |
| CORS(app, supports_credentials=True, origins=[ | |
| 'http://localhost:5000', | |
| 'http://127.0.0.1:5000', | |
| 'http://122.51.80.140:5000', | |
| 'capacitor://localhost', # Capacitor iOS | |
| 'http://localhost', # Capacitor Android | |
| 'https://localhost', # Capacitor Android (some versions) | |
| 'https://chaofenghui-short-track.hf.space', # HF Space slave | |
| ]) | |
| # API 限流 | |
| limiter = Limiter( | |
| app=app, | |
| key_func=get_remote_address, | |
| default_limits=["200 per minute"], | |
| storage_uri="memory://", | |
| ) | |
| # CSRF 保护:mutating 请求必须有合法的 Content-Type 或空 body | |
| def csrf_protect(): | |
| """轻量级 CSRF 保护:验证 mutating 请求的 Content-Type""" | |
| if request.method in ('GET', 'HEAD', 'OPTIONS'): | |
| return None | |
| # 空 body 的请求(如 DELETE、简单 POST 触发操作)允许通过 | |
| # SameSite=Lax cookie 提供实际 CSRF 保护 | |
| if not request.content_length: | |
| return None | |
| content_type = request.content_type or '' | |
| if not any(ct in content_type for ct in ('application/json', 'multipart/form-data', 'application/x-www-form-urlencoded')): | |
| return jsonify({"error": "不支持的请求类型"}), 415 | |
| def login_required(f): | |
| """登录验证装饰器 — 支持 Session Cookie 和 Bearer Token""" | |
| def decorated(*args, **kwargs): | |
| if 'user_id' in session: | |
| return f(*args, **kwargs) | |
| # 小程序 Bearer Token 鉴权 | |
| token = request.headers.get("Authorization", "").replace("Bearer ", "") | |
| if token: | |
| user = get_user_by_token(token) | |
| if user: | |
| session['user_id'] = user['id'] | |
| session['username'] = user['username'] | |
| session['role'] = user['role'] | |
| return f(*args, **kwargs) | |
| return jsonify({"error": "请先登录"}), 401 | |
| return decorated | |
| def admin_required(f): | |
| """管理员权限装饰器""" | |
| def decorated(*args, **kwargs): | |
| if 'user_id' not in session: | |
| return jsonify({"error": "请先登录"}), 401 | |
| if session.get('role') not in ('admin', 'superadmin'): | |
| return jsonify({"error": "需要管理员权限"}), 403 | |
| return f(*args, **kwargs) | |
| return decorated | |
| def superadmin_required(f): | |
| """超级管理员权限装饰器""" | |
| def decorated(*args, **kwargs): | |
| if 'user_id' not in session: | |
| return jsonify({"error": "请先登录"}), 401 | |
| if session.get('role') != 'superadmin': | |
| return jsonify({"error": "需要超级管理员权限"}), 403 | |
| return f(*args, **kwargs) | |
| return decorated | |
| def _build_user_payload(user, family=None): | |
| """构建统一用户返回结构""" | |
| family = family or get_user_family(user['id']) | |
| return { | |
| "id": user['id'], | |
| "username": user['username'], | |
| "role": user['role'], | |
| "is_superadmin": user['role'] == 'superadmin', | |
| "athlete_name": user.get('athlete_name'), | |
| "relationship": user.get('relationship'), | |
| "family_approved": family.get('approved') if family else None, | |
| "family_athlete": family.get('athlete_name') if family else None, | |
| "bio": user.get('bio') or '', | |
| "team_id": user.get('team_id') or 'chaoyang', | |
| } | |
| def _get_issued_team(): | |
| """获取当前发行队伍配置""" | |
| return get_team_config(database.get_default_issued_team_id()) | |
| def _resolve_effective_team(user=None): | |
| """解析当前请求实际生效的队伍上下文""" | |
| issued_team = _get_issued_team() | |
| if not user: | |
| return issued_team | |
| user_team = get_team_config(user.get('team_id') or issued_team['team_id']) | |
| if user.get('role') == 'superadmin': | |
| view_team_id = session.get('view_team_id') or user_team['team_id'] | |
| return get_team_config(view_team_id) | |
| return user_team | |
| def _build_team_context_payload(user=None): | |
| """构建队伍上下文返回结构""" | |
| issued_team = _get_issued_team() | |
| effective_team = _resolve_effective_team(user) | |
| user_team = get_team_config(user.get('team_id') or issued_team['team_id']) if user else issued_team | |
| return { | |
| "issued_team": issued_team, | |
| "effective_team": effective_team, | |
| "user_team": user_team, | |
| "view_team_id": effective_team['team_id'], | |
| "can_switch": bool(user and user.get('role') == 'superadmin'), | |
| } | |
| def _get_request_user(): | |
| """获取当前请求用户""" | |
| if 'username' not in session: | |
| return None | |
| return get_user_by_username(session['username']) | |
| def _get_effective_team_id(user=None): | |
| """获取当前请求生效队伍 ID""" | |
| return _resolve_effective_team(user or _get_request_user())['team_id'] | |
| def _get_team_alias_values(conn, team_id): | |
| """获取队伍别名列表""" | |
| rows = conn.execute( | |
| "SELECT alias FROM team_aliases WHERE is_active = 1 AND team_id = ? ORDER BY alias", | |
| (team_id,) | |
| ).fetchall() | |
| aliases = [r['alias'] for r in rows if r['alias']] | |
| team = get_team_config(team_id) | |
| if team and team.get('team_name'): | |
| aliases.append(team['team_name']) | |
| aliases.append(team_id) | |
| seen = set() | |
| result = [] | |
| for alias in aliases: | |
| if alias and alias not in seen: | |
| seen.add(alias) | |
| result.append(alias) | |
| return result | |
| def _build_team_filter_clause(conn, team_id, result_alias='r', include_override_join=False): | |
| """构建队伍过滤 SQL 条件""" | |
| aliases = _get_team_alias_values(conn, team_id) | |
| params = [team_id, team_id] | |
| team_checks = [] | |
| for alias in aliases: | |
| team_checks.append(f"{result_alias}.team LIKE ?") | |
| params.append(f"%{alias}%") | |
| clauses = [ | |
| f"EXISTS (SELECT 1 FROM athlete_overrides ao_team_in WHERE ao_team_in.name = {result_alias}.name AND ao_team_in.team_id = ? AND ao_team_in.is_chaoyang = 1)", | |
| f"NOT EXISTS (SELECT 1 FROM athlete_overrides ao_team_out WHERE ao_team_out.name = {result_alias}.name AND ao_team_out.team_id = ? AND ao_team_out.is_chaoyang = 0)" | |
| ] | |
| if team_checks: | |
| clauses.append("(" + " OR ".join(team_checks) + ")") | |
| if team_id == 'chaoyang': | |
| clauses.append(f"{result_alias}.is_chaoyang = 1") | |
| clause = "(" + " OR ".join([ | |
| clauses[0], | |
| "(" + clauses[1] + (" AND (" + " OR ".join(team_checks + ([f"{result_alias}.is_chaoyang = 1"] if team_id == 'chaoyang' else [])) + ")" if (team_checks or team_id == 'chaoyang') else "") + ")" | |
| ]) + ")" | |
| return clause, params | |
| def _row_belongs_to_team(row, team_id, aliases=None): | |
| """判断单条结果是否属于当前队伍""" | |
| aliases = aliases if aliases is not None else [] | |
| name = (row.get('athlete_name') or row.get('name') or '') if isinstance(row, dict) else '' | |
| team_text = (row.get('team') or '') if isinstance(row, dict) else '' | |
| if not isinstance(row, dict): | |
| return False | |
| conn = get_db() | |
| override_in = conn.execute( | |
| "SELECT 1 FROM athlete_overrides WHERE name = ? AND team_id = ? AND is_chaoyang = 1 LIMIT 1", | |
| (name, team_id) | |
| ).fetchone() | |
| if override_in: | |
| return True | |
| override_out = conn.execute( | |
| "SELECT 1 FROM athlete_overrides WHERE name = ? AND team_id = ? AND is_chaoyang = 0 LIMIT 1", | |
| (name, team_id) | |
| ).fetchone() | |
| if override_out: | |
| return False | |
| if team_id == 'chaoyang' and row.get('is_chaoyang'): | |
| return True | |
| if any(alias and alias in team_text for alias in aliases): | |
| return True | |
| return False | |
| def _get_team_athlete_names(conn, team_id, include_override_only=True): | |
| """获取当前队伍的运动员名单""" | |
| filter_clause, filter_params = _build_team_filter_clause(conn, team_id, 'r') | |
| rows = conn.execute(f""" | |
| SELECT DISTINCT r.name | |
| FROM results r | |
| WHERE r.name IS NOT NULL AND r.name != '' | |
| AND {filter_clause} | |
| ORDER BY r.name | |
| """, filter_params).fetchall() | |
| seen = set() | |
| names = [] | |
| for row in rows: | |
| name = row['name'] | |
| if name and name not in seen: | |
| seen.add(name) | |
| names.append(name) | |
| if team_id == 'chaoyang': | |
| manual_rows = conn.execute( | |
| "SELECT name FROM athletes WHERE deleted_at IS NULL ORDER BY name" | |
| ).fetchall() | |
| for row in manual_rows: | |
| name = row['name'] | |
| if name and name not in seen: | |
| seen.add(name) | |
| names.append(name) | |
| for name in get_active_athlete_names(): | |
| if name and name not in seen: | |
| seen.add(name) | |
| names.append(name) | |
| if include_override_only: | |
| override_rows = conn.execute( | |
| "SELECT name FROM athlete_overrides WHERE team_id = ? AND is_chaoyang = 1 ORDER BY name", | |
| (team_id,) | |
| ).fetchall() | |
| for row in override_rows: | |
| name = row['name'] | |
| if name and name not in seen: | |
| seen.add(name) | |
| names.append(name) | |
| return names | |
| def _get_team_sync_payload(conn, team_id): | |
| """获取当前队伍同步所需的运动员与成绩数据""" | |
| filter_clause, filter_params = _build_team_filter_clause(conn, team_id, 'r') | |
| aliases = _get_team_alias_values(conn, team_id) | |
| athlete_rows = conn.execute(f""" | |
| SELECT DISTINCT r.name, a.gender, a.category | |
| FROM results r | |
| LEFT JOIN athletes a ON a.name = r.name AND a.deleted_at IS NULL | |
| WHERE {filter_clause} | |
| ORDER BY r.name | |
| """, filter_params).fetchall() | |
| athletes = [] | |
| seen_names = set() | |
| for row in athlete_rows: | |
| if row['name'] in seen_names: | |
| continue | |
| seen_names.add(row['name']) | |
| athletes.append({ | |
| 'name': row['name'], | |
| 'gender': row['gender'], | |
| 'category': row['category'], | |
| 'is_chaoyang': 1, | |
| }) | |
| if team_id == 'chaoyang': | |
| manual_rows = conn.execute("SELECT name, gender, category FROM athletes WHERE deleted_at IS NULL ORDER BY name").fetchall() | |
| for row in manual_rows: | |
| if row['name'] not in seen_names: | |
| seen_names.add(row['name']) | |
| athletes.append({ | |
| 'name': row['name'], | |
| 'gender': row['gender'], | |
| 'category': row['category'], | |
| 'is_chaoyang': 1, | |
| }) | |
| override_rows = conn.execute( | |
| "SELECT name FROM athlete_overrides WHERE team_id = ? AND is_chaoyang = 1 ORDER BY name", | |
| (team_id,) | |
| ).fetchall() | |
| for row in override_rows: | |
| if row['name'] not in seen_names: | |
| manual = conn.execute( | |
| "SELECT gender, category FROM athletes WHERE name = ? AND deleted_at IS NULL", | |
| (row['name'],) | |
| ).fetchone() | |
| seen_names.add(row['name']) | |
| athletes.append({ | |
| 'name': row['name'], | |
| 'gender': manual['gender'] if manual else None, | |
| 'category': manual['category'] if manual else None, | |
| 'is_chaoyang': 1, | |
| }) | |
| results = conn.execute(f""" | |
| SELECT r.name as athlete_name, r.team, r.place, r.time_result, r.qual, r.is_chaoyang, | |
| rg.race_date, rg.event, rg.category, rg.gender as race_gender, rg.round_type, | |
| m.title as match_title, m.season, m.venue | |
| FROM results r | |
| LEFT JOIN race_groups rg ON r.rcbh = rg.rcbh | |
| LEFT JOIN matches m ON rg.match_id = m.id | |
| WHERE {filter_clause} | |
| ORDER BY rg.race_date DESC | |
| """, filter_params).fetchall() | |
| result_dicts = [dict(r) for r in results] | |
| opponents = [] | |
| seen_opponents = set() | |
| for row in result_dicts: | |
| if row['athlete_name'] in seen_names: | |
| continue | |
| if _row_belongs_to_team(row, team_id, aliases): | |
| continue | |
| key = (row['athlete_name'], row.get('team') or '') | |
| if key in seen_opponents: | |
| continue | |
| seen_opponents.add(key) | |
| opponents.append({ | |
| 'name': row['athlete_name'], | |
| 'team': row.get('team'), | |
| 'is_chaoyang': 0, | |
| }) | |
| return athletes, result_dicts, opponents | |
| # 全局错误处理中间件 | |
| def internal_error(error): | |
| """统一500错误处理,返回JSON而非HTML""" | |
| StructuredLogger.error("internal_error", error=str(error), path=request.path) | |
| return jsonify({"error": "服务器内部错误,请稍后重试"}), 500 | |
| def not_found(error): | |
| """统一404错误处理""" | |
| if request.path.startswith('/api/'): | |
| return jsonify({"error": "资源不存在"}), 404 | |
| return send_from_directory('static', 'index.html') | |
| def method_not_allowed(error): | |
| """统一405错误处理""" | |
| return jsonify({"error": "请求方法不允许"}), 405 | |
| def handle_exception(error): | |
| """捕获所有未处理异常,返回JSON""" | |
| if request.path.startswith('/api/'): | |
| StructuredLogger.error("unhandled_exception", error=str(error), path=request.path, method=request.method) | |
| return jsonify({"error": "服务器内部错误,请稍后重试"}), 500 | |
| raise error | |
| def shutdown_session(exception=None): | |
| """请求结束时关闭数据库连接""" | |
| close_db() | |
| def trigger_db_sync(response): | |
| """写操作成功后,仅 Master 异步推送DB到 Slave""" | |
| if SERVER_ROLE == 'master' and PEER_URL and request.method in ("POST", "PUT", "DELETE", "PATCH"): | |
| if 200 <= response.status_code < 300: | |
| schedule_db_push() | |
| return response | |
| def add_no_cache_headers(response): | |
| """禁止浏览器缓存HTML页面,确保总是获取最新版本""" | |
| if response.content_type and 'text/html' in response.content_type: | |
| response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' | |
| response.headers['Pragma'] = 'no-cache' | |
| response.headers['Expires'] = '0' | |
| return response | |
| def index(): | |
| return send_from_directory("static", "index.html") | |
| def api_ping(): | |
| """健康检查 — 供前端测速和角色判断""" | |
| from database import get_db | |
| try: | |
| db = get_db() | |
| db.execute("SELECT 1") | |
| db_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", "short_track.db") | |
| db_size = os.path.getsize(db_path) if os.path.exists(db_path) else 0 | |
| return jsonify({ | |
| "status": "ok", | |
| "server_role": SERVER_ROLE, | |
| "version": APP_VERSION, | |
| "db_size_bytes": db_size | |
| }), 200 | |
| except Exception as e: | |
| return jsonify({"status": "error", "message": str(e)}), 500 | |
| def api_log_visit(): | |
| """前端记录访问(避免Service Worker缓存导致不记录)""" | |
| try: | |
| ip = request.headers.get('X-Forwarded-For', request.remote_addr or '').split(',')[0].strip() | |
| if not ip: | |
| ip = request.remote_addr or 'unknown' | |
| user_agent = request.headers.get('User-Agent', '')[:500] | |
| page = request.json.get('page', '/') if request.is_json else '/' | |
| log_visit(ip, user_agent, page) | |
| except Exception: | |
| pass | |
| return jsonify({"status": "ok"}) | |
| def is_athlete_active(conn, name, team_id='chaoyang'): | |
| """判断队员是否仍在当前队伍的活跃名单中""" | |
| if team_id == 'chaoyang': | |
| manual = conn.execute("SELECT id FROM athletes WHERE name = ? AND deleted_at IS NULL", (name,)).fetchone() | |
| if manual: | |
| return True | |
| override = conn.execute( | |
| "SELECT is_chaoyang FROM athlete_overrides WHERE name = ? AND team_id = ?", | |
| (name, team_id) | |
| ).fetchone() | |
| if override and override["is_chaoyang"]: | |
| return True | |
| if team_id == 'chaoyang': | |
| cutoff = (datetime.now() - timedelta(days=180)).strftime('%Y-%m-%d') | |
| has_training = conn.execute( | |
| """SELECT 1 FROM training_records tr | |
| LEFT JOIN athlete_nicknames an ON tr.athlete_name = an.nickname | |
| WHERE (tr.athlete_name = ? OR an.real_name = ?) AND tr.training_date >= ? LIMIT 1""", | |
| (name, name, cutoff) | |
| ).fetchone() | |
| if has_training: | |
| return True | |
| filter_clause, filter_params = _build_team_filter_clause(conn, team_id, 'r') | |
| last_team_row = conn.execute(f""" | |
| SELECT m.season | |
| FROM results r | |
| JOIN race_groups rg ON r.rcbh = rg.rcbh | |
| JOIN matches m ON rg.match_id = m.id | |
| WHERE r.name = ? AND {filter_clause} | |
| ORDER BY m.date_range DESC, rg.race_date DESC | |
| LIMIT 1 | |
| """, [name] + filter_params).fetchone() | |
| if not last_team_row: | |
| return False | |
| seasons = [s["season"] for s in conn.execute( | |
| "SELECT DISTINCT season FROM matches ORDER BY date_range DESC" | |
| ).fetchall()] | |
| if not seasons: | |
| return True | |
| return last_team_row["season"] in seasons[:2] | |
| def api_athletes(): | |
| """获取当前队伍当前活跃运动员列表,按荣誉排名""" | |
| try: | |
| from database import get_db | |
| conn = get_db() | |
| team_id = _get_effective_team_id() | |
| team_names = _get_team_athlete_names(conn, team_id, include_override_only=False) | |
| all_athletes_data = database.get_athletes_with_details(team_names) | |
| athlete_data_map = {a['name']: a for a in all_athletes_data} | |
| athlete_map = {} | |
| for name in sorted(team_names): | |
| if name in athlete_map: | |
| continue | |
| if _is_team_name(name): | |
| continue | |
| if not is_athlete_active(conn, name, team_id): | |
| continue | |
| row = conn.execute(""" | |
| SELECT team | |
| FROM results r | |
| WHERE r.name = ? | |
| ORDER BY CASE WHEN r.team IS NULL OR r.team = '' THEN 1 ELSE 0 END, r.id DESC | |
| LIMIT 1 | |
| """, (name,)).fetchone() | |
| team_name = row['team'] if row and row['team'] else get_team_config(team_id)['team_name'] | |
| athlete_map[name] = {"name": name, "team": team_name, "categories": [], "genders": []} | |
| filter_clause, filter_params = _build_team_filter_clause(conn, team_id, 'r') | |
| for name in athlete_map: | |
| cat_rows = conn.execute(f""" | |
| SELECT rg.category, rg.gender, m.date_range | |
| FROM results r | |
| JOIN race_groups rg ON r.rcbh = rg.rcbh | |
| JOIN matches m ON rg.match_id = m.id | |
| WHERE r.name = ? AND {filter_clause} AND rg.category IS NOT NULL | |
| """, [name] + filter_params).fetchall() | |
| def parse_end_date(dr): | |
| try: | |
| end_part = dr.split('—')[-1].strip() | |
| parts = end_part.split('/') | |
| return f"{parts[0]}-{int(parts[1]):02d}-{int(parts[2]):02d}" | |
| except Exception: | |
| return dr | |
| cat_rows_sorted = sorted(cat_rows, key=lambda r: parse_end_date(r['date_range']), reverse=True) | |
| seen_cats = set() | |
| seen_genders = set() | |
| for lc in cat_rows_sorted: | |
| if lc["category"] and lc["category"] not in seen_cats: | |
| athlete_map[name]["categories"].append(lc["category"]) | |
| seen_cats.add(lc["category"]) | |
| if lc["gender"] and lc["gender"] not in seen_genders: | |
| athlete_map[name]["genders"].append(lc["gender"]) | |
| seen_genders.add(lc["gender"]) | |
| if team_id == 'chaoyang': | |
| manual_athletes = conn.execute("SELECT name, gender, category FROM athletes WHERE deleted_at IS NULL").fetchall() | |
| for ma in manual_athletes: | |
| name = ma["name"] | |
| if name not in athlete_map and not _is_team_name(name): | |
| athlete_map[name] = {"name": name, "team": get_team_config(team_id)['team_name'], "categories": [], "genders": []} | |
| if ma["category"]: | |
| athlete_map[name]["categories"].insert(0, ma["category"]) | |
| if ma["gender"]: | |
| athlete_map[name]["genders"].insert(0, ma["gender"]) | |
| active_training_names = get_active_athlete_names() | |
| for name in active_training_names: | |
| if name not in athlete_map and not _is_team_name(name): | |
| athlete_map[name] = {"name": name, "team": get_team_config(team_id)['team_name'], "categories": [], "genders": []} | |
| all_honors = get_athlete_honors() | |
| honors_map = {} | |
| for h in all_honors: | |
| if h['athlete_name'] in athlete_map: | |
| honors_map.setdefault(h['athlete_name'], []).append(h) | |
| relay_filter_clause, relay_filter_params = _build_team_filter_clause(conn, team_id, 'r') | |
| relay_medals_raw = conn.execute(f""" | |
| SELECT rm.athlete_name, r.place | |
| FROM relay_members rm | |
| JOIN results r ON r.name = rm.relay_team_name | |
| JOIN race_groups rg ON r.rcbh = rg.rcbh | |
| JOIN matches m ON rg.match_id = m.id | |
| WHERE {relay_filter_clause} | |
| AND r.place IN ('1', '2', '3') | |
| AND (rg.round_type = '决赛/Finals' OR rg.round_type = '决赛A' OR rg.round_type = '决赛' OR rg.round_type = '决赛B') | |
| AND (rm.season IS NULL OR rm.season = m.season) | |
| AND (rm.event IS NULL OR rm.event = rg.event) | |
| AND rm.athlete_name IS NOT NULL | |
| """, relay_filter_params).fetchall() | |
| relay_gold_map = {} | |
| relay_medal_map = {} | |
| for rm in relay_medals_raw: | |
| n = rm['athlete_name'] | |
| p = str(rm['place']) | |
| if p == '1': | |
| relay_gold_map[n] = relay_gold_map.get(n, 0) + 1 | |
| relay_medal_map[n] = relay_medal_map.get(n, 0) + 1 | |
| for name, info in athlete_map.items(): | |
| gold = 0 | |
| total = 0 | |
| athlete_results = conn.execute(f""" | |
| SELECT r.place, rg.round_type | |
| FROM results r | |
| JOIN race_groups rg ON r.rcbh = rg.rcbh | |
| WHERE r.name = ? AND {filter_clause} | |
| """, [name] + filter_params).fetchall() | |
| for result_row in athlete_results: | |
| p = str(result_row['place']) | |
| rt = result_row['round_type'] or '' | |
| is_final = rt in ('决赛', '决赛/Finals', '决赛A', '决赛B') | |
| if p == '1' and is_final: | |
| gold += 1 | |
| if p in ('1', '2', '3') and is_final: | |
| total += 1 | |
| gold += relay_gold_map.get(name, 0) | |
| total += relay_medal_map.get(name, 0) | |
| info['gold_medals'] = gold | |
| info['total_medals'] = total | |
| info['total_results'] = len(athlete_results) | |
| info['honors'] = honors_map.get(name, []) | |
| result_list = list(athlete_map.values()) | |
| result_list.sort(key=lambda x: (-x['gold_medals'], -x['total_medals'], x['name'])) | |
| return jsonify(result_list) | |
| except Exception as e: | |
| logging.error(f"Error fetching athletes: {e}") | |
| return jsonify({'error': 'Failed to fetch athletes'}), 500 | |
| def api_athlete_detail(name): | |
| # 获取运动员参加的项目列表 | |
| events = get_athlete_events(name) | |
| # 获取运动员参加的项目类别 | |
| categories = get_athlete_categories(name) | |
| # 获取运动员比赛成绩 | |
| results = get_athlete_results(name) | |
| # 获取运动员接力比赛成绩 | |
| relay_results = get_athlete_relay_results(name) | |
| # 获取运动员代表过的所有队伍 | |
| from database import get_db as _get_db | |
| conn = _get_db() | |
| teams = conn.execute(""" | |
| SELECT DISTINCT r.team, r.is_chaoyang, | |
| COUNT(*) as race_count | |
| FROM results r | |
| WHERE r.name = ? | |
| GROUP BY r.team, r.is_chaoyang | |
| ORDER BY race_count DESC | |
| """, (name,)).fetchall() | |
| # 获取手动添加的队员信息 | |
| manual_info = conn.execute("SELECT gender, category, notes FROM athletes WHERE name = ? AND deleted_at IS NULL", (name,)).fetchone() | |
| conn.close() | |
| # 获取运动员荣誉 | |
| honors = get_athlete_honors(name) | |
| # 获取运动员自定义成绩(同步显示到运动成绩页) | |
| custom_results = get_athlete_custom_results(name) | |
| return jsonify({ | |
| "name": name, | |
| "events": events, | |
| "categories": categories, | |
| "results": results, | |
| "relay_results": relay_results, | |
| "teams": [dict(t) for t in teams], | |
| "manual_info": dict(manual_info) if manual_info else None, | |
| "honors": honors, | |
| "custom_results": custom_results, | |
| }) | |
| def api_athlete_comparison(name): | |
| """获取运动员与对手的对比数据""" | |
| event = request.args.get("event", "") | |
| category = request.args.get("category", "") | |
| gender = request.args.get("gender", "") | |
| if not all([event, category, gender]): | |
| return jsonify({"error": "需要提供 event, category, gender 参数"}), 400 | |
| # 获取运动员自己的数据 | |
| athlete_data = get_event_timeseries(name, event) | |
| for d in athlete_data: | |
| d["seconds"] = parse_time_to_seconds(d.get("time_result", "")) | |
| # 获取对手数据 | |
| opponents = get_opponent_results(event, category, gender, exclude_name=name) | |
| # 按对手名字分组 | |
| opponent_map = {} | |
| for o in opponents: | |
| oname = o["name"] | |
| if oname not in opponent_map: | |
| opponent_map[oname] = {"name": oname, "team": o["team"], "data": []} | |
| seconds = parse_time_to_seconds(o.get("time_result", "")) | |
| if seconds is not None: | |
| opponent_map[oname]["data"].append({ | |
| **o, | |
| "seconds": seconds, | |
| }) | |
| return jsonify({ | |
| "athlete": {"name": name, "data": athlete_data}, | |
| "opponents": list(opponent_map.values()), | |
| }) | |
| def api_opponents(): | |
| """获取指定项目/组别的对手列表""" | |
| event = request.args.get("event", "") | |
| category = request.args.get("category", "") | |
| gender = request.args.get("gender", "") | |
| if not all([event, category, gender]): | |
| return jsonify({"error": "需要提供 event, category, gender 参数"}), 400 | |
| opponents = get_opponent_results(event, category, gender) | |
| # 按名字分组 | |
| opponent_map = {} | |
| for o in opponents: | |
| oname = o["name"] | |
| if oname not in opponent_map: | |
| opponent_map[oname] = {"name": oname, "team": o["team"]} | |
| return jsonify(list(opponent_map.values())) | |
| def api_opponent_timeseries(name): | |
| """获取对手的成绩时间序列""" | |
| event = request.args.get("event", "") | |
| if not event: | |
| return jsonify({"error": "需要提供 event 参数"}), 400 | |
| from database import get_db | |
| conn = get_db() | |
| rows = conn.execute(""" | |
| SELECT r.time_result, r.place, r.qual, rg.race_date, rg.round_type, | |
| rg.category, rg.gender, m.title as match_title, m.season | |
| FROM results r | |
| JOIN race_groups rg ON r.rcbh = rg.rcbh | |
| JOIN matches m ON rg.match_id = m.id | |
| INNER JOIN ( | |
| SELECT rg2.match_id, MIN(r2.time_result) as best_time | |
| FROM results r2 | |
| JOIN race_groups rg2 ON r2.rcbh = rg2.rcbh | |
| WHERE r2.name = ? AND rg2.event = ? | |
| AND r2.time_result NOT LIKE '%00:00.000%' | |
| AND r2.qual != '犯规' | |
| GROUP BY rg2.match_id | |
| ) best ON rg.match_id = best.match_id AND r.time_result = best.best_time | |
| WHERE r.name = ? AND rg.event = ? | |
| AND r.time_result NOT LIKE '%00:00.000%' | |
| AND r.qual != '犯规' | |
| GROUP BY rg.match_id | |
| ORDER BY m.season, rg.race_date | |
| """, (name, event, name, event)).fetchall() | |
| conn.close() | |
| data = [] | |
| for r in rows: | |
| d = dict(r) | |
| d["seconds"] = parse_time_to_seconds(d.get("time_result", "")) | |
| if d["seconds"] is not None: | |
| d["_sort_date"] = parse_chinese_date(d.get("race_date", "")) | |
| data.append(d) | |
| # 按赛季+真实日期排序 | |
| data.sort(key=lambda x: (x.get("season", ""), x.get("_sort_date", ""))) | |
| for d in data: | |
| d.pop("_sort_date", None) | |
| return jsonify(data) | |
| def api_opponent_summary(name): | |
| """获取对手所有项目的最好成绩概要""" | |
| from database import get_db | |
| conn = get_db() | |
| rows = conn.execute(""" | |
| SELECT rg.event, rg.category, | |
| MIN(r.time_result) as best_time, | |
| MIN(CAST(r.place AS INTEGER)) as best_place, | |
| COUNT(DISTINCT m.id) as match_count, | |
| COUNT(*) as race_count | |
| FROM results r | |
| JOIN race_groups rg ON r.rcbh = rg.rcbh | |
| JOIN matches m ON rg.match_id = m.id | |
| WHERE r.name = ? | |
| AND r.time_result NOT LIKE '%00:00.000%' | |
| AND r.qual != '犯规' | |
| GROUP BY rg.event, rg.category | |
| ORDER BY rg.event, rg.category | |
| """, (name,)).fetchall() | |
| conn.close() | |
| # 按项目分组 | |
| event_map = {} | |
| for r in rows: | |
| ev = r["event"] | |
| if ev not in event_map: | |
| event_map[ev] = [] | |
| event_map[ev].append({ | |
| "category": r["category"], | |
| "best_time": r["best_time"], | |
| "best_place": r["best_place"], | |
| "match_count": r["match_count"], | |
| "race_count": r["race_count"], | |
| }) | |
| return jsonify(event_map) | |
| def api_athlete_personal_bests(name): | |
| """获取运动员各项目的个人最好成绩(PB)""" | |
| from database import get_db | |
| conn = get_db() | |
| rows = conn.execute(""" | |
| SELECT rg.event, | |
| MIN(r.time_result) as best_time, | |
| MIN(CAST(r.place AS INTEGER)) as best_place, | |
| COUNT(DISTINCT m.id) as match_count, | |
| COUNT(*) as race_count | |
| FROM results r | |
| JOIN race_groups rg ON r.rcbh = rg.rcbh | |
| JOIN matches m ON rg.match_id = m.id | |
| WHERE r.name = ? | |
| AND r.time_result NOT LIKE '%00:00.000%' | |
| AND r.time_result NOT LIKE '%NO TIME%' | |
| AND r.time_result != '' | |
| AND r.qual != '犯规' | |
| GROUP BY rg.event | |
| ORDER BY rg.event | |
| """, (name,)).fetchall() | |
| # Get the match info for each PB (the specific race where PB was achieved) | |
| pb_matches = conn.execute(""" | |
| SELECT rg.event, m.title as pb_match_title, rg.race_date as pb_race_date, | |
| rg.round_type as pb_round_type, rg.category as pb_category | |
| FROM results r | |
| JOIN race_groups rg ON r.rcbh = rg.rcbh | |
| JOIN matches m ON rg.match_id = m.id | |
| WHERE r.name = ? | |
| AND r.qual != '犯规' | |
| AND r.time_result NOT LIKE '%00:00.000%' | |
| AND r.time_result NOT LIKE '%NO TIME%' | |
| AND r.time_result != '' | |
| AND r.time_result IN ( | |
| SELECT MIN(r2.time_result) | |
| FROM results r2 | |
| JOIN race_groups rg2 ON r2.rcbh = rg2.rcbh | |
| WHERE r2.name = ? | |
| AND r2.qual != '犯规' | |
| AND r2.time_result NOT LIKE '%00:00.000%' | |
| AND r2.time_result NOT LIKE '%NO TIME%' | |
| AND r2.time_result != '' | |
| AND rg2.event = rg.event | |
| ) | |
| """, (name, name)).fetchall() | |
| pb_match_map = {r["event"]: dict(r) for r in pb_matches} | |
| conn.close() | |
| result = [] | |
| for r in rows: | |
| pb_info = pb_match_map.get(r["event"], {}) | |
| result.append({ | |
| "event": r["event"], | |
| "best_time": r["best_time"], | |
| "best_place": r["best_place"], | |
| "match_count": r["match_count"], | |
| "race_count": r["race_count"], | |
| "pb_match_title": pb_info.get("pb_match_title", ""), | |
| "pb_race_date": pb_info.get("pb_race_date", ""), | |
| "pb_round_type": pb_info.get("pb_round_type", ""), | |
| "pb_category": pb_info.get("pb_category", ""), | |
| }) | |
| return jsonify(result) | |
| # ========== 运动员荣誉 API ========== | |
| def api_athlete_honors(name): | |
| """获取运动员荣誉列表""" | |
| honors = get_athlete_honors(name) | |
| return jsonify(honors) | |
| def api_all_honors(): | |
| """获取所有运动员荣誉(管理员)""" | |
| honors = get_athlete_honors() | |
| return jsonify(honors) | |
| def api_add_honor(): | |
| """添加运动员荣誉""" | |
| data = request.json | |
| athlete_name = data.get("athlete_name", "").strip() | |
| title = data.get("title", "").strip() | |
| awarded_date = data.get("awarded_date", "").strip() or None | |
| source = data.get("source", "").strip() or None | |
| if not athlete_name or not title: | |
| return jsonify({"error": "队员姓名和荣誉名称不能为空"}), 400 | |
| if add_athlete_honor(athlete_name, title, awarded_date, source): | |
| return jsonify({"status": "success"}) | |
| return jsonify({"error": "添加失败"}), 400 | |
| def api_update_honor(honor_id): | |
| """更新运动员荣誉""" | |
| data = request.json | |
| title = data.get("title") | |
| awarded_date = data.get("awarded_date") | |
| source = data.get("source") | |
| update_athlete_honor(honor_id, title, awarded_date, source) | |
| return jsonify({"status": "success"}) | |
| def api_delete_honor(honor_id): | |
| """删除运动员荣誉""" | |
| delete_athlete_honor(honor_id) | |
| return jsonify({"status": "success"}) | |
| # ========== 运动员个人中心 API ========== | |
| def api_athlete_profile(name): | |
| """获取运动员个人中心数据""" | |
| profile = get_athlete_profile(name) | |
| custom_results = get_athlete_custom_results(name) | |
| honors = get_athlete_honors(name) | |
| events = get_athlete_events(name) | |
| # 获取运动员代表过的所有队伍 | |
| from database import get_db as _get_db | |
| conn = _get_db() | |
| teams = conn.execute(""" | |
| SELECT DISTINCT r.team, r.is_chaoyang, | |
| COUNT(*) as race_count | |
| FROM results r | |
| WHERE r.name = ? | |
| GROUP BY r.team, r.is_chaoyang | |
| ORDER BY race_count DESC | |
| """, (name,)).fetchall() | |
| # 获取手动添加的队员信息 | |
| manual_info = conn.execute("SELECT gender, category, notes FROM athletes WHERE name = ? AND deleted_at IS NULL", (name,)).fetchone() | |
| # 获取运动员荣誉 | |
| honors = get_athlete_honors(name) | |
| # 获取运动员自定义成绩(同步显示到运动成绩页) | |
| custom_results = get_athlete_custom_results(name) | |
| # 获取运动员爬虫比赛成绩(用于个人中心成绩TAB) | |
| results = conn.execute(""" | |
| SELECT r.time_result, r.place, r.qual, r.is_chaoyang, r.team, | |
| rg.race_date, rg.round_type, rg.event, rg.category, rg.gender, | |
| m.title as match_title, m.season, m.id as match_id | |
| FROM results r | |
| JOIN race_groups rg ON r.rcbh = rg.rcbh | |
| JOIN matches m ON rg.match_id = m.id | |
| WHERE r.name = ? | |
| ORDER BY COALESCE(m.date_range, '') DESC, rg.race_date DESC | |
| """, (name,)).fetchall() | |
| conn.close() | |
| # Sort results by date (newest first) | |
| results_list = [dict(r) for r in results] | |
| results_list.sort(key=lambda r: ( | |
| r.get('season', ''), | |
| parse_chinese_date(r.get('race_date', '')) | |
| ), reverse=True) | |
| # 获取运动员接力成绩 | |
| relay_results = get_athlete_relay_results(name) | |
| return jsonify({ | |
| "name": name, | |
| "profile": profile, | |
| "events": events, | |
| "teams": [dict(t) for t in teams], | |
| "manual_info": dict(manual_info) if manual_info else None, | |
| "honors": honors, | |
| "custom_results": custom_results, | |
| "results": results_list, | |
| "relay_results": relay_results, | |
| "custom_medals": { | |
| "gold": sum(1 for r in custom_results if str(r.get("place", "")) == "1"), | |
| "silver": sum(1 for r in custom_results if str(r.get("place", "")) == "2"), | |
| "bronze": sum(1 for r in custom_results if str(r.get("place", "")) == "3"), | |
| }, | |
| }) | |
| def api_update_athlete_bio(name): | |
| """更新队员简介""" | |
| if not can_edit_athlete(name): | |
| return jsonify({"error": "没有权限编辑该队员资料"}), 403 | |
| data = request.json | |
| bio = (data.get("bio") or "").strip() | |
| if len(bio) > 500: | |
| return jsonify({"error": "简介不能超过500字"}), 400 | |
| update_athlete_profile(name, bio, updated_by=session.get('username')) | |
| return jsonify({"status": "success"}) | |
| def api_add_custom_result(name): | |
| """添加自定义成绩""" | |
| if not can_edit_athlete(name): | |
| return jsonify({"error": "没有权限编辑该队员资料"}), 403 | |
| data = request.json | |
| competition_date = (data.get("competition_date") or "").strip() | |
| competition_name = (data.get("competition_name") or "").strip() | |
| result_text = (data.get("result_text") or "").strip() | |
| event = (data.get("event") or "").strip() | |
| if not competition_name or not result_text: | |
| return jsonify({"error": "比赛名称和成绩不能为空"}), 400 | |
| add_athlete_custom_result( | |
| name, competition_date or None, competition_name, result_text, | |
| created_by=session.get('username'), | |
| event=event or None, | |
| round_type=(data.get("round_type") or "").strip() or None, | |
| team=(data.get("team") or "").strip() or None, | |
| time_result=(data.get("time_result") or "").strip() or None, | |
| place=data.get("place"), | |
| season=(data.get("season") or "").strip() or None | |
| ) | |
| return jsonify({"status": "success"}) | |
| def api_athlete_events_list(name): | |
| """获取运动员参赛项目列表""" | |
| events = get_athlete_events(name) | |
| return jsonify(events) | |
| def api_athlete_timeseries(name, event): | |
| """获取运动员某项目的时间序列成绩(用于曲线图)""" | |
| from database import get_db | |
| conn = get_db() | |
| rows = conn.execute(""" | |
| SELECT r.time_result, r.place, r.qual, rg.race_date, rg.round_type, | |
| rg.category, rg.gender, m.title as match_title, m.season | |
| FROM results r | |
| JOIN race_groups rg ON r.rcbh = rg.rcbh | |
| JOIN matches m ON rg.match_id = m.id | |
| INNER JOIN ( | |
| SELECT rg2.match_id, MIN(r2.time_result) as best_time | |
| FROM results r2 | |
| JOIN race_groups rg2 ON r2.rcbh = rg2.rcbh | |
| WHERE r2.name = ? AND rg2.event = ? | |
| AND r2.time_result NOT LIKE '%00:00.000%' | |
| AND r2.qual != '犯规' | |
| GROUP BY rg2.match_id | |
| ) best ON rg.match_id = best.match_id AND r.time_result = best.best_time | |
| WHERE r.name = ? AND rg.event = ? | |
| AND r.time_result NOT LIKE '%00:00.000%' | |
| AND r.qual != '犯规' | |
| GROUP BY rg.match_id | |
| ORDER BY m.season, rg.race_date | |
| """, (name, event, name, event)).fetchall() | |
| data = [] | |
| for r in rows: | |
| d = dict(r) | |
| d["seconds"] = parse_time_to_seconds(d.get("time_result", "")) | |
| if d["seconds"] is not None: | |
| d["_sort_date"] = parse_chinese_date(d.get("race_date", "")) | |
| data.append(d) | |
| # Include custom results for the same event | |
| custom_rows = conn.execute(""" | |
| SELECT cr.time_result, CAST(cr.place AS TEXT) as place, cr.round_type as qual, | |
| cr.competition_date as race_date, cr.round_type, | |
| NULL as category, NULL as gender, cr.competition_name as match_title, cr.season | |
| FROM athlete_custom_results cr | |
| WHERE cr.athlete_name = ? AND cr.event = ? | |
| AND cr.time_result IS NOT NULL AND cr.time_result != '' | |
| """, (name, event)).fetchall() | |
| for r in custom_rows: | |
| d = dict(r) | |
| d["seconds"] = parse_time_to_seconds(d.get("time_result", "")) | |
| if d["seconds"] is not None: | |
| d["_sort_date"] = d.get("race_date", "") or "" | |
| d["source"] = "custom" | |
| data.append(d) | |
| # 按赛季+真实日期排序 | |
| data.sort(key=lambda x: (x.get("season", ""), x.get("_sort_date", ""))) | |
| for d in data: | |
| d.pop("_sort_date", None) | |
| conn.close() | |
| return jsonify(data) | |
| def api_recalculate_chaoyang(): | |
| """重新计算朝阳队标记""" | |
| recalculate_chaoyang_flags() | |
| return jsonify({"status": "success"}) | |
| def api_delete_custom_result(name, result_id): | |
| """删除自定义成绩""" | |
| if not can_edit_athlete(name): | |
| return jsonify({"error": "没有权限编辑该队员资料"}), 403 | |
| delete_athlete_custom_result(result_id, name) | |
| return jsonify({"status": "success"}) | |
| def api_update_custom_result(name, result_id): | |
| """修改自定义成绩""" | |
| if not can_edit_athlete(name): | |
| return jsonify({"error": "没有权限编辑该队员资料"}), 403 | |
| data = request.json | |
| competition_date = (data.get("competition_date") or "").strip() | |
| competition_name = (data.get("competition_name") or "").strip() | |
| result_text = (data.get("result_text") or "").strip() | |
| event = (data.get("event") or "").strip() | |
| round_type = (data.get("round_type") or "").strip() | |
| team = (data.get("team") or "").strip() | |
| time_result = (data.get("time_result") or "").strip() | |
| place = data.get("place") | |
| season = (data.get("season") or "").strip() | |
| if not competition_name: | |
| return jsonify({"error": "比赛名称不能为空"}), 400 | |
| if place is not None: | |
| try: | |
| place = int(place) | |
| except (ValueError, TypeError): | |
| place = None | |
| update_athlete_custom_result(result_id, name, | |
| competition_date=competition_date or None, | |
| competition_name=competition_name or None, | |
| result_text=result_text or None, | |
| event=event or None, | |
| round_type=round_type or None, | |
| team=team or None, | |
| time_result=time_result or None, | |
| place=place, | |
| season=season or None) | |
| return jsonify({"status": "success"}) | |
| def can_edit_athlete(name): | |
| """判断当前用户是否可以编辑指定运动员资料""" | |
| if session.get('role') in ('admin', 'superadmin'): | |
| return True | |
| return is_family_of(session['user_id'], name) | |
| def api_family_add_honor(name): | |
| """家属添加荣誉(待审核)""" | |
| if not can_edit_athlete(name): | |
| return jsonify({"error": "没有权限编辑该队员资料"}), 403 | |
| data = request.json | |
| title = (data.get("title") or "").strip() | |
| awarded_date = (data.get("awarded_date") or "").strip() or None | |
| if not title: | |
| return jsonify({"error": "荣誉名称不能为空"}), 400 | |
| add_athlete_honor(name, title, awarded_date, source="家属添加") | |
| return jsonify({"status": "success"}) | |
| def api_family_delete_honor(name, honor_id): | |
| """家属删除荣誉""" | |
| if not can_edit_athlete(name): | |
| return jsonify({"error": "没有权限编辑该队员资料"}), 403 | |
| delete_athlete_honor(honor_id) | |
| return jsonify({"status": "success"}) | |
| def api_matches(): | |
| """获取比赛列表(默认只返回朝阳队有人参加的比赛)""" | |
| season = request.args.get("season", "") | |
| matches = get_all_matches(season if season else None, chaoyang_only=True) | |
| return jsonify(matches) | |
| def api_current_matches(): | |
| """获取正在进行的比赛""" | |
| matches = get_current_matches() | |
| return jsonify(matches) | |
| def api_match_groups(match_id): | |
| """获取比赛的分组信息""" | |
| groups = get_match_race_groups(match_id) | |
| return jsonify(groups) | |
| def api_match_chaoyang_opponents(match_id): | |
| """获取当前比赛中所有分组信息,包括有朝阳队员和没有朝阳队员的分组""" | |
| from database import get_db | |
| conn = get_db() | |
| groups = get_match_race_groups(match_id) | |
| result = [] | |
| for g in groups: | |
| rcbh = g["rcbh"] | |
| race_results = conn.execute(""" | |
| SELECT r.name, r.team, r.place, r.time_result, r.qual, r.is_chaoyang, r.heat_number | |
| FROM results r | |
| WHERE r.rcbh = ? | |
| ORDER BY r.heat_number, r.place | |
| """, (rcbh,)).fetchall() | |
| # 按 heat_number 分组 | |
| heat_map = {} | |
| for r in race_results: | |
| hn = r["heat_number"] or 1 | |
| if hn not in heat_map: | |
| heat_map[hn] = [] | |
| heat_map[hn].append(dict(r)) | |
| # 检查是否有朝阳队员 | |
| has_chaoyang = any(r["is_chaoyang"] for r in race_results) | |
| if has_chaoyang: | |
| # 为每个小组中的朝阳队员收集同组对手 | |
| for heat_num, heat_results in heat_map.items(): | |
| chaoyang_results = [r for r in heat_results if r["is_chaoyang"] and is_athlete_active(conn, r["name"])] | |
| if not chaoyang_results: | |
| continue | |
| for cr in chaoyang_results: | |
| opponents = [r for r in heat_results if not r["is_chaoyang"] and "00:00.000" not in (r["time_result"] or "")] | |
| entry = { | |
| "event": g["event"], | |
| "category": g["category"], | |
| "gender": g["gender"], | |
| "round_type": g["round_type"], | |
| "race_date": g.get("race_date", ""), | |
| "heat_number": heat_num, | |
| "has_chaoyang": True, | |
| "chaoyang_athlete": { | |
| "name": cr["name"], | |
| "place": cr["place"], | |
| "time_result": cr["time_result"], | |
| }, | |
| "opponents": [{"name": o["name"], "team": o["team"], "place": o["place"], "time_result": o["time_result"]} for o in opponents] | |
| } | |
| result.append(entry) | |
| else: | |
| # 没有朝阳队员的分组:仍然返回,让前端显示完整赛程 | |
| all_athletes = [dict(r) for r in race_results] | |
| def _safe_place(p): | |
| try: return int(p) if p else 999 | |
| except (ValueError, TypeError): return 999 | |
| all_athletes.sort(key=lambda r: (_safe_place(r["place"]))) | |
| # Use the actual heat_number from results (rcbh is now heat-specific) | |
| actual_heat = race_results[0]["heat_number"] if race_results else 1 | |
| entry = { | |
| "event": g["event"], | |
| "category": g["category"], | |
| "gender": g["gender"], | |
| "round_type": g["round_type"], | |
| "race_date": g.get("race_date", ""), | |
| "heat_number": actual_heat, | |
| "has_chaoyang": False, | |
| "chaoyang_athlete": None, | |
| "opponents": [{"name": a["name"], "team": a["team"], "place": a["place"], "time_result": a["time_result"]} for a in all_athletes if "00:00.000" not in (a["time_result"] or "")] | |
| } | |
| result.append(entry) | |
| conn.close() | |
| return jsonify(result) | |
| def api_match_chaoyang_results(match_id): | |
| """获取某个比赛中当前队伍队员的成绩详情""" | |
| from database import get_db | |
| conn = get_db() | |
| team_id = _get_effective_team_id() | |
| filter_clause, filter_params = _build_team_filter_clause(conn, team_id, 'r') | |
| # 获取比赛信息 | |
| match = conn.execute("SELECT * FROM matches WHERE id = ?", (match_id,)).fetchone() | |
| if not match: | |
| conn.close() | |
| return jsonify({"error": "比赛不存在"}), 404 | |
| match_info = dict(match) | |
| # 获取该比赛中当前队伍队员的成绩 | |
| rows = conn.execute(f""" | |
| SELECT r.name, r.team, r.place, r.lane, r.time_result, r.qual, r.heat_number, | |
| rg.event, rg.category, rg.gender, rg.round_type, rg.race_date, rg.race_time, rg.rcbh | |
| FROM results r | |
| JOIN race_groups rg ON r.rcbh = rg.rcbh | |
| WHERE rg.match_id = ? AND {filter_clause} | |
| ORDER BY r.name, rg.event, rg.race_date, rg.race_time, | |
| CASE rg.round_type | |
| WHEN '决赛/Finals' THEN 0 WHEN '决赛A' THEN 0 WHEN '决赛' THEN 0 | |
| WHEN '决赛B' THEN 1 WHEN '半决赛/Semifinals' THEN 2 WHEN '半决赛' THEN 2 | |
| WHEN '1/4赛/Quarterfinals' THEN 3 WHEN '1/4赛' THEN 3 | |
| WHEN '预赛/Heats' THEN 4 WHEN '预赛' THEN 4 WHEN '初赛' THEN 4 | |
| ELSE 5 | |
| END, r.heat_number, r.place | |
| """, [match_id] + filter_params).fetchall() | |
| conn.close() | |
| results = [dict(r) for r in rows] | |
| # 按队员名字分组 | |
| athlete_map = {} | |
| for r in results: | |
| name = r["name"] | |
| if name not in athlete_map: | |
| athlete_map[name] = { | |
| "name": name, | |
| "team": r["team"], | |
| "events": {} | |
| } | |
| event = r["event"] | |
| if event not in athlete_map[name]["events"]: | |
| athlete_map[name]["events"] = athlete_map[name].get("events", {}) | |
| athlete_map[name]["events"][event] = [] | |
| athlete_map[name]["events"][event].append(r) | |
| return jsonify({ | |
| "match": match_info, | |
| "athletes": list(athlete_map.values()) | |
| }) | |
| def api_match_summary(match_id): | |
| """获取或生成比赛总结(专业体育记者风格)""" | |
| import random as _random | |
| from database import get_db | |
| conn = get_db() | |
| match = conn.execute("SELECT * FROM matches WHERE id = ?", (match_id,)).fetchone() | |
| if not match: | |
| conn.close() | |
| return jsonify({"error": "比赛不存在"}), 404 | |
| match_info = dict(match) | |
| force = request.args.get("force", "0") | |
| if match_info.get("summary") and force != "1": | |
| conn.close() | |
| return jsonify({"summary": match_info["summary"], "generated": False}) | |
| team_id = _get_effective_team_id() | |
| filter_clause, filter_params = _build_team_filter_clause(conn, team_id, 'r') | |
| # --- 基础数据 --- | |
| athletes = conn.execute(f""" | |
| SELECT DISTINCT r.name FROM results r | |
| JOIN race_groups rg ON r.rcbh = rg.rcbh | |
| WHERE rg.match_id = ? AND {filter_clause} | |
| """, [match_id] + filter_params).fetchall() | |
| athlete_names = [a["name"] for a in athletes] | |
| athlete_count = len(athlete_names) | |
| if athlete_count == 0: | |
| conn.close() | |
| return jsonify({"summary": "", "generated": False, "message": "该比赛无本队队员参赛"}) | |
| # --- 奖牌统计 --- | |
| medals = conn.execute(f""" | |
| SELECT r.name, r.place, rg.event, rg.round_type, r.time_result | |
| FROM results r | |
| JOIN race_groups rg ON r.rcbh = rg.rcbh | |
| WHERE rg.match_id = ? AND {filter_clause} | |
| AND r.place IN ('1','2','3') | |
| AND (rg.round_type = '决赛' OR rg.round_type = '决赛/Finals' OR rg.round_type = '决赛A' OR rg.round_type = '决赛B') | |
| AND r.qual != '犯规' | |
| AND r.time_result NOT LIKE '%00:00.000%' | |
| """, [match_id] + filter_params).fetchall() | |
| gold_athletes = {} | |
| silver_athletes = {} | |
| bronze_athletes = {} | |
| event_medals = {} # event -> {gold,silver,bronze} | |
| for m in medals: | |
| name = m["name"] | |
| evt = m["event"] | |
| if evt not in event_medals: | |
| event_medals[evt] = {"gold": 0, "silver": 0, "bronze": 0} | |
| if m["place"] == "1": | |
| gold_athletes[name] = gold_athletes.get(name, 0) + 1 | |
| event_medals[evt]["gold"] += 1 | |
| elif m["place"] == "2": | |
| silver_athletes[name] = silver_athletes.get(name, 0) + 1 | |
| event_medals[evt]["silver"] += 1 | |
| elif m["place"] == "3": | |
| bronze_athletes[name] = bronze_athletes.get(name, 0) + 1 | |
| event_medals[evt]["bronze"] += 1 | |
| gold_total = sum(gold_athletes.values()) | |
| silver_total = sum(silver_athletes.values()) | |
| bronze_total = sum(bronze_athletes.values()) | |
| medal_total = gold_total + silver_total + bronze_total | |
| # --- 奖牌运动员排名 --- | |
| medal_counts = {} | |
| for name in athlete_names: | |
| g = gold_athletes.get(name, 0) | |
| s = silver_athletes.get(name, 0) | |
| b = bronze_athletes.get(name, 0) | |
| if g + s + b > 0: | |
| medal_counts[name] = (g, s, b) | |
| top_athletes = sorted(medal_counts.items(), key=lambda x: (x[1][0], x[1][1], x[1][2]), reverse=True)[:5] | |
| # --- 首次获奖牌者 --- | |
| first_medalists = [] | |
| for name in medal_counts: | |
| prev_medals = conn.execute(""" | |
| SELECT COUNT(*) FROM results r | |
| JOIN race_groups rg ON r.rcbh = rg.rcbh | |
| JOIN matches m2 ON rg.match_id = m2.id | |
| WHERE r.name = ? AND m2.date_range < ? | |
| AND r.place IN ('1','2','3') | |
| AND (rg.round_type = '决赛' OR rg.round_type = '决赛/Finals' OR rg.round_type = '决赛A' OR rg.round_type = '决赛B') | |
| AND r.qual != '犯规' | |
| """, (name, match_info.get("date_range", "9999"))).fetchone()[0] | |
| if prev_medals == 0: | |
| first_medalists.append(name) | |
| # --- 各项最佳成绩 --- | |
| best_times = conn.execute(f""" | |
| SELECT r.name, rg.event, r.time_result, rg.round_type | |
| FROM results r | |
| JOIN race_groups rg ON r.rcbh = rg.rcbh | |
| WHERE rg.match_id = ? AND {filter_clause} | |
| AND r.time_result != '' AND r.time_result NOT LIKE '%00:00.000%' | |
| AND r.qual != '犯规' | |
| ORDER BY r.time_result ASC | |
| """, [match_id] + filter_params).fetchall() | |
| # group best per event | |
| event_best = {} | |
| for bt in best_times: | |
| evt = bt["event"] | |
| if evt not in event_best: | |
| event_best[evt] = bt | |
| # --- 参赛项目统计 --- | |
| events = conn.execute(f""" | |
| SELECT DISTINCT rg.event FROM results r | |
| JOIN race_groups rg ON r.rcbh = rg.rcbh | |
| WHERE rg.match_id = ? AND {filter_clause} | |
| """, [match_id] + filter_params).fetchall() | |
| event_count = len(events) | |
| event_list = [e["event"] for e in events] | |
| # --- 进入决赛人数 --- | |
| finalists = conn.execute(f""" | |
| SELECT COUNT(DISTINCT r.name) FROM results r | |
| JOIN race_groups rg ON r.rcbh = rg.rcbh | |
| WHERE rg.match_id = ? AND {filter_clause} | |
| AND (rg.round_type = '决赛' OR rg.round_type = '决赛/Finals' OR rg.round_type = '决赛A' OR rg.round_type = '决赛B') | |
| """, [match_id] + filter_params).fetchone()[0] | |
| # ======== 生成记者风格总结 ======== | |
| match_title = match_info["title"] or "本次比赛" | |
| venue = match_info.get("venue", "") | |
| date_range = match_info.get("date_range", "") | |
| match_type = match_info.get("match_type", "") | |
| # 提取比赛地点简称 | |
| venue_short = venue.replace("北京市", "").replace("北京", "").strip() if venue else "" | |
| # 提取赛季 | |
| season = match_info.get("season", "") | |
| parts = [] | |
| # ==== 导语:多样化的开场方式 ==== | |
| if medal_total >= 10: | |
| openings = [ | |
| f"{match_title}于{date_range}在{venue}落下帷幕,我队在本站比赛中掀起夺牌狂潮,交出了一份令人振奋的成绩单。", | |
| f"随着{date_range}{match_title}的终场哨响,我队以压倒性的表现完成了本站征程,奖牌榜上捷报频传。", | |
| f"{venue}的冰面见证了又一场酣畅淋漓的战役——{match_title}收官,我队将士用一枚枚奖牌书写了属于自己的荣光。", | |
| ] | |
| elif medal_total >= 4: | |
| openings = [ | |
| f"{match_title}于{date_range}在{venue}圆满收官,我队运动员在多个项目中展现出扎实的竞技实力,收获颇丰。", | |
| f"{date_range},{match_title}在{venue}画上句号。我队选手稳扎稳打,在激烈竞争中拼出了属于自己的位置。", | |
| f"为期数日的{match_title}在{venue}落下帷幕,我队健儿不畏强手、敢打敢拼,交出了一份亮眼的答卷。", | |
| ] | |
| elif medal_total > 0: | |
| openings = [ | |
| f"{match_title}于{date_range}在{venue}结束全部赛程,我队运动员在有限的参赛项目中抓住机会,拼下宝贵奖牌。", | |
| f"在{venue}举办的{match_title}({date_range})已告一段落,我队小将们在实战中经受住了考验。", | |
| ] | |
| else: | |
| openings = [ | |
| f"{match_title}于{date_range}在{venue}落幕。虽然本站未能站上领奖台,但队员们在冰场上展现出的拼搏精神同样值得铭记。", | |
| f"{date_range},{match_title}在{venue}收官。每一场比赛都是成长的阶梯,我队运动员在本次赛事中积累了宝贵的实战经验。", | |
| ] | |
| parts.append(_random.choice(openings)) | |
| # ==== 队伍规模与参赛面 ==== | |
| scale_phrases = [ | |
| f"本次比赛我队派出**{athlete_count}名**运动员,覆盖**{event_count}个**项目({'、'.join(event_list)}),展现了队伍在各距离、各项目上的全面布局。", | |
| f"本站我队**{athlete_count}人**出战,在{'、'.join(event_list)}等**{event_count}个**项目中全面出击,从短距离爆发到长距离耐力均有涉猎。", | |
| f"面对{match_type or '本次赛事'}的考验,我队**{athlete_count}名**健儿在{'、'.join(event_list) if event_count <= 3 else f'{event_count}个项目'}中轮番上阵,阵容齐整。", | |
| ] | |
| parts.append(_random.choice(scale_phrases)) | |
| # ==== 奖牌战报 ==== | |
| if medal_total > 0: | |
| if gold_total >= 5: | |
| medal_lead = f"最终,我队以**{gold_total}金{silver_total}银{bronze_total}铜**的优异战绩傲视群雄," | |
| if gold_total > 0: | |
| medal_lead += f"在{'、'.join(e for e in event_medals if event_medals[e]['gold'] > 0)}等项目上均有金牌入账," | |
| medal_lead += f"全队累计斩获**{medal_total}枚**奖牌。" | |
| elif gold_total >= 1: | |
| medal_lead = f"奖牌榜方面,我队共收获**{gold_total}金{silver_total}银{bronze_total}铜**,总计**{medal_total}枚**奖牌。" | |
| if gold_total == 1 and len(gold_athletes) == 1: | |
| gold_name = list(gold_athletes.keys())[0] | |
| medal_lead += f"**{gold_name}**在决赛中摘得桂冠,为全队赢得了宝贵金牌。" | |
| else: | |
| medal_lead = f"虽然没有金牌入账,但我队仍拼下**{silver_total}银{bronze_total}铜**,每一枚奖牌都凝结着队员们的汗水与坚持。" | |
| parts.append(medal_lead) | |
| # 奖牌分布 | |
| medal_events = [e for e in event_medals if sum(event_medals[e].values()) > 0] | |
| if len(medal_events) >= 2: | |
| top_evt = max(medal_events, key=lambda e: event_medals[e]["gold"] * 3 + event_medals[e]["silver"] * 2 + event_medals[e]["bronze"]) | |
| top_evt_data = event_medals[top_evt] | |
| parts.append(f"在**{top_evt}**项目上,我队表现最为抢眼,贡献了{top_evt_data['gold']}金{top_evt_data['silver']}银{top_evt_data['bronze']}铜的亮眼成绩单。") | |
| # ==== 奖牌运动员亮点(团队视角,不突出个人)==== | |
| if top_athletes: | |
| top_names = [name for name, _ in top_athletes[:3]] | |
| if len(top_athletes) == 1: | |
| name, (g, s, b) = top_athletes[0] | |
| star_phrases = [ | |
| f"**{name}**本站斩获{g}金{s}银{b}铜,用扎实的成绩为团队做出了重要贡献。", | |
| f"**{name}**收获{g}金{s}银{b}铜,每一枚奖牌背后都是日复一日的刻苦训练。", | |
| ] | |
| elif len(top_athletes) <= 3: | |
| star_phrases = [ | |
| f"{'、'.join(f'**{n}**' for n in top_names)}等队员在各自项目中发挥出色,为团队贡献了宝贵奖牌。", | |
| f"{'、'.join(f'**{n}**' for n in top_names)}在本站比赛中均有亮眼发挥,在各自项目上展现了扎实的竞技水平。", | |
| ] | |
| else: | |
| star_phrases = [ | |
| f"**{'、'.join(top_names[:3])}**等多名队员均有奖牌入账,全队多点开花。{'、'.join(f'**{n}**' for n in top_names)}在各自的主攻项目上均展现出了明显的进步。", | |
| f"多名队员在不同项目上交替闪耀,{'、'.join(f'**{n}**' for n in top_names[:3])}等人均有斩获,展现了队伍深厚的人才储备与均衡的竞争力。", | |
| ] | |
| parts.append(_random.choice(star_phrases)) | |
| # ==== 首次获奖牌者 ==== | |
| if first_medalists and len(first_medalists) <= 5: | |
| if len(first_medalists) == 1: | |
| parts.append(f"本站还有一个令人欣喜的突破——**{first_medalists[0]}**首次站上了领奖台,实现了个人奖牌零的突破。这一刻,所有训练中的汗水都化作了最灿烂的笑容。") | |
| else: | |
| names_str = "、".join(f"**{n}**" for n in first_medalists) | |
| parts.append(f"更令人振奋的是,{names_str}在本站比赛中均收获了个人首枚奖牌,新人的崛起让我队的未来更加可期。") | |
| # ==== 最佳成绩亮点 ==== | |
| if event_best: | |
| # pick 2-3 events with meaningful times | |
| best_items = [] | |
| for evt, data in list(event_best.items())[:3]: | |
| time_str = data["time_result"] | |
| if time_str and ":" in time_str: | |
| best_items.append(f"{evt}**{data['name']}**跑出**{time_str}**") | |
| if best_items: | |
| parts.append(f"成绩方面,{','.join(best_items)},这些成绩在同期选手中具有相当的竞争力。") | |
| # ==== 决赛参与度 ==== | |
| if finalists > 0: | |
| finalist_ratio = finalists / athlete_count if athlete_count > 0 else 0 | |
| if finalist_ratio >= 0.6: | |
| parts.append(f"全队**{finalists}人**闯入各项目决赛,决赛参与率高达{int(finalist_ratio * 100)}%,充分体现了我队在各项目上的整体竞争力。") | |
| elif finalist_ratio >= 0.3: | |
| parts.append(f"共有**{finalists}名**队员打进决赛轮次,在与各路高手的正面交锋中积累了宝贵的实战经验。") | |
| # ==== 收尾:多样化结语(绝不重复)==== | |
| if medal_total >= 10: | |
| closings = [ | |
| "这是一次堪称完美的出征。但短道速滑的冰面从不停歇,下一站的起点线已在眼前。愿队员们带着这份自信与锐气,向着更高的目标全速滑行。⛸️", | |
| "奖牌是最好的证明,但绝不是终点。每一场比赛都是一块磨刀石,磨出更锋利的刃、更沉稳的心。期待将士们在接下来的赛事中续写辉煌。", | |
| "从预赛到决赛,这支队伍用一场又一场的硬仗证明了自己的实力。冰场不会忘记每一个拼尽全力的弯道,我们下一站再见。", | |
| ] | |
| elif medal_total >= 4: | |
| closings = [ | |
| "这份成绩单有亮点也有遗憾,而这恰恰是竞技体育的魅力所在——永远有下一个弯道可以超越,永远有下一次出发值得期待。", | |
| "站上领奖台的瞬间固然耀眼,但真正令人动容的是每一轮预赛、每一次弯道中队员们眼神里的坚定。这支队伍正在一步一个脚印地向上攀登。", | |
| "比赛落幕,征程继续。每一次出征都是一次淬炼,每一次对决都是一面镜子。愿队员们看清优势也正视差距,在接下来的训练中有的放矢、更进一步。", | |
| ] | |
| elif medal_total > 0: | |
| closings = [ | |
| "奖牌虽少,分量不轻。在短道速滑的世界里,每一个弯道都可能改写历史,每一次蹬冰都蕴藏着翻盘的希望。这支年轻的队伍,正在实战中悄然生长。", | |
| "对于一支正在爬坡的队伍来说,每一枚奖牌都是对汗水的最好回馈,每一次参赛都是走向更强的必经之路。抬起头,下一站会更好。", | |
| "竞技体育的字典里没有「容易」二字。今天的每一份付出都会在未来的某一天开花结果。保持信念,保持专注,属于你们的高光时刻正在路上。", | |
| ] | |
| else: | |
| closings = [ | |
| "没有奖牌不代表没有收获。每一位站上赛场的队员,都在与更强的对手交锋中看到了自己的不足与可能。这是一种比奖牌更珍贵的成长。", | |
| "短道速滑的冰面上,摔倒过的人才知道如何更好地站立。本站虽未夺牌,但队员们在实战中积累的经验、暴露的问题,都是下一阶段训练的方向。", | |
| "真正的强者从不畏惧暂时的低谷。每一次大赛都是成长的阶梯,每一次失败都为成功埋下伏笔。愿队员们从中汲取力量,在下一站绽放。", | |
| ] | |
| parts.append(_random.choice(closings)) | |
| summary = "\n\n".join(parts) | |
| try: | |
| conn.execute("UPDATE matches SET summary = ? WHERE id = ?", (summary, match_id)) | |
| conn.commit() | |
| except Exception: | |
| pass | |
| conn.close() | |
| return jsonify({"summary": summary, "generated": True}) | |
| def api_race_results(rcbh): | |
| """获取分组比赛结果""" | |
| results = get_race_group_results(rcbh) | |
| return jsonify(results) | |
| def api_match_final_results(match_id): | |
| """获取指定比赛的决赛分组全部成绩(含所有队伍)""" | |
| event = request.args.get("event", "") | |
| category = request.args.get("category", "") | |
| athlete_name = request.args.get("athlete_name", "").strip() | |
| conn = get_db() | |
| # Find the final race group(s) for this event | |
| groups = conn.execute(""" | |
| SELECT rg.rcbh, rg.event, rg.round_type, rg.category, rg.gender, rg.race_date | |
| FROM race_groups rg | |
| WHERE rg.match_id = ? | |
| AND rg.event = ? | |
| AND (rg.round_type = '决赛' OR rg.round_type = '决赛/Finals' OR rg.round_type = '决赛A' OR rg.round_type = '决赛B') | |
| """, (match_id, event)).fetchall() | |
| if category: | |
| groups = [g for g in groups if g["category"] == category] | |
| if athlete_name: | |
| filtered = [] | |
| for g in groups: | |
| # Check individual results | |
| if conn.execute( | |
| "SELECT 1 FROM results WHERE rcbh = ? AND name = ? LIMIT 1", | |
| (g["rcbh"], athlete_name) | |
| ).fetchone(): | |
| filtered.append(g) | |
| continue | |
| # For relay events, check relay_members | |
| if '接力' in g["event"]: | |
| if conn.execute( | |
| "SELECT 1 FROM relay_members WHERE athlete_name = ? AND event = ? LIMIT 1", | |
| (athlete_name, g["event"]) | |
| ).fetchone(): | |
| filtered.append(g) | |
| groups = filtered | |
| if not groups: | |
| conn.close() | |
| return jsonify({"error": "未找到决赛分组"}), 404 | |
| # Get results for each group | |
| result_groups = [] | |
| for g in groups: | |
| rows = conn.execute(""" | |
| SELECT r.name, r.place, r.time_result, r.qual, r.team, r.is_chaoyang, | |
| rg.round_type, rg.category, rg.gender, rg.event | |
| FROM results r | |
| JOIN race_groups rg ON r.rcbh = rg.rcbh | |
| WHERE r.rcbh = ? | |
| ORDER BY CAST(r.place AS INTEGER) | |
| """, (g["rcbh"],)).fetchall() | |
| result_groups.append({ | |
| "round_type": g["round_type"], | |
| "category": g["category"], | |
| "gender": g["gender"], | |
| "results": [dict(r) for r in rows] | |
| }) | |
| # Get match info | |
| match = conn.execute("SELECT title, date_range, venue FROM matches WHERE id = ?", (match_id,)).fetchone() | |
| conn.close() | |
| return jsonify({ | |
| "match": dict(match) if match else {}, | |
| "groups": result_groups | |
| }) | |
| def api_seasons(): | |
| """获取赛季列表""" | |
| seasons = get_seasons() | |
| return jsonify(seasons) | |
| def api_leaderboard(): | |
| """队内排行榜 — 按项目/组别/性别筛选,返回当前队伍队员的最佳成绩排名""" | |
| event = request.args.get("event", "") | |
| category = request.args.get("category", "") | |
| gender = request.args.get("gender", "") | |
| season = request.args.get("season", "") | |
| conn = get_db() | |
| team_id = _get_effective_team_id() | |
| filter_clause, filter_params = _build_team_filter_clause(conn, team_id, 'r') | |
| conditions = [filter_clause, "r.place != ''", "(r.qual IS NULL OR r.qual NOT IN ('犯规','红牌','黄牌','弃权','伤病','DNF','DNS','DQ','退赛'))"] | |
| params = list(filter_params) | |
| if event: | |
| conditions.append("rg.event = ?") | |
| params.append(event) | |
| if category: | |
| conditions.append("rg.category = ?") | |
| params.append(category) | |
| if gender: | |
| conditions.append("rg.gender = ?") | |
| params.append(gender) | |
| if season: | |
| conditions.append("m.season = ?") | |
| params.append(season) | |
| where = " AND ".join(conditions) | |
| rows = conn.execute(f""" | |
| SELECT r.name, rg.event, rg.category, rg.gender, | |
| MIN(r.time_result) as best_time, | |
| MIN(CAST(r.place AS INTEGER)) as best_place, | |
| COUNT(DISTINCT m.id) as race_count | |
| FROM results r | |
| JOIN race_groups rg ON r.rcbh = rg.rcbh | |
| JOIN matches m ON rg.match_id = m.id | |
| WHERE {where} | |
| AND r.time_result != '' | |
| AND r.time_result NOT LIKE '%00:00.000%' | |
| AND r.time_result NOT LIKE '%NO TIME%' | |
| GROUP BY r.name, rg.event | |
| """, params).fetchall() | |
| results = [] | |
| for r in rows: | |
| seconds = parse_time_to_seconds(r["best_time"]) | |
| if seconds is not None: | |
| results.append({ | |
| "name": r["name"], | |
| "event": r["event"], | |
| "category": r["category"], | |
| "best_time": r["best_time"], | |
| "best_place": r["best_place"], | |
| "race_count": r["race_count"], | |
| "seconds": seconds, | |
| "trend": 0, | |
| }) | |
| results.sort(key=lambda x: x["seconds"]) | |
| trend_filter_clause, trend_filter_params = _build_team_filter_clause(conn, team_id, 'r') | |
| for res in results: | |
| name = res["name"] | |
| evt = res["event"] | |
| try: | |
| recent = conn.execute(f""" | |
| SELECT r.time_result, m.date_range | |
| FROM results r | |
| JOIN race_groups rg ON r.rcbh = rg.rcbh | |
| JOIN matches m ON rg.match_id = m.id | |
| WHERE r.name = ? AND rg.event = ? AND {trend_filter_clause} | |
| ORDER BY m.date_range DESC | |
| LIMIT 3 | |
| """, [name, evt] + trend_filter_params).fetchall() | |
| if len(recent) >= 2: | |
| s1 = parse_time_to_seconds(recent[0]["time_result"]) | |
| s2 = parse_time_to_seconds(recent[-1]["time_result"]) | |
| if s1 is not None and s2 is not None: | |
| diff = round(s2 - s1, 2) | |
| res["trend"] = diff if abs(diff) < 100 else 0 | |
| except Exception: | |
| pass | |
| total = len(results) | |
| for i, r in enumerate(results): | |
| r["rank"] = i + 1 | |
| r["total"] = total | |
| if total > 1: | |
| r["percentile"] = round((r["rank"] / total) * 100, 1) | |
| else: | |
| r["percentile"] = 100.0 | |
| # Season comparison: if a specific season is selected, compare with previous season | |
| if season: | |
| try: | |
| parts = season.split("-") | |
| if len(parts) == 2: | |
| prev_season = f"{int(parts[0]) - 1}-{int(parts[1]) - 1}" | |
| prev_rows = conn.execute(f""" | |
| SELECT r.name, rg.event, | |
| MIN(r.time_result) as best_time, | |
| MIN(CAST(r.place AS INTEGER)) as best_place | |
| FROM results r | |
| JOIN race_groups rg ON r.rcbh = rg.rcbh | |
| JOIN matches m ON rg.match_id = m.id | |
| WHERE {filter_clause} | |
| AND r.place != '' | |
| AND r.time_result != '' | |
| AND r.time_result NOT LIKE '%00:00.000%' | |
| AND r.time_result NOT LIKE '%NO TIME%' | |
| AND m.season = ? | |
| {"AND rg.event = ?" if event else ""} | |
| {"AND rg.category = ?" if category else ""} | |
| {"AND rg.gender = ?" if gender else ""} | |
| AND (r.qual IS NULL OR r.qual NOT IN ('犯规','红牌','黄牌','弃权','伤病','DNF','DNS','DQ','退赛')) | |
| GROUP BY r.name, rg.event | |
| """, [prev_season] + params[2:]).fetchall() | |
| prev_map = {} | |
| prev_results = [] | |
| for r2 in prev_rows: | |
| s2 = parse_time_to_seconds(r2["best_time"]) | |
| if s2 is not None: | |
| prev_results.append({"name": r2["name"], "event": r2["event"], "seconds": s2, "best_place": r2["best_place"]}) | |
| prev_results.sort(key=lambda x: x["seconds"]) | |
| for j, pr in enumerate(prev_results): | |
| prev_map[(pr["name"], pr["event"])] = {"prev_rank": j + 1, "prev_total": len(prev_results)} | |
| for r in results: | |
| key = (r["name"], r["event"]) | |
| if key in prev_map: | |
| p = prev_map[key] | |
| r["prev_rank"] = p["prev_rank"] | |
| r["prev_total"] = p["prev_total"] | |
| r["rank_change"] = p["prev_rank"] - r["rank"] | |
| except Exception: | |
| pass | |
| return jsonify(results) | |
| def api_export_csv(): | |
| """导出数据为 CSV 格式 | |
| 参数: type=leaderboard|athletes|results | |
| """ | |
| import csv | |
| import io | |
| export_type = request.args.get("type", "leaderboard") | |
| conn = get_db() | |
| output = io.StringIO() | |
| writer = csv.writer(output) | |
| if export_type == "leaderboard": | |
| event = request.args.get("event", "") | |
| category = request.args.get("category", "") | |
| gender = request.args.get("gender", "") | |
| season = request.args.get("season", "") | |
| team_id = _get_effective_team_id() | |
| filter_clause, filter_params = _build_team_filter_clause(conn, team_id, 'r') | |
| conditions = [filter_clause, "r.place != ''", "(r.qual IS NULL OR r.qual NOT IN ('犯规','红牌','黄牌','弃权','伤病','DNF','DNS','DQ','退赛'))"] | |
| params = list(filter_params) | |
| if event: | |
| conditions.append("rg.event = ?"); params.append(event) | |
| if category: | |
| conditions.append("rg.category = ?"); params.append(category) | |
| if gender: | |
| conditions.append("rg.gender = ?"); params.append(gender) | |
| if season: | |
| conditions.append("m.season = ?"); params.append(season) | |
| where = " AND ".join(conditions) | |
| writer.writerow(["排名", "姓名", "项目", "组别", "性别", "最佳成绩", "参赛次数"]) | |
| rows = conn.execute(f""" | |
| SELECT r.name, rg.event, rg.category, rg.gender, | |
| MIN(r.time_result) as best_time, COUNT(DISTINCT m.id) as race_count | |
| FROM results r | |
| JOIN race_groups rg ON r.rcbh = rg.rcbh | |
| JOIN matches m ON rg.match_id = m.id | |
| WHERE {where} | |
| AND r.time_result != '' | |
| AND r.time_result NOT LIKE '%00:00.000%' | |
| GROUP BY r.name, rg.event | |
| ORDER BY best_time | |
| """, params).fetchall() | |
| for i, r in enumerate(rows, 1): | |
| writer.writerow([i, r["name"], r["event"], r["category"] or "", | |
| r["gender"] or "", r["best_time"] or "", r["race_count"]]) | |
| elif export_type == "athletes": | |
| writer.writerow(["姓名", "组别", "性别"]) | |
| athletes_info = conn.execute( | |
| "SELECT name, category, gender FROM athletes WHERE deleted_at IS NULL ORDER BY name" | |
| ).fetchall() | |
| for a in athletes_info: | |
| writer.writerow([a["name"], a["category"] or "", a["gender"] or ""]) | |
| elif export_type == "results": | |
| athlete_name = request.args.get("athlete_name", "") | |
| writer.writerow(["姓名", "项目", "组别", "性别", "轮次", "成绩", "名次", "队伍", "比赛名称", "赛季"]) | |
| query = """ | |
| SELECT r.name, rg.event, rg.category, rg.gender, rg.round_type, | |
| r.time_result, r.place, r.team, m.title, rg.race_date, m.season | |
| FROM results r | |
| JOIN race_groups rg ON r.rcbh = rg.rcbh | |
| JOIN matches m ON rg.match_id = m.id | |
| WHERE r.is_chaoyang = 1 | |
| """ | |
| params = [] | |
| if athlete_name: | |
| query += " AND r.name = ?" | |
| params.append(athlete_name) | |
| query += " ORDER BY m.date_range DESC, rg.race_date DESC" | |
| for row in conn.execute(query, params).fetchall(): | |
| writer.writerow([row["name"], row["event"], row["category"] or "", | |
| row["gender"] or "", row["round_type"] or "", | |
| row["time_result"] or "", row["place"] or "", | |
| row["team"] or "", row["title"] or "", row["season"] or ""]) | |
| else: | |
| conn.close() | |
| return jsonify({"error": "不支持的导出类型"}), 400 | |
| conn.close() | |
| csv_content = output.getvalue() | |
| output.close() | |
| from flask import Response | |
| return Response( | |
| csv_content, | |
| mimetype="text/csv; charset=utf-8-sig", | |
| headers={"Content-Disposition": f"attachment; filename=short_track_{export_type}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"} | |
| ) | |
| def api_achievements(): | |
| """成就系统 — 计算当前队伍解锁的成就""" | |
| conn = get_db() | |
| team_id = _get_effective_team_id() | |
| filter_clause, filter_params = _build_team_filter_clause(conn, team_id, 'r') | |
| team_athlete_names = set(_get_team_athlete_names(conn, team_id)) | |
| athlete_stats = conn.execute(f""" | |
| SELECT r.name, | |
| COUNT(DISTINCT m.id) as match_count, | |
| SUM(CASE WHEN CAST(r.place AS INTEGER) = 1 THEN 1 ELSE 0 END) as gold_count, | |
| SUM(CASE WHEN CAST(r.place AS INTEGER) IN (1,2,3) THEN 1 ELSE 0 END) as medal_count | |
| FROM results r | |
| JOIN race_groups rg ON r.rcbh = rg.rcbh | |
| JOIN matches m ON rg.match_id = m.id | |
| WHERE {filter_clause} | |
| AND (rg.round_type = '决赛/Finals' OR rg.round_type = '决赛A' OR rg.round_type = '决赛' OR rg.round_type = '决赛B') | |
| GROUP BY r.name | |
| """, filter_params).fetchall() | |
| stats_map = {r["name"]: dict(r) for r in athlete_stats} | |
| training_data = conn.execute(""" | |
| SELECT athlete_name, COUNT(DISTINCT training_date) as training_days | |
| FROM training_records | |
| GROUP BY athlete_name | |
| """).fetchall() | |
| training_map = {r["athlete_name"]: r["training_days"] for r in training_data if r["athlete_name"] in team_athlete_names} | |
| attendance_data = conn.execute(""" | |
| SELECT athlete_name, training_date, | |
| COUNT(*) as sessions | |
| FROM training_records | |
| GROUP BY athlete_name, training_date | |
| """).fetchall() | |
| achievements = [] | |
| first_race_names = list(stats_map.keys()) | |
| if first_race_names: | |
| achievements.append({ | |
| "id": "first_race", | |
| "name": "初出茅庐", | |
| "desc": "首次参加正式比赛", | |
| "unlocked": True, | |
| "athletes": first_race_names[:5], | |
| }) | |
| triple = [n for n, s in stats_map.items() if s["gold_count"] >= 3] | |
| if triple: | |
| achievements.append({ | |
| "id": "triple_crown", | |
| "name": "三冠王", | |
| "desc": "累计获得3枚金牌", | |
| "unlocked": True, | |
| "athletes": triple, | |
| }) | |
| veterans = [n for n, s in stats_map.items() if s["match_count"] >= 10] | |
| if veterans: | |
| achievements.append({ | |
| "id": "veteran", | |
| "name": "百战老兵", | |
| "desc": "参加10场以上比赛", | |
| "unlocked": True, | |
| "athletes": veterans, | |
| }) | |
| five_races = [n for n, s in stats_map.items() if s["match_count"] >= 5] | |
| if five_races: | |
| achievements.append({ | |
| "id": "five_races", | |
| "name": "五战健将", | |
| "desc": "参加5场以上比赛", | |
| "unlocked": True, | |
| "athletes": five_races, | |
| }) | |
| collectors = [n for n, s in stats_map.items() if s["medal_count"] >= 10] | |
| if collectors: | |
| achievements.append({ | |
| "id": "medal_collector", | |
| "name": "奖牌收藏家", | |
| "desc": "累计获得10枚奖牌", | |
| "unlocked": True, | |
| "athletes": collectors, | |
| }) | |
| ten_golds = [n for n, s in stats_map.items() if s["gold_count"] >= 10] | |
| if ten_golds: | |
| achievements.append({ | |
| "id": "ten_golds", | |
| "name": "十金传奇", | |
| "desc": "累计获得10枚金牌", | |
| "unlocked": True, | |
| "athletes": ten_golds, | |
| }) | |
| iron = [n for n, d in training_map.items() if d >= 30] | |
| if iron: | |
| achievements.append({ | |
| "id": "iron_training", | |
| "name": "铁人训练", | |
| "desc": "累计训练30天以上", | |
| "unlocked": True, | |
| "athletes": iron, | |
| }) | |
| full_attendance_athletes = [] | |
| monthly_attendance = {} | |
| for row in attendance_data: | |
| athlete_name = row["athlete_name"] | |
| if athlete_name not in team_athlete_names: | |
| continue | |
| training_date = row["training_date"] or '' | |
| month_key = training_date[:7] | |
| if not month_key: | |
| continue | |
| monthly_attendance.setdefault((athlete_name, month_key), 0) | |
| monthly_attendance[(athlete_name, month_key)] += 1 | |
| full_attendance_athletes = sorted({name for (name, month_key), sessions in monthly_attendance.items() if month_key and sessions >= 20}) | |
| achievements.append({ | |
| "id": "full_attendance", | |
| "name": "全勤之星", | |
| "desc": "某月训练出勤率100%", | |
| "unlocked": bool(full_attendance_athletes), | |
| "athletes": full_attendance_athletes, | |
| }) | |
| unlocked_ids = {a["id"] for a in achievements if a["unlocked"]} | |
| for defn in [ | |
| {"id": "first_race", "name": "初出茅庐", "desc": "首次参加正式比赛"}, | |
| {"id": "triple_crown", "name": "三冠王", "desc": "累计获得3枚金牌"}, | |
| {"id": "veteran", "name": "百战老兵", "desc": "参加10场以上比赛"}, | |
| {"id": "five_races", "name": "五战健将", "desc": "参加5场以上比赛"}, | |
| {"id": "medal_collector", "name": "奖牌收藏家", "desc": "累计获得10枚奖牌"}, | |
| {"id": "ten_golds", "name": "十金传奇", "desc": "累计获得10枚金牌"}, | |
| {"id": "iron_training", "name": "铁人训练", "desc": "累计训练30天以上"}, | |
| {"id": "full_attendance", "name": "全勤之星", "desc": "某月训练出勤率100%"}, | |
| ]: | |
| if defn["id"] not in unlocked_ids: | |
| achievements.append({"id": defn["id"], "name": defn["name"], "desc": defn["desc"], "unlocked": False, "athletes": []}) | |
| return jsonify(achievements) | |
| def api_athlete_growth(name): | |
| """运动员成长时间轴 — 自动生成关键里程碑""" | |
| conn = get_db() | |
| events = [] | |
| # Get all results for this athlete chronologically | |
| results = conn.execute(""" | |
| SELECT r.name, r.place, r.time_result, r.qual, r.team, | |
| rg.event, rg.category, rg.gender, rg.round_type, | |
| m.title, m.date_range, m.season | |
| FROM results r | |
| JOIN race_groups rg ON r.rcbh = rg.rcbh | |
| JOIN matches m ON rg.match_id = m.id | |
| WHERE r.name = ? | |
| ORDER BY m.date_range ASC, rg.event | |
| """, (name,)).fetchall() | |
| if not results: | |
| return jsonify({"events": []}) | |
| # First race | |
| first = results[0] | |
| events.append({ | |
| "type": "first_race", | |
| "date": first["date_range"], | |
| "title": f"首次参赛 · {first['title']}", | |
| "detail": f"项目: {first['event']} | {first['category'] or ''}", | |
| }) | |
| # Track PBs per event | |
| pb_map = {} | |
| for r in results: | |
| evt = r["event"] | |
| seconds = parse_time_to_seconds(r["time_result"]) | |
| if seconds is None: | |
| continue | |
| if evt not in pb_map or seconds < pb_map[evt]: | |
| old_best = pb_map.get(evt) | |
| pb_map[evt] = seconds | |
| if old_best is not None: | |
| diff = round(old_best - seconds, 2) | |
| events.append({ | |
| "type": "pb", | |
| "date": r["date_range"], | |
| "title": f"⚡ {evt} 刷新PB", | |
| "detail": f"{r['time_result']} (提升 {diff}秒)", | |
| }) | |
| # Medals | |
| for r in results: | |
| place_str = str(r["place"]) | |
| if place_str in ("1", "2", "3"): | |
| medal = {"1": "🥇", "2": "🥈", "3": "🥉"}.get(place_str, "") | |
| events.append({ | |
| "type": "medal", | |
| "date": r["date_range"], | |
| "title": f"{medal} {r['title']} · {r['event']}", | |
| "detail": f"第{r['place']}名 | {r['category'] or ''}", | |
| }) | |
| # Honors | |
| honors = conn.execute( | |
| "SELECT title, awarded_date, source FROM athlete_honors WHERE athlete_name = ? ORDER BY awarded_date", | |
| (name,) | |
| ).fetchall() | |
| for h in honors: | |
| events.append({ | |
| "type": "honor", | |
| "date": h["awarded_date"] or "", | |
| "title": f"🎖️ {h['title']}", | |
| "detail": h["source"] or "", | |
| }) | |
| # Sort by date | |
| events.sort(key=lambda e: e.get("date", "") or "") | |
| return jsonify({"events": events}) | |
| def api_sources(): | |
| """获取数据源列表""" | |
| conn = get_db() | |
| rows = conn.execute("SELECT data_source, COUNT(*) as cnt FROM matches GROUP BY data_source").fetchall() | |
| sources = [{"data_source": r["data_source"], "match_count": r["cnt"]} for r in rows] | |
| conn.close() | |
| return jsonify(sources) | |
| # ========== 接力队员关联管理 ========== | |
| # ========== 运动员管理 ========== | |
| def api_get_athletes(): | |
| """获取所有手动添加的队员(支持 include_deleted 参数)""" | |
| include_deleted = request.args.get("include_deleted", "0") == "1" | |
| conn = get_db() | |
| if include_deleted: | |
| rows = conn.execute("SELECT * FROM athletes ORDER BY name").fetchall() | |
| else: | |
| rows = conn.execute("SELECT * FROM athletes WHERE deleted_at IS NULL ORDER BY name").fetchall() | |
| conn.close() | |
| return jsonify([dict(a) for a in rows]) | |
| def api_timeline(): | |
| """获取赛季比赛日历""" | |
| season = request.args.get("season", "") | |
| conn = get_db() | |
| params = [] | |
| where = "" | |
| if season: | |
| where = "WHERE m.season = ?" | |
| params.append(season) | |
| rows = conn.execute(f"SELECT m.id, m.title, m.date_range, m.season, m.venue FROM matches m {where} ORDER BY m.date_range DESC", params).fetchall() | |
| conn.close() | |
| return jsonify([dict(r) for r in rows]) | |
| def api_last_update(): | |
| """获取最后更新时间""" | |
| last = get_last_update() | |
| return jsonify({"last_update": last}) | |
| def api_sync_all_athletes_results(): | |
| """全量队员成绩同步(移动端离线数据用)""" | |
| from datetime import datetime | |
| db = get_db() | |
| team_id = _get_effective_team_id() | |
| athletes, results, _ = _get_team_sync_payload(db, team_id) | |
| return jsonify({ | |
| "athletes": athletes, | |
| "results": results, | |
| "sync_time": datetime.now().isoformat() | |
| }) | |
| def api_sync_full(): | |
| """完整数据同步(移动端首次打开或完整刷新时使用),返回所有离线所需数据+data_version""" | |
| from datetime import datetime | |
| db = get_db() | |
| team_id = _get_effective_team_id() | |
| athletes, results, _ = _get_team_sync_payload(db, team_id) | |
| seasons = db.execute("SELECT DISTINCT season FROM matches ORDER BY season DESC").fetchall() | |
| current_matches = get_current_matches() | |
| training_types = get_training_types() | |
| recent_matches = db.execute(""" | |
| SELECT m.id, m.title, m.date_range, m.venue, m.season, m.status, m.data_source | |
| FROM matches m ORDER BY m.date_range DESC LIMIT 50 | |
| """).fetchall() | |
| last_update = get_last_update() | |
| data_version = int(datetime.now().timestamp() * 1000) | |
| return jsonify({ | |
| "athletes": athletes, | |
| "results": results, | |
| "seasons": [dict(s) for s in seasons], | |
| "current_matches": current_matches, | |
| "training_types": training_types, | |
| "recent_matches": [dict(m) for m in recent_matches], | |
| "data_version": data_version, | |
| "last_update": last_update, | |
| "sync_time": datetime.now().isoformat() | |
| }) | |
| def api_sync_preload(): | |
| """预加载数据:一次性返回所有需要离线缓存的数据,减少请求数""" | |
| from datetime import datetime | |
| db = get_db() | |
| team_id = _get_effective_team_id() | |
| athletes, results, opponents = _get_team_sync_payload(db, team_id) | |
| seasons = db.execute("SELECT DISTINCT season FROM matches ORDER BY season DESC").fetchall() | |
| matches = db.execute(""" | |
| SELECT m.id, m.title, m.date_range, m.venue, m.season, m.status, m.match_type, m.data_source | |
| FROM matches m ORDER BY m.date_range DESC | |
| """).fetchall() | |
| current_matches = get_current_matches() | |
| dashboard_summary = db.execute( | |
| "SELECT COUNT(DISTINCT name) as athlete_count FROM athletes WHERE deleted_at IS NULL" | |
| ).fetchone() | |
| training_types = get_training_types() | |
| team_aliases = db.execute( | |
| "SELECT id, alias, is_active, team_id FROM team_aliases WHERE team_id = ? ORDER BY alias", | |
| (team_id,) | |
| ).fetchall() | |
| athlete_overrides = db.execute( | |
| "SELECT name, is_chaoyang, updated_at, team_id FROM athlete_overrides WHERE team_id = ? ORDER BY name", | |
| (team_id,) | |
| ).fetchall() | |
| athlete_names = [{"name": a["name"]} for a in athletes] | |
| nicknames = db.execute("SELECT id, nickname, real_name FROM athlete_nicknames ORDER BY nickname").fetchall() | |
| last_update = get_last_update() | |
| data_version = int(datetime.now().timestamp() * 1000) | |
| return jsonify({ | |
| "athletes": athletes, | |
| "all_athletes_results": { | |
| "athletes": athletes, | |
| "results": results, | |
| }, | |
| "seasons": [dict(s) for s in seasons], | |
| "matches": [dict(m) for m in matches], | |
| "current_matches": current_matches, | |
| "opponents": opponents, | |
| "dashboard_summary": { | |
| "athlete_count": dashboard_summary["athlete_count"] if dashboard_summary else 0, | |
| "medals": [], | |
| "season_stats": [], | |
| "last_update": last_update, | |
| }, | |
| "training_types": training_types, | |
| "team_aliases": [dict(t) for t in team_aliases], | |
| "athlete_overrides": [dict(a) for a in athlete_overrides], | |
| "athlete_names": athlete_names, | |
| "nicknames": [dict(n) for n in nicknames], | |
| "data_version": data_version, | |
| "last_update": last_update, | |
| "sync_time": datetime.now().isoformat() | |
| }) | |
| def api_sync_since(): | |
| """增量同步:仅返回指定data_version之后更新的数据 | |
| 查询参数: | |
| - data_version: 上次同步时返回的data_version值(毫秒时间戳) | |
| - 返回 has_updates=True/False 和更新后的 data_version | |
| """ | |
| from datetime import datetime | |
| data_version = request.args.get("data_version", "0") | |
| try: | |
| since_ts = float(data_version) / 1000.0 | |
| since_dt = datetime.fromtimestamp(since_ts) | |
| since_str = since_dt.strftime('%Y-%m-%d %H:%M:%S') | |
| except (ValueError, OSError): | |
| since_str = "2000-01-01 00:00:00" | |
| db = get_db() | |
| team_id = _get_effective_team_id() | |
| has_new_matches = db.execute(""" | |
| SELECT 1 FROM matches WHERE last_updated >= ? LIMIT 1 | |
| """, (since_str,)).fetchone() | |
| has_new_athletes = db.execute(""" | |
| SELECT 1 FROM athletes WHERE created_at >= ? AND deleted_at IS NULL LIMIT 1 | |
| """, (since_str,)).fetchone() | |
| has_new_race_groups = db.execute(""" | |
| SELECT 1 FROM race_groups rg | |
| JOIN matches m ON rg.match_id = m.id | |
| WHERE m.last_updated >= ? LIMIT 1 | |
| """, (since_str,)).fetchone() | |
| has_updates = bool(has_new_matches or has_new_athletes or has_new_race_groups) | |
| if not has_updates and str(data_version) == '0': | |
| athletes, results, _ = _get_team_sync_payload(db, team_id) | |
| has_updates = bool(athletes or results) | |
| else: | |
| athletes = results = None | |
| new_data_version = int(datetime.now().timestamp() * 1000) | |
| if not has_updates: | |
| return jsonify({ | |
| "has_updates": False, | |
| "data_version": new_data_version, | |
| "sync_time": datetime.now().isoformat() | |
| }) | |
| if athletes is None or results is None: | |
| athletes, results, _ = _get_team_sync_payload(db, team_id) | |
| current_matches = get_current_matches() | |
| seasons = db.execute("SELECT DISTINCT season FROM matches ORDER BY season DESC").fetchall() | |
| training_types = get_training_types() | |
| return jsonify({ | |
| "has_updates": True, | |
| "athletes": athletes, | |
| "results": results, | |
| "seasons": [dict(s) for s in seasons], | |
| "current_matches": current_matches, | |
| "training_types": training_types, | |
| "data_version": new_data_version, | |
| "sync_time": datetime.now().isoformat() | |
| }) | |
| # ========== 多队伍管理 ========== | |
| def api_get_teams(): | |
| """获取所有队伍配置""" | |
| user = get_user_by_username(session['username']) | |
| return jsonify({"teams": get_all_teams(), "team_context": _build_team_context_payload(user)}) | |
| def api_get_active_team(): | |
| """获取当前请求实际生效的队伍上下文""" | |
| user = get_user_by_username(session['username']) if 'username' in session else None | |
| team_context = _build_team_context_payload(user) | |
| return jsonify(team_context['effective_team']) | |
| def api_set_active_team(): | |
| """切换超级管理员当前会话的查看队伍""" | |
| data = request.get_json() or {} | |
| team_id = data.get("team_id") | |
| if not team_id: | |
| return jsonify({"error": "缺少 team_id"}), 400 | |
| teams = get_all_teams() | |
| if not any(t["team_id"] == team_id for t in teams): | |
| return jsonify({"error": "无效的 team_id"}), 400 | |
| session['view_team_id'] = team_id | |
| user = get_user_by_username(session['username']) | |
| team_context = _build_team_context_payload(user) | |
| return jsonify({"status": "success", "team": team_context['effective_team'], "team_context": team_context}) | |
| def api_app_version(): | |
| """获取移动端最新版本信息""" | |
| import os | |
| static_dir = os.path.join(os.path.dirname(__file__), 'static') | |
| version = os.environ.get("APP_VERSION") or globals().get("APP_VERSION", "1.0.0") | |
| android_variants = [] | |
| for filename in ('app-debug-latest.apk', 'app-debug.apk'): | |
| apk_path = os.path.join(static_dir, filename) | |
| if os.path.exists(apk_path): | |
| android_variants.append({ | |
| "channel": "direct-apk", | |
| "url": f"/{filename}", | |
| "size": os.path.getsize(apk_path), | |
| "label": "下载安装包", | |
| "min_os": "Android 5.0+", | |
| "filename": '朝阳短道.apk', | |
| }) | |
| break | |
| ios_variants = [{ | |
| "channel": "xcode-project", | |
| "url": None, | |
| "label": "请通过 Xcode / TestFlight 分发", | |
| "min_os": "iOS", | |
| }] | |
| return jsonify({ | |
| "version": version, | |
| "platforms": { | |
| "android": { | |
| "available": bool(android_variants), | |
| "variants": android_variants, | |
| "primary_action": android_variants[0] if android_variants else None, | |
| }, | |
| "ios": { | |
| "available": True, | |
| "variants": ios_variants, | |
| "primary_action": ios_variants[0], | |
| }, | |
| "web": { | |
| "available": False, | |
| "variants": [], | |
| "primary_action": None, | |
| } | |
| } | |
| }) | |
| def download_apk(): | |
| """下载APK文件""" | |
| import os | |
| from flask import send_from_directory | |
| static_folder = os.path.join(os.path.dirname(__file__), 'static') | |
| filename = os.path.basename(request.path) | |
| return send_from_directory(static_folder, filename, as_attachment=True, download_name='朝阳短道.apk') | |
| def is_match_day(): | |
| """判断今天是否是比赛日(有进行中或即将开始的比赛,或日期在比赛范围内)""" | |
| from datetime import datetime | |
| db = get_db() | |
| today_str = datetime.now().strftime('%Y/%m/%d') | |
| today_dash = datetime.now().strftime('%Y-%m-%d') | |
| # 检查是否有进行中/即将开始的比赛 | |
| ongoing = db.execute(""" | |
| SELECT 1 FROM matches WHERE status LIKE '%进行%' OR status LIKE '%即将%' LIMIT 1 | |
| """).fetchone() | |
| if ongoing: | |
| return True | |
| # 检查今天是否在任何比赛的日期范围内 | |
| in_range = db.execute(""" | |
| SELECT date_range FROM matches WHERE date_range IS NOT NULL AND date_range != '' | |
| """).fetchall() | |
| for row in in_range: | |
| dr = row['date_range'] or '' | |
| parts = dr.split('—') | |
| if len(parts) == 2: | |
| try: | |
| start = _norm_date(parts[0].strip()) | |
| end = _norm_date(parts[1].strip()) | |
| if start <= today_dash <= end: | |
| return True | |
| except: | |
| pass | |
| elif today_str in dr: | |
| return True | |
| return False | |
| def _norm_date(raw): | |
| """标准化日期为 YYYY-MM-DD 格式,补齐前导零""" | |
| s = raw.replace('/', '-').replace('.', '-').strip() | |
| parts = s.split('-') | |
| if len(parts) == 3: | |
| return "{}-{:02d}-{:02d}".format(int(parts[0]), int(parts[1]), int(parts[2])) | |
| return s | |
| def crawl_update_all(): | |
| """更新所有数据源""" | |
| print("开始更新 zhitikeji 数据源...") | |
| try: | |
| crawl_update_zhitikeji() | |
| except Exception as e: | |
| print(f"zhitikeji 更新出错: {e}") | |
| print("开始更新 chinacsa 数据源...") | |
| try: | |
| crawl_update_chinacsa() | |
| except Exception as e: | |
| print(f"chinacsa 更新出错: {e}") | |
| _update_running = False | |
| def api_trigger_update(): | |
| """手动触发数据更新(所有数据源),后台异步执行""" | |
| global _update_running | |
| if _update_running: | |
| return jsonify({"status": "already_running", "message": "更新正在进行中,请稍候"}) | |
| import threading | |
| def run_update(): | |
| global _update_running | |
| _update_running = True | |
| try: | |
| crawl_update_all() | |
| except Exception as e: | |
| print(f"后台更新出错: {e}") | |
| finally: | |
| _update_running = False | |
| t = threading.Thread(target=run_update, daemon=True) | |
| t.start() | |
| return jsonify({"status": "started", "message": "数据更新已开始,预计需要几分钟"}) | |
| def api_update_status(): | |
| """查询更新是否正在进行""" | |
| return jsonify({"running": _update_running}) | |
| def api_datasources(): | |
| """获取数据源信息""" | |
| from database import get_db | |
| conn = get_db() | |
| sources = {} | |
| for row in conn.execute(""" | |
| SELECT data_source, COUNT(*) as match_count | |
| FROM matches | |
| GROUP BY data_source | |
| """).fetchall(): | |
| sources[row["data_source"]] = {"match_count": row["match_count"]} | |
| # 统计每个数据源的记录数 | |
| for row in conn.execute(""" | |
| SELECT rg.data_source, COUNT(DISTINCT rg.rcbh) as group_count, COUNT(r.id) as result_count | |
| FROM race_groups rg | |
| LEFT JOIN results r ON r.rcbh = rg.rcbh | |
| GROUP BY rg.data_source | |
| """).fetchall(): | |
| if row["data_source"] in sources: | |
| sources[row["data_source"]]["group_count"] = row["group_count"] | |
| sources[row["data_source"]]["result_count"] = row["result_count"] | |
| conn.close() | |
| return jsonify(sources) | |
| def _is_team_name(name): | |
| """判断名字是否为集体名称(接力队名),不应作为运动员显示""" | |
| import re | |
| if not name: | |
| return False | |
| # 匹配 U15、U13、U17、U19 等年龄段标记 | |
| if re.search(r'U\d{2}', name, re.IGNORECASE): | |
| return True | |
| # 匹配纯集体名称 | |
| team_only = ['集体', '混合接力', '混合', '接力队'] | |
| if name in team_only: | |
| return True | |
| # 含空格的多队组合名(如"东城朝阳"、"朝阳 东城 平谷"、"北京朝阳 蓓蕾") | |
| if ' ' in name or '\u3000' in name: | |
| return True | |
| # 匹配"区+组"格式(如"朝阳区丁组"、"朝阳区乙组") | |
| if re.search(r'[市区县].*[甲乙丙丁]组$', name): | |
| return True | |
| # 匹配"组+区名"格式(如"乙组朝阳区"、"丁组朝阳区") | |
| if re.match(r'^[甲乙丙丁]组.*[市区县]', name): | |
| return True | |
| # 匹配纯地区区名(如"朝阳区"、"海淀区") | |
| relay_team_patterns = ['朝阳区', '北京市朝阳区', '海淀区', '西城区', '东城区', '丰台区', '石景山区', '延庆区'] | |
| if name in relay_team_patterns: | |
| return True | |
| # 匹配含俱乐部/体校/学校/冰雪/体育等关键词的队名 | |
| if re.search(r'俱乐部|体校|学校|冰雪|轮滑|体育|蓓蕾|星锐|瑞杰|启鸣|巅峰|飞扬|巩华|昆仑|斗罗|添越|尽责|梦起源|平谷|雪人|点冰|奥通|蓓蕾|星锐', name): | |
| return True | |
| # 短队名精确匹配(<=6字,不含关键词但已知是队名) | |
| short_team_names = ['朝阳三体校', '朝阳蓓蕾', '朝阳启鸣', '东城朝阳', '朝阳三体'] | |
| if name in short_team_names: | |
| return True | |
| return False | |
| def api_dashboard_summary(): | |
| """Dashboard概要信息""" | |
| from database import get_db | |
| conn = get_db() | |
| team_id = _get_effective_team_id() | |
| team_names = _get_team_athlete_names(conn, team_id) | |
| active_names = set() | |
| for name in team_names: | |
| if name in active_names or _is_team_name(name): | |
| continue | |
| if is_athlete_active(conn, name, team_id): | |
| active_names.add(name) | |
| filter_clause, filter_params = _build_team_filter_clause(conn, team_id, 'r') | |
| season_stats = conn.execute(f""" | |
| SELECT m.season, COUNT(DISTINCT m.id) as match_count, | |
| COUNT(DISTINCT r.name) as athlete_count, | |
| COUNT(DISTINCT rg.event) as event_count | |
| FROM matches m | |
| JOIN race_groups rg ON rg.match_id = m.id | |
| JOIN results r ON r.rcbh = rg.rcbh | |
| WHERE {filter_clause} | |
| GROUP BY m.season | |
| ORDER BY m.season | |
| """, filter_params).fetchall() | |
| medal_stats = conn.execute(f""" | |
| SELECT m.id as match_id, m.season, r.name, rg.event, rg.category, r.place, | |
| rg.race_date, m.title as match_title | |
| FROM results r | |
| JOIN race_groups rg ON r.rcbh = rg.rcbh | |
| JOIN matches m ON rg.match_id = m.id | |
| WHERE {filter_clause} | |
| AND r.place IN ('1', '2', '3') | |
| AND (rg.round_type = '决赛/Finals' OR rg.round_type = '决赛A' OR rg.round_type = '决赛') | |
| AND r.time_result NOT LIKE '%00:00.000%' | |
| AND r.qual != '犯规' | |
| GROUP BY m.id, r.name, rg.event, rg.category | |
| HAVING r.place = MIN(CAST(r.place AS INTEGER)) | |
| ORDER BY m.season DESC, rg.race_date DESC | |
| """, filter_params).fetchall() | |
| medal_list = [dict(m) for m in medal_stats if m["name"] in active_names or _is_team_name(m["name"])] | |
| if active_names: | |
| relay_name_placeholders = ','.join(['?'] * len(active_names)) | |
| relay_filter_clause, relay_filter_params = _build_team_filter_clause(conn, team_id, 'r') | |
| relay_member_filter_clause, relay_member_filter_params = _build_team_filter_clause(conn, team_id, 'r3') | |
| relay_medal_stats = conn.execute(f""" | |
| SELECT m.id as match_id, m.season, rm.athlete_name as name, rg.event, rg.category, r.place, | |
| rg.race_date, m.title as match_title | |
| FROM relay_members rm | |
| JOIN results r ON r.name = rm.relay_team_name | |
| JOIN race_groups rg ON r.rcbh = rg.rcbh | |
| JOIN matches m ON rg.match_id = m.id | |
| WHERE {relay_filter_clause} | |
| AND r.place IN ('1', '2', '3') | |
| AND (rg.round_type = '决赛/Finals' OR rg.round_type = '决赛A' OR rg.round_type = '决赛') | |
| AND r.time_result NOT LIKE '%%00:00.000%%' | |
| AND r.qual != '犯规' | |
| AND (rm.season IS NULL OR rm.season = m.season) | |
| AND (rm.event IS NULL OR rm.event = rg.event) | |
| AND rm.athlete_name IN ({relay_name_placeholders}) | |
| AND EXISTS ( | |
| SELECT 1 FROM results r3 | |
| JOIN race_groups rg3 ON r3.rcbh = rg3.rcbh | |
| WHERE r3.name = rm.athlete_name | |
| AND rg3.match_id = m.id | |
| AND rg3.event NOT LIKE '%%接力%%' | |
| AND {relay_member_filter_clause} | |
| ) | |
| GROUP BY m.id, rm.athlete_name, rg.event, rg.category | |
| HAVING r.place = MIN(CAST(r.place AS INTEGER)) | |
| ORDER BY m.season DESC, rg.race_date DESC | |
| """, relay_filter_params + list(active_names) + relay_member_filter_params).fetchall() | |
| medal_list.extend([dict(m) for m in relay_medal_stats]) | |
| relay_team_medals = conn.execute(f""" | |
| SELECT m.id as match_id, m.season, r.name, rg.event, rg.category, r.place, | |
| rg.race_date, m.title as match_title | |
| FROM results r | |
| JOIN race_groups rg ON r.rcbh = rg.rcbh | |
| JOIN matches m ON rg.match_id = m.id | |
| WHERE {filter_clause} | |
| AND r.place IN ('1', '2', '3') | |
| AND (rg.round_type = '决赛/Finals' OR rg.round_type = '决赛A' OR rg.round_type = '决赛') | |
| AND r.time_result NOT LIKE '%00:00.000%' | |
| AND r.qual != '犯规' | |
| AND rg.event LIKE '%接力%' | |
| GROUP BY m.id, r.name, rg.event, rg.category | |
| HAVING r.place = MIN(CAST(r.place AS INTEGER)) | |
| """, filter_params).fetchall() | |
| mapped_teams = set() | |
| if active_names: | |
| mapped = conn.execute( | |
| "SELECT DISTINCT rm.relay_team_name FROM relay_members rm WHERE rm.athlete_name IN ({})".format(relay_name_placeholders), | |
| list(active_names) | |
| ).fetchall() | |
| mapped_teams = {r["relay_team_name"] for r in mapped} | |
| existing_keys = {(m.get('match_id'), m.get('name'), m.get('event'), m.get('category')) for m in medal_list} | |
| for rm in relay_team_medals: | |
| key = (rm['match_id'], rm['name'], rm['event'], rm['category']) | |
| if rm["name"] not in mapped_teams and key not in existing_keys: | |
| medal_list.append(dict(rm)) | |
| medal_list.sort(key=lambda m: ( | |
| m.get('season', ''), | |
| parse_chinese_date(m.get('race_date', '')) | |
| ), reverse=True) | |
| current = get_current_matches() | |
| conn.close() | |
| return jsonify({ | |
| "athlete_count": len(active_names), | |
| "athletes": list(active_names), | |
| "season_stats": [dict(s) for s in season_stats], | |
| "medals": medal_list, | |
| "current_matches": current, | |
| "last_update": get_last_update(), | |
| }) | |
| def parse_chinese_date(date_str): | |
| """将中文日期(如 2024年10月19日)转为可排序的字符串(如 2024-10-19)""" | |
| if not date_str: | |
| return "" | |
| import re | |
| m = re.match(r'(\d+)年(\d+)月(\d+)日', date_str.strip()) | |
| if m: | |
| return f"{int(m.group(1)):04d}-{int(m.group(2)):02d}-{int(m.group(3)):02d}" | |
| return date_str | |
| def parse_time_to_seconds(time_str): | |
| """将时间字符串(如 01:42.619)转为秒数""" | |
| if not time_str or time_str == "00:00.000": | |
| return None | |
| try: | |
| parts = time_str.split(":") | |
| if len(parts) == 2: | |
| minutes = int(parts[0]) | |
| seconds = float(parts[1]) | |
| return minutes * 60 + seconds | |
| elif len(parts) == 3: | |
| hours = int(parts[0]) | |
| minutes = int(parts[1]) | |
| seconds = float(parts[2]) | |
| return hours * 3600 + minutes * 60 + seconds | |
| else: | |
| return float(time_str) | |
| except (ValueError, IndexError): | |
| return None | |
| # ========== 用户认证 API ========== | |
| def api_auth_athlete_search(): | |
| """公开接口:注册时搜索队员(仅返回名字和组别,不暴露详细成绩)""" | |
| from database import get_db as _get_db | |
| q = request.args.get("q", "").strip() | |
| if not q or len(q) < 1: | |
| return jsonify([]) | |
| conn = _get_db() | |
| rows = conn.execute(""" | |
| SELECT DISTINCT name, category FROM athletes | |
| WHERE name LIKE ? AND deleted_at IS NULL | |
| ORDER BY name LIMIT 20 | |
| """, (f'%{q}%',)).fetchall() | |
| # Also search from race results for names not in athletes table | |
| race_rows = conn.execute(""" | |
| SELECT DISTINCT r.name, rg.category | |
| FROM results r | |
| JOIN race_groups rg ON r.rcbh = rg.rcbh | |
| WHERE r.name LIKE ? AND r.is_chaoyang = 1 | |
| ORDER BY r.name LIMIT 20 | |
| """, (f'%{q}%',)).fetchall() | |
| # Merge and deduplicate | |
| seen = set() | |
| result = [] | |
| for r in list(rows) + list(race_rows): | |
| key = r["name"] | |
| if key not in seen: | |
| seen.add(key) | |
| result.append({"name": r["name"], "category": r["category"] or ""}) | |
| conn.close() | |
| return jsonify(result[:20]) | |
| def api_register(): | |
| """用户注册(可选关联队员和关系)""" | |
| data = request.json | |
| username = data.get("username", "").strip() | |
| password = data.get("password", "") | |
| athlete_name = data.get("athlete_name", "").strip() | |
| relationship = data.get("relationship", "").strip() | |
| identity = data.get("identity", "family").strip() | |
| if not username or not password: | |
| return jsonify({"error": "用户名和密码不能为空"}), 400 | |
| # 支持手机号格式(11位纯数字)或普通用户名(2-20字符) | |
| if username.isdigit(): | |
| if len(username) != 11: | |
| return jsonify({"error": "手机号格式错误,请输入11位数字"}), 400 | |
| elif len(username) < 2 or len(username) > 20: | |
| return jsonify({"error": "用户名长度2-20个字符"}), 400 | |
| if len(password) < 6: | |
| return jsonify({"error": "密码至少6个字符"}), 400 | |
| existing = get_user_by_username(username) | |
| if existing: | |
| return jsonify({"error": "用户名已存在"}), 400 | |
| # 教练身份:无需关联队员,直接注册 | |
| if identity == 'coach': | |
| relationship = '教练' | |
| team_id = _get_issued_team()['team_id'] | |
| password_hash = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') | |
| if create_user(username, password_hash, 'user', None, relationship, 1, team_id=team_id): | |
| user = get_user_by_username(username) | |
| session.permanent = True | |
| session['user_id'] = user['id'] | |
| session['username'] = user['username'] | |
| session['role'] = user['role'] | |
| if user['role'] != 'superadmin': | |
| session.pop('view_team_id', None) | |
| team_context = _build_team_context_payload(user) | |
| return jsonify({ | |
| "status": "success", | |
| "user": _build_user_payload(user), | |
| "team_context": team_context, | |
| "active_team": team_context['effective_team'] | |
| }) | |
| return jsonify({"error": "注册失败"}), 400 | |
| # 亲属身份:检查同一队员的同一关系是否已被注册(抢注检查) | |
| if athlete_name and relationship: | |
| conn = get_db() | |
| taken = conn.execute(""" | |
| SELECT username FROM users | |
| WHERE athlete_name = ? AND relationship = ? AND approved = 1 | |
| """, (athlete_name, relationship)).fetchone() | |
| conn.close() | |
| if taken: | |
| return jsonify({"error": f"该队员的「{relationship}」关系已被用户「{taken['username']}」注册,如确为您本人请联系管理员确认"}), 400 | |
| # 检查关联的队员是否存在于系统中 | |
| athlete_found = False | |
| if athlete_name: | |
| conn = get_db() | |
| # 从成绩表中查找 | |
| row = conn.execute("SELECT 1 FROM results WHERE name = ? LIMIT 1", (athlete_name,)).fetchone() | |
| # 从手动队员表中查找 | |
| if not row: | |
| row = conn.execute("SELECT 1 FROM athletes WHERE name = ? AND deleted_at IS NULL LIMIT 1", (athlete_name,)).fetchone() | |
| conn.close() | |
| athlete_found = row is not None | |
| # 如果关联了队员但队员不在系统中,需要管理员审核 | |
| needs_approval = bool(athlete_name) and not athlete_found | |
| password_hash = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') | |
| approved = 0 if needs_approval else 1 | |
| team_id = _get_issued_team()['team_id'] | |
| if create_user(username, password_hash, 'user', athlete_name or None, relationship or None, approved, team_id=team_id): | |
| user = get_user_by_username(username) | |
| # 亲属用户:同步写入athlete_family表 | |
| if athlete_name and relationship: | |
| from database import get_db as _get_db | |
| from datetime import datetime as _dt | |
| try: | |
| _conn = _get_db() | |
| _conn.execute("INSERT OR IGNORE INTO athlete_family (athlete_name, user_id, relationship, approved, created_at) VALUES (?, ?, ?, ?, ?)", | |
| (athlete_name, user['id'], relationship, approved, _dt.now().isoformat())) | |
| _conn.commit() | |
| _conn.close() | |
| except Exception: | |
| pass | |
| if needs_approval: | |
| # 需要管理员审核,不自动登录 | |
| return jsonify({ | |
| "status": "pending", | |
| "message": "注册成功,您关联的队员未在系统中找到,需等待管理员审核批准后才能登录", | |
| "username": username | |
| }) | |
| session.permanent = True | |
| session['user_id'] = user['id'] | |
| session['username'] = user['username'] | |
| session['role'] = user['role'] | |
| if user['role'] != 'superadmin': | |
| session.pop('view_team_id', None) | |
| family = get_user_family(user['id']) | |
| team_context = _build_team_context_payload(user) | |
| return jsonify({ | |
| "status": "success", | |
| "user": _build_user_payload(user, family), | |
| "team_context": team_context, | |
| "active_team": team_context['effective_team'] | |
| }) | |
| return jsonify({"error": "注册失败"}), 400 | |
| def api_login(): | |
| """用户登录""" | |
| data = request.json | |
| username = data.get("username", "").strip() | |
| password = data.get("password", "") | |
| if not username or not password: | |
| return jsonify({"error": "用户名和密码不能为空"}), 400 | |
| user = get_user_by_username(username) | |
| if not user: | |
| return jsonify({"error": "用户名或密码错误"}), 401 | |
| stored_hash = user['password_hash'] | |
| password_ok = False | |
| if stored_hash.startswith('$2'): | |
| # bcrypt hash | |
| try: | |
| password_ok = bcrypt.checkpw(password.encode('utf-8'), stored_hash.encode('utf-8')) | |
| except (ValueError, TypeError): | |
| pass | |
| else: | |
| # 旧格式:明文、MD5 或 SHA-256,兼容验证 | |
| import hashlib | |
| sha256_hash = hashlib.sha256(password.encode('utf-8')).hexdigest() | |
| md5_hash = hashlib.md5(password.encode('utf-8')).hexdigest() | |
| if stored_hash == password or stored_hash == md5_hash or stored_hash == sha256_hash: | |
| password_ok = True | |
| # 自动升级为 bcrypt | |
| try: | |
| new_hash = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') | |
| update_user_password(user['id'], new_hash) | |
| except Exception: | |
| pass | |
| if not password_ok: | |
| return jsonify({"error": "用户名或密码错误"}), 401 | |
| # 检查用户是否已被批准 | |
| if not user.get('approved'): | |
| return jsonify({"error": "您的账号尚未通过审核,请等待管理员批准"}), 403 | |
| session.permanent = True # 使 PERMANENT_SESSION_LIFETIME 配置生效 | |
| session['user_id'] = user['id'] | |
| session['username'] = user['username'] | |
| session['role'] = user['role'] | |
| if user['role'] != 'superadmin': | |
| session.pop('view_team_id', None) | |
| family = get_user_family(user['id']) | |
| team_context = _build_team_context_payload(user) | |
| # 生成 token 供小程序 Bearer 鉴权 | |
| token = create_user_token(user['id']) | |
| return jsonify({ | |
| "status": "success", | |
| "user": _build_user_payload(user, family), | |
| "team_context": team_context, | |
| "active_team": team_context['effective_team'], | |
| "token": token | |
| }) | |
| def api_logout(): | |
| """用户登出""" | |
| session.clear() | |
| return jsonify({"status": "success"}) | |
| # ====== 小程序微信登录 ====== | |
| def api_wx_login(): | |
| """小程序微信登录 — 用 code 换取 openid 并验证绑定""" | |
| data = request.json | |
| code = data.get("code", "").strip() | |
| athlete_name = data.get("athlete_name", "").strip() | |
| relationship = data.get("relationship", "本人").strip() | |
| if not code: | |
| return jsonify({"error": "缺少登录凭证"}), 400 | |
| if not athlete_name: | |
| return jsonify({"error": "请选择关联的队员"}), 400 | |
| # 从数据库获取小程序的 appsecret(存储在 app_settings 中) | |
| appsecret = _get_wx_appsecret() | |
| if not appsecret: | |
| return jsonify({"error": "服务端未配置微信小程序 AppSecret"}), 500 | |
| # 调用微信 API 换取 openid | |
| wx_api = "https://api.weixin.qq.com/sns/jscode2session" | |
| try: | |
| resp = http_requests.get(wx_api, params={ | |
| "appid": "wx1379263366089442", | |
| "secret": appsecret, | |
| "js_code": code, | |
| "grant_type": "authorization_code" | |
| }, timeout=10) | |
| wx_data = resp.json() | |
| except Exception as e: | |
| return jsonify({"error": f"微信服务调用失败: {str(e)}"}), 502 | |
| openid = wx_data.get("openid") | |
| if not openid: | |
| errmsg = wx_data.get("errmsg", "未知错误") | |
| return jsonify({"error": f"微信登录失败: {errmsg}"}), 401 | |
| # 检查是否已有绑定 | |
| binding = get_wx_binding_by_openid(openid) | |
| if binding: | |
| if binding["approved"]: | |
| # 已审核通过 → 直接登录 | |
| user = get_or_create_wx_user(binding["athlete_name"], openid) | |
| if not user: | |
| return jsonify({"error": "关联账号异常,请联系管理员"}), 500 | |
| token = create_user_token(user["id"]) | |
| family = get_user_family(user["id"]) | |
| return jsonify({ | |
| "status": "success", | |
| "user": _build_user_payload(user, family), | |
| "token": token, | |
| "binding_status": "approved" | |
| }) | |
| else: | |
| return jsonify({ | |
| "status": "pending", | |
| "message": "您的绑定申请正在等待管理员审核,请稍后再试", | |
| "athlete_name": binding["athlete_name"] | |
| }) | |
| # 新绑定 → 创建待审核记录 | |
| create_wx_binding(openid, athlete_name, relationship) | |
| return jsonify({ | |
| "status": "pending", | |
| "message": f"已提交绑定申请({athlete_name},{relationship}),等待管理员审核", | |
| "athlete_name": athlete_name | |
| }) | |
| def api_wx_pending_bindings(): | |
| """管理员查看待审核的微信绑定""" | |
| bindings = get_pending_wx_bindings() | |
| return jsonify(bindings) | |
| def api_wx_approve_binding(binding_id): | |
| """管理员批准微信绑定""" | |
| approve_wx_binding(binding_id) | |
| return jsonify({"status": "ok"}) | |
| def api_wx_reject_binding(binding_id): | |
| """管理员拒绝微信绑定 — 直接删除记录""" | |
| conn = get_db() | |
| conn.execute("DELETE FROM wx_bindings WHERE id = ?", (binding_id,)) | |
| conn.commit() | |
| return jsonify({"status": "ok"}) | |
| def _get_wx_appsecret(): | |
| """从本地文件读取微信小程序 AppSecret""" | |
| # 优先从环境变量读取 | |
| from os import environ | |
| secret = environ.get("WX_APPSECRET", "") | |
| if secret: | |
| return secret | |
| # 从数据库 app_settings 读取(管理员通过 API 设置) | |
| conn = get_db() | |
| row = conn.execute( | |
| "SELECT value FROM app_settings WHERE key = 'wx_appsecret'" | |
| ).fetchone() | |
| return row["value"] if row else "" | |
| def api_change_password(): | |
| """修改自己的密码""" | |
| data = request.json | |
| old_password = data.get("old_password", "") | |
| new_password = data.get("new_password", "") | |
| if not old_password or not new_password: | |
| return jsonify({"error": "请输入旧密码和新密码"}), 400 | |
| if len(new_password) < 6: | |
| return jsonify({"error": "新密码至少6个字符"}), 400 | |
| # 验证旧密码 | |
| conn = get_db() | |
| row = conn.execute("SELECT password_hash FROM users WHERE id = ?", (session['user_id'],)).fetchone() | |
| conn.close() | |
| if not row: | |
| return jsonify({"error": "旧密码不正确"}), 400 | |
| old_hash = row['password_hash'] | |
| old_ok = False | |
| if old_hash.startswith('$2'): | |
| try: | |
| old_ok = bcrypt.checkpw(old_password.encode('utf-8'), old_hash.encode('utf-8')) | |
| except (ValueError, TypeError): | |
| pass | |
| else: | |
| import hashlib | |
| if old_hash == hashlib.sha256(old_password.encode('utf-8')).hexdigest() or old_hash == hashlib.md5(old_password.encode('utf-8')).hexdigest() or old_hash == old_password: | |
| old_ok = True | |
| if not old_ok: | |
| return jsonify({"error": "旧密码不正确"}), 400 | |
| new_hash = bcrypt.hashpw(new_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') | |
| update_user_password(session['user_id'], new_hash) | |
| return jsonify({"status": "success"}) | |
| def api_reset_user_password(user_id): | |
| """重置用户密码(仅超级管理员)""" | |
| data = request.json | |
| new_password = data.get("new_password", "") | |
| if not new_password: | |
| return jsonify({"error": "新密码不能为空"}), 400 | |
| if len(new_password) < 6: | |
| return jsonify({"error": "新密码至少6个字符"}), 400 | |
| # 不允许重置超级管理员密码(除了自己) | |
| conn = get_db() | |
| row = conn.execute("SELECT role FROM users WHERE id = ?", (user_id,)).fetchone() | |
| conn.close() | |
| if row and row['role'] == 'superadmin' and user_id != session.get('user_id'): | |
| return jsonify({"error": "不能重置其他超级管理员的密码"}), 403 | |
| new_hash = bcrypt.hashpw(new_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') | |
| update_user_password(user_id, new_hash) | |
| return jsonify({"status": "success"}) | |
| def api_auth_me(): | |
| """获取当前登录用户信息""" | |
| if 'user_id' in session: | |
| user = get_user_by_username(session['username']) | |
| if not user: | |
| session.clear() | |
| return jsonify({"logged_in": False}) | |
| family = get_user_family(session['user_id']) | |
| team_context = _build_team_context_payload(user) | |
| return jsonify({ | |
| "logged_in": True, | |
| "user": _build_user_payload(user, family), | |
| "team_context": team_context, | |
| "active_team": team_context['effective_team'] | |
| }) | |
| return jsonify({"logged_in": False, "team_context": _build_team_context_payload(None), "active_team": _get_issued_team()}) | |
| def api_users(): | |
| """获取用户列表(管理员)""" | |
| users = get_all_users() | |
| # 标记超级管理员 | |
| for u in users: | |
| u['is_superadmin'] = u.get('role') == 'superadmin' | |
| return jsonify(users) | |
| def api_approve_user(user_id): | |
| """批准用户(管理员)""" | |
| conn = get_db() | |
| row = conn.execute("SELECT role FROM users WHERE id = ?", (user_id,)).fetchone() | |
| conn.close() | |
| if not row: | |
| return jsonify({"error": "用户不存在"}), 404 | |
| if row['role'] == 'superadmin': | |
| return jsonify({"error": "不能操作超级管理员"}), 403 | |
| approve_user(user_id) | |
| return jsonify({"status": "success"}) | |
| def api_reject_user(user_id): | |
| """拒绝用户(管理员)""" | |
| conn = get_db() | |
| row = conn.execute("SELECT role FROM users WHERE id = ?", (user_id,)).fetchone() | |
| conn.close() | |
| if not row: | |
| return jsonify({"error": "用户不存在"}), 404 | |
| if row['role'] == 'superadmin': | |
| return jsonify({"error": "不能操作超级管理员"}), 403 | |
| reject_user(user_id) | |
| return jsonify({"status": "success"}) | |
| def api_update_user_role(user_id): | |
| """更新用户角色(仅超级管理员可操作)""" | |
| data = request.json | |
| role = data.get("role", "") | |
| if role not in ('admin', 'user'): | |
| return jsonify({"error": "无效角色,仅支持 admin 和 user"}), 400 | |
| # 不允许修改超级管理员的角色 | |
| conn = get_db() | |
| row = conn.execute("SELECT role, username FROM users WHERE id = ?", (user_id,)).fetchone() | |
| conn.close() | |
| if row and row['role'] == 'superadmin': | |
| return jsonify({"error": "不能修改超级管理员的角色"}), 403 | |
| update_user_role(user_id, role) | |
| return jsonify({"status": "success"}) | |
| def api_delete_user(user_id): | |
| """删除用户(仅超级管理员可操作)""" | |
| if user_id == session.get('user_id'): | |
| return jsonify({"error": "不能删除自己"}), 400 | |
| # 不允许删除超级管理员 | |
| conn = get_db() | |
| row = conn.execute("SELECT role FROM users WHERE id = ?", (user_id,)).fetchone() | |
| conn.close() | |
| if row and row['role'] == 'superadmin': | |
| return jsonify({"error": "不能删除超级管理员"}), 403 | |
| delete_user(user_id) | |
| return jsonify({"status": "success"}) | |
| # ========== 队员管理 API ========== | |
| def api_team_aliases(): | |
| """获取当前队伍的队名别名列表""" | |
| team_id = _get_effective_team_id() | |
| aliases = get_team_aliases(team_id) | |
| return jsonify(aliases) | |
| def api_add_team_alias(): | |
| """添加当前队伍的队名别名""" | |
| data = request.json | |
| alias = data.get("alias", "").strip() | |
| if not alias: | |
| return jsonify({"error": "别名不能为空"}), 400 | |
| team_id = _get_effective_team_id() | |
| if add_team_alias(alias, team_id): | |
| recalculate_chaoyang_flags() | |
| return jsonify({"status": "success"}) | |
| return jsonify({"error": "别名已存在"}), 400 | |
| def api_delete_team_alias(alias_id): | |
| """删除当前队伍的队名别名""" | |
| team_id = _get_effective_team_id() | |
| delete_team_alias(alias_id, team_id) | |
| recalculate_chaoyang_flags() | |
| return jsonify({"status": "success"}) | |
| def api_athlete_overrides(): | |
| """获取当前队伍的手动管理队员覆盖列表""" | |
| team_id = _get_effective_team_id() | |
| overrides = get_athlete_overrides(team_id) | |
| return jsonify(overrides) | |
| def api_athlete_registered_relationships(name): | |
| """查询某队员已被注册的亲属关系列表""" | |
| conn = get_db() | |
| # 从 users 表查找已批准用户中关联了该队员的关系 | |
| rows = conn.execute(""" | |
| SELECT relationship FROM users | |
| WHERE athlete_name = ? AND approved = 1 AND relationship IS NOT NULL AND relationship != '' | |
| """, (name,)).fetchall() | |
| conn.close() | |
| relationships = [r['relationship'] for r in rows] | |
| return jsonify(relationships) | |
| def api_athletes_search(): | |
| """搜索队员(用于自由对比等场景)""" | |
| from database import get_db as _get_db | |
| q = request.args.get("q", "").strip() | |
| if not q: | |
| return jsonify([]) | |
| conn = _get_db() | |
| # 从比赛成绩中搜索,按姓名或队伍名匹配 | |
| rows = conn.execute(""" | |
| SELECT DISTINCT name, team, | |
| CASE WHEN is_chaoyang = 1 THEN 1 ELSE 0 END as is_chaoyang | |
| FROM results | |
| WHERE name LIKE ? OR team LIKE ? | |
| ORDER BY | |
| CASE WHEN name LIKE ? THEN 0 ELSE 1 END, | |
| name | |
| LIMIT 30 | |
| """, (f'%{q}%', f'%{q}%', f'{q}%')).fetchall() | |
| # Deduplicate by name: same athlete may appear with different teams | |
| seen_names = set() | |
| result_rows = [] | |
| for r in rows: | |
| if r["name"] in seen_names: | |
| continue | |
| seen_names.add(r["name"]) | |
| rd = dict(r) | |
| cnt = conn.execute("SELECT COUNT(*) as cnt FROM results WHERE name = ?", (rd["name"],)).fetchone() | |
| rd["result_count"] = cnt["cnt"] if cnt else 0 | |
| teams = conn.execute("SELECT DISTINCT team FROM results WHERE name = ?", (rd["name"],)).fetchall() | |
| rd["all_teams"] = [t["team"] for t in teams] | |
| result_rows.append(rd) | |
| # 也从手动添加的队员中搜索 | |
| manual_rows = conn.execute(""" | |
| SELECT name, gender, category FROM athletes WHERE name LIKE ? | |
| """, (f'%{q}%',)).fetchall() | |
| for m in manual_rows: | |
| if m["name"] not in seen_names: | |
| result_rows.append({"name": m["name"], "team": "手动添加", "is_chaoyang": 1, "result_count": 0, "all_teams": []}) | |
| conn.close() | |
| return jsonify(result_rows) | |
| def api_set_athlete_override(): | |
| """手动设置当前队伍的队员标记""" | |
| data = request.json | |
| name = data.get("name", "").strip() | |
| is_cy = data.get("is_chaoyang", 1) | |
| if not name: | |
| return jsonify({"error": "队员姓名不能为空"}), 400 | |
| team_id = _get_effective_team_id() | |
| set_athlete_override(name, is_cy, team_id) | |
| recalculate_chaoyang_flags() | |
| return jsonify({"status": "success"}) | |
| def api_delete_athlete_override(name): | |
| """删除当前队伍的队员覆盖标记""" | |
| team_id = _get_effective_team_id() | |
| delete_athlete_override(name, team_id) | |
| recalculate_chaoyang_flags() | |
| return jsonify({"status": "success"}) | |
| # ========== 接力队员关联管理 ========== | |
| def api_get_relay_members(): | |
| """获取接力队员关联列表""" | |
| team_name = request.args.get("team_name", "").strip() | |
| athlete_name = request.args.get("athlete_name", "").strip() | |
| members = get_relay_members(relay_team_name=team_name or None, athlete_name=athlete_name or None) | |
| return jsonify(members) | |
| def api_set_relay_members(): | |
| """设置接力队队员列表""" | |
| data = request.json | |
| relay_team_name = data.get("relay_team_name", "").strip() | |
| athlete_names = data.get("athlete_names", []) | |
| season = data.get("season", "").strip() or None | |
| event = data.get("event", "").strip() or None | |
| if not relay_team_name: | |
| return jsonify({"error": "接力队名不能为空"}), 400 | |
| if not athlete_names: | |
| return jsonify({"error": "队员列表不能为空"}), 400 | |
| set_relay_members(relay_team_name, athlete_names, season=season, event=event) | |
| return jsonify({"status": "success"}) | |
| def api_delete_relay_member(member_id): | |
| """删除单条接力队员关联""" | |
| delete_relay_member(member_id) | |
| return jsonify({"status": "success"}) | |
| def api_auto_populate_relay_members(): | |
| """根据 category + gender 自动推断接力队员关联""" | |
| count = auto_populate_relay_members() | |
| return jsonify({"status": "success", "inserted": count}) | |
| # ========== 运动员照片 API ========== | |
| def api_athlete_photo(name): | |
| """获取运动员照片(公开访问,img标签需无鉴权)""" | |
| photo = get_athlete_photo(name) | |
| if not photo or not photo["photo_data"]: | |
| return '', 404 | |
| from flask import Response | |
| return Response(photo["photo_data"], mimetype=photo["content_type"]) | |
| def api_upload_athlete_photo(name): | |
| """上传运动员照片(管理员或该队员指定家属)""" | |
| # 检查权限:超级管理员或该队员的指定家属 | |
| if session.get('role') != 'superadmin' and not is_family_of(session['user_id'], name): | |
| return jsonify({"error": "您没有权限修改该队员的照片,只有超级管理员和队员亲属可以修改"}), 403 | |
| if 'photo' not in request.files: | |
| return jsonify({"error": "未找到照片文件"}), 400 | |
| file = request.files['photo'] | |
| if not file.filename: | |
| return jsonify({"error": "未选择文件"}), 400 | |
| # 限制文件大小(2MB) | |
| file.seek(0, 2) | |
| size = file.tell() | |
| file.seek(0) | |
| if size > 2 * 1024 * 1024: | |
| return jsonify({"error": "照片文件不能超过2MB"}), 400 | |
| content_type = file.content_type or 'image/jpeg' | |
| photo_data = file.read() | |
| set_athlete_photo(name, photo_data, content_type) | |
| return jsonify({"status": "success"}) | |
| def api_delete_athlete_photo(name): | |
| """删除运动员照片""" | |
| delete_athlete_photo(name) | |
| return jsonify({"status": "success"}) | |
| # ========== 训练记录 API ========== | |
| def api_training_types(): | |
| """获取训练类型列表""" | |
| types = get_training_types() | |
| return jsonify(types) | |
| def api_add_training_type(): | |
| """添加训练类型""" | |
| data = request.json | |
| name = data.get("name", "").strip() | |
| description = data.get("description", "").strip() | |
| is_class = data.get("is_class", 0) | |
| if not name: | |
| return jsonify({"error": "训练类型名称不能为空"}), 400 | |
| if add_training_type(name, description, is_class): | |
| return jsonify({"status": "success"}) | |
| return jsonify({"error": "训练类型已存在"}), 400 | |
| def api_delete_training_type(type_id): | |
| """删除训练类型""" | |
| delete_training_type(type_id) | |
| return jsonify({"status": "success"}) | |
| def api_training_records(): | |
| """获取训练记录(普通用户只能查看关联队员的记录)""" | |
| athlete_name = request.args.get("athlete_name", "") | |
| # 权限控制:普通用户只能查看关联队员 | |
| if session.get('role') not in ('admin', 'superadmin'): | |
| accessible = get_accessible_athletes(session['user_id'], session['role']) | |
| if athlete_name and athlete_name not in accessible: | |
| return jsonify({"error": "无权查看该队员数据"}), 403 | |
| if not athlete_name: | |
| # 返回所有可访问队员的记录 | |
| result = [] | |
| for name in accessible: | |
| records = get_training_records(name) | |
| result.extend(records) | |
| return jsonify(result) | |
| records = get_training_records(athlete_name if athlete_name else None) | |
| return jsonify(records) | |
| def api_training_calendar_dates(): | |
| """获取训练日期列表(用于日历高亮),返回 {date: {is_class: bool, count: int}} """ | |
| athlete_name = request.args.get("athlete_name", "") | |
| year = request.args.get("year", "") | |
| month = request.args.get("month", "") | |
| # 权限控制 | |
| if session.get('role') not in ('admin', 'superadmin'): | |
| accessible = get_accessible_athletes(session['user_id'], session['role']) | |
| if athlete_name and athlete_name not in accessible: | |
| return jsonify({"error": "无权查看该队员数据"}), 403 | |
| if not athlete_name: | |
| athlete_name = None # 将在下面遍历 accessible | |
| from database import get_db | |
| conn = get_db() | |
| if athlete_name: | |
| names = [athlete_name] | |
| elif session.get('role') not in ('admin', 'superadmin'): | |
| names = accessible | |
| else: | |
| names = None # 全部队员 | |
| # 构建查询 | |
| conditions = [] | |
| params = [] | |
| if year: | |
| conditions.append("strftime('%Y', training_date) = ?") | |
| params.append(year) | |
| if month: | |
| conditions.append("strftime('%m', training_date) = ?") | |
| params.append(month.zfill(2)) | |
| if names is not None: | |
| placeholders = ','.join(['?'] * len(names)) | |
| conditions.append(f"athlete_name IN ({placeholders})") | |
| params.extend(names) | |
| where_clause = (" AND " + " AND ".join(conditions)) if conditions else "" | |
| rows = conn.execute(f""" | |
| SELECT training_date, tt.is_class, COUNT(*) as cnt | |
| FROM training_records tr | |
| JOIN training_types tt ON tr.training_type_id = tt.id | |
| WHERE 1=1{where_clause} | |
| GROUP BY training_date, tt.is_class | |
| """, params).fetchall() | |
| conn.close() | |
| # 汇总:每个日期有大课/小课标记 | |
| result = {} | |
| for row in rows: | |
| date = row["training_date"] | |
| if date not in result: | |
| result[date] = {"has_class": False, "has_custom": False, "count": 0} | |
| if row["is_class"]: | |
| result[date]["has_class"] = True | |
| else: | |
| result[date]["has_custom"] = True | |
| result[date]["count"] += row["cnt"] | |
| return jsonify(result) | |
| def api_add_training_record(): | |
| """添加训练记录(管理员/超管可添加任意队员,普通用户只能添加关联队员)""" | |
| data = request.json | |
| athlete_name = data.get("athlete_name", "").strip() | |
| training_type_id = data.get("training_type_id") | |
| training_date = data.get("training_date", "").strip() | |
| duration_minutes = data.get("duration_minutes") | |
| notes = data.get("notes", "").strip() | |
| if not all([athlete_name, training_type_id, training_date]): | |
| return jsonify({"error": "运动员姓名、训练类型和日期不能为空"}), 400 | |
| # 权限控制:普通用户只能为关联队员添加记录 | |
| if session.get('role') not in ('admin', 'superadmin'): | |
| accessible = get_accessible_athletes(session['user_id'], session.get('role', 'user')) | |
| if athlete_name not in accessible: | |
| return jsonify({"error": "无权为该队员添加训练记录"}), 403 | |
| add_training_record(athlete_name, training_type_id, training_date, duration_minutes, notes) | |
| return jsonify({"status": "success"}) | |
| def api_delete_training_record(record_id): | |
| """删除训练记录""" | |
| delete_training_record(record_id) | |
| return jsonify({"status": "success"}) | |
| def api_update_training_record(record_id): | |
| """修改训练记录 | |
| - 管理员/超级管理员:直接修改 | |
| - 普通用户(家属):提交修改申请,需管理员审批 | |
| """ | |
| data = request.json | |
| training_type_id = data.get("training_type_id") | |
| training_date = data.get("training_date") | |
| duration_minutes = data.get("duration_minutes") | |
| notes = data.get("notes") | |
| # 获取该记录的运动员名 | |
| conn = get_db() | |
| record = conn.execute("SELECT athlete_name FROM training_records WHERE id = ?", (record_id,)).fetchone() | |
| conn.close() | |
| if not record: | |
| return jsonify({"error": "训练记录不存在"}), 404 | |
| athlete_name = record["athlete_name"] | |
| if session.get('role') in ('admin', 'superadmin'): | |
| # 管理员直接修改 | |
| update_training_record(record_id, training_type_id, training_date, duration_minutes, notes) | |
| return jsonify({"status": "success"}) | |
| else: | |
| # 普通用户检查权限 | |
| if not can_edit_athlete(athlete_name): | |
| return jsonify({"error": "没有权限修改该队员训练记录"}), 403 | |
| # 提交审批 | |
| changes = {} | |
| if training_type_id is not None: | |
| changes["training_type_id"] = training_type_id | |
| if training_date is not None: | |
| changes["training_date"] = training_date | |
| if duration_minutes is not None: | |
| changes["duration_minutes"] = duration_minutes | |
| if notes is not None: | |
| changes["notes"] = notes | |
| if not changes: | |
| return jsonify({"error": "没有修改内容"}), 400 | |
| submit_training_change(record_id, athlete_name, changes, session['user_id']) | |
| return jsonify({"status": "pending"}) | |
| def api_get_pending_changes(): | |
| """获取待审批的训练记录修改(管理员)""" | |
| changes = get_pending_changes() | |
| return jsonify(changes) | |
| def api_approve_pending_change(change_id): | |
| """批准待审批修改""" | |
| if approve_pending_change(change_id, session['user_id']): | |
| return jsonify({"status": "success"}) | |
| return jsonify({"error": "审批失败,可能已被处理"}), 400 | |
| def api_reject_pending_change(change_id): | |
| """拒绝待审批修改""" | |
| if reject_pending_change(change_id, session['user_id']): | |
| return jsonify({"status": "success"}) | |
| return jsonify({"error": "操作失败"}), 400 | |
| def api_attendance_stats(): | |
| """获取出勤统计(家长只能查看自己孩子的)""" | |
| start_date = request.args.get("start_date", "") | |
| end_date = request.args.get("end_date", "") | |
| athlete_name = request.args.get("athlete_name", "") | |
| show_inactive = request.args.get("show_inactive", "0") == "1" | |
| active_only = not show_inactive | |
| # 权限控制 | |
| if session.get('role') not in ('admin', 'superadmin'): | |
| accessible = get_accessible_athletes(session['user_id'], session['role']) | |
| if athlete_name and athlete_name not in accessible: | |
| return jsonify({"error": "无权查看该队员数据"}), 403 | |
| # 普通用户只能查看自己的队员 | |
| if not athlete_name: | |
| # 返回所有可访问队员的统计 | |
| result = [] | |
| for name in accessible: | |
| stats = get_attendance_stats( | |
| start_date if start_date else None, | |
| end_date if end_date else None, | |
| name, | |
| active_only=False # 已按accessible过滤 | |
| ) | |
| result.extend(stats) | |
| return jsonify(result) | |
| stats = get_attendance_stats( | |
| start_date if start_date else None, | |
| end_date if end_date else None, | |
| athlete_name if athlete_name else None, | |
| active_only=active_only | |
| ) | |
| return jsonify(stats) | |
| def api_attendance_detail(athlete_name): | |
| """获取某个队员的出勤明细(家长只能查看自己孩子的)""" | |
| # 权限控制 | |
| if session.get('role') not in ('admin', 'superadmin'): | |
| accessible = get_accessible_athletes(session['user_id'], session['role']) | |
| if athlete_name not in accessible: | |
| return jsonify({"error": "无权查看该队员数据"}), 403 | |
| start_date = request.args.get("start_date", "") | |
| end_date = request.args.get("end_date", "") | |
| detail_mode = request.args.get("detail", "") | |
| if detail_mode == "1": | |
| # 返回逐条出勤明细 | |
| records = get_athlete_attendance_detail( | |
| athlete_name, | |
| start_date if start_date else None, | |
| end_date if end_date else None | |
| ) | |
| return jsonify(records) | |
| detail = get_attendance_detail( | |
| athlete_name, | |
| start_date if start_date else None, | |
| end_date if end_date else None | |
| ) | |
| return jsonify(detail) | |
| # ========== 费用管理 API ========== | |
| def api_fee_config(): | |
| """获取费用配置""" | |
| config = get_fee_config() | |
| return jsonify(config) | |
| def api_set_fee_config(): | |
| """设置训练类型单价""" | |
| data = request.json | |
| training_type_id = data.get("training_type_id") | |
| price = data.get("price_per_session", 0) | |
| if not training_type_id: | |
| return jsonify({"error": "训练类型ID不能为空"}), 400 | |
| try: | |
| price = float(price) | |
| except (ValueError, TypeError): | |
| return jsonify({"error": "单价必须为数字"}), 400 | |
| if price < 0: | |
| return jsonify({"error": "单价不能为负数"}), 400 | |
| set_fee_config(training_type_id, price) | |
| return jsonify({"status": "success"}) | |
| def api_athlete_exemptions(): | |
| """获取队员费用豁免列表""" | |
| athlete_name = request.args.get("athlete_name", "") | |
| exemptions = get_athlete_exemptions(athlete_name if athlete_name else None) | |
| return jsonify(exemptions) | |
| def api_set_athlete_exemption(): | |
| """设置队员费用豁免""" | |
| data = request.json | |
| athlete_name = data.get("athlete_name", "").strip() | |
| training_type_id = data.get("training_type_id") | |
| is_exempt = data.get("is_exempt", True) | |
| if not athlete_name or not training_type_id: | |
| return jsonify({"error": "队员姓名和训练类型不能为空"}), 400 | |
| set_athlete_exemption(athlete_name, training_type_id, is_exempt) | |
| return jsonify({"status": "success"}) | |
| def api_delete_athlete_exemption(): | |
| """删除队员费用豁免""" | |
| data = request.json | |
| athlete_name = data.get("athlete_name", "").strip() | |
| training_type_id = data.get("training_type_id") | |
| if not athlete_name or not training_type_id: | |
| return jsonify({"error": "队员姓名和训练类型不能为空"}), 400 | |
| delete_athlete_exemption(athlete_name, training_type_id) | |
| return jsonify({"status": "success"}) | |
| def api_calculate_fees(): | |
| """计算费用(普通用户只能查看关联队员的,管理员可查看所有)""" | |
| start_date = request.args.get("start_date", "") | |
| end_date = request.args.get("end_date", "") | |
| athlete_name = request.args.get("athlete_name", "") | |
| show_inactive = request.args.get("show_inactive", "0") == "1" | |
| active_only = not show_inactive | |
| # 权限控制:普通用户只能查看自己关联的队员 | |
| if session.get('role') not in ('admin', 'superadmin'): | |
| accessible = get_accessible_athletes(session['user_id'], session['role']) | |
| if athlete_name and athlete_name not in accessible: | |
| return jsonify({"error": "无权查看该队员数据"}), 403 | |
| # 只查询可访问的队员 | |
| if not athlete_name: | |
| # 返回所有可访问队员的费用 | |
| result = [] | |
| for name in accessible: | |
| fees = calculate_fees( | |
| start_date if start_date else None, | |
| end_date if end_date else None, | |
| name, | |
| active_only=False # 已按accessible过滤 | |
| ) | |
| result.extend(fees) | |
| return jsonify(result) | |
| fees = calculate_fees( | |
| start_date if start_date else None, | |
| end_date if end_date else None, | |
| athlete_name if athlete_name else None, | |
| active_only=active_only | |
| ) | |
| return jsonify(fees) | |
| # ========== 个性化费用配置 API ========== | |
| def api_athlete_fee_config(): | |
| """获取队员个性化费用配置""" | |
| athlete_name = request.args.get("athlete_name", "") | |
| training_type_id = request.args.get("training_type_id", "") | |
| config = get_athlete_fee_config( | |
| athlete_name if athlete_name else None, | |
| int(training_type_id) if training_type_id else None | |
| ) | |
| return jsonify(config) | |
| def api_set_athlete_fee_config(): | |
| """设置队员个性化费用""" | |
| data = request.json | |
| athlete_name = data.get("athlete_name", "").strip() | |
| training_type_id = data.get("training_type_id") | |
| price = data.get("price_per_session", 0) | |
| start_date = data.get("start_date", "").strip() or None | |
| if not athlete_name or not training_type_id: | |
| return jsonify({"error": "队员姓名和训练类型不能为空"}), 400 | |
| try: | |
| price = float(price) | |
| except (ValueError, TypeError): | |
| return jsonify({"error": "单价必须为数字"}), 400 | |
| set_athlete_fee_config(athlete_name, training_type_id, price, start_date) | |
| return jsonify({"status": "success"}) | |
| def api_delete_athlete_fee_config(): | |
| """删除队员个性化费用(回退到全局配置)""" | |
| data = request.json | |
| athlete_name = data.get("athlete_name", "").strip() | |
| training_type_id = data.get("training_type_id") | |
| if not athlete_name or not training_type_id: | |
| return jsonify({"error": "队员姓名和训练类型不能为空"}), 400 | |
| delete_athlete_fee_config(athlete_name, training_type_id) | |
| return jsonify({"status": "success"}) | |
| # ========== 预付费管理 API ========== | |
| def api_prepaid_balances(): | |
| """获取预付费余额""" | |
| athlete_name = request.args.get("athlete_name", "") | |
| balances = get_prepaid_balances(athlete_name if athlete_name else None) | |
| return jsonify(balances) | |
| def api_set_prepaid_balance(): | |
| """充值/设置预付费""" | |
| data = request.json | |
| athlete_name = data.get("athlete_name", "").strip() | |
| training_type_id = data.get("training_type_id") | |
| amount = data.get("amount", 0) | |
| start_date = data.get("start_date", "").strip() or None | |
| if not athlete_name or not training_type_id: | |
| return jsonify({"error": "队员姓名和训练类型不能为空"}), 400 | |
| try: | |
| amount = float(amount) | |
| except (ValueError, TypeError): | |
| return jsonify({"error": "金额必须为数字"}), 400 | |
| set_prepaid_balance(athlete_name, training_type_id, amount, start_date) | |
| return jsonify({"status": "success"}) | |
| def api_delete_prepaid_balance(): | |
| """删除预付费余额""" | |
| data = request.json | |
| athlete_name = data.get("athlete_name", "").strip() | |
| training_type_id = data.get("training_type_id") | |
| if not athlete_name or not training_type_id: | |
| return jsonify({"error": "队员姓名和训练类型不能为空"}), 400 | |
| delete_prepaid_balance(athlete_name, training_type_id) | |
| return jsonify({"status": "success"}) | |
| def api_prepaid_transactions(): | |
| """获取预付费交易记录""" | |
| athlete_name = request.args.get("athlete_name", "") | |
| limit = request.args.get("limit", 100, type=int) | |
| transactions = get_prepaid_transactions(athlete_name if athlete_name else None, limit) | |
| return jsonify(transactions) | |
| # ========== 费用结算管理 API ========== | |
| def api_fee_settlements(): | |
| """获取费用结算记录""" | |
| athlete_name = request.args.get("athlete_name", "") | |
| training_type_id = request.args.get("training_type_id", "") | |
| settlements = get_fee_settlements( | |
| athlete_name if athlete_name else None, | |
| int(training_type_id) if training_type_id else None | |
| ) | |
| return jsonify(settlements) | |
| def api_add_fee_settlement(): | |
| """添加费用结算记录""" | |
| data = request.json | |
| athlete_name = data.get("athlete_name", "").strip() | |
| training_type_id = data.get("training_type_id") | |
| start_date = data.get("start_date", "").strip() | |
| end_date = data.get("end_date", "").strip() | |
| amount = data.get("amount", 0) | |
| note = data.get("note", "").strip() or None | |
| if not all([athlete_name, training_type_id, start_date, end_date]): | |
| return jsonify({"error": "队员姓名、训练类型、开始日期和结束日期不能为空"}), 400 | |
| try: | |
| amount = float(amount) | |
| except (ValueError, TypeError): | |
| return jsonify({"error": "金额必须为数字"}), 400 | |
| settled_by = session.get('username', 'unknown') | |
| add_fee_settlement(athlete_name, training_type_id, start_date, end_date, amount, note, settled_by) | |
| return jsonify({"status": "success"}) | |
| def api_delete_fee_settlement(settlement_id): | |
| """删除费用结算记录""" | |
| delete_fee_settlement(settlement_id) | |
| return jsonify({"status": "success"}) | |
| # ========== 家长查询 API ========== | |
| def api_my_athletes(): | |
| """获取当前用户可查看的队员列表""" | |
| accessible = get_accessible_athletes(session['user_id'], session.get('role', 'user')) | |
| return jsonify(accessible) | |
| def api_my_training_data(): | |
| """获取当前用户关联队员的训练数据""" | |
| accessible = get_accessible_athletes(session['user_id'], session.get('role', 'user')) | |
| if not accessible: | |
| return jsonify([]) | |
| start_date = request.args.get("start_date", "") | |
| end_date = request.args.get("end_date", "") | |
| result = [] | |
| for name in accessible: | |
| stats = get_attendance_stats( | |
| start_date if start_date else None, | |
| end_date if end_date else None, | |
| name | |
| ) | |
| detail = get_attendance_detail( | |
| name, | |
| start_date if start_date else None, | |
| end_date if end_date else None | |
| ) | |
| fees = calculate_fees( | |
| start_date if start_date else None, | |
| end_date if end_date else None, | |
| name | |
| ) | |
| result.append({ | |
| "athlete_name": name, | |
| "stats": stats[0] if stats else None, | |
| "detail": detail, | |
| "fees": fees[0] if fees else None, | |
| }) | |
| return jsonify(result) | |
| # ========== 每周训练计划 API ========== | |
| def api_weekly_schedule(): | |
| """获取每周训练计划""" | |
| schedule = get_weekly_schedule() | |
| return jsonify(schedule) | |
| def api_set_weekly_schedule(): | |
| """设置某天的训练类型""" | |
| data = request.json | |
| day_of_week = data.get("day_of_week") | |
| training_type_id = data.get("training_type_id") | |
| if not day_of_week or not training_type_id: | |
| return jsonify({"error": "星期几和训练类型不能为空"}), 400 | |
| if day_of_week < 1 or day_of_week > 7: | |
| return jsonify({"error": "星期几必须在1-7之间"}), 400 | |
| if set_weekly_schedule(day_of_week, training_type_id): | |
| return jsonify({"status": "success"}) | |
| return jsonify({"error": "设置失败"}), 400 | |
| def api_delete_weekly_schedule(schedule_id): | |
| """删除周训练计划条目""" | |
| delete_weekly_schedule(schedule_id) | |
| return jsonify({"status": "success"}) | |
| def api_schedule_for_date(): | |
| """根据日期获取当天的训练类型""" | |
| date_str = request.args.get("date", "") | |
| if not date_str: | |
| return jsonify([]) | |
| schedule = get_schedule_for_date(date_str) | |
| return jsonify(schedule) | |
| def api_add_training_records_batch(): | |
| """批量添加训练记录""" | |
| data = request.json | |
| records = data.get("records", []) | |
| if not records: | |
| return jsonify({"error": "记录列表不能为空"}), 400 | |
| count = add_training_records_batch(records) | |
| return jsonify({"status": "success", "count": count}) | |
| # ========== 昵称映射 API ========== | |
| def api_nicknames(): | |
| """获取所有昵称映射""" | |
| nicknames = get_all_nicknames() | |
| return jsonify(nicknames) | |
| def api_add_nickname(): | |
| """添加昵称映射""" | |
| data = request.json | |
| nickname = data.get("nickname", "").strip() | |
| real_name = data.get("real_name", "").strip() | |
| if not nickname or not real_name: | |
| return jsonify({"error": "昵称和真名不能为空"}), 400 | |
| if add_nickname(nickname, real_name): | |
| return jsonify({"status": "success"}) | |
| return jsonify({"error": "添加失败,昵称可能已存在"}), 400 | |
| def api_delete_nickname(nickname_id): | |
| """删除昵称映射""" | |
| delete_nickname(nickname_id) | |
| return jsonify({"status": "success"}) | |
| # ========== 留言板 API ========== | |
| def api_messages(): | |
| """获取留言列表""" | |
| limit = request.args.get("limit", 50, type=int) | |
| messages = get_messages(limit) | |
| # Enrich with family info for each message's author | |
| from database import get_db as _get_db | |
| conn = _get_db() | |
| for m in messages: | |
| if m.get('username'): | |
| row = conn.execute(""" | |
| SELECT af.athlete_name, af.relationship | |
| FROM athlete_family af | |
| JOIN users u ON u.id = af.user_id | |
| WHERE u.username = ? AND af.approved = 1 | |
| """, (m['username'],)).fetchone() | |
| if row: | |
| m['family_athlete'] = row['athlete_name'] | |
| m['family_relationship'] = row['relationship'] | |
| else: | |
| m['family_athlete'] = None | |
| m['family_relationship'] = None | |
| else: | |
| m['family_athlete'] = None | |
| m['family_relationship'] = None | |
| conn.close() | |
| # 获取当前用户的点赞状态 | |
| msg_ids = [m['id'] for m in messages] | |
| liked_set = get_message_likes_by_user(session['user_id'], msg_ids) if 'user_id' in session else set() | |
| for m in messages: | |
| m['is_liked'] = m['id'] in liked_set | |
| return jsonify(messages) | |
| def api_add_message(): | |
| """添加留言(需登录)""" | |
| data = request.json | |
| content = data.get("content", "").strip() | |
| image_data = data.get("image_data", "") | |
| reply_to_id = data.get("reply_to_id") | |
| if not content and not image_data: | |
| return jsonify({"error": "留言内容不能为空"}), 400 | |
| if len(content) > 500: | |
| return jsonify({"error": "留言内容不能超过500字"}), 400 | |
| # Limit image size (base64 ~1.5MB) | |
| if image_data and len(image_data) > 2000000: | |
| return jsonify({"error": "图片太大,请压缩后上传"}), 400 | |
| if 'user_id' in session: | |
| username = session['username'] | |
| else: | |
| username = data.get("username", "匿名").strip() | |
| if not username: | |
| username = "匿名" | |
| add_message(username, content, image_data if image_data else None, reply_to_id=reply_to_id) | |
| return jsonify({"status": "success"}) | |
| def api_like_message(msg_id): | |
| """给留言点赞/取消赞""" | |
| likes, liked = like_message(msg_id, session['user_id']) | |
| return jsonify({"status": "success", "likes": likes, "liked": liked}) | |
| def api_delete_message(msg_id): | |
| """删除留言:管理员可删任何留言,普通用户5分钟内可删自己的""" | |
| from database import get_db as _get_db | |
| conn = _get_db() | |
| msg = conn.execute("SELECT username, created_at FROM messages WHERE id = ?", (msg_id,)).fetchone() | |
| conn.close() | |
| if not msg: | |
| return jsonify({"error": "留言不存在"}), 404 | |
| is_admin = session.get('role') in ('admin', 'superadmin') | |
| is_owner = session.get('username') == msg['username'] | |
| if is_admin: | |
| delete_message(msg_id) | |
| return jsonify({"status": "success"}) | |
| if is_owner: | |
| # Check 5-minute window | |
| from datetime import datetime, timedelta | |
| try: | |
| created = datetime.fromisoformat(msg['created_at']) | |
| if datetime.now() - created <= timedelta(minutes=5): | |
| delete_message(msg_id) | |
| return jsonify({"status": "success"}) | |
| else: | |
| return jsonify({"error": "超过5分钟,无法自行删除,请联系管理员"}), 403 | |
| except Exception: | |
| return jsonify({"error": "无法判断留言时间"}), 500 | |
| return jsonify({"error": "无权删除此留言"}), 403 | |
| def api_edit_message(msg_id): | |
| """修改留言:仅作者在5分钟内可修改""" | |
| from database import get_db as _get_db | |
| conn = _get_db() | |
| msg = conn.execute("SELECT username, created_at FROM messages WHERE id = ?", (msg_id,)).fetchone() | |
| conn.close() | |
| if not msg: | |
| return jsonify({"error": "留言不存在"}), 404 | |
| is_owner = session.get('username') == msg['username'] | |
| if not is_owner: | |
| return jsonify({"error": "只能修改自己的留言"}), 403 | |
| from datetime import datetime, timedelta | |
| try: | |
| created = datetime.fromisoformat(msg['created_at']) | |
| if datetime.now() - created > timedelta(minutes=5): | |
| return jsonify({"error": "超过5分钟,无法修改,请联系管理员"}), 403 | |
| except Exception: | |
| return jsonify({"error": "无法判断留言时间"}), 500 | |
| data = request.json | |
| content = data.get("content", "").strip() | |
| if not content and not data.get("image_data"): | |
| return jsonify({"error": "留言内容不能为空"}), 400 | |
| if len(content) > 500: | |
| return jsonify({"error": "留言内容不能超过500字"}), 400 | |
| image_data = data.get("image_data", "") | |
| if image_data and len(image_data) > 2000000: | |
| return jsonify({"error": "图片太大,请压缩后上传"}), 400 | |
| conn = _get_db() | |
| conn.execute("UPDATE messages SET content = ?, image_data = ? WHERE id = ?", | |
| (content, image_data if image_data else None, msg_id)) | |
| conn.commit() | |
| conn.close() | |
| return jsonify({"status": "success"}) | |
| # ========== 用户头像 API ========== | |
| def api_user_avatar(user_id): | |
| """获取用户头像""" | |
| avatar = get_user_avatar(user_id) | |
| if not avatar or not avatar["avatar_data"]: | |
| return '', 404 | |
| from flask import Response | |
| return Response(avatar["avatar_data"], mimetype=avatar["avatar_type"]) | |
| def api_user_avatar_by_name(username): | |
| """按用户名获取用户头像""" | |
| user = get_user_by_username(username) | |
| if not user: | |
| return '', 404 | |
| avatar = get_user_avatar(user['id']) | |
| if not avatar or not avatar["avatar_data"]: | |
| return '', 404 | |
| from flask import Response | |
| return Response(avatar["avatar_data"], mimetype=avatar["avatar_type"]) | |
| def api_upload_user_avatar(): | |
| """上传当前用户头像""" | |
| if 'avatar' not in request.files: | |
| return jsonify({"error": "未找到头像文件"}), 400 | |
| file = request.files['avatar'] | |
| if not file.filename: | |
| return jsonify({"error": "未选择文件"}), 400 | |
| # 限制 2MB | |
| file.seek(0, 2) | |
| size = file.tell() | |
| file.seek(0) | |
| if size > 2 * 1024 * 1024: | |
| return jsonify({"error": "头像文件不能超过2MB"}), 400 | |
| content_type = file.content_type or 'image/jpeg' | |
| avatar_data = file.read() | |
| set_user_avatar(session['user_id'], avatar_data, content_type) | |
| return jsonify({"status": "success"}) | |
| def api_delete_user_avatar(): | |
| """删除当前用户头像""" | |
| set_user_avatar(session['user_id'], None, None) | |
| return jsonify({"status": "success"}) | |
| def api_update_bio(): | |
| """更新当前用户个性签名""" | |
| data = request.json | |
| bio = (data.get("bio") or "").strip() | |
| if len(bio) > 50: | |
| return jsonify({"error": "签名不能超过50字"}), 400 | |
| update_user_bio(session['user_id'], bio) | |
| return jsonify({"status": "success"}) | |
| def api_user_profile(username): | |
| """获取用户公开信息(头像+签名)""" | |
| profile = get_user_public_profile(username) | |
| if not profile: | |
| return jsonify({"error": "用户不存在"}), 404 | |
| result = {"username": profile["username"], "bio": profile.get("bio") or ""} | |
| if profile.get("avatar_data"): | |
| import base64 | |
| result["avatar"] = f"data:{profile.get('avatar_type', 'image/jpeg')};base64,{base64.b64encode(profile['avatar_data']).decode()}" | |
| return jsonify(result) | |
| # ========== 访问统计 API ========== | |
| def api_visit_stats(): | |
| """获取访问统计(管理员)""" | |
| try: | |
| stats = get_visit_stats() | |
| return jsonify(stats) | |
| except Exception as e: | |
| import traceback | |
| traceback.print_exc() | |
| return jsonify({"total": 0, "today": 0, "unique_visitors": 0, "daily": [], "monthly": [], "error": str(e)}) | |
| # ========== 家属管理 API ========== | |
| def api_families(): | |
| """获取所有家属关联(管理员)""" | |
| families = get_all_families() | |
| return jsonify(families) | |
| def api_set_family(): | |
| """设置队员的指定家属(管理员)""" | |
| data = request.json | |
| athlete_name = data.get("athlete_name", "").strip() | |
| user_id = data.get("user_id") | |
| relationship = data.get("relationship", "").strip() | |
| if not athlete_name or not user_id: | |
| return jsonify({"error": "队员姓名和用户ID不能为空"}), 400 | |
| if set_athlete_family(athlete_name, user_id, relationship): | |
| return jsonify({"status": "success"}) | |
| return jsonify({"error": "设置失败"}), 400 | |
| def api_delete_family(athlete_name): | |
| """删除队员的指定家属(管理员)""" | |
| delete_athlete_family(athlete_name) | |
| return jsonify({"status": "success"}) | |
| # ========== 多服务器同步 API ========== | |
| def api_sync_check_hash(): | |
| """检查对端是否已有此版本的 DB(避免冗余传输)""" | |
| token = request.headers.get("Authorization", "").replace("Bearer ", "") | |
| if token != SYNC_SECRET: | |
| return jsonify({"error": "unauthorized"}), 403 | |
| data = request.get_json(silent=True) or {} | |
| remote_hash = data.get("hash", "") | |
| db_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", "short_track.db") | |
| if not os.path.exists(db_path): | |
| return jsonify({"match": False}), 200 | |
| sha = hashlib.sha256() | |
| with open(db_path, 'rb') as f: | |
| while chunk := f.read(8192): | |
| sha.update(chunk) | |
| local_hash = sha.hexdigest() | |
| return jsonify({"match": local_hash == remote_hash}), 200 | |
| def api_sync_push_db(): | |
| """接收对端推送的完整数据库文件并原子替换(带备份轮转)""" | |
| token = request.headers.get("Authorization", "").replace("Bearer ", "") | |
| if token != SYNC_SECRET: | |
| return jsonify({"error": "unauthorized"}), 403 | |
| if "db_file" not in request.files: | |
| return jsonify({"error": "missing db_file"}), 400 | |
| db_file = request.files["db_file"] | |
| db_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", "short_track.db") | |
| data_dir = os.path.dirname(db_path) | |
| temp_path = db_path + ".incoming" | |
| db_file.save(temp_path) | |
| import sqlite3 | |
| try: | |
| test_conn = sqlite3.connect(temp_path) | |
| result = test_conn.execute("PRAGMA integrity_check").fetchone() | |
| test_conn.close() | |
| if result[0] != "ok": | |
| try: | |
| os.remove(temp_path) | |
| except Exception: | |
| pass | |
| print(f"[sync] Received DB failed integrity check: {result[0]}, rejected") | |
| return jsonify({"error": f"corrupted database: {result[0]}"}), 400 | |
| except Exception as e: | |
| try: | |
| os.remove(temp_path) | |
| except Exception: | |
| pass | |
| return jsonify({"error": f"invalid database: {e}"}), 400 | |
| try: | |
| close_db() | |
| except Exception: | |
| pass | |
| # 备份轮转:保留最近 10 个备份 | |
| if os.path.exists(db_path): | |
| ts = datetime.now().strftime("%Y%m%d_%H%M%S") | |
| rotated = os.path.join(data_dir, f"short_track.db.bak_{ts}") | |
| os.rename(db_path, rotated) | |
| # 清理超出 10 个的旧备份 | |
| backups = sorted( | |
| [f for f in os.listdir(data_dir) if f.startswith("short_track.db.bak_")], | |
| reverse=True | |
| ) | |
| for old in backups[10:]: | |
| try: | |
| os.remove(os.path.join(data_dir, old)) | |
| except Exception: | |
| pass | |
| os.rename(temp_path, db_path) | |
| # 清理旧的 WAL/SHM 文件(新 DB 会创建新的) | |
| for suffix in ("-wal", "-shm"): | |
| wf = db_path + suffix | |
| if os.path.exists(wf): | |
| try: | |
| os.remove(wf) | |
| except Exception: | |
| pass | |
| print(f"[sync] Database replaced successfully from peer push ({os.path.getsize(db_path)} bytes)") | |
| return jsonify({"status": "ok", "size": os.path.getsize(db_path)}), 200 | |
| def api_sync_pull_db(): | |
| """供对端拉取完整数据库(用于自动恢复)""" | |
| token = request.headers.get("Authorization", "").replace("Bearer ", "") | |
| if token != SYNC_SECRET: | |
| return jsonify({"error": "unauthorized"}), 403 | |
| from database import get_db | |
| db_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", "short_track.db") | |
| if not os.path.exists(db_path): | |
| return jsonify({"error": "no database"}), 404 | |
| # 拉取前执行 checkpoint 确保文件完整 | |
| try: | |
| db = get_db() | |
| db.execute("PRAGMA wal_checkpoint(TRUNCATE)") | |
| except Exception: | |
| pass | |
| return send_file(db_path, mimetype="application/octet-stream", as_attachment=True, download_name="short_track.db") | |
| init_db() | |
| # 设置定时自动更新(仅在直接运行 app.py 时启动,gunicorn 通过 wsgi.py 管理) | |
| # 每5分钟检查一次,比赛日执行爬取,非比赛日跳过(7天也会执行一次兜底) | |
| from apscheduler.schedulers.background import BackgroundScheduler | |
| _last_non_match_day_run = [0] # track last run time on non-match days | |
| def smart_crawl_update(): | |
| """智能爬取:比赛日5分钟一次,非比赛日7天一次""" | |
| import time | |
| try: | |
| match_day = is_match_day() | |
| except Exception: | |
| match_day = False | |
| if match_day: | |
| print("比赛日 - 执行数据更新") | |
| crawl_update_all() | |
| else: | |
| # 非比赛日:7天跑一次兜底 | |
| now = time.time() | |
| SEVEN_DAYS = 7 * 24 * 3600 | |
| if now - _last_non_match_day_run[0] >= SEVEN_DAYS: | |
| print("非比赛日 - 7天兜底更新") | |
| crawl_update_all() | |
| _last_non_match_day_run[0] = now | |
| # else: skip | |
| scheduler = BackgroundScheduler() | |
| scheduler.add_job( | |
| func=smart_crawl_update, | |
| trigger="interval", | |
| minutes=5, | |
| id="auto_update", | |
| name="智能自动更新(比赛日5分钟,非比赛日7天)", | |
| replace_existing=True, | |
| max_instances=1, | |
| coalesce=True, | |
| misfire_grace_time=120, | |
| ) | |
| # 周期性 DB 完整性检查 + 自动恢复(每5分钟) | |
| scheduler.add_job( | |
| func=db_health_check, | |
| trigger="interval", | |
| minutes=5, | |
| id="db_health_check", | |
| name="DB完整性检查+自动恢复", | |
| replace_existing=True, | |
| max_instances=1, | |
| coalesce=True, | |
| misfire_grace_time=120, | |
| ) | |
| scheduler.start() | |
| print("已启动智能定时更新:比赛日5分钟一次,非比赛日7天一次") | |
| print("已启动 DB 完整性检查:每5分钟一次,异常时自动恢复") | |
| # 启动时执行一次完整性检查 | |
| db_health_check() | |
| app.run(debug=False, host="0.0.0.0", port=5000) | |