Spaces:
Running
Running
| """ | |
| 短道速滑竞赛成绩数据库模块 | |
| """ | |
| import sqlite3 | |
| import os | |
| import json | |
| import threading | |
| import time | |
| from datetime import datetime, timedelta | |
| DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", "short_track.db") | |
| # 线程局部连接池 | |
| _local = threading.local() | |
| # DB 重建信号路径(与 app.py 中 DB_REBUILD_SIGNAL 保持一致) | |
| _DB_REBUILD_SIGNAL = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", ".db_rebuild_signal") | |
| def get_db(): | |
| """获取数据库连接(线程局部缓存,同一请求复用连接) | |
| 检测 DB_REBUILD_SIGNAL:当 db_health_check 替换了底层 DB 文件后, | |
| 所有 worker 的下一次 get_db() 会自动关闭旧连接并重建。 | |
| """ | |
| conn = getattr(_local, 'conn', None) | |
| # 检测重建信号:DB 文件已被替换,需要强制重建连接 | |
| _check_rebuild_signal() | |
| if conn is not None: | |
| try: | |
| conn.execute("SELECT 1") | |
| return conn | |
| except (sqlite3.Error, Exception): | |
| try: | |
| conn.close() | |
| except Exception: | |
| pass | |
| _local.conn = None | |
| os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) | |
| conn = sqlite3.connect(DB_PATH, timeout=30) | |
| conn.row_factory = sqlite3.Row | |
| conn.execute("PRAGMA journal_mode=WAL") | |
| conn.execute("PRAGMA foreign_keys=ON") | |
| conn.execute("PRAGMA temp_store=MEMORY") | |
| conn.execute("PRAGMA busy_timeout=10000") | |
| conn.execute("PRAGMA wal_autocheckpoint=1000") | |
| _local.conn = conn | |
| return conn | |
| def _check_rebuild_signal(): | |
| """检测 DB 重建信号文件,若存在且时间戳新于上次重建则关闭旧连接""" | |
| if not os.path.exists(_DB_REBUILD_SIGNAL): | |
| return | |
| try: | |
| with open(_DB_REBUILD_SIGNAL, 'r') as f: | |
| sig_time = float(f.read().strip()) | |
| except Exception: | |
| sig_time = 0.0 | |
| last_rebuild = getattr(_local, 'last_rebuild_time', 0.0) | |
| if sig_time <= last_rebuild: | |
| return # 已处理过此信号 | |
| conn = getattr(_local, 'conn', None) | |
| if conn is not None: | |
| try: | |
| conn.close() | |
| except Exception: | |
| pass | |
| _local.conn = None | |
| _local.last_rebuild_time = sig_time | |
| def close_db(): | |
| """关闭当前线程的数据库连接(请求结束时调用)""" | |
| conn = getattr(_local, 'conn', None) | |
| if conn is not None: | |
| try: | |
| conn.close() | |
| except Exception: | |
| pass | |
| _local.conn = None | |
| def init_db(): | |
| """初始化数据库表结构""" | |
| conn = get_db() | |
| cursor = conn.cursor() | |
| # 迁移:为已有表添加 data_source 列(必须在 CREATE TABLE IF NOT EXISTS 之前) | |
| for table in ["matches", "race_groups", "results"]: | |
| try: | |
| cols = [row[1] for row in cursor.execute(f"PRAGMA table_info({table})").fetchall()] | |
| if "data_source" not in cols: | |
| cursor.execute(f"ALTER TABLE {table} ADD COLUMN data_source TEXT DEFAULT 'zhitikeji'") | |
| print(f"已为 {table} 表添加 data_source 列") | |
| except Exception as e: | |
| # 表不存在,不需要迁移 | |
| pass | |
| # 迁移:为 users 表添加 athlete_name 和 relationship 列 | |
| try: | |
| user_cols = [row[1] for row in cursor.execute("PRAGMA table_info(users)").fetchall()] | |
| if "athlete_name" not in user_cols: | |
| cursor.execute("ALTER TABLE users ADD COLUMN athlete_name TEXT") | |
| print("已为 users 表添加 athlete_name 列") | |
| if "relationship" not in user_cols: | |
| cursor.execute("ALTER TABLE users ADD COLUMN relationship TEXT") | |
| print("已为 users 表添加 relationship 列") | |
| if "approved" not in user_cols: | |
| cursor.execute("ALTER TABLE users ADD COLUMN approved INTEGER DEFAULT 1") | |
| print("已为 users 表添加 approved 列") | |
| # 已有用户默认为已批准 | |
| cursor.execute("UPDATE users SET approved = 1 WHERE approved IS NULL") | |
| except Exception: | |
| pass | |
| # 迁移:为 athletes 表添加 deleted_at 列 | |
| try: | |
| athlete_cols = [row[1] for row in cursor.execute("PRAGMA table_info(athletes)").fetchall()] | |
| if "deleted_at" not in athlete_cols: | |
| cursor.execute("ALTER TABLE athletes ADD COLUMN deleted_at TEXT") | |
| print("已为 athletes 表添加 deleted_at 列") | |
| except Exception: | |
| pass | |
| # 迁移:为 messages 表添加 image_data 和 likes 列 | |
| try: | |
| msg_cols = [row[1] for row in cursor.execute("PRAGMA table_info(messages)").fetchall()] | |
| if "image_data" not in msg_cols: | |
| cursor.execute("ALTER TABLE messages ADD COLUMN image_data TEXT") | |
| print("已为 messages 表添加 image_data 列") | |
| if "likes" not in msg_cols: | |
| cursor.execute("ALTER TABLE messages ADD COLUMN likes INTEGER DEFAULT 0") | |
| print("已为 messages 表添加 likes 列") | |
| if "reply_to_id" not in msg_cols: | |
| cursor.execute("ALTER TABLE messages ADD COLUMN reply_to_id INTEGER") | |
| print("已为 messages 表添加 reply_to_id 列") | |
| except Exception: | |
| pass | |
| # 迁移:创建 message_likes 表(记录谁赞了哪条留言) | |
| try: | |
| cursor.execute(""" | |
| CREATE TABLE IF NOT EXISTS message_likes ( | |
| message_id INTEGER NOT NULL, | |
| user_id INTEGER NOT NULL, | |
| created_at TEXT, | |
| PRIMARY KEY (message_id, user_id), | |
| FOREIGN KEY (message_id) REFERENCES messages(id), | |
| FOREIGN KEY (user_id) REFERENCES users(id) | |
| ) | |
| """) | |
| except Exception: | |
| pass | |
| # 迁移:为 users 表添加 avatar_data 和 avatar_type 列 | |
| try: | |
| user_avatar_cols = [row[1] for row in cursor.execute("PRAGMA table_info(users)").fetchall()] | |
| if "avatar_data" not in user_avatar_cols: | |
| cursor.execute("ALTER TABLE users ADD COLUMN avatar_data BLOB") | |
| print("已为 users 表添加 avatar_data 列") | |
| if "avatar_type" not in user_avatar_cols: | |
| cursor.execute("ALTER TABLE users ADD COLUMN avatar_type TEXT DEFAULT 'image/jpeg'") | |
| print("已为 users 表添加 avatar_type 列") | |
| except Exception: | |
| pass | |
| # 迁移:为 users 表添加 bio 列 | |
| try: | |
| user_bio_cols = [row[1] for row in cursor.execute("PRAGMA table_info(users)").fetchall()] | |
| if "bio" not in user_bio_cols: | |
| cursor.execute("ALTER TABLE users ADD COLUMN bio TEXT DEFAULT ''") | |
| print("已为 users 表添加 bio 列") | |
| except Exception: | |
| pass | |
| # 迁移:创建新表(如果不存在) | |
| # 运动员个人资料表 | |
| try: | |
| cursor.execute(""" | |
| CREATE TABLE IF NOT EXISTS athlete_profiles ( | |
| athlete_name TEXT PRIMARY KEY, | |
| bio TEXT DEFAULT '', | |
| updated_at TEXT, | |
| updated_by TEXT | |
| ) | |
| """) | |
| except Exception: | |
| pass | |
| # 运动员自定义成绩表(未统计的比赛成绩) | |
| try: | |
| cursor.execute(""" | |
| CREATE TABLE IF NOT EXISTS athlete_custom_results ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| athlete_name TEXT NOT NULL, | |
| competition_date TEXT, | |
| competition_name TEXT NOT NULL, | |
| event TEXT, | |
| round_type TEXT, | |
| team TEXT, | |
| time_result TEXT, | |
| place INTEGER, | |
| result_text TEXT, | |
| season TEXT, | |
| created_at TEXT, | |
| created_by TEXT | |
| ) | |
| """) | |
| cursor.execute("CREATE INDEX IF NOT EXISTS idx_custom_results_athlete ON athlete_custom_results(athlete_name)") | |
| # 迁移:为旧表添加新字段 | |
| for col, coldef in [('event', 'TEXT'), ('round_type', 'TEXT'), ('team', 'TEXT'), ('time_result', 'TEXT'), ('place', 'INTEGER'), ('season', 'TEXT')]: | |
| try: | |
| cursor.execute(f"ALTER TABLE athlete_custom_results ADD COLUMN {col} {coldef}") | |
| except Exception: | |
| pass # 列已存在 | |
| except Exception: | |
| pass | |
| try: | |
| cursor.execute(""" | |
| CREATE TABLE IF NOT EXISTS athlete_fee_config ( | |
| athlete_name TEXT NOT NULL, | |
| training_type_id INTEGER NOT NULL, | |
| price_per_session REAL NOT NULL, | |
| start_date TEXT, | |
| updated_at TEXT, | |
| PRIMARY KEY (athlete_name, training_type_id), | |
| FOREIGN KEY (training_type_id) REFERENCES training_types(id) | |
| ) | |
| """) | |
| except Exception: | |
| pass | |
| try: | |
| cursor.execute(""" | |
| CREATE TABLE IF NOT EXISTS prepaid_balance ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| athlete_name TEXT NOT NULL, | |
| training_type_id INTEGER NOT NULL, | |
| balance REAL NOT NULL DEFAULT 0, | |
| start_date TEXT, | |
| updated_at TEXT, | |
| UNIQUE(athlete_name, training_type_id), | |
| FOREIGN KEY (training_type_id) REFERENCES training_types(id) | |
| ) | |
| """) | |
| except Exception: | |
| pass | |
| try: | |
| cursor.execute(""" | |
| CREATE TABLE IF NOT EXISTS prepaid_transactions ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| athlete_name TEXT NOT NULL, | |
| training_type_id INTEGER NOT NULL, | |
| amount REAL NOT NULL, | |
| transaction_type TEXT NOT NULL, | |
| description TEXT, | |
| related_date TEXT, | |
| created_at TEXT, | |
| FOREIGN KEY (training_type_id) REFERENCES training_types(id) | |
| ) | |
| """) | |
| cursor.execute("CREATE INDEX IF NOT EXISTS idx_prepaid_transactions_athlete ON prepaid_transactions(athlete_name)") | |
| except Exception: | |
| pass | |
| try: | |
| cursor.execute(""" | |
| CREATE TABLE IF NOT EXISTS fee_settlements ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| athlete_name TEXT NOT NULL, | |
| training_type_id INTEGER NOT NULL, | |
| start_date TEXT NOT NULL, | |
| end_date TEXT NOT NULL, | |
| amount REAL NOT NULL DEFAULT 0, | |
| note TEXT, | |
| settled_by TEXT, | |
| created_at TEXT, | |
| FOREIGN KEY (training_type_id) REFERENCES training_types(id) | |
| ) | |
| """) | |
| cursor.execute("CREATE INDEX IF NOT EXISTS idx_fee_settlements_athlete ON fee_settlements(athlete_name)") | |
| except Exception: | |
| pass | |
| cursor.executescript(""" | |
| CREATE TABLE IF NOT EXISTS matches ( | |
| id INTEGER PRIMARY KEY, | |
| season TEXT NOT NULL, | |
| title TEXT, | |
| match_type TEXT, | |
| round_info TEXT, | |
| date_range TEXT, | |
| venue TEXT, | |
| status TEXT, | |
| last_updated TEXT, | |
| data_source TEXT DEFAULT 'zhitikeji' | |
| ); | |
| CREATE TABLE IF NOT EXISTS race_groups ( | |
| rcbh INTEGER PRIMARY KEY, | |
| match_id INTEGER NOT NULL, | |
| race_date TEXT, | |
| race_time TEXT, | |
| event TEXT, | |
| category TEXT, | |
| gender TEXT, | |
| round_type TEXT, | |
| bsbh INTEGER, | |
| race_id INTEGER, | |
| data_source TEXT DEFAULT 'zhitikeji', | |
| FOREIGN KEY (match_id) REFERENCES matches(id) | |
| ); | |
| CREATE TABLE IF NOT EXISTS results ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| rcbh INTEGER NOT NULL, | |
| heat_number INTEGER DEFAULT 1, | |
| place TEXT, | |
| lane TEXT, | |
| helmet TEXT, | |
| name TEXT NOT NULL, | |
| team TEXT, | |
| time_result TEXT, | |
| qual TEXT, | |
| finished TEXT, | |
| is_chaoyang INTEGER DEFAULT 0, | |
| data_source TEXT DEFAULT 'zhitikeji', | |
| FOREIGN KEY (rcbh) REFERENCES race_groups(rcbh) | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_results_name ON results(name); | |
| CREATE INDEX IF NOT EXISTS idx_results_team ON results(team); | |
| CREATE INDEX IF NOT EXISTS idx_results_chaoyang ON results(is_chaoyang); | |
| CREATE INDEX IF NOT EXISTS idx_race_groups_match ON race_groups(match_id); | |
| CREATE INDEX IF NOT EXISTS idx_race_groups_event ON race_groups(event); | |
| CREATE INDEX IF NOT EXISTS idx_matches_season ON matches(season); | |
| CREATE INDEX IF NOT EXISTS idx_matches_source ON matches(data_source); | |
| CREATE INDEX IF NOT EXISTS idx_race_groups_source ON race_groups(data_source); | |
| CREATE INDEX IF NOT EXISTS idx_results_source ON results(data_source); | |
| CREATE TABLE IF NOT EXISTS update_log ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| update_time TEXT NOT NULL, | |
| season TEXT, | |
| match_id INTEGER, | |
| status TEXT, | |
| message TEXT | |
| ); | |
| CREATE TABLE IF NOT EXISTS team_aliases ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| alias TEXT NOT NULL UNIQUE, | |
| is_active INTEGER DEFAULT 1 | |
| ); | |
| CREATE TABLE IF NOT EXISTS athlete_overrides ( | |
| name TEXT PRIMARY KEY, | |
| is_chaoyang INTEGER NOT NULL, | |
| updated_at TEXT | |
| ); | |
| CREATE TABLE IF NOT EXISTS athletes ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| name TEXT NOT NULL UNIQUE, | |
| gender TEXT, | |
| category TEXT, | |
| notes TEXT, | |
| created_at TEXT, | |
| deleted_at TEXT | |
| ); | |
| CREATE TABLE IF NOT EXISTS users ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| username TEXT NOT NULL UNIQUE, | |
| password_hash TEXT NOT NULL, | |
| role TEXT NOT NULL DEFAULT 'user', | |
| athlete_name TEXT, | |
| relationship TEXT, | |
| approved INTEGER DEFAULT 1, | |
| team_id TEXT DEFAULT 'chaoyang', | |
| avatar_data BLOB, | |
| avatar_type TEXT DEFAULT 'image/jpeg', | |
| bio TEXT DEFAULT '', | |
| created_at TEXT | |
| ); | |
| CREATE TABLE IF NOT EXISTS athlete_family ( | |
| athlete_name TEXT NOT NULL, | |
| user_id INTEGER NOT NULL, | |
| relationship TEXT, | |
| approved INTEGER DEFAULT 0, | |
| created_at TEXT, | |
| PRIMARY KEY (athlete_name), | |
| FOREIGN KEY (user_id) REFERENCES users(id) | |
| ); | |
| CREATE TABLE IF NOT EXISTS athlete_photos ( | |
| name TEXT PRIMARY KEY, | |
| photo_data BLOB, | |
| content_type TEXT DEFAULT 'image/jpeg', | |
| updated_at TEXT | |
| ); | |
| CREATE TABLE IF NOT EXISTS training_types ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| name TEXT NOT NULL UNIQUE, | |
| description TEXT, | |
| is_class INTEGER DEFAULT 0 | |
| ); | |
| CREATE TABLE IF NOT EXISTS training_records ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| athlete_name TEXT NOT NULL, | |
| training_type_id INTEGER NOT NULL, | |
| training_date TEXT NOT NULL, | |
| duration_minutes INTEGER, | |
| notes TEXT, | |
| created_at TEXT, | |
| FOREIGN KEY (training_type_id) REFERENCES training_types(id) | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_training_records_athlete ON training_records(athlete_name); | |
| CREATE INDEX IF NOT EXISTS idx_training_records_date ON training_records(training_date); | |
| CREATE TABLE IF NOT EXISTS training_pending_changes ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| record_id INTEGER NOT NULL, | |
| athlete_name TEXT NOT NULL, | |
| changes TEXT NOT NULL, | |
| submitted_by INTEGER NOT NULL, | |
| submitted_at TEXT NOT NULL, | |
| status TEXT DEFAULT 'pending', | |
| reviewed_by INTEGER, | |
| reviewed_at TEXT, | |
| FOREIGN KEY (record_id) REFERENCES training_records(id), | |
| FOREIGN KEY (submitted_by) REFERENCES users(id) | |
| ); | |
| CREATE TABLE IF NOT EXISTS messages ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| username TEXT, | |
| content TEXT NOT NULL, | |
| image_data TEXT, | |
| likes INTEGER DEFAULT 0, | |
| created_at TEXT, | |
| reply_to_id INTEGER | |
| ); | |
| CREATE TABLE IF NOT EXISTS visit_logs ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| ip TEXT, | |
| user_agent TEXT, | |
| page TEXT, | |
| visited_at TEXT | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at); | |
| CREATE INDEX IF NOT EXISTS idx_visit_logs_date ON visit_logs(visited_at); | |
| CREATE TABLE IF NOT EXISTS fee_config ( | |
| training_type_id INTEGER NOT NULL, | |
| price_per_session REAL DEFAULT 0, | |
| updated_at TEXT, | |
| PRIMARY KEY (training_type_id), | |
| FOREIGN KEY (training_type_id) REFERENCES training_types(id) | |
| ); | |
| CREATE TABLE IF NOT EXISTS athlete_fee_exemptions ( | |
| athlete_name TEXT NOT NULL, | |
| training_type_id INTEGER NOT NULL, | |
| is_exempt INTEGER DEFAULT 1, | |
| updated_at TEXT, | |
| PRIMARY KEY (athlete_name, training_type_id), | |
| FOREIGN KEY (training_type_id) REFERENCES training_types(id) | |
| ); | |
| -- 每个队员每个训练类型的个性化费用(覆盖全局fee_config) | |
| CREATE TABLE IF NOT EXISTS athlete_fee_config ( | |
| athlete_name TEXT NOT NULL, | |
| training_type_id INTEGER NOT NULL, | |
| price_per_session REAL NOT NULL, | |
| start_date TEXT, | |
| updated_at TEXT, | |
| PRIMARY KEY (athlete_name, training_type_id), | |
| FOREIGN KEY (training_type_id) REFERENCES training_types(id) | |
| ); | |
| -- 预付费余额管理 | |
| CREATE TABLE IF NOT EXISTS prepaid_balance ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| athlete_name TEXT NOT NULL, | |
| training_type_id INTEGER NOT NULL, | |
| balance REAL NOT NULL DEFAULT 0, | |
| start_date TEXT, | |
| updated_at TEXT, | |
| UNIQUE(athlete_name, training_type_id), | |
| FOREIGN KEY (training_type_id) REFERENCES training_types(id) | |
| ); | |
| -- 预付费充值/扣款记录 | |
| CREATE TABLE IF NOT EXISTS prepaid_transactions ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| athlete_name TEXT NOT NULL, | |
| training_type_id INTEGER NOT NULL, | |
| amount REAL NOT NULL, | |
| transaction_type TEXT NOT NULL, | |
| description TEXT, | |
| related_date TEXT, | |
| created_at TEXT, | |
| FOREIGN KEY (training_type_id) REFERENCES training_types(id) | |
| ); | |
| -- 费用结算标记(按时间段标记某队员某类型费用已结清) | |
| CREATE TABLE IF NOT EXISTS fee_settlements ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| athlete_name TEXT NOT NULL, | |
| training_type_id INTEGER NOT NULL, | |
| start_date TEXT NOT NULL, | |
| end_date TEXT NOT NULL, | |
| amount REAL NOT NULL DEFAULT 0, | |
| note TEXT, | |
| settled_by TEXT, | |
| created_at TEXT, | |
| FOREIGN KEY (training_type_id) REFERENCES training_types(id) | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_fee_settlements_athlete ON fee_settlements(athlete_name); | |
| CREATE INDEX IF NOT EXISTS idx_prepaid_transactions_athlete ON prepaid_transactions(athlete_name); | |
| CREATE TABLE IF NOT EXISTS athlete_honors ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| athlete_name TEXT NOT NULL, | |
| title TEXT NOT NULL, | |
| awarded_date TEXT, | |
| source TEXT, | |
| created_at TEXT, | |
| updated_at TEXT | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_athlete_honors_name ON athlete_honors(athlete_name); | |
| CREATE TABLE IF NOT EXISTS weekly_training_schedule ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| day_of_week INTEGER NOT NULL, | |
| training_type_id INTEGER NOT NULL, | |
| UNIQUE(day_of_week, training_type_id), | |
| FOREIGN KEY (training_type_id) REFERENCES training_types(id) | |
| ); | |
| CREATE TABLE IF NOT EXISTS athlete_nicknames ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| nickname TEXT NOT NULL UNIQUE, | |
| real_name TEXT NOT NULL, | |
| created_at TEXT | |
| ); | |
| CREATE TABLE IF NOT EXISTS relay_members ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| relay_team_name TEXT NOT NULL, | |
| athlete_name TEXT NOT NULL, | |
| season TEXT, | |
| event TEXT, | |
| created_at TEXT, | |
| updated_at TEXT, | |
| UNIQUE(relay_team_name, athlete_name, season, event) | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_relay_members_team ON relay_members(relay_team_name); | |
| CREATE INDEX IF NOT EXISTS idx_relay_members_athlete ON relay_members(athlete_name); | |
| -- 性能优化:补充索引 | |
| CREATE INDEX IF NOT EXISTS idx_results_rcbh ON results(rcbh); | |
| CREATE INDEX IF NOT EXISTS idx_training_records_type_date ON training_records(training_type_id, training_date); | |
| CREATE INDEX IF NOT EXISTS idx_athlete_family_user ON athlete_family(user_id); | |
| CREATE INDEX IF NOT EXISTS idx_athlete_family_athlete ON athlete_family(athlete_name); | |
| CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); | |
| CREATE INDEX IF NOT EXISTS idx_matches_last_updated ON matches(last_updated); | |
| -- 复合索引:加速 is_athlete_active 和运动员详情查询 | |
| CREATE INDEX IF NOT EXISTS idx_results_name_chaoyang ON results(name, is_chaoyang); | |
| CREATE INDEX IF NOT EXISTS idx_results_rcbh_name ON results(rcbh, name); | |
| CREATE INDEX IF NOT EXISTS idx_training_records_athlete_date ON training_records(athlete_name, training_date); | |
| CREATE INDEX IF NOT EXISTS idx_athlete_overrides_name ON athlete_overrides(name); | |
| CREATE INDEX IF NOT EXISTS idx_athletes_name_active ON athletes(name, deleted_at); | |
| CREATE INDEX IF NOT EXISTS idx_race_groups_rcbh ON race_groups(rcbh); | |
| CREATE INDEX IF NOT EXISTS idx_race_groups_event_category ON race_groups(event, category); | |
| """) | |
| # 多队伍配置表 | |
| cursor.executescript(""" | |
| CREATE TABLE IF NOT EXISTS team_configs ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| team_id TEXT UNIQUE NOT NULL, | |
| team_name TEXT NOT NULL, | |
| color_primary TEXT NOT NULL, | |
| color_accent TEXT NOT NULL, | |
| color_light TEXT NOT NULL | |
| ); | |
| CREATE TABLE IF NOT EXISTS app_settings ( | |
| key TEXT PRIMARY KEY, | |
| value TEXT | |
| ); | |
| """) | |
| # 小程序微信登录绑定 | |
| cursor.execute(""" | |
| CREATE TABLE IF NOT EXISTS wx_bindings ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| openid TEXT NOT NULL, | |
| athlete_name TEXT NOT NULL, | |
| relationship TEXT DEFAULT '本人', | |
| approved INTEGER DEFAULT 0, | |
| created_at TEXT, | |
| user_id INTEGER | |
| ); | |
| """) | |
| # 迁移:为 wx_bindings 添加 user_id 列 | |
| try: | |
| wxb_cols = [row[1] for row in cursor.execute("PRAGMA table_info(wx_bindings)").fetchall()] | |
| if "user_id" not in wxb_cols: | |
| cursor.execute("ALTER TABLE wx_bindings ADD COLUMN user_id INTEGER") | |
| print("已为 wx_bindings 表添加 user_id 列") | |
| except Exception: | |
| pass | |
| # 用户 Token 表(供小程序 Bearer 鉴权) | |
| cursor.execute(""" | |
| CREATE TABLE IF NOT EXISTS user_tokens ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| user_id INTEGER NOT NULL, | |
| token TEXT NOT NULL UNIQUE, | |
| expires_at TEXT, | |
| created_at TEXT | |
| ); | |
| """) | |
| # 初始化默认队伍配置 | |
| team_presets = [ | |
| ('chaoyang', '朝阳', '#0F4C81', '#4A90D9', '#E8F0FE'), | |
| ('haidian', '海淀', '#8B1A1A', '#D94444', '#FEE8E8'), | |
| ('yanqing', '延庆', '#166534', '#3E8E5E', '#E8F5E9'), | |
| ] | |
| for team_id, team_name, primary, accent, light in team_presets: | |
| try: | |
| cursor.execute( | |
| "INSERT OR IGNORE INTO team_configs (team_id, team_name, color_primary, color_accent, color_light) VALUES (?, ?, ?, ?, ?)", | |
| (team_id, team_name, primary, accent, light) | |
| ) | |
| except Exception: | |
| pass | |
| # 初始化默认活跃队伍 | |
| try: | |
| cursor.execute("INSERT OR IGNORE INTO app_settings (key, value) VALUES ('active_team', 'chaoyang')") | |
| except Exception: | |
| pass | |
| # 初始化默认超级管理员(密码: admin123) | |
| import bcrypt | |
| admin_hash = bcrypt.hashpw('admin123'.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') | |
| try: | |
| cursor.execute("INSERT OR IGNORE INTO users (username, password_hash, role, created_at) VALUES (?, ?, ?, ?)", | |
| ('chui', admin_hash, 'superadmin', datetime.now().isoformat())) | |
| except Exception: | |
| pass | |
| # 迁移:将默认超级管理员用户名从 admin 改为 chui(如果还是 admin 的话) | |
| try: | |
| # 如果 chui 不存在且 admin 存在且是 superadmin,则改名 | |
| existing_chui = cursor.execute("SELECT id FROM users WHERE username = 'chui'").fetchone() | |
| existing_admin = cursor.execute("SELECT id FROM users WHERE username = 'admin' AND role = 'superadmin'").fetchone() | |
| if not existing_chui and existing_admin: | |
| cursor.execute("UPDATE users SET username = 'chui' WHERE username = 'admin' AND role = 'superadmin'") | |
| except Exception: | |
| pass | |
| # 迁移:为 team_aliases 和 athlete_overrides 添加 team_id 列 | |
| try: | |
| alias_cols = [row[1] for row in cursor.execute("PRAGMA table_info(team_aliases)").fetchall()] | |
| if "team_id" not in alias_cols: | |
| cursor.execute("ALTER TABLE team_aliases ADD COLUMN team_id TEXT DEFAULT 'chaoyang'") | |
| print("已为 team_aliases 表添加 team_id 列") | |
| except Exception: | |
| pass | |
| try: | |
| override_cols = [row[1] for row in cursor.execute("PRAGMA table_info(athlete_overrides)").fetchall()] | |
| if "team_id" not in override_cols: | |
| cursor.execute("ALTER TABLE athlete_overrides ADD COLUMN team_id TEXT DEFAULT 'chaoyang'") | |
| print("已为 athlete_overrides 表添加 team_id 列") | |
| except Exception: | |
| pass | |
| try: | |
| user_cols = [row[1] for row in cursor.execute("PRAGMA table_info(users)").fetchall()] | |
| if "team_id" not in user_cols: | |
| cursor.execute("ALTER TABLE users ADD COLUMN team_id TEXT DEFAULT 'chaoyang'") | |
| print("已为 users 表添加 team_id 列") | |
| cursor.execute("UPDATE users SET team_id = 'chaoyang' WHERE team_id IS NULL OR team_id = ''") | |
| except Exception: | |
| pass | |
| # 迁移:为 matches 表添加 summary 列 | |
| try: | |
| match_cols = [row[1] for row in cursor.execute("PRAGMA table_info(matches)").fetchall()] | |
| if "summary" not in match_cols: | |
| cursor.execute("ALTER TABLE matches ADD COLUMN summary TEXT DEFAULT ''") | |
| print("已为 matches 表添加 summary 列") | |
| except Exception: | |
| pass | |
| # 初始化默认队名别名 | |
| default_aliases = [ | |
| "朝阳区", "朝阳", "北京市朝阳区第三少儿业余体校", | |
| "北京市朝阳区蓓蕾青少年体育俱乐部", "第三少儿体校", | |
| "北京市朝阳区第三少儿业余体校U15北京市朝阳区第三少儿业余体校", | |
| "朝阳区U15朝阳区", "朝阳区乙组朝阳区" | |
| ] | |
| for alias in default_aliases: | |
| try: | |
| cursor.execute("INSERT OR IGNORE INTO team_aliases (alias, team_id) VALUES (?, 'chaoyang')", (alias,)) | |
| except Exception: | |
| pass | |
| # 初始化默认昵称映射(接龙中常用的小名) | |
| default_nicknames = [ | |
| ("六六", "潘宏阳"), | |
| ("七七", "潘宏明"), | |
| ("VV", "闫昱烨"), | |
| ("蛋蛋", "周煜博"), | |
| ] | |
| for nickname, real_name in default_nicknames: | |
| try: | |
| cursor.execute("INSERT OR IGNORE INTO athlete_nicknames (nickname, real_name, created_at) VALUES (?, ?, ?)", | |
| (nickname, real_name, datetime.now().isoformat())) | |
| except Exception: | |
| pass | |
| conn.commit() | |
| # 如果 athletes 表意外清空,自动从 results 重建 | |
| auto_repair_athletes() | |
| def upsert_match(conn, match_id, season, title, match_type, round_info, date_range, venue, status, data_source="zhitikeji"): | |
| """插入或更新比赛信息""" | |
| conn.execute(""" | |
| INSERT INTO matches (id, season, title, match_type, round_info, date_range, venue, status, last_updated, data_source) | |
| VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| ON CONFLICT(id) DO UPDATE SET | |
| season=excluded.season, title=excluded.title, match_type=excluded.match_type, | |
| round_info=excluded.round_info, date_range=excluded.date_range, venue=excluded.venue, | |
| status=excluded.status, last_updated=excluded.last_updated, data_source=excluded.data_source | |
| """, (match_id, season, title, match_type, round_info, date_range, venue, status, datetime.now().isoformat(), data_source)) | |
| def upsert_race_group(conn, rcbh, match_id, race_date, race_time, event, category, gender, round_type, bsbh, race_id, data_source="zhitikeji"): | |
| """插入或更新比赛分组""" | |
| conn.execute(""" | |
| INSERT INTO race_groups (rcbh, match_id, race_date, race_time, event, category, gender, round_type, bsbh, race_id, data_source) | |
| VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| ON CONFLICT(rcbh) DO UPDATE SET | |
| match_id=excluded.match_id, race_date=excluded.race_date, race_time=excluded.race_time, | |
| event=excluded.event, category=excluded.category, gender=excluded.gender, | |
| round_type=excluded.round_type, bsbh=excluded.bsbh, race_id=excluded.race_id, data_source=excluded.data_source | |
| """, (rcbh, match_id, race_date, race_time, event, category, gender, round_type, bsbh, race_id, data_source)) | |
| def upsert_result(conn, rcbh, place, lane, helmet, name, team, time_result, qual, finished, is_chaoyang, heat_number=1, data_source="zhitikeji"): | |
| """插入或更新比赛结果""" | |
| # 检查是否已存在相同记录 | |
| existing = conn.execute( | |
| "SELECT id FROM results WHERE rcbh=? AND name=? AND helmet=?", | |
| (rcbh, name, helmet) | |
| ).fetchone() | |
| if existing: | |
| conn.execute(""" | |
| UPDATE results SET place=?, lane=?, time_result=?, qual=?, finished=?, is_chaoyang=?, team=?, heat_number=?, data_source=? | |
| WHERE id=? | |
| """, (place, lane, time_result, qual, finished, is_chaoyang, team, heat_number, data_source, existing["id"])) | |
| else: | |
| conn.execute(""" | |
| INSERT INTO results (rcbh, heat_number, place, lane, helmet, name, team, time_result, qual, finished, is_chaoyang, data_source) | |
| VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| """, (rcbh, heat_number, place, lane, helmet, name, team, time_result, qual, finished, is_chaoyang, data_source)) | |
| def log_update(conn, season, match_id, status, message): | |
| """记录更新日志""" | |
| conn.execute(""" | |
| INSERT INTO update_log (update_time, season, match_id, status, message) | |
| VALUES (?, ?, ?, ?, ?) | |
| """, (datetime.now().isoformat(), season, match_id, status, message)) | |
| def get_chaoyang_athletes(): | |
| """获取所有朝阳队运动员列表""" | |
| conn = get_db() | |
| rows = conn.execute(""" | |
| SELECT DISTINCT name, team, category, gender | |
| FROM results r | |
| JOIN race_groups rg ON r.rcbh = rg.rcbh | |
| WHERE r.is_chaoyang = 1 | |
| ORDER BY name | |
| """).fetchall() | |
| return [dict(r) for r in rows] | |
| def get_athlete_results(name, event=None): | |
| """获取运动员的比赛成绩""" | |
| conn = get_db() | |
| query = """ | |
| SELECT r.name, r.team, r.place, r.lane, r.helmet, r.time_result, r.qual, r.finished, | |
| rg.event, rg.category, rg.gender, rg.round_type, rg.race_date, rg.race_time, | |
| m.title as match_title, m.season, m.match_type, m.round_info, m.date_range, m.venue, m.status | |
| FROM results r | |
| JOIN race_groups rg ON r.rcbh = rg.rcbh | |
| JOIN matches m ON rg.match_id = m.id | |
| WHERE r.name = ? | |
| """ | |
| params = [name] | |
| if event: | |
| query += " AND rg.event = ?" | |
| params.append(event) | |
| query += " ORDER BY m.season DESC, rg.race_date DESC, rg.race_time DESC, rg.event, rg.round_type" | |
| rows = conn.execute(query, params).fetchall() | |
| return [dict(r) for r in rows] | |
| def get_athlete_events(name): | |
| """获取运动员参加的所有项目""" | |
| conn = get_db() | |
| rows = conn.execute(""" | |
| SELECT DISTINCT rg.event | |
| FROM results r | |
| JOIN race_groups rg ON r.rcbh = rg.rcbh | |
| WHERE r.name = ? | |
| ORDER BY rg.event | |
| """, (name,)).fetchall() | |
| return [r["event"] for r in rows] | |
| 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 get_event_timeseries(name, event): | |
| """获取运动员某个项目的时间序列数据(同一比赛同一项目只取最好成绩)""" | |
| 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, m.date_range, m.status | |
| 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 | |
| """, (name, event, name, event)).fetchall() | |
| result = [dict(r) for r in rows] | |
| # 按赛季+真实日期排序(中文日期字符串排序不对) | |
| result.sort(key=lambda x: (x.get("season", ""), parse_chinese_date(x.get("race_date", "")))) | |
| return result | |
| def get_opponent_results(event, category, gender, season=None, exclude_name=None): | |
| """获取对手的成绩数据""" | |
| conn = get_db() | |
| query = """ | |
| SELECT r.name, r.team, r.time_result, r.place, r.qual, rg.race_date, rg.round_type, | |
| 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 | |
| WHERE rg.event = ? AND rg.category = ? AND rg.gender = ? | |
| AND r.time_result NOT LIKE '%00:00.000%' | |
| AND r.qual != '犯规' | |
| """ | |
| params = [event, category, gender] | |
| if season: | |
| query += " AND m.season = ?" | |
| params.append(season) | |
| if exclude_name: | |
| query += " AND r.name != ?" | |
| params.append(exclude_name) | |
| query += " ORDER BY m.season, rg.race_date, r.time_result" | |
| rows = conn.execute(query, params).fetchall() | |
| return [dict(r) for r in rows] | |
| def get_current_matches(): | |
| """获取有分组数据的比赛(状态文字不可靠,以实际数据为准)""" | |
| conn = get_db() | |
| rows = conn.execute(""" | |
| SELECT DISTINCT m.* FROM matches m | |
| INNER JOIN race_groups rg ON rg.match_id = m.id | |
| WHERE m.status NOT LIKE '%结束%' | |
| ORDER BY m.id DESC | |
| """).fetchall() | |
| return [dict(r) for r in rows] | |
| def get_all_matches(season=None, chaoyang_only=False): | |
| """获取所有比赛 | |
| Args: | |
| season: 指定赛季,为空则返回全部 | |
| chaoyang_only: 为True时只返回朝阳队有人参加的比赛 | |
| """ | |
| conn = get_db() | |
| if chaoyang_only: | |
| # 只返回朝阳队有人参加的比赛 | |
| if season: | |
| rows = conn.execute(""" | |
| SELECT DISTINCT m.* FROM matches m | |
| JOIN race_groups rg ON rg.match_id = m.id | |
| JOIN results r ON r.rcbh = rg.rcbh | |
| WHERE m.season = ? AND r.is_chaoyang = 1 | |
| ORDER BY m.season DESC, m.date_range DESC | |
| """, (season,)).fetchall() | |
| else: | |
| rows = conn.execute(""" | |
| SELECT DISTINCT m.* FROM matches m | |
| JOIN race_groups rg ON rg.match_id = m.id | |
| JOIN results r ON r.rcbh = rg.rcbh | |
| WHERE r.is_chaoyang = 1 | |
| ORDER BY m.season DESC, m.date_range DESC | |
| """).fetchall() | |
| else: | |
| if season: | |
| rows = conn.execute("SELECT * FROM matches WHERE season = ? ORDER BY date_range DESC", (season,)).fetchall() | |
| else: | |
| rows = conn.execute("SELECT * FROM matches ORDER BY season DESC, date_range DESC").fetchall() | |
| matches = [dict(r) for r in rows] | |
| # 按解析后的日期从新到旧排序 | |
| matches.sort(key=lambda m: (m.get("season", ""), parse_chinese_date(m.get("date_range", ""))), reverse=True) | |
| return matches | |
| def get_seasons(): | |
| """获取所有赛季""" | |
| conn = get_db() | |
| rows = conn.execute("SELECT DISTINCT season FROM matches ORDER BY season DESC").fetchall() | |
| return [r["season"] for r in rows] | |
| def get_last_update(): | |
| """获取最后更新时间""" | |
| conn = get_db() | |
| row = conn.execute("SELECT MAX(update_time) as last_update FROM update_log").fetchone() | |
| return row["last_update"] if row and row["last_update"] else None | |
| def get_match_race_groups(match_id): | |
| """获取比赛的所有分组""" | |
| conn = get_db() | |
| rows = conn.execute(""" | |
| SELECT rg.*, m.title, m.season, m.status | |
| FROM race_groups rg | |
| JOIN matches m ON rg.match_id = m.id | |
| WHERE rg.match_id = ? | |
| ORDER BY rg.race_date, rg.race_time | |
| """, (match_id,)).fetchall() | |
| return [dict(r) for r in rows] | |
| def get_race_group_results(rcbh): | |
| """获取分组的比赛结果(rcbh 已为小组级,直接查询即可)""" | |
| conn = get_db() | |
| rows = conn.execute(""" | |
| SELECT r.*, rg.event, rg.category, rg.gender, rg.round_type, rg.race_date | |
| FROM results r | |
| JOIN race_groups rg ON r.rcbh = rg.rcbh | |
| WHERE r.rcbh = ? | |
| ORDER BY CAST(r.place AS INTEGER) | |
| """, (rcbh,)).fetchall() | |
| return [dict(r) for r in rows] | |
| def get_athlete_categories(name): | |
| """获取运动员参加的组别信息(按最新比赛排序)""" | |
| conn = get_db() | |
| rows = conn.execute(""" | |
| 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 rg.category IS NOT NULL | |
| """, (name,)).fetchall() | |
| # 解析date_range结束日期用于排序(格式如 "2026/4/11—2026/4/12") | |
| 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 | |
| rows_sorted = sorted(rows, key=lambda r: parse_end_date(dict(r)['date_range']), reverse=True) | |
| # 去重,保留最新的在前 | |
| seen = set() | |
| result = [] | |
| for r in rows_sorted: | |
| rd = dict(r) | |
| key = (rd['category'], rd['gender']) | |
| if key not in seen: | |
| seen.add(key) | |
| result.append(rd) | |
| return result | |
| def get_team_aliases(team_id=None): | |
| """获取队名别名""" | |
| conn = get_db() | |
| if team_id: | |
| rows = conn.execute("SELECT * FROM team_aliases WHERE team_id = ? ORDER BY id", (team_id,)).fetchall() | |
| else: | |
| rows = conn.execute("SELECT * FROM team_aliases ORDER BY id").fetchall() | |
| return [dict(r) for r in rows] | |
| def add_team_alias(alias, team_id='chaoyang'): | |
| """添加队名别名""" | |
| conn = get_db() | |
| try: | |
| conn.execute("INSERT INTO team_aliases (alias, team_id) VALUES (?, ?)", (alias, team_id or 'chaoyang')) | |
| conn.commit() | |
| return True | |
| except Exception: | |
| return False | |
| def delete_team_alias(alias_id, team_id=None): | |
| """删除队名别名""" | |
| conn = get_db() | |
| if team_id: | |
| conn.execute("DELETE FROM team_aliases WHERE id = ? AND team_id = ?", (alias_id, team_id)) | |
| else: | |
| conn.execute("DELETE FROM team_aliases WHERE id = ?", (alias_id,)) | |
| conn.commit() | |
| def get_athlete_overrides(team_id=None): | |
| """获取手动管理的队员覆盖""" | |
| conn = get_db() | |
| if team_id: | |
| rows = conn.execute("SELECT * FROM athlete_overrides WHERE team_id = ? ORDER BY name", (team_id,)).fetchall() | |
| else: | |
| rows = conn.execute("SELECT * FROM athlete_overrides ORDER BY name").fetchall() | |
| return [dict(r) for r in rows] | |
| def set_athlete_override(name, is_chaoyang, team_id='chaoyang'): | |
| """设置队员的队伍覆盖标记""" | |
| conn = get_db() | |
| now = datetime.now().isoformat() | |
| conn.execute(""" | |
| INSERT INTO athlete_overrides (name, is_chaoyang, updated_at, team_id) | |
| VALUES (?, ?, ?, ?) | |
| ON CONFLICT(name) DO UPDATE SET is_chaoyang=?, updated_at=?, team_id=? | |
| """, (name, is_chaoyang, now, team_id or 'chaoyang', is_chaoyang, now, team_id or 'chaoyang')) | |
| conn.commit() | |
| def delete_athlete_override(name, team_id=None): | |
| """删除队员的覆盖标记""" | |
| conn = get_db() | |
| if team_id: | |
| conn.execute("DELETE FROM athlete_overrides WHERE name = ? AND team_id = ?", (name, team_id)) | |
| else: | |
| conn.execute("DELETE FROM athlete_overrides WHERE name = ?", (name,)) | |
| conn.commit() | |
| def is_chaoyang_team(team_name): | |
| """根据配置表判断是否为朝阳队""" | |
| conn = get_db() | |
| aliases = conn.execute("SELECT alias FROM team_aliases WHERE is_active = 1").fetchall() | |
| return any(row["alias"] in team_name for row in aliases) | |
| # ========== 队员管理 ========== | |
| def add_athlete(name, gender=None, category=None, notes=None): | |
| """手动添加队员(不依赖比赛成绩),如果已软删除则恢复""" | |
| conn = get_db() | |
| try: | |
| # 检查是否已有该队员(包括已软删除的) | |
| existing = conn.execute("SELECT id, deleted_at FROM athletes WHERE name = ?", (name,)).fetchone() | |
| if existing: | |
| if existing["deleted_at"]: | |
| conn.execute("UPDATE athletes SET deleted_at = NULL WHERE id = ?", (existing["id"],)) | |
| return True | |
| else: | |
| conn.execute("INSERT INTO athletes (name, gender, category, notes) VALUES (?, ?, ?, ?)", (name, gender, category, notes)) | |
| return True | |
| except Exception: | |
| return False | |
| def delete_athlete(name): | |
| """软删除队员""" | |
| conn = get_db() | |
| try: | |
| conn.execute("UPDATE athletes SET deleted_at = ? WHERE name = ?", (datetime.now().isoformat(), name)) | |
| return True | |
| except Exception: | |
| return False | |
| def get_athletes_with_details(athlete_names=None): | |
| """ | |
| 批量获取运动员详细信息,避免N+1查询问题 | |
| Args: | |
| athlete_names: 运动员姓名列表,如果为None则获取所有运动员 | |
| Returns: | |
| list of athlete details with all related data | |
| """ | |
| conn = get_db() | |
| # 构建基础查询条件 | |
| if athlete_names: | |
| name_placeholders = ','.join('?' * len(athlete_names)) | |
| base_condition = f"WHERE r.name IN ({name_placeholders})" | |
| query_params = athlete_names | |
| else: | |
| base_condition = "" | |
| query_params = [] | |
| # 获取所有相关数据的联合查询 | |
| results_query = f""" | |
| SELECT | |
| r.name, | |
| rg.race_date as date, | |
| m.season, | |
| m.title as competition, | |
| rg.event, | |
| rg.round_type as round, | |
| r.place, | |
| r.time_result as time, | |
| r.team, | |
| r.is_chaoyang, | |
| r.data_source, | |
| a.gender, | |
| a.category, | |
| a.notes | |
| FROM results r | |
| JOIN race_groups rg ON r.rcbh = rg.rcbh | |
| JOIN matches m ON rg.match_id = m.id | |
| LEFT JOIN athletes a ON r.name = a.name AND a.deleted_at IS NULL | |
| {base_condition} | |
| ORDER BY rg.race_date DESC, m.title, rg.event, rg.round_type | |
| """ | |
| results_rows = conn.execute(results_query, query_params).fetchall() | |
| # 获取荣誉信息 | |
| honors_condition = base_condition.replace('r.name', 'h.athlete_name') if base_condition else '' | |
| honors_query = f""" | |
| SELECT | |
| h.athlete_name, | |
| h.title, | |
| h.awarded_date, | |
| h.source | |
| FROM athlete_honors h | |
| {honors_condition} | |
| ORDER BY h.awarded_date DESC | |
| """ | |
| honors_rows = conn.execute(honors_query, query_params).fetchall() | |
| # 获取队伍信息 | |
| teams_query = f""" | |
| SELECT | |
| r.name, | |
| r.team, | |
| r.is_chaoyang, | |
| COUNT(*) as race_count | |
| FROM results r | |
| {base_condition} | |
| GROUP BY r.name, r.team, r.is_chaoyang | |
| ORDER BY r.name, race_count DESC | |
| """ | |
| teams_rows = conn.execute(teams_query, query_params).fetchall() | |
| # 组织数据结构 | |
| athletes_data = {} | |
| # 初始化运动员数据 | |
| names_to_process = athlete_names or set(row['name'] for row in results_rows) | |
| for name in names_to_process: | |
| athletes_data[name] = { | |
| 'name': name, | |
| 'results': [], | |
| 'honors': [], | |
| 'teams': [], | |
| 'gender': None, | |
| 'category': None, | |
| 'notes': None | |
| } | |
| # 填充比赛结果 | |
| for row in results_rows: | |
| name = row['name'] | |
| if name in athletes_data: | |
| athletes_data[name]['results'].append({ | |
| 'date': row['date'], | |
| 'season': row['season'], | |
| 'competition': row['competition'], | |
| 'event': row['event'], | |
| 'round': row['round'], | |
| 'place': row['place'], | |
| 'time': row['time'], | |
| 'team': row['team'], | |
| 'is_chaoyang': row['is_chaoyang'], | |
| 'data_source': row['data_source'] | |
| }) | |
| # 保存手动添加的信息 | |
| if row['gender']: | |
| athletes_data[name]['gender'] = row['gender'] | |
| if row['category']: | |
| athletes_data[name]['category'] = row['category'] | |
| if row['notes']: | |
| athletes_data[name]['notes'] = row['notes'] | |
| # 填充荣誉信息 | |
| for row in honors_rows: | |
| name = row['athlete_name'] | |
| if name in athletes_data: | |
| athletes_data[name]['honors'].append({ | |
| 'title': row['title'], | |
| 'awarded_date': row['awarded_date'], | |
| 'source': row['source'], | |
| }) | |
| # 填充队伍信息 | |
| current_name = None | |
| current_teams = [] | |
| for row in teams_rows: | |
| name = row['name'] | |
| if name != current_name: | |
| if current_name and current_name in athletes_data: | |
| athletes_data[current_name]['teams'] = current_teams | |
| current_name = name | |
| current_teams = [] | |
| current_teams.append({ | |
| 'team': row['team'], | |
| 'is_chaoyang': row['is_chaoyang'], | |
| 'race_count': row['race_count'] | |
| }) | |
| # 处理最后一个运动员 | |
| if current_name and current_name in athletes_data: | |
| athletes_data[current_name]['teams'] = current_teams | |
| return list(athletes_data.values()) | |
| def get_medal_records_batch(): | |
| """ | |
| 批量获取奖牌记录,优化性能 | |
| """ | |
| conn = get_db() | |
| query = """ | |
| SELECT | |
| r.name, | |
| m.title as competition, | |
| rg.event, | |
| rg.race_date as date, | |
| r.place, | |
| r.team, | |
| r.is_chaoyang, | |
| a.gender, | |
| a.category | |
| FROM results r | |
| JOIN race_groups rg ON r.rcbh = rg.rcbh | |
| JOIN matches m ON rg.match_id = m.id | |
| LEFT JOIN athletes a ON r.name = a.name AND a.deleted_at IS NULL | |
| WHERE r.place IN (1, 2, 3) | |
| ORDER BY rg.race_date DESC, r.place ASC | |
| """ | |
| rows = conn.execute(query).fetchall() | |
| medals = [] | |
| for row in rows: | |
| medal_type = "🥇" if str(row['place']) == '1' else "🥈" if str(row['place']) == '2' else "🥉" | |
| medals.append({ | |
| 'name': row['name'], | |
| 'competition': row['competition'], | |
| 'event': row['event'], | |
| 'date': row['date'], | |
| 'place': row['place'], | |
| 'medal_type': medal_type, | |
| 'team': row['team'], | |
| 'is_chaoyang': row['is_chaoyang'], | |
| 'gender': row['gender'], | |
| 'category': row['category'] | |
| }) | |
| return medals | |
| def update_athlete(name, gender=None, category=None, notes=None): | |
| """ | |
| 更新队员信息 | |
| """ | |
| conn = get_db() | |
| existing = conn.execute("SELECT * FROM athletes WHERE name=? AND deleted_at IS NULL", (name,)).fetchone() | |
| try: | |
| if existing: | |
| if existing['deleted_at']: | |
| # 已软删除,恢复 | |
| conn.execute("UPDATE athletes SET gender=?, category=?, notes=?, deleted_at=NULL WHERE id=?", | |
| (gender, category, notes, existing["id"])) | |
| conn.commit() | |
| return True | |
| else: | |
| # 已存在且未删除 | |
| return False | |
| conn.execute(""" | |
| INSERT INTO athletes (name, gender, category, notes, created_at, deleted_at) | |
| VALUES (?, ?, ?, ?, ?, NULL) | |
| """, (name, gender, category, notes, datetime.now().isoformat())) | |
| conn.commit() | |
| return True | |
| except Exception: | |
| return False | |
| def get_all_athletes(include_deleted=False): | |
| """获取所有手动添加的队员""" | |
| conn = get_db() | |
| if include_deleted: | |
| rows = conn.execute("SELECT * FROM athletes ORDER BY deleted_at ASC, name").fetchall() | |
| else: | |
| rows = conn.execute("SELECT * FROM athletes WHERE deleted_at IS NULL ORDER BY name").fetchall() | |
| return [dict(r) for r in rows] | |
| def soft_delete_athlete(athlete_id): | |
| """软删除队员""" | |
| conn = get_db() | |
| conn.execute("UPDATE athletes SET deleted_at = ? WHERE id = ?", (datetime.now().isoformat(), athlete_id)) | |
| conn.commit() | |
| def restore_athlete(athlete_id): | |
| """恢复被软删除的队员""" | |
| conn = get_db() | |
| conn.execute("UPDATE athletes SET deleted_at = NULL WHERE id = ?", (athlete_id,)) | |
| conn.commit() | |
| def delete_athlete(athlete_id): | |
| """永久删除队员""" | |
| conn = get_db() | |
| conn.execute("DELETE FROM athletes WHERE id = ?", (athlete_id,)) | |
| conn.commit() | |
| def auto_repair_athletes(): | |
| """athletes 表为空是合法状态,队员列表主要来自 results + profiles + training。 | |
| 此函数仅做一次诊断日志,不自动重建,避免导入非朝阳队员或复合队名。""" | |
| conn = get_db() | |
| count = conn.execute("SELECT COUNT(*) FROM athletes").fetchone()[0] | |
| if count == 0: | |
| print("[auto_repair] athletes 表为空,这是合法状态,不自动重建。手动添加的队员请通过 Web UI 操作。") | |
| return 0 | |
| def get_athlete_names(): | |
| """获取所有队员名称(比赛成绩 + 手动添加 + 训练记录),去重,排除已软删除,昵称解析为真名""" | |
| conn = get_db() | |
| # 从比赛成绩中获取 | |
| result_names = conn.execute("SELECT DISTINCT name FROM results").fetchall() | |
| # 从手动添加中获取(排除已软删除的) | |
| manual_names = conn.execute("SELECT name, gender, category FROM athletes WHERE deleted_at IS NULL").fetchall() | |
| # 从训练记录中获取(只训练不比赛的队员),同时解析昵称 | |
| training_names = conn.execute(""" | |
| SELECT DISTINCT tr.athlete_name, an.real_name | |
| FROM training_records tr | |
| LEFT JOIN athlete_nicknames an ON tr.athlete_name = an.nickname | |
| """).fetchall() | |
| names = set() | |
| for r in result_names: | |
| names.add(r["name"]) | |
| manual_info = {} | |
| for m in manual_names: | |
| names.add(m["name"]) | |
| manual_info[m["name"]] = {"gender": m["gender"], "category": m["category"]} | |
| nickname_map = get_nickname_map() | |
| for t in training_names: | |
| # 优先用映射表解析,否则用LEFT JOIN的结果 | |
| resolved = nickname_map.get(t["athlete_name"], t["real_name"] or t["athlete_name"]) | |
| names.add(resolved) | |
| return sorted(names), manual_info | |
| def batch_get_active_athletes(conn): | |
| """批量获取所有活跃队员名称(单次查询替代N次 is_athlete_active 调用) | |
| 活跃条件(满足任一): | |
| 1. 手动添加到 athletes 表且未删除 | |
| 2. 手动覆盖为朝阳队 | |
| 3. 最近半年有训练记录 | |
| 4. 最近一次出场代表朝阳队且在最近2个赛季内 | |
| """ | |
| from datetime import datetime, timedelta | |
| active = set() | |
| # 1. 手动添加的队员 | |
| manual = conn.execute("SELECT name FROM athletes WHERE deleted_at IS NULL").fetchall() | |
| for r in manual: | |
| active.add(r["name"]) | |
| # 2. 手动覆盖为朝阳队的 | |
| overrides = conn.execute("SELECT name FROM athlete_overrides WHERE is_chaoyang = 1").fetchall() | |
| for r in overrides: | |
| active.add(r["name"]) | |
| # 3. 最近半年有训练记录的(含昵称解析) | |
| cutoff = (datetime.now() - timedelta(days=180)).strftime('%Y-%m-%d') | |
| nickname_map = get_nickname_map() | |
| training = conn.execute( | |
| "SELECT DISTINCT athlete_name FROM training_records WHERE training_date >= ?", | |
| (cutoff,) | |
| ).fetchall() | |
| for r in training: | |
| name = nickname_map.get(r["athlete_name"], r["athlete_name"]) | |
| active.add(name) | |
| # 4. 最近2个赛季内代表朝阳队出场的(排除已转队的) | |
| # 获取所有赛季按时间倒序 | |
| seasons = [s["season"] for s in conn.execute( | |
| "SELECT DISTINCT season FROM matches ORDER BY date_range DESC" | |
| ).fetchall()] | |
| recent_seasons = set(seasons[:2]) if seasons else set() | |
| # 获取最近一次出场非朝阳的(已转队) | |
| transferred = conn.execute(""" | |
| SELECT r.name 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 = 0 | |
| GROUP BY r.name | |
| HAVING m.date_range = MAX( | |
| (SELECT MAX(m2.date_range) FROM results r2 | |
| JOIN race_groups rg2 ON r2.rcbh = rg2.rcbh | |
| JOIN matches m2 ON rg2.match_id = m2.id | |
| WHERE r2.name = r.name) | |
| ) | |
| """).fetchall() | |
| transferred_names = {r["name"] for r in transferred} | |
| # 获取最近2个赛季朝阳队出场过的 | |
| if recent_seasons: | |
| placeholders = ','.join(['?'] * len(recent_seasons)) | |
| cy_recent = conn.execute(f""" | |
| SELECT DISTINCT r.name 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 AND m.season IN ({placeholders}) | |
| """, list(recent_seasons)).fetchall() | |
| for r in cy_recent: | |
| if r["name"] not in transferred_names: | |
| active.add(r["name"]) | |
| return active | |
| def get_active_athlete_names(months=6): | |
| """获取最近N个月还有训练记录的活跃队员名称 | |
| Args: | |
| months: 月数,默认6个月(半年) | |
| Returns: | |
| set of active athlete names(已解析昵称为真名) | |
| """ | |
| from datetime import datetime, timedelta | |
| conn = get_db() | |
| cutoff = (datetime.now() - timedelta(days=months * 30)).strftime('%Y-%m-%d') | |
| rows = conn.execute( | |
| "SELECT DISTINCT athlete_name FROM training_records WHERE training_date >= ?", | |
| (cutoff,) | |
| ).fetchall() | |
| # 解析昵称为真名 | |
| names = set() | |
| nickname_map = get_nickname_map() | |
| for r in rows: | |
| name = r["athlete_name"] | |
| names.add(nickname_map.get(name, name)) | |
| return names | |
| # ========== 用户管理 ========== | |
| def create_user(username, password_hash, role='user', athlete_name=None, relationship=None, approved=1, team_id='chaoyang'): | |
| """创建用户""" | |
| conn = get_db() | |
| try: | |
| conn.execute("INSERT INTO users (username, password_hash, role, athlete_name, relationship, approved, team_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", | |
| (username, password_hash, role, athlete_name, relationship, approved, team_id or 'chaoyang', datetime.now().isoformat())) | |
| conn.commit() | |
| return True | |
| except Exception: | |
| return False | |
| finally: | |
| pass | |
| def get_user_by_username(username): | |
| """根据用户名获取用户""" | |
| conn = get_db() | |
| row = conn.execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone() | |
| return dict(row) if row else None | |
| def get_all_users(): | |
| """获取所有用户""" | |
| conn = get_db() | |
| rows = conn.execute("SELECT id, username, role, athlete_name, relationship, approved, team_id, created_at FROM users ORDER BY id").fetchall() | |
| return [dict(r) for r in rows] | |
| def update_user_role(user_id, role): | |
| """更新用户角色""" | |
| conn = get_db() | |
| conn.execute("UPDATE users SET role = ? WHERE id = ?", (role, user_id)) | |
| conn.commit() | |
| def update_user_password(user_id, password_hash): | |
| """更新用户密码""" | |
| conn = get_db() | |
| conn.execute("UPDATE users SET password_hash = ? WHERE id = ?", (password_hash, user_id)) | |
| conn.commit() | |
| def update_user_bio(user_id, bio): | |
| """更新用户个性签名""" | |
| conn = get_db() | |
| conn.execute("UPDATE users SET bio = ? WHERE id = ?", (bio, user_id)) | |
| conn.commit() | |
| def get_user_public_profile(username): | |
| """获取用户公开信息(头像+签名)""" | |
| conn = get_db() | |
| row = conn.execute("SELECT username, bio, avatar_data, avatar_type FROM users WHERE username = ?", (username,)).fetchone() | |
| return dict(row) if row else None | |
| def delete_user(user_id): | |
| """删除用户""" | |
| conn = get_db() | |
| conn.execute("DELETE FROM users WHERE id = ?", (user_id,)) | |
| conn.commit() | |
| def get_user_avatar(user_id): | |
| """获取用户头像""" | |
| conn = get_db() | |
| row = conn.execute("SELECT avatar_data, avatar_type FROM users WHERE id = ?", (user_id,)).fetchone() | |
| if row and row["avatar_data"]: | |
| return {"avatar_data": row["avatar_data"], "avatar_type": row["avatar_type"] or "image/jpeg"} | |
| return None | |
| def set_user_avatar(user_id, avatar_data, avatar_type="image/jpeg"): | |
| """设置用户头像""" | |
| conn = get_db() | |
| conn.execute("UPDATE users SET avatar_data = ?, avatar_type = ? WHERE id = ?", | |
| (avatar_data, avatar_type, user_id)) | |
| conn.commit() | |
| def approve_user(user_id): | |
| """批准用户""" | |
| conn = get_db() | |
| conn.execute("UPDATE users SET approved = 1 WHERE id = ?", (user_id,)) | |
| # 同步更新athlete_family表 | |
| user = conn.execute("SELECT athlete_name, relationship FROM users WHERE id = ?", (user_id,)).fetchone() | |
| if user and user['athlete_name']: | |
| conn.execute("INSERT OR IGNORE INTO athlete_family (athlete_name, user_id, relationship, approved, created_at) VALUES (?, ?, ?, 1, ?)", | |
| (user['athlete_name'], user_id, user['relationship'], datetime.now().isoformat())) | |
| conn.execute("UPDATE athlete_family SET approved = 1 WHERE user_id = ?", (user_id,)) | |
| conn.commit() | |
| def reject_user(user_id): | |
| """拒绝用户(删除)""" | |
| conn = get_db() | |
| conn.execute("DELETE FROM users WHERE id = ? AND role != 'superadmin'", (user_id,)) | |
| conn.commit() | |
| def get_athlete_photo(name): | |
| """获取运动员照片""" | |
| conn = get_db() | |
| row = conn.execute("SELECT photo_data, content_type FROM athlete_photos WHERE name = ?", (name,)).fetchone() | |
| if row: | |
| return {"photo_data": row["photo_data"], "content_type": row["content_type"]} | |
| return None | |
| def set_athlete_photo(name, photo_data, content_type="image/jpeg"): | |
| """设置运动员照片""" | |
| conn = get_db() | |
| conn.execute(""" | |
| INSERT INTO athlete_photos (name, photo_data, content_type, updated_at) | |
| VALUES (?, ?, ?, ?) | |
| ON CONFLICT(name) DO UPDATE SET photo_data=?, content_type=?, updated_at=? | |
| """, (name, photo_data, content_type, datetime.now().isoformat(), | |
| photo_data, content_type, datetime.now().isoformat())) | |
| conn.commit() | |
| def delete_athlete_photo(name): | |
| """删除运动员照片""" | |
| conn = get_db() | |
| conn.execute("DELETE FROM athlete_photos WHERE name = ?", (name,)) | |
| conn.commit() | |
| def get_training_types(): | |
| """获取所有训练类型""" | |
| conn = get_db() | |
| rows = conn.execute("SELECT * FROM training_types ORDER BY id").fetchall() | |
| return [dict(r) for r in rows] | |
| def add_training_type(name, description="", is_class=0): | |
| """添加训练类型""" | |
| conn = get_db() | |
| try: | |
| conn.execute("INSERT INTO training_types (name, description, is_class) VALUES (?, ?, ?)", | |
| (name, description, is_class)) | |
| conn.commit() | |
| return True | |
| except Exception: | |
| return False | |
| finally: | |
| pass | |
| def delete_training_type(type_id): | |
| """删除训练类型""" | |
| conn = get_db() | |
| conn.execute("DELETE FROM training_types WHERE id = ?", (type_id,)) | |
| conn.commit() | |
| def get_training_records(athlete_name=None): | |
| """获取训练记录,支持昵称查询,返回real_name字段""" | |
| conn = get_db() | |
| nickname_map = get_nickname_map() | |
| if athlete_name: | |
| # 获取该队员的所有昵称 | |
| nick_rows = conn.execute("SELECT nickname FROM athlete_nicknames WHERE real_name = ?", (athlete_name,)).fetchall() | |
| all_names = [athlete_name] + [r["nickname"] for r in nick_rows] | |
| placeholders = ','.join(['?'] * len(all_names)) | |
| rows = conn.execute(f""" | |
| SELECT tr.*, tt.name as type_name, tt.is_class | |
| FROM training_records tr | |
| JOIN training_types tt ON tr.training_type_id = tt.id | |
| WHERE tr.athlete_name IN ({placeholders}) | |
| ORDER BY tr.training_date DESC | |
| """, list(all_names)).fetchall() | |
| else: | |
| rows = conn.execute(""" | |
| SELECT tr.*, tt.name as type_name, tt.is_class | |
| FROM training_records tr | |
| JOIN training_types tt ON tr.training_type_id = tt.id | |
| ORDER BY tr.training_date DESC | |
| """).fetchall() | |
| result = [] | |
| for r in rows: | |
| d = dict(r) | |
| # 附加真名(解析昵称) | |
| d['real_name'] = nickname_map.get(d['athlete_name'], d['athlete_name']) | |
| result.append(d) | |
| return result | |
| def get_athlete_attendance_detail(athlete_name, start_date=None, end_date=None): | |
| """获取单个队员在指定时间段的出勤明细记录,昵称合并""" | |
| conn = get_db() | |
| # 获取该队员的所有昵称 | |
| nick_rows = conn.execute("SELECT nickname FROM athlete_nicknames WHERE real_name = ?", (athlete_name,)).fetchall() | |
| all_names = [athlete_name] + [r["nickname"] for r in nick_rows] | |
| placeholders = ','.join(['?'] * len(all_names)) | |
| query = f""" | |
| SELECT tr.id, tr.training_date, tr.training_type_id, tr.duration_minutes, tr.notes, | |
| tt.name as type_name, tt.is_class | |
| FROM training_records tr | |
| JOIN training_types tt ON tr.training_type_id = tt.id | |
| WHERE tr.athlete_name IN ({placeholders}) | |
| """ | |
| params = list(all_names) | |
| if start_date: | |
| query += " AND tr.training_date >= ?" | |
| params.append(start_date) | |
| if end_date: | |
| query += " AND tr.training_date <= ?" | |
| params.append(end_date) | |
| query += " ORDER BY tr.training_date ASC, tt.is_class DESC" | |
| rows = conn.execute(query, params).fetchall() | |
| return [dict(r) for r in rows] | |
| def add_training_record(athlete_name, training_type_id, training_date, duration_minutes=None, notes=""): | |
| """添加训练记录""" | |
| conn = get_db() | |
| conn.execute(""" | |
| INSERT INTO training_records (athlete_name, training_type_id, training_date, duration_minutes, notes, created_at) | |
| VALUES (?, ?, ?, ?, ?, ?) | |
| """, (athlete_name, training_type_id, training_date, duration_minutes, notes, datetime.now().isoformat())) | |
| conn.commit() | |
| def delete_training_record(record_id): | |
| """删除训练记录""" | |
| conn = get_db() | |
| conn.execute("DELETE FROM training_records WHERE id = ?", (record_id,)) | |
| conn.commit() | |
| # ========== 出勤统计 ========== | |
| def get_attendance_stats(start_date=None, end_date=None, athlete_name=None, active_only=True): | |
| """获取出勤统计,按队员+大课/小课分组,昵称合并到真名 | |
| Args: | |
| active_only: 只统计最近半年还活跃的队员(默认True) | |
| 返回: [ | |
| { | |
| "athlete_name": "张三", | |
| "class_count": 10, # 大课次数 | |
| "class_duration": 900, # 大课总时长(分钟) | |
| "custom_count": 5, # 小课次数 | |
| "custom_duration": 450, # 小课总时长(分钟) | |
| "total_count": 15, # 总次数 | |
| "total_duration": 1350 # 总时长(分钟) | |
| }, | |
| ... | |
| ] | |
| """ | |
| conn = get_db() | |
| # 获取活跃队员列表 | |
| active_names = None | |
| if active_only: | |
| active_names = get_active_athlete_names() | |
| query = """ | |
| SELECT COALESCE(an.real_name, tr.athlete_name) as athlete_name, | |
| SUM(CASE WHEN tt.is_class = 1 THEN 1 ELSE 0 END) as class_count, | |
| SUM(CASE WHEN tt.is_class = 1 THEN COALESCE(tr.duration_minutes, 0) ELSE 0 END) as class_duration, | |
| SUM(CASE WHEN tt.is_class = 0 OR tt.is_class IS NULL THEN 1 ELSE 0 END) as custom_count, | |
| SUM(CASE WHEN tt.is_class = 0 OR tt.is_class IS NULL THEN COALESCE(tr.duration_minutes, 0) ELSE 0 END) as custom_duration, | |
| COUNT(*) as total_count, | |
| SUM(COALESCE(tr.duration_minutes, 0)) as total_duration | |
| FROM training_records tr | |
| JOIN training_types tt ON tr.training_type_id = tt.id | |
| LEFT JOIN athlete_nicknames an ON tr.athlete_name = an.nickname | |
| WHERE 1=1 | |
| """ | |
| params = [] | |
| if start_date: | |
| query += " AND tr.training_date >= ?" | |
| params.append(start_date) | |
| if end_date: | |
| query += " AND tr.training_date <= ?" | |
| params.append(end_date) | |
| if athlete_name: | |
| query += " AND (COALESCE(an.real_name, tr.athlete_name) = ? OR tr.athlete_name = ?)" | |
| params.append(athlete_name) | |
| params.append(athlete_name) | |
| query += " GROUP BY COALESCE(an.real_name, tr.athlete_name) ORDER BY total_count DESC" | |
| rows = conn.execute(query, params).fetchall() | |
| result = [dict(r) for r in rows] | |
| # 过滤非活跃队员 | |
| if active_only and active_names is not None: | |
| result = [r for r in result if r['athlete_name'] in active_names] | |
| return result | |
| def get_attendance_detail(athlete_name, start_date=None, end_date=None): | |
| """获取某个队员的出勤明细,按训练类型分组,昵称合并 | |
| 返回: [ | |
| { | |
| "training_type_id": 1, | |
| "type_name": "区队大课", | |
| "is_class": 1, | |
| "count": 10, | |
| "total_duration": 900, | |
| "dates": ["2026-04-01", "2026-04-02", ...] | |
| }, | |
| ... | |
| ] | |
| """ | |
| conn = get_db() | |
| # 获取该队员的所有昵称 | |
| nick_rows = conn.execute("SELECT nickname FROM athlete_nicknames WHERE real_name = ?", (athlete_name,)).fetchall() | |
| all_names = [athlete_name] + [r["nickname"] for r in nick_rows] | |
| placeholders = ','.join(['?'] * len(all_names)) | |
| query = f""" | |
| SELECT tt.id as training_type_id, tt.name as type_name, tt.is_class, | |
| COUNT(*) as count, | |
| SUM(COALESCE(tr.duration_minutes, 0)) as total_duration | |
| FROM training_records tr | |
| JOIN training_types tt ON tr.training_type_id = tt.id | |
| WHERE tr.athlete_name IN ({placeholders}) | |
| """ | |
| params = list(all_names) | |
| if start_date: | |
| query += " AND tr.training_date >= ?" | |
| params.append(start_date) | |
| if end_date: | |
| query += " AND tr.training_date <= ?" | |
| params.append(end_date) | |
| query += " GROUP BY tt.id ORDER BY tt.is_class DESC, tt.name" | |
| rows = conn.execute(query, params).fetchall() | |
| result = [] | |
| for r in rows: | |
| d = dict(r) | |
| # 获取具体日期列表 | |
| date_query = f""" | |
| SELECT tr.training_date | |
| FROM training_records tr | |
| WHERE tr.athlete_name IN ({placeholders}) AND tr.training_type_id = ? | |
| """ | |
| date_params = list(all_names) + [r["training_type_id"]] | |
| if start_date: | |
| date_query += " AND tr.training_date >= ?" | |
| date_params.append(start_date) | |
| if end_date: | |
| date_query += " AND tr.training_date <= ?" | |
| date_params.append(end_date) | |
| date_query += " ORDER BY tr.training_date" | |
| dates = conn.execute(date_query, date_params).fetchall() | |
| d["dates"] = [row["training_date"] for row in dates] | |
| result.append(d) | |
| return result | |
| # ========== 费用配置 ========== | |
| def get_fee_config(): | |
| """获取所有训练类型的费用配置""" | |
| conn = get_db() | |
| rows = conn.execute(""" | |
| SELECT tt.id as training_type_id, tt.name as type_name, tt.is_class, | |
| COALESCE(fc.price_per_session, 0) as price_per_session, | |
| fc.updated_at | |
| FROM training_types tt | |
| LEFT JOIN fee_config fc ON tt.id = fc.training_type_id | |
| ORDER BY tt.is_class DESC, tt.name | |
| """).fetchall() | |
| return [dict(r) for r in rows] | |
| def set_fee_config(training_type_id, price_per_session): | |
| """设置训练类型的单价""" | |
| conn = get_db() | |
| conn.execute(""" | |
| INSERT INTO fee_config (training_type_id, price_per_session, updated_at) | |
| VALUES (?, ?, ?) | |
| ON CONFLICT(training_type_id) DO UPDATE SET price_per_session=?, updated_at=? | |
| """, (training_type_id, price_per_session, datetime.now().isoformat(), | |
| price_per_session, datetime.now().isoformat())) | |
| conn.commit() | |
| def get_athlete_exemptions(athlete_name=None): | |
| """获取队员的费用豁免配置""" | |
| conn = get_db() | |
| if athlete_name: | |
| rows = conn.execute(""" | |
| SELECT afe.athlete_name, afe.training_type_id, afe.is_exempt, afe.updated_at, | |
| tt.name as type_name, tt.is_class | |
| FROM athlete_fee_exemptions afe | |
| JOIN training_types tt ON afe.training_type_id = tt.id | |
| WHERE afe.athlete_name = ? | |
| ORDER BY tt.is_class DESC, tt.name | |
| """, (athlete_name,)).fetchall() | |
| else: | |
| rows = conn.execute(""" | |
| SELECT afe.athlete_name, afe.training_type_id, afe.is_exempt, afe.updated_at, | |
| tt.name as type_name, tt.is_class | |
| FROM athlete_fee_exemptions afe | |
| JOIN training_types tt ON afe.training_type_id = tt.id | |
| ORDER BY afe.athlete_name, tt.is_class DESC, tt.name | |
| """).fetchall() | |
| return [dict(r) for r in rows] | |
| def set_athlete_exemption(athlete_name, training_type_id, is_exempt=True): | |
| """设置队员某训练类型的费用豁免""" | |
| conn = get_db() | |
| conn.execute(""" | |
| INSERT INTO athlete_fee_exemptions (athlete_name, training_type_id, is_exempt, updated_at) | |
| VALUES (?, ?, ?, ?) | |
| ON CONFLICT(athlete_name, training_type_id) DO UPDATE SET is_exempt=?, updated_at=? | |
| """, (athlete_name, training_type_id, 1 if is_exempt else 0, datetime.now().isoformat(), | |
| 1 if is_exempt else 0, datetime.now().isoformat())) | |
| conn.commit() | |
| def delete_athlete_exemption(athlete_name, training_type_id): | |
| """删除队员某训练类型的费用豁免""" | |
| conn = get_db() | |
| conn.execute( | |
| "DELETE FROM athlete_fee_exemptions WHERE athlete_name = ? AND training_type_id = ?", | |
| (athlete_name, training_type_id) | |
| ) | |
| conn.commit() | |
| def calculate_fees(start_date=None, end_date=None, athlete_name=None, active_only=True): | |
| """计算费用,根据出勤 × 单价 - 豁免 - 结算,昵称合并到真名 | |
| Args: | |
| active_only: 只统计最近半年还活跃的队员(默认True) | |
| 费用计算规则: | |
| 1. 每个队员每个训练类型的单价:优先使用athlete_fee_config,否则使用全局fee_config | |
| 2. 预付费大课:从start_date开始,每次上课扣一次费,从prepaid_balance扣减 | |
| 3. 小课:按次收费,直接计算次数×单价 | |
| 4. 豁免:免费 | |
| 5. 已结算的费用不再重复计算 | |
| 返回: [ | |
| { | |
| "athlete_name": "张三", | |
| "items": [ | |
| { | |
| "training_type_id": 1, | |
| "type_name": "区队大课", | |
| "is_class": 1, | |
| "count": 10, | |
| "price_per_session": 100, | |
| "is_exempt": 0, | |
| "is_prepaid": 1, | |
| "prepaid_deducted": 10, | |
| "settled_count": 3, | |
| "unsettled_count": 7, | |
| "subtotal": 700, | |
| "settled_amount": 300 | |
| }, | |
| ... | |
| ], | |
| "total_fee": 1000, | |
| "total_settled": 300, | |
| "total_unsettled": 700 | |
| } | |
| ] | |
| """ | |
| conn = get_db() | |
| # 获取出勤明细,昵称合并到真名 | |
| query = """ | |
| SELECT COALESCE(an.real_name, tr.athlete_name) as athlete_name, | |
| tt.id as training_type_id, tt.name as type_name, tt.is_class, | |
| COUNT(*) as count | |
| FROM training_records tr | |
| JOIN training_types tt ON tr.training_type_id = tt.id | |
| LEFT JOIN athlete_nicknames an ON tr.athlete_name = an.nickname | |
| WHERE 1=1 | |
| """ | |
| params = [] | |
| if start_date: | |
| query += " AND tr.training_date >= ?" | |
| params.append(start_date) | |
| if end_date: | |
| query += " AND tr.training_date <= ?" | |
| params.append(end_date) | |
| if athlete_name: | |
| query += " AND (COALESCE(an.real_name, tr.athlete_name) = ? OR tr.athlete_name = ?)" | |
| params.append(athlete_name) | |
| params.append(athlete_name) | |
| query += " GROUP BY COALESCE(an.real_name, tr.athlete_name), tt.id ORDER BY COALESCE(an.real_name, tr.athlete_name), tt.is_class DESC, tt.name" | |
| rows = conn.execute(query, params).fetchall() | |
| # 获取全局费用配置 | |
| fee_rows = conn.execute("SELECT training_type_id, price_per_session FROM fee_config").fetchall() | |
| fee_map = {r["training_type_id"]: r["price_per_session"] for r in fee_rows} | |
| # 获取个性化费用配置 | |
| athlete_fee_rows = conn.execute("SELECT athlete_name, training_type_id, price_per_session FROM athlete_fee_config").fetchall() | |
| athlete_fee_map = {} | |
| for r in athlete_fee_rows: | |
| key = (r["athlete_name"], r["training_type_id"]) | |
| athlete_fee_map[key] = r["price_per_session"] | |
| # 获取豁免配置 | |
| exempt_rows = conn.execute("SELECT athlete_name, training_type_id, is_exempt FROM athlete_fee_exemptions").fetchall() | |
| exempt_map = {} | |
| for r in exempt_rows: | |
| key = (r["athlete_name"], r["training_type_id"]) | |
| exempt_map[key] = r["is_exempt"] | |
| # 获取预付费配置 | |
| prepaid_rows = conn.execute("SELECT athlete_name, training_type_id, balance, start_date FROM prepaid_balance").fetchall() | |
| prepaid_map = {} | |
| for r in prepaid_rows: | |
| key = (r["athlete_name"], r["training_type_id"]) | |
| prepaid_map[key] = {"balance": r["balance"], "start_date": r["start_date"]} | |
| # 获取已结算记录 | |
| settled_rows = conn.execute(""" | |
| SELECT athlete_name, training_type_id, start_date, end_date, amount | |
| FROM fee_settlements | |
| """).fetchall() | |
| # 按队员分组 | |
| athlete_map = {} | |
| for r in rows: | |
| name = r["athlete_name"] | |
| if name not in athlete_map: | |
| athlete_map[name] = [] | |
| tid = r["training_type_id"] | |
| # 单价:优先使用个性化配置 | |
| price = athlete_fee_map.get((name, tid), fee_map.get(tid, 0)) | |
| is_exempt = exempt_map.get((name, tid), 0) | |
| # 预付费 | |
| prepaid_info = prepaid_map.get((name, tid)) | |
| is_prepaid = 1 if prepaid_info else 0 | |
| prepaid_deducted = 0 | |
| if is_prepaid and not is_exempt: | |
| # 预付费:从start_date开始,每次上课扣一次 | |
| prepaid_start = prepaid_info["start_date"] | |
| prepaid_balance = prepaid_info["balance"] | |
| # 计算从start_date起的上课次数 | |
| # 简化:假设所有count都扣预付费 | |
| prepaid_deducted = min(r["count"], int(prepaid_balance / price)) if price > 0 else 0 | |
| # 计算已结算的费用 | |
| settled_amount = 0 | |
| settled_count = 0 | |
| for sr in settled_rows: | |
| if sr["athlete_name"] == name and sr["training_type_id"] == tid: | |
| if start_date and sr["end_date"] < start_date: | |
| continue | |
| if end_date and sr["start_date"] > end_date: | |
| continue | |
| # 结算区间与查询区间有重叠 | |
| settled_amount += sr["amount"] | |
| # 估算已结算的次数 | |
| if price > 0: | |
| settled_count += int(sr["amount"] / price) | |
| if is_exempt: | |
| subtotal = 0 | |
| else: | |
| unsettled_count = max(0, r["count"] - settled_count - prepaid_deducted) | |
| subtotal = unsettled_count * price | |
| athlete_map[name].append({ | |
| "training_type_id": tid, | |
| "type_name": r["type_name"], | |
| "is_class": r["is_class"], | |
| "count": r["count"], | |
| "price_per_session": price, | |
| "is_exempt": is_exempt, | |
| "is_prepaid": is_prepaid, | |
| "prepaid_deducted": prepaid_deducted, | |
| "settled_count": settled_count, | |
| "unsettled_count": max(0, r["count"] - settled_count), | |
| "subtotal": subtotal, | |
| "settled_amount": settled_amount, | |
| }) | |
| # 计算每人的总费用 | |
| # 获取活跃队员列表 | |
| active_names = get_active_athlete_names() if active_only else None | |
| result = [] | |
| for name, items in athlete_map.items(): | |
| # 过滤非活跃队员 | |
| if active_only and active_names is not None and name not in active_names: | |
| continue | |
| total_fee = sum(item["subtotal"] for item in items) | |
| total_settled = sum(item["settled_amount"] for item in items) | |
| total_unsettled = total_fee | |
| result.append({ | |
| "athlete_name": name, | |
| "items": items, | |
| "total_fee": total_fee, | |
| "total_settled": total_settled, | |
| "total_unsettled": total_unsettled, | |
| }) | |
| result.sort(key=lambda x: x["total_fee"], reverse=True) | |
| return result | |
| # ========== 留言板 ========== | |
| def get_messages(limit=50): | |
| """获取留言列表""" | |
| conn = get_db() | |
| rows = conn.execute( | |
| "SELECT id, username, content, image_data, likes, created_at, reply_to_id FROM messages ORDER BY created_at DESC LIMIT ?", | |
| (limit,) | |
| ).fetchall() | |
| return [dict(r) for r in rows] | |
| def add_message(username, content, image_data=None, reply_to_id=None): | |
| """添加留言""" | |
| conn = get_db() | |
| conn.execute("INSERT INTO messages (username, content, image_data, likes, created_at, reply_to_id) VALUES (?, ?, ?, 0, ?, ?)", | |
| (username, content, image_data, datetime.now().isoformat(), reply_to_id)) | |
| conn.commit() | |
| def like_message(msg_id, user_id): | |
| """给留言点赞(取消赞),返回点赞数和是否已赞""" | |
| conn = get_db() | |
| # 检查是否已赞 | |
| existing = conn.execute( | |
| "SELECT 1 FROM message_likes WHERE message_id = ? AND user_id = ?", | |
| (msg_id, user_id) | |
| ).fetchone() | |
| if existing: | |
| # 取消赞 | |
| conn.execute("DELETE FROM message_likes WHERE message_id = ? AND user_id = ?", (msg_id, user_id)) | |
| conn.execute("UPDATE messages SET likes = MAX(0, likes - 1) WHERE id = ?", (msg_id,)) | |
| liked = False | |
| else: | |
| # 点赞 | |
| conn.execute("INSERT INTO message_likes (message_id, user_id, created_at) VALUES (?, ?, ?)", | |
| (msg_id, user_id, datetime.now().isoformat())) | |
| conn.execute("UPDATE messages SET likes = likes + 1 WHERE id = ?", (msg_id,)) | |
| liked = True | |
| conn.commit() | |
| row = conn.execute("SELECT likes FROM messages WHERE id = ?", (msg_id,)).fetchone() | |
| return row["likes"] if row else 0, liked | |
| def get_message_likes_by_user(user_id, message_ids): | |
| """获取用户对指定留言的点赞状态""" | |
| if not message_ids: | |
| return set() | |
| conn = get_db() | |
| placeholders = ','.join('?' * len(message_ids)) | |
| rows = conn.execute( | |
| f"SELECT message_id FROM message_likes WHERE user_id = ? AND message_id IN ({placeholders})", | |
| [user_id] + list(message_ids) | |
| ).fetchall() | |
| return {row["message_id"] for row in rows} | |
| def delete_message(msg_id): | |
| """删除留言""" | |
| conn = get_db() | |
| conn.execute("DELETE FROM messages WHERE id = ?", (msg_id,)) | |
| conn.commit() | |
| # ========== 家属管理 ========== | |
| def set_athlete_family(athlete_name, user_id, relationship="", approved=True): | |
| """设置队员的指定家属(每个队员只能有一个指定家属)""" | |
| conn = get_db() | |
| try: | |
| conn.execute(""" | |
| INSERT INTO athlete_family (athlete_name, user_id, relationship, approved, created_at) | |
| VALUES (?, ?, ?, ?, ?) | |
| ON CONFLICT(athlete_name) DO UPDATE SET user_id=?, relationship=?, approved=? | |
| """, (athlete_name, user_id, relationship, 1 if approved else 0, datetime.now().isoformat(), | |
| user_id, relationship, 1 if approved else 0)) | |
| conn.commit() | |
| return True | |
| except Exception: | |
| return False | |
| finally: | |
| pass | |
| def delete_athlete_family(athlete_name): | |
| """删除队员的指定家属""" | |
| conn = get_db() | |
| conn.execute("DELETE FROM athlete_family WHERE athlete_name = ?", (athlete_name,)) | |
| conn.commit() | |
| def get_athlete_family(athlete_name): | |
| """获取队员的指定家属""" | |
| conn = get_db() | |
| row = conn.execute(""" | |
| SELECT af.*, u.username | |
| FROM athlete_family af | |
| JOIN users u ON af.user_id = u.id | |
| WHERE af.athlete_name = ? | |
| """, (athlete_name,)).fetchone() | |
| return dict(row) if row else None | |
| def get_user_family(user_id): | |
| """获取用户关联的队员信息(优先从athlete_family表,回退到users表)""" | |
| conn = get_db() | |
| row = conn.execute(""" | |
| SELECT af.athlete_name, af.relationship, af.approved | |
| FROM athlete_family af | |
| WHERE af.user_id = ? | |
| """, (user_id,)).fetchone() | |
| if row: | |
| return dict(row) | |
| # 回退:从users表获取家庭关联信息,并自动补录到athlete_family表 | |
| user = conn.execute("SELECT athlete_name, relationship, approved FROM users WHERE id = ?", (user_id,)).fetchone() | |
| if user and user['athlete_name']: | |
| try: | |
| conn.execute("INSERT OR IGNORE INTO athlete_family (athlete_name, user_id, relationship, approved, created_at) VALUES (?, ?, ?, ?, ?)", | |
| (user['athlete_name'], user_id, user['relationship'], user['approved'], datetime.now().isoformat())) | |
| conn.commit() | |
| result = {"athlete_name": user['athlete_name'], "relationship": user['relationship'], "approved": user['approved']} | |
| except Exception: | |
| result = {"athlete_name": user['athlete_name'], "relationship": user['relationship'], "approved": user['approved']} | |
| else: | |
| result = None | |
| return result | |
| def get_all_families(): | |
| """获取所有家属关联""" | |
| conn = get_db() | |
| rows = conn.execute(""" | |
| SELECT af.athlete_name, af.user_id, af.relationship, af.approved, af.created_at, | |
| u.username | |
| FROM athlete_family af | |
| JOIN users u ON af.user_id = u.id | |
| ORDER BY af.athlete_name | |
| """).fetchall() | |
| return [dict(r) for r in rows] | |
| def is_family_of(user_id, athlete_name): | |
| """判断用户是否是指定队员的授权家属""" | |
| conn = get_db() | |
| row = conn.execute( | |
| "SELECT approved FROM athlete_family WHERE user_id = ? AND athlete_name = ? AND approved = 1", | |
| (user_id, athlete_name) | |
| ).fetchone() | |
| return row is not None | |
| # ========== 访问统计 ========== | |
| def log_visit(ip, user_agent, page="/"): | |
| """记录访问""" | |
| conn = get_db() | |
| conn.execute("INSERT INTO visit_logs (ip, user_agent, page, visited_at) VALUES (?, ?, ?, ?)", | |
| (ip, user_agent, page, datetime.now().isoformat())) | |
| conn.commit() | |
| def get_visit_stats(): | |
| """获取访问统计""" | |
| conn = get_db() | |
| try: | |
| # 检查 visit_logs 表是否存在 | |
| table_exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='visit_logs'").fetchone() | |
| if not table_exists: | |
| return {"total": 0, "today": 0, "unique_visitors": 0, "daily": [], "monthly": []} | |
| # 总访问量 | |
| total = conn.execute("SELECT COUNT(*) as cnt FROM visit_logs").fetchone()["cnt"] | |
| # 今日访问量 | |
| today = datetime.now().strftime("%Y-%m-%d") | |
| today_count = conn.execute( | |
| "SELECT COUNT(*) as cnt FROM visit_logs WHERE visited_at LIKE ?", | |
| (f'{today}%',) | |
| ).fetchone()["cnt"] | |
| # 最近7天每日访问量 | |
| daily = conn.execute(""" | |
| SELECT DATE(visited_at) as date, COUNT(*) as count | |
| FROM visit_logs | |
| WHERE visited_at >= DATE('now', '-7 days') | |
| GROUP BY DATE(visited_at) | |
| ORDER BY date | |
| """).fetchall() | |
| # 最近30天每日访问量(用于图表) | |
| monthly = conn.execute(""" | |
| SELECT DATE(visited_at) as date, COUNT(*) as count | |
| FROM visit_logs | |
| WHERE visited_at >= DATE('now', '-30 days') | |
| GROUP BY DATE(visited_at) | |
| ORDER BY date | |
| """).fetchall() | |
| # 唯一IP数 | |
| unique_ips = conn.execute("SELECT COUNT(DISTINCT ip) as cnt FROM visit_logs").fetchone()["cnt"] | |
| return { | |
| "total": total, | |
| "today": today_count, | |
| "unique_visitors": unique_ips, | |
| "daily": [dict(r) for r in daily], | |
| "monthly": [dict(r) for r in monthly], | |
| } | |
| finally: | |
| pass | |
| def recalculate_chaoyang_flags(team_id=None): | |
| """根据配置表和覆盖规则重新计算所有记录的 is_chaoyang 标记""" | |
| conn = get_db() | |
| if team_id is None: | |
| row = conn.execute("SELECT value FROM app_settings WHERE key = 'active_team'").fetchone() | |
| team_id = row["value"] if row else "chaoyang" | |
| # 获取当前队伍的队名别名 | |
| aliases = [row["alias"] for row in conn.execute( | |
| "SELECT alias FROM team_aliases WHERE is_active = 1 AND (team_id = ? OR team_id IS NULL)", (team_id,) | |
| ).fetchall()] | |
| # 获取当前队伍的手动覆盖 | |
| overrides = {} | |
| for row in conn.execute( | |
| "SELECT name, is_chaoyang FROM athlete_overrides WHERE team_id = ? OR team_id IS NULL", (team_id,) | |
| ).fetchall(): | |
| overrides[row["name"]] = row["is_chaoyang"] | |
| # 先清零所有 is_chaoyang | |
| conn.execute("UPDATE results SET is_chaoyang = 0") | |
| # 按队名自动识别(匹配 team 列) | |
| for alias in aliases: | |
| conn.execute("UPDATE results SET is_chaoyang = 1 WHERE team LIKE ?", (f'%{alias}%',)) | |
| # 同时匹配 name 列(接力队名可能在 name 里,team 列是数据源名称) | |
| for alias in aliases: | |
| conn.execute("UPDATE results SET is_chaoyang = 1 WHERE name LIKE ? AND is_chaoyang = 0", (f'%{alias}%',)) | |
| # 排除长春市朝阳区(不是北京朝阳) | |
| if team_id == 'chaoyang': | |
| conn.execute("UPDATE results SET is_chaoyang = 0 WHERE team LIKE '%长春%朝阳%'") | |
| # 应用手动覆盖(添加或移除) | |
| for name, flag in overrides.items(): | |
| conn.execute("UPDATE results SET is_chaoyang = ? WHERE name = ?", (flag, name)) | |
| conn.commit() | |
| # ========== 多队伍管理 ========== | |
| def get_team_config(team_id): | |
| """根据 team_id 获取队伍配置""" | |
| conn = get_db() | |
| if not team_id: | |
| team_id = 'chaoyang' | |
| team = conn.execute("SELECT * FROM team_configs WHERE team_id = ?", (team_id,)).fetchone() | |
| return dict(team) if team else {"team_id": "chaoyang", "team_name": "朝阳", "color_primary": "#0F4C81", "color_accent": "#4A90D9", "color_light": "#E8F0FE"} | |
| def get_active_team(): | |
| """获取当前活跃队伍的完整配置""" | |
| conn = get_db() | |
| row = conn.execute("SELECT value FROM app_settings WHERE key = 'active_team'").fetchone() | |
| team_id = row["value"] if row else "chaoyang" | |
| return get_team_config(team_id) | |
| def get_all_teams(): | |
| """获取所有队伍配置""" | |
| conn = get_db() | |
| return [dict(r) for r in conn.execute("SELECT * FROM team_configs ORDER BY id").fetchall()] | |
| def get_default_issued_team_id(): | |
| """获取当前发行环境默认队伍""" | |
| return os.environ.get('ISSUED_TEAM_ID', 'chaoyang').strip() or 'chaoyang' | |
| def set_active_team(team_id): | |
| """切换活跃队伍并重新计算标记""" | |
| conn = get_db() | |
| conn.execute("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('active_team', ?)", (team_id,)) | |
| conn.commit() | |
| recalculate_chaoyang_flags(team_id) | |
| return get_active_team() | |
| # ========== 运动员荣誉 ========== | |
| def get_athlete_honors(athlete_name=None): | |
| """获取运动员荣誉列表""" | |
| conn = get_db() | |
| if athlete_name: | |
| rows = conn.execute( | |
| "SELECT * FROM athlete_honors WHERE athlete_name = ? ORDER BY awarded_date DESC, id DESC", | |
| (athlete_name,) | |
| ).fetchall() | |
| else: | |
| rows = conn.execute("SELECT * FROM athlete_honors ORDER BY athlete_name, awarded_date DESC").fetchall() | |
| return [dict(r) for r in rows] | |
| def add_athlete_honor(athlete_name, title, awarded_date=None, source=None): | |
| """添加运动员荣誉""" | |
| conn = get_db() | |
| try: | |
| conn.execute( | |
| "INSERT INTO athlete_honors (athlete_name, title, awarded_date, source, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", | |
| (athlete_name, title, awarded_date, source, datetime.now().isoformat(), datetime.now().isoformat()) | |
| ) | |
| conn.commit() | |
| return True | |
| except Exception: | |
| return False | |
| finally: | |
| pass | |
| def update_athlete_honor(honor_id, title=None, awarded_date=None, source=None): | |
| """更新运动员荣誉""" | |
| conn = get_db() | |
| updates = [] | |
| params = [] | |
| if title is not None: | |
| updates.append("title = ?") | |
| params.append(title) | |
| if awarded_date is not None: | |
| updates.append("awarded_date = ?") | |
| params.append(awarded_date) | |
| if source is not None: | |
| updates.append("source = ?") | |
| params.append(source) | |
| if not updates: | |
| return | |
| updates.append("updated_at = ?") | |
| params.append(datetime.now().isoformat()) | |
| params.append(honor_id) | |
| conn.execute(f"UPDATE athlete_honors SET {', '.join(updates)} WHERE id = ?", params) | |
| conn.commit() | |
| def delete_athlete_honor(honor_id): | |
| """删除运动员荣誉""" | |
| conn = get_db() | |
| conn.execute("DELETE FROM athlete_honors WHERE id = ?", (honor_id,)) | |
| conn.commit() | |
| # ========== 运动员个人资料 ========== | |
| def get_athlete_profile(athlete_name): | |
| """获取运动员个人资料""" | |
| conn = get_db() | |
| row = conn.execute("SELECT * FROM athlete_profiles WHERE athlete_name = ?", (athlete_name,)).fetchone() | |
| return dict(row) if row else None | |
| def update_athlete_profile(athlete_name, bio, updated_by=None): | |
| """更新运动员个人资料(简介)""" | |
| conn = get_db() | |
| conn.execute(""" | |
| INSERT INTO athlete_profiles (athlete_name, bio, updated_at, updated_by) | |
| VALUES (?, ?, ?, ?) | |
| ON CONFLICT(athlete_name) DO UPDATE SET bio=?, updated_at=?, updated_by=? | |
| """, (athlete_name, bio, datetime.now().isoformat(), updated_by, | |
| bio, datetime.now().isoformat(), updated_by)) | |
| conn.commit() | |
| def get_athlete_custom_results(athlete_name): | |
| """获取运动员自定义成绩""" | |
| conn = get_db() | |
| rows = conn.execute( | |
| "SELECT * FROM athlete_custom_results WHERE athlete_name = ? ORDER BY competition_date DESC, id DESC", | |
| (athlete_name,) | |
| ).fetchall() | |
| return [dict(r) for r in rows] | |
| def add_athlete_custom_result(athlete_name, competition_date, competition_name, result_text, created_by=None, event=None, round_type=None, team=None, time_result=None, place=None, season=None): | |
| """添加运动员自定义成绩""" | |
| conn = get_db() | |
| conn.execute(""" | |
| INSERT INTO athlete_custom_results (athlete_name, competition_date, competition_name, result_text, event, round_type, team, time_result, place, season, created_at, created_by) | |
| VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| """, (athlete_name, competition_date, competition_name, result_text, event, round_type, team, time_result, place, season, datetime.now().isoformat(), created_by)) | |
| conn.commit() | |
| return True | |
| def delete_athlete_custom_result(result_id, athlete_name=None): | |
| """删除运动员自定义成绩""" | |
| conn = get_db() | |
| if athlete_name: | |
| conn.execute("DELETE FROM athlete_custom_results WHERE id = ? AND athlete_name = ?", (result_id, athlete_name)) | |
| else: | |
| conn.execute("DELETE FROM athlete_custom_results WHERE id = ?", (result_id,)) | |
| conn.commit() | |
| def update_athlete_custom_result(result_id, athlete_name, competition_date=None, competition_name=None, | |
| result_text=None, event=None, round_type=None, team=None, | |
| time_result=None, place=None, season=None): | |
| """更新运动员自定义成绩""" | |
| conn = get_db() | |
| updates = [] | |
| params = [] | |
| if competition_date is not None: | |
| updates.append("competition_date = ?") | |
| params.append(competition_date) | |
| if competition_name is not None: | |
| updates.append("competition_name = ?") | |
| params.append(competition_name) | |
| if result_text is not None: | |
| updates.append("result_text = ?") | |
| params.append(result_text) | |
| if event is not None: | |
| updates.append("event = ?") | |
| params.append(event) | |
| if round_type is not None: | |
| updates.append("round_type = ?") | |
| params.append(round_type) | |
| if team is not None: | |
| updates.append("team = ?") | |
| params.append(team) | |
| if time_result is not None: | |
| updates.append("time_result = ?") | |
| params.append(time_result) | |
| if place is not None: | |
| updates.append("place = ?") | |
| params.append(place) | |
| if season is not None: | |
| updates.append("season = ?") | |
| params.append(season) | |
| if not updates: | |
| return False | |
| params.extend([result_id, athlete_name]) | |
| conn.execute(f"UPDATE athlete_custom_results SET {', '.join(updates)} WHERE id = ? AND athlete_name = ?", params) | |
| conn.commit() | |
| return True | |
| # ========== 接力队员关联 ========== | |
| def get_relay_members(relay_team_name=None, athlete_name=None): | |
| """获取接力队员关联""" | |
| conn = get_db() | |
| if relay_team_name: | |
| rows = conn.execute("SELECT * FROM relay_members WHERE relay_team_name = ? ORDER BY id", (relay_team_name,)).fetchall() | |
| elif athlete_name: | |
| rows = conn.execute("SELECT * FROM relay_members WHERE athlete_name = ? ORDER BY id", (athlete_name,)).fetchall() | |
| else: | |
| rows = conn.execute("SELECT * FROM relay_members ORDER BY relay_team_name, id").fetchall() | |
| return [dict(r) for r in rows] | |
| def set_relay_members(relay_team_name, athlete_names, season=None, event=None): | |
| """设置接力队的队员列表(替换模式)""" | |
| conn = get_db() | |
| # 删除旧的 | |
| if season and event: | |
| conn.execute("DELETE FROM relay_members WHERE relay_team_name = ? AND season = ? AND event = ?", | |
| (relay_team_name, season, event)) | |
| elif season: | |
| conn.execute("DELETE FROM relay_members WHERE relay_team_name = ? AND season = ?", | |
| (relay_team_name, season)) | |
| else: | |
| conn.execute("DELETE FROM relay_members WHERE relay_team_name = ?", (relay_team_name,)) | |
| # 插入新的 | |
| now = datetime.now().isoformat() | |
| for name in athlete_names: | |
| conn.execute("""INSERT OR IGNORE INTO relay_members | |
| (relay_team_name, athlete_name, season, event, created_at, updated_at) | |
| VALUES (?, ?, ?, ?, ?, ?)""", | |
| (relay_team_name, name, season, event, now, now)) | |
| conn.commit() | |
| return True | |
| def delete_relay_member(member_id): | |
| """删除单条接力队员关联""" | |
| conn = get_db() | |
| conn.execute("DELETE FROM relay_members WHERE id = ?", (member_id,)) | |
| conn.commit() | |
| return True | |
| def auto_populate_relay_members(): | |
| """根据同一比赛(m.id)同一场接力赛的参赛队员自动推断接力队员关联 | |
| 只匹配同一比赛中、同样category+gender的个人运动员(有非接力项目成绩), | |
| 并且要求该运动员在同比赛中有非接力成绩记录(证明确实参加了该比赛)。 | |
| 注意:此方法为估算,可能不准确。建议管理员手动确认。 | |
| """ | |
| conn = get_db() | |
| now = datetime.now().isoformat() | |
| # 先清除旧的自动填充数据(保留手动添加的——有 updated_at 不同的) | |
| conn.execute("DELETE FROM relay_members WHERE created_at = updated_at") | |
| inserted = 0 | |
| # 获取所有朝阳接力队名及其比赛信息 | |
| relay_teams = conn.execute(""" | |
| SELECT DISTINCT r.name as relay_team_name, rg.category, rg.gender, rg.event, | |
| rg.race_date, m.id as match_id, m.season | |
| FROM results r | |
| JOIN race_groups rg ON r.rcbh = rg.rcbh | |
| JOIN matches m ON rg.match_id = m.id | |
| WHERE rg.event LIKE '%接力%' | |
| AND r.is_chaoyang = 1 | |
| """).fetchall() | |
| for rt in relay_teams: | |
| team_name = rt["relay_team_name"] | |
| match_id = rt["match_id"] | |
| event = rt["event"] | |
| season = rt["season"] | |
| # 只查找同一比赛中有非接力项目成绩的运动员 | |
| athletes = conn.execute(""" | |
| SELECT DISTINCT r2.name | |
| FROM results r2 | |
| JOIN race_groups rg2 ON r2.rcbh = rg2.rcbh | |
| WHERE rg2.match_id = ? | |
| AND rg2.category = ? | |
| AND rg2.gender = ? | |
| AND r2.is_chaoyang = 1 | |
| AND length(r2.name) <= 4 | |
| AND r2.name NOT IN ('朝阳区', '乙组朝阳区', '北京市朝阳区', '集体', '混合接力', '混合', '接力队') | |
| AND rg2.event NOT LIKE '%接力%' | |
| ORDER BY r2.name | |
| """, (match_id, rt["category"], rt["gender"])).fetchall() | |
| for a in athletes: | |
| try: | |
| conn.execute("""INSERT OR IGNORE INTO relay_members | |
| (relay_team_name, athlete_name, season, event, created_at, updated_at) | |
| VALUES (?, ?, ?, ?, ?, ?)""", | |
| (team_name, a["name"], season, event, now, now)) | |
| inserted += 1 | |
| except Exception: | |
| pass | |
| conn.commit() | |
| return inserted | |
| def get_athlete_relay_results(athlete_name): | |
| """获取运动员关联的接力成绩(通过 relay_members 表)""" | |
| conn = get_db() | |
| rows = conn.execute(""" | |
| SELECT r.name as relay_team_name, r.team, r.place, r.lane, r.helmet, r.time_result, r.qual, r.finished, | |
| rg.event, rg.category, rg.gender, rg.round_type, rg.race_date, rg.race_time, | |
| m.title as match_title, m.season, m.match_type, m.round_info, m.date_range, m.venue, m.status, | |
| 'relay' as result_type | |
| 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 rm.athlete_name = ? | |
| AND (rm.season IS NULL OR rm.season = m.season) | |
| AND (rm.event IS NULL OR rm.event = rg.event) | |
| ORDER BY m.season DESC, rg.race_date DESC, rg.race_time DESC, rg.event, rg.round_type | |
| """, (athlete_name,)).fetchall() | |
| return [dict(r) for r in rows] | |
| # ========== 每周训练计划 ========== | |
| DAY_NAMES = {1: '周一', 2: '周二', 3: '周三', 4: '周四', 5: '周五', 6: '周六', 7: '周日'} | |
| def get_weekly_schedule(): | |
| """获取每周训练计划""" | |
| conn = get_db() | |
| rows = conn.execute(""" | |
| SELECT ws.id, ws.day_of_week, ws.training_type_id, tt.name as type_name, tt.is_class | |
| FROM weekly_training_schedule ws | |
| JOIN training_types tt ON ws.training_type_id = tt.id | |
| ORDER BY ws.day_of_week, tt.is_class DESC, tt.name | |
| """).fetchall() | |
| result = [] | |
| for r in rows: | |
| d = dict(r) | |
| d['day_name'] = DAY_NAMES.get(r['day_of_week'], '') | |
| result.append(d) | |
| return result | |
| def set_weekly_schedule(day_of_week, training_type_id): | |
| """设置某天的训练类型""" | |
| if day_of_week < 1 or day_of_week > 7: | |
| return False | |
| conn = get_db() | |
| try: | |
| conn.execute(""" | |
| INSERT INTO weekly_training_schedule (day_of_week, training_type_id) | |
| VALUES (?, ?) | |
| ON CONFLICT(day_of_week, training_type_id) DO UPDATE SET training_type_id = excluded.training_type_id | |
| """, (day_of_week, training_type_id)) | |
| conn.commit() | |
| return True | |
| except Exception: | |
| return False | |
| finally: | |
| pass | |
| def delete_weekly_schedule(schedule_id): | |
| """删除周训练计划条目""" | |
| conn = get_db() | |
| conn.execute("DELETE FROM weekly_training_schedule WHERE id = ?", (schedule_id,)) | |
| conn.commit() | |
| def get_schedule_for_date(date_str): | |
| """根据日期获取当天的训练类型(根据星期几)""" | |
| if not date_str: | |
| return [] | |
| from datetime import datetime as dt | |
| try: | |
| d = dt.strptime(date_str, '%Y-%m-%d') | |
| day_of_week = d.isoweekday() # 1=Monday, 7=Sunday | |
| except ValueError: | |
| return [] | |
| conn = get_db() | |
| rows = conn.execute(""" | |
| SELECT ws.id, ws.day_of_week, ws.training_type_id, tt.name as type_name, tt.is_class | |
| FROM weekly_training_schedule ws | |
| JOIN training_types tt ON ws.training_type_id = tt.id | |
| WHERE ws.day_of_week = ? | |
| ORDER BY tt.is_class DESC, tt.name | |
| """, (day_of_week,)).fetchall() | |
| result = [] | |
| for r in rows: | |
| d = dict(r) | |
| d['day_name'] = DAY_NAMES.get(r['day_of_week'], '') | |
| result.append(d) | |
| return result | |
| def add_training_records_batch(records): | |
| """批量添加训练记录 | |
| records: list of {athlete_name, training_type_id, training_date, duration_minutes, notes} | |
| """ | |
| conn = get_db() | |
| now = datetime.now().isoformat() | |
| for r in records: | |
| conn.execute(""" | |
| INSERT INTO training_records (athlete_name, training_type_id, training_date, duration_minutes, notes, created_at) | |
| VALUES (?, ?, ?, ?, ?, ?) | |
| """, (r['athlete_name'], r['training_type_id'], r['training_date'], | |
| r.get('duration_minutes'), r.get('notes', ''), now)) | |
| conn.commit() | |
| return len(records) | |
| def update_training_record(record_id, training_type_id=None, training_date=None, duration_minutes=None, notes=None): | |
| """直接更新训练记录(管理员使用)""" | |
| conn = get_db() | |
| sets = [] | |
| params = [] | |
| if training_type_id is not None: | |
| sets.append("training_type_id = ?") | |
| params.append(training_type_id) | |
| if training_date is not None: | |
| sets.append("training_date = ?") | |
| params.append(training_date) | |
| if duration_minutes is not None: | |
| sets.append("duration_minutes = ?") | |
| params.append(duration_minutes) | |
| if notes is not None: | |
| sets.append("notes = ?") | |
| params.append(notes) | |
| if not sets: | |
| return False | |
| params.append(record_id) | |
| conn.execute(f"UPDATE training_records SET {', '.join(sets)} WHERE id = ?", params) | |
| conn.commit() | |
| return True | |
| def submit_training_change(record_id, athlete_name, changes, submitted_by): | |
| """提交训练记录修改申请(普通用户使用,需审批)""" | |
| import json | |
| conn = get_db() | |
| now = datetime.now().isoformat() | |
| conn.execute(""" | |
| INSERT INTO training_pending_changes (record_id, athlete_name, changes, submitted_by, submitted_at, status) | |
| VALUES (?, ?, ?, ?, ?, 'pending') | |
| """, (record_id, athlete_name, json.dumps(changes, ensure_ascii=False), submitted_by, now)) | |
| conn.commit() | |
| return True | |
| def get_pending_changes(): | |
| """获取所有待审批的训练记录修改""" | |
| conn = get_db() | |
| rows = conn.execute(""" | |
| SELECT pc.*, u.username as submitted_by_name | |
| FROM training_pending_changes pc | |
| LEFT JOIN users u ON pc.submitted_by = u.id | |
| WHERE pc.status = 'pending' | |
| ORDER BY pc.submitted_at ASC | |
| """).fetchall() | |
| return [dict(r) for r in rows] | |
| def approve_pending_change(change_id, reviewer_id): | |
| """批准待审批修改,将变更应用到训练记录""" | |
| import json | |
| conn = get_db() | |
| row = conn.execute("SELECT * FROM training_pending_changes WHERE id = ? AND status = 'pending'", (change_id,)).fetchone() | |
| if not row: | |
| return False | |
| changes = json.loads(row['changes']) | |
| record_id = row['record_id'] | |
| # Apply changes to training_records | |
| sets = [] | |
| params = [] | |
| for k, v in changes.items(): | |
| if k in ('training_type_id', 'training_date', 'duration_minutes', 'notes'): | |
| sets.append(f"{k} = ?") | |
| params.append(v) | |
| if sets: | |
| params.append(record_id) | |
| conn.execute(f"UPDATE training_records SET {', '.join(sets)} WHERE id = ?", params) | |
| # Mark as approved | |
| now = datetime.now().isoformat() | |
| conn.execute("UPDATE training_pending_changes SET status = 'approved', reviewed_by = ?, reviewed_at = ? WHERE id = ?", | |
| (reviewer_id, now, change_id)) | |
| conn.commit() | |
| return True | |
| def reject_pending_change(change_id, reviewer_id): | |
| """拒绝待审批修改""" | |
| conn = get_db() | |
| now = datetime.now().isoformat() | |
| conn.execute("UPDATE training_pending_changes SET status = 'rejected', reviewed_by = ?, reviewed_at = ? WHERE id = ?", | |
| (reviewer_id, now, change_id)) | |
| conn.commit() | |
| return True | |
| # ========== 个性化费用配置 ========== | |
| def get_athlete_fee_config(athlete_name=None, training_type_id=None): | |
| """获取队员个性化费用配置""" | |
| conn = get_db() | |
| query = """ | |
| SELECT afc.athlete_name, afc.training_type_id, afc.price_per_session, afc.start_date, | |
| afc.updated_at, tt.name as type_name, tt.is_class | |
| FROM athlete_fee_config afc | |
| JOIN training_types tt ON afc.training_type_id = tt.id | |
| WHERE 1=1 | |
| """ | |
| params = [] | |
| if athlete_name: | |
| query += " AND afc.athlete_name = ?" | |
| params.append(athlete_name) | |
| if training_type_id: | |
| query += " AND afc.training_type_id = ?" | |
| params.append(training_type_id) | |
| query += " ORDER BY afc.athlete_name, tt.is_class DESC, tt.name" | |
| rows = conn.execute(query, params).fetchall() | |
| return [dict(r) for r in rows] | |
| def set_athlete_fee_config(athlete_name, training_type_id, price_per_session, start_date=None): | |
| """设置队员某训练类型的个性化单价""" | |
| conn = get_db() | |
| conn.execute(""" | |
| INSERT INTO athlete_fee_config (athlete_name, training_type_id, price_per_session, start_date, updated_at) | |
| VALUES (?, ?, ?, ?, ?) | |
| ON CONFLICT(athlete_name, training_type_id) DO UPDATE SET | |
| price_per_session=?, start_date=COALESCE(?, start_date), updated_at=? | |
| """, (athlete_name, training_type_id, price_per_session, start_date, datetime.now().isoformat(), | |
| price_per_session, start_date, datetime.now().isoformat())) | |
| conn.commit() | |
| def delete_athlete_fee_config(athlete_name, training_type_id): | |
| """删除队员个性化费用配置(回退到全局配置)""" | |
| conn = get_db() | |
| conn.execute("DELETE FROM athlete_fee_config WHERE athlete_name = ? AND training_type_id = ?", | |
| (athlete_name, training_type_id)) | |
| conn.commit() | |
| # ========== 预付费管理 ========== | |
| def get_prepaid_balances(athlete_name=None): | |
| """获取预付费余额""" | |
| conn = get_db() | |
| if athlete_name: | |
| rows = conn.execute(""" | |
| SELECT pb.athlete_name, pb.training_type_id, pb.balance, pb.start_date, pb.updated_at, | |
| tt.name as type_name, tt.is_class | |
| FROM prepaid_balance pb | |
| JOIN training_types tt ON pb.training_type_id = tt.id | |
| WHERE pb.athlete_name = ? | |
| ORDER BY tt.is_class DESC, tt.name | |
| """, (athlete_name,)).fetchall() | |
| else: | |
| rows = conn.execute(""" | |
| SELECT pb.athlete_name, pb.training_type_id, pb.balance, pb.start_date, pb.updated_at, | |
| tt.name as type_name, tt.is_class | |
| FROM prepaid_balance pb | |
| JOIN training_types tt ON pb.training_type_id = tt.id | |
| ORDER BY pb.athlete_name, tt.is_class DESC, tt.name | |
| """).fetchall() | |
| return [dict(r) for r in rows] | |
| def set_prepaid_balance(athlete_name, training_type_id, balance, start_date=None): | |
| """设置预付费余额(充值或调整) | |
| 如果已有记录,增加余额;如果不存在则创建 | |
| start_date: 费用开始扣的起始时间 | |
| """ | |
| conn = get_db() | |
| now = datetime.now().isoformat() | |
| existing = conn.execute( | |
| "SELECT balance, start_date FROM prepaid_balance WHERE athlete_name = ? AND training_type_id = ?", | |
| (athlete_name, training_type_id) | |
| ).fetchone() | |
| if existing: | |
| new_balance = existing["balance"] + balance | |
| new_start = start_date or existing["start_date"] | |
| conn.execute(""" | |
| UPDATE prepaid_balance SET balance = ?, start_date = ?, updated_at = ? | |
| WHERE athlete_name = ? AND training_type_id = ? | |
| """, (new_balance, new_start, now, athlete_name, training_type_id)) | |
| else: | |
| conn.execute(""" | |
| INSERT INTO prepaid_balance (athlete_name, training_type_id, balance, start_date, updated_at) | |
| VALUES (?, ?, ?, ?, ?) | |
| """, (athlete_name, training_type_id, balance, start_date, now)) | |
| # 记录充值交易 | |
| conn.execute(""" | |
| INSERT INTO prepaid_transactions (athlete_name, training_type_id, amount, transaction_type, description, created_at) | |
| VALUES (?, ?, ?, 'recharge', ?, ?) | |
| """, (athlete_name, training_type_id, balance, f'充值{balance}元', now)) | |
| conn.commit() | |
| def delete_prepaid_balance(athlete_name, training_type_id): | |
| """删除预付费余额""" | |
| conn = get_db() | |
| conn.execute("DELETE FROM prepaid_balance WHERE athlete_name = ? AND training_type_id = ?", | |
| (athlete_name, training_type_id)) | |
| conn.commit() | |
| def get_prepaid_transactions(athlete_name=None, limit=100): | |
| """获取预付费交易记录""" | |
| conn = get_db() | |
| if athlete_name: | |
| rows = conn.execute(""" | |
| SELECT pt.*, tt.name as type_name | |
| FROM prepaid_transactions pt | |
| JOIN training_types tt ON pt.training_type_id = tt.id | |
| WHERE pt.athlete_name = ? | |
| ORDER BY pt.created_at DESC | |
| LIMIT ? | |
| """, (athlete_name, limit)).fetchall() | |
| else: | |
| rows = conn.execute(""" | |
| SELECT pt.*, tt.name as type_name | |
| FROM prepaid_transactions pt | |
| JOIN training_types tt ON pt.training_type_id = tt.id | |
| ORDER BY pt.created_at DESC | |
| LIMIT ? | |
| """, (limit,)).fetchall() | |
| return [dict(r) for r in rows] | |
| # ========== 费用结算管理 ========== | |
| def get_fee_settlements(athlete_name=None, training_type_id=None): | |
| """获取费用结算记录""" | |
| conn = get_db() | |
| query = """ | |
| SELECT fs.*, tt.name as type_name, tt.is_class | |
| FROM fee_settlements fs | |
| JOIN training_types tt ON fs.training_type_id = tt.id | |
| WHERE 1=1 | |
| """ | |
| params = [] | |
| if athlete_name: | |
| query += " AND fs.athlete_name = ?" | |
| params.append(athlete_name) | |
| if training_type_id: | |
| query += " AND fs.training_type_id = ?" | |
| params.append(training_type_id) | |
| query += " ORDER BY fs.created_at DESC" | |
| rows = conn.execute(query, params).fetchall() | |
| return [dict(r) for r in rows] | |
| def add_fee_settlement(athlete_name, training_type_id, start_date, end_date, amount, note=None, settled_by=None): | |
| """添加费用结算记录(标记某时间段某类型的费用已结清)""" | |
| conn = get_db() | |
| conn.execute(""" | |
| INSERT INTO fee_settlements (athlete_name, training_type_id, start_date, end_date, amount, note, settled_by, created_at) | |
| VALUES (?, ?, ?, ?, ?, ?, ?, ?) | |
| """, (athlete_name, training_type_id, start_date, end_date, amount, note, settled_by, datetime.now().isoformat())) | |
| conn.commit() | |
| def delete_fee_settlement(settlement_id): | |
| """删除费用结算记录""" | |
| conn = get_db() | |
| conn.execute("DELETE FROM fee_settlements WHERE id = ?", (settlement_id,)) | |
| conn.commit() | |
| # ========== 家长权限查询 ========== | |
| def get_accessible_athletes(user_id, role): | |
| """获取用户可查看的队员列表 | |
| - superadmin/admin: 可查看所有队员 | |
| - 普通用户: 只能查看自己关联的队员 | |
| """ | |
| if role in ('admin', 'superadmin'): | |
| # 返回所有活跃队员 | |
| names, _ = get_athlete_names() | |
| return names | |
| # 普通用户:通过athlete_family表查找关联的队员 | |
| conn = get_db() | |
| rows = conn.execute(""" | |
| SELECT athlete_name FROM athlete_family | |
| WHERE user_id = ? AND approved = 1 | |
| """, (user_id,)).fetchall() | |
| # 也检查users表中的athlete_name | |
| user = conn.execute("SELECT athlete_name FROM users WHERE id = ?", (user_id,)).fetchone() | |
| result = [r["athlete_name"] for r in rows] | |
| if user and user["athlete_name"] and user["athlete_name"] not in result: | |
| result.append(user["athlete_name"]) | |
| return result | |
| # ========== 昵称映射 ========== | |
| def get_nickname_map(): | |
| """获取昵称到真名的映射字典 {nickname: real_name}""" | |
| conn = get_db() | |
| rows = conn.execute("SELECT nickname, real_name FROM athlete_nicknames").fetchall() | |
| return {r["nickname"]: r["real_name"] for r in rows} | |
| def get_all_nicknames(): | |
| """获取所有昵称映射""" | |
| conn = get_db() | |
| rows = conn.execute("SELECT * FROM athlete_nicknames ORDER BY real_name, nickname").fetchall() | |
| return [dict(r) for r in rows] | |
| def add_nickname(nickname, real_name): | |
| """添加昵称映射""" | |
| conn = get_db() | |
| try: | |
| conn.execute("INSERT INTO athlete_nicknames (nickname, real_name, created_at) VALUES (?, ?, ?)", | |
| (nickname, real_name, datetime.now().isoformat())) | |
| conn.commit() | |
| return True | |
| except Exception: | |
| return False | |
| finally: | |
| pass | |
| def delete_nickname(nickname_id): | |
| """删除昵称映射""" | |
| conn = get_db() | |
| conn.execute("DELETE FROM athlete_nicknames WHERE id = ?", (nickname_id,)) | |
| conn.commit() | |
| def resolve_nickname(name): | |
| """将昵称解析为真名,如果不是昵称则返回原名""" | |
| nickname_map = get_nickname_map() | |
| return nickname_map.get(name, name) | |
| # ====== 小程序微信登录相关 ====== | |
| def get_wx_binding_by_openid(openid): | |
| """查询 openid 是否已绑定""" | |
| conn = get_db() | |
| row = conn.execute( | |
| "SELECT * FROM wx_bindings WHERE openid = ?", (openid,) | |
| ).fetchone() | |
| return dict(row) if row else None | |
| def create_wx_binding(openid, athlete_name, relationship="本人", user_id=None, approved=0): | |
| """创建微信绑定""" | |
| conn = get_db() | |
| conn.execute( | |
| "INSERT OR REPLACE INTO wx_bindings (openid, athlete_name, relationship, approved, created_at, user_id) VALUES (?, ?, ?, ?, ?, ?)", | |
| (openid, athlete_name, relationship, approved, datetime.now().isoformat(), user_id) | |
| ) | |
| conn.commit() | |
| def approve_wx_binding(binding_id): | |
| """管理员批准微信绑定""" | |
| conn = get_db() | |
| conn.execute("UPDATE wx_bindings SET approved = 1 WHERE id = ?", (binding_id,)) | |
| conn.commit() | |
| def get_pending_wx_bindings(): | |
| """获取待审批的微信绑定列表""" | |
| conn = get_db() | |
| rows = conn.execute( | |
| "SELECT * FROM wx_bindings WHERE approved = 0 ORDER BY created_at DESC" | |
| ).fetchall() | |
| return [dict(r) for r in rows] | |
| def create_user_token(user_id): | |
| """创建用户 Token,返回 token 字符串""" | |
| import secrets | |
| token = secrets.token_hex(32) | |
| conn = get_db() | |
| # 30天过期 | |
| expires = (datetime.now() + timedelta(days=30)).isoformat() | |
| conn.execute( | |
| "INSERT INTO user_tokens (user_id, token, expires_at, created_at) VALUES (?, ?, ?, ?)", | |
| (user_id, token, expires, datetime.now().isoformat()) | |
| ) | |
| conn.commit() | |
| return token | |
| def get_user_by_token(token): | |
| """通过 token 获取用户信息""" | |
| conn = get_db() | |
| row = conn.execute( | |
| "SELECT u.* FROM users u JOIN user_tokens t ON u.id = t.user_id WHERE t.token = ? AND t.expires_at > ?", | |
| (token, datetime.now().isoformat()) | |
| ).fetchone() | |
| return dict(row) if row else None | |
| def get_or_create_wx_user(athlete_name, openid): | |
| """为微信登录的运动员创建或查找用户账号""" | |
| conn = get_db() | |
| # 先看是否已绑定并通过审核 | |
| binding = get_wx_binding_by_openid(openid) | |
| if not binding or not binding['approved']: | |
| return None | |
| # 如果绑定时指定了 user_id,直接返回该用户 | |
| if binding.get('user_id'): | |
| user = conn.execute( | |
| "SELECT * FROM users WHERE id = ?", (binding['user_id'],) | |
| ).fetchone() | |
| if user: | |
| return dict(user) | |
| # 查找该运动员对应的用户 | |
| user = conn.execute( | |
| "SELECT * FROM users WHERE athlete_name = ? AND role = 'user'", | |
| (athlete_name,) | |
| ).fetchone() | |
| if not user: | |
| # 自动创建用户(无密码,仅微信登录) | |
| username = f"wx_{athlete_name}" | |
| existing = conn.execute("SELECT id FROM users WHERE username = ?", (username,)).fetchone() | |
| if existing: | |
| username = f"wx_{athlete_name}_{openid[-6:]}" | |
| conn.execute( | |
| "INSERT INTO users (username, password_hash, role, athlete_name, relationship, approved, created_at) VALUES (?, '', 'user', ?, '本人', 1, ?)", | |
| (username, athlete_name, datetime.now().isoformat()) | |
| ) | |
| conn.commit() | |
| user = conn.execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone() | |
| return dict(user) if user else None | |