# -*- coding: utf-8 -*- """ 管理后台 API(/api/admin/*) ============================ 功能: 1. 登录认证(Bearer Token,密码存 site_config,默认 admin123) 2. 通用数据 CRUD:teams / players / games / game_player_stats / standings / news / persons / sports / leagues 3. 站点配置管理:开场白、快捷问题、站点名称、页脚等(site_config 表) 安全: - 表名白名单 + 字段名白名单(PRAGMA 校验),防 SQL 注入 - 登录失败限速(10 分钟 5 次锁定) - Token 有效期 12 小时 """ import json import logging import secrets import time from functools import wraps from flask import jsonify, request from db import query, query_one, execute, executemany log = logging.getLogger("admin") # ------------------------------------------------------------------ 常量 TABLES = { "teams": "球队", "players": "球员", "games": "比赛", "game_player_stats": "比赛技术统计", "standings": "排名", "news": "新闻/百科", "persons": "人物", "sports": "运动项目", "leagues": "联赛", } # 不允许前端直接改的字段(系统维护) RO_FIELDS = {"id", "created_at"} _tokens = {} # token -> expire_ts TOKEN_TTL = 12 * 3600 MAX_FAIL = 5 FAIL_WINDOW = 600 # 10 分钟 _fails = [] # [(ip, ts), ...] DEFAULT_CONFIG = { "site_name": "NBA球迷大全", "site_subtitle": "比赛 · 球员 · 球队 · 资讯 · 人物 · 百科", "welcome_text": "你好,我是**NBA球迷大全**助手!可以问我任何关于比赛、球员、球队、新闻、人物的问题,我会基于数据库给你准确答案~", "welcome_hint": "试试:", "suggestions": json.dumps([ "最近一场比赛结果", "湖人本赛季战绩怎么样", "库里本赛季场均数据", "2026年总决赛谁赢了", "SGA拿了什么荣誉", "NBA工资帽是什么", "介绍一下波波维奇", "今天有什么新闻", "西部排名", "雷霆和凯尔特人总决赛G6数据", ], ensure_ascii=False), "footer_text": "NBA球迷大全 · 数据为模拟演示数据(2025-26 赛季) · LLM: DeepSeek · 向量: Chroma + bge-large-zh", "admin_password": "admin123", "entity_mark_mode": "first", # 实体标记:first=只标记首次出现 / all=全部标记 } SEARCHABLE = { # 每个表可搜索的 TEXT 字段 "teams": ("name", "name_en", "code", "city"), "players": ("name", "name_en", "country", "bio"), "games": ("round_name", "game_time", "venue"), "game_player_stats": (), "standings": ("conference",), "news": ("title", "author", "source", "tags", "content"), "persons": ("name", "name_en", "role", "role_cn", "title"), "sports": ("code", "name"), "leagues": ("code", "name", "country"), } # ------------------------------------------------------------------ 初始化 def init_defaults(): """写入默认配置(已存在的跳过)""" for k, v in DEFAULT_CONFIG.items(): if not query_one("SELECT 1 FROM site_config WHERE key=?", (k,)): execute("INSERT INTO site_config(key, value) VALUES(?,?)", (k, v)) def get_config(): rows = query("SELECT key, value FROM site_config") return {r["key"]: r["value"] for r in rows} def set_config(key, value): execute("""INSERT INTO site_config(key, value, updated_at) VALUES(?,?,datetime('now','localtime')) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=datetime('now','localtime')""", (key, value)) # ------------------------------------------------------------------ 认证 def _check_pw(pw): cfg = get_config() return bool(pw) and pw == cfg.get("admin_password", "") def _too_many_fails(ip): now = time.time() _fails[:] = [(i, t) for i, t in _fails if now - t < FAIL_WINDOW] return sum(1 for i, _ in _fails if i == ip) >= MAX_FAIL def login(): body = request.get_json(force=True, silent=True) or {} pw = body.get("password") or "" ip = request.remote_addr or "?" if _too_many_fails(ip): return jsonify({"error": "失败次数过多,请 10 分钟后再试"}), 429 if not _check_pw(pw): _fails.append((ip, time.time())) return jsonify({"error": "密码错误"}), 401 token = secrets.token_hex(24) _tokens[token] = time.time() + TOKEN_TTL return jsonify({"token": token, "expires_in": TOKEN_TTL, "site_name": get_config().get("site_name", "NBA球迷大全")}) def logout(): auth = request.headers.get("Authorization", "") token = auth.replace("Bearer ", "").strip() _tokens.pop(token, None) return jsonify({"ok": True}) def require_admin(fn): @wraps(fn) def wrapper(*a, **kw): auth = request.headers.get("Authorization", "") token = auth.replace("Bearer ", "").strip() if token not in _tokens or _tokens[token] < time.time(): return jsonify({"error": "未登录或登录已过期"}), 401 return fn(*a, **kw) return wrapper # ------------------------------------------------------------------ 工具 def _check_table(table): if table not in TABLES: raise ValueError(f"未知数据表: {table}") def _columns(table): """返回表字段 [{name, type, pk}](白名单)""" _check_table(table) rows = query(f"PRAGMA table_info({table})") return [{"name": r["name"], "type": r["type"], "pk": bool(r["pk"])} for r in rows] def _valid_field(table, field): return field in {c["name"] for c in _columns(table)} def _clean_payload(table, data): """过滤非法字段、剔除系统字段;TEXT 空字符串 → None 可留空""" cols = {c["name"]: c["type"] for c in _columns(table)} out = {} for k, v in (data or {}).items(): if k in RO_FIELDS or k not in cols: continue if v == "": v = None if cols[k] in ("INTEGER", "REAL") and v is not None: try: v = int(v) if cols[k] == "INTEGER" else float(v) except (TypeError, ValueError): v = None out[k] = v return out def _fmt_row(table, row): """JSON 友好化:TEXT 字段里的 JSON 字符串(如 news.tags)原样返回,前端解析""" return row # ------------------------------------------------------------------ 统计 def stats(): return jsonify({ "tables": {t: {"cn": cn, "count": query_one(f"SELECT COUNT(*) AS c FROM {t}")["c"]} for t, cn in TABLES.items()}, "config": len(query("SELECT 1 FROM site_config")), }) # ------------------------------------------------------------------ 通用 CRUD def list_rows(table): try: _check_table(table) except ValueError as e: return jsonify({"error": str(e)}), 400 page = max(1, int(request.args.get("page", 1))) size = min(100, max(1, int(request.args.get("size", 20)))) q = (request.args.get("q") or "").strip() cols = _columns(table) where, args = "", [] if q: fields = SEARCHABLE.get(table) or () if fields: like = f"%{q}%" where = "WHERE " + " OR ".join(f"{f} LIKE ? ESCAPE '\\'" for f in fields) args = [like] * len(fields) total = query_one(f"SELECT COUNT(*) AS c FROM {table} {where}", args)["c"] rows = query(f"SELECT * FROM {table} {where} ORDER BY id DESC LIMIT ? OFFSET ?", args + [size, (page - 1) * size]) return jsonify({"table": table, "cn": TABLES[table], "columns": cols, "total": total, "page": page, "size": size, "rows": rows}) def get_row(table, rid): try: _check_table(table) except ValueError as e: return jsonify({"error": str(e)}), 400 row = query_one(f"SELECT * FROM {table} WHERE id=?", (rid,)) if not row: return jsonify({"error": "记录不存在"}), 404 return jsonify(row) def create_row(table): try: _check_table(table) except ValueError as e: return jsonify({"error": str(e)}), 400 data = _clean_payload(table, request.get_json(force=True, silent=True) or {}) if not data: return jsonify({"error": "没有可写入的字段"}), 400 cols = list(data.keys()) marks = ",".join("?" for _ in cols) nid = execute(f"INSERT INTO {table}({','.join(cols)}) VALUES({marks})", [data[c] for c in cols]) _after_write(table, nid, data) return jsonify({"ok": True, "id": nid}) def update_row(table, rid): try: _check_table(table) except ValueError as e: return jsonify({"error": str(e)}), 400 if not query_one(f"SELECT 1 FROM {table} WHERE id=?", (rid,)): return jsonify({"error": "记录不存在"}), 404 data = _clean_payload(table, request.get_json(force=True, silent=True) or {}) if not data: return jsonify({"error": "没有可更新的字段"}), 400 sets = ",".join(f"{c}=?" for c in data) execute(f"UPDATE {table} SET {sets} WHERE id=?", [data[c] for c in data] + [rid]) _after_write(table, rid, data) return jsonify({"ok": True, "id": rid}) def delete_row(table, rid): try: _check_table(table) except ValueError as e: return jsonify({"error": str(e)}), 400 if not query_one(f"SELECT 1 FROM {table} WHERE id=?", (rid,)): return jsonify({"error": "记录不存在"}), 404 # 级联清理:删除比赛时清理技术统计;删除球队时清理其球员/新闻归属 if table == "games": execute("DELETE FROM game_player_stats WHERE game_id=?", (rid,)) if table == "teams": execute("UPDATE players SET team_id=NULL WHERE team_id=?", (rid,)) execute("UPDATE news SET team_id=NULL WHERE team_id=?", (rid,)) execute(f"DELETE FROM {table} WHERE id=?", (rid,)) return jsonify({"ok": True}) def _after_write(table, rid, data): """写入后的联动维护:比赛改比分/球队时同步球队得分(可选扩展)""" pass # ------------------------------------------------------------------ 配置 def get_config_api(): cfg = get_config() out = dict(cfg) out["_protected"] = ["admin_password"] return jsonify(out) def update_config(): body = request.get_json(force=True, silent=True) or {} if not isinstance(body, dict): return jsonify({"error": "参数格式错误"}), 400 for k, v in body.items(): if k.startswith("_") or not isinstance(v, str): continue if k == "admin_password" and not v.strip(): continue # 空密码 = 不修改 set_config(k, v) return jsonify({"ok": True, "config": get_config()}) # ------------------------------------------------------------------ 恢复默认快捷问题 def reset_suggestions(): set_config("suggestions", DEFAULT_CONFIG["suggestions"]) return jsonify({"ok": True, "suggestions": json.loads(DEFAULT_CONFIG["suggestions"])})