Files
nba-fan-hub/admin.py
T

298 lines
11 KiB
Python
Raw Normal View History

# -*- coding: utf-8 -*-
"""
管理后台 API/api/admin/*
============================
功能:
1. 登录认证(Bearer Token,密码存 site_config,默认 admin123
2. 通用数据 CRUDteams / 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=全部标记
"suggestion_count": "3", # 对话中底部快捷问题预测个数(默认3)
}
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"])})