"""Flask backend for the Mengya Drift Bottle application. This module exposes a JSON-first API that is consumed by the React frontend located in ``mengyadriftbottle-frontend``. The historical server-rendered templates are preserved for the admin console, while the public-facing experience now lives entirely in the frontend project. """ from __future__ import annotations import hashlib import json import os import random import re import secrets import time from collections import defaultdict from datetime import datetime from functools import wraps from pathlib import Path from typing import Any, Dict, Iterable from flask import ( Flask, abort, flash, jsonify, redirect, render_template, request, send_from_directory, session, url_for, ) from flask_cors import CORS # --------------------------------------------------------------------------- # Paths & global configuration # --------------------------------------------------------------------------- BACKEND_ROOT = Path(__file__).resolve().parent # Backend directory PROJECT_ROOT = BACKEND_ROOT.parent # Root project directory FRONTEND_ROOT = PROJECT_ROOT / "mengyadriftbottle-frontend" # Frontend directory TEMPLATES_DIR = FRONTEND_ROOT / "templates" # Templates in frontend STATIC_DIR = FRONTEND_ROOT / "static" # Static files in frontend FRONTEND_DIST = Path( os.environ.get("DRIFT_BOTTLE_FRONTEND_DIST") or PROJECT_ROOT / "frontend-dist" ) DATA_DIR = Path(os.environ.get("DRIFT_BOTTLE_DATA_DIR", BACKEND_ROOT)) DATA_DIR.mkdir(parents=True, exist_ok=True) BOTTLES_FILE = DATA_DIR / "bottles.json" FILTER_WORDS_FILE = DATA_DIR / "filter_words.json" MOTTOS_FILE = DATA_DIR / "mottos.json" CONFIG_FILE = DATA_DIR / "config.json" API_PREFIX = "/api" OPERATION_INTERVAL = 5 # seconds app = Flask( __name__, static_folder=str(STATIC_DIR), template_folder=str(TEMPLATES_DIR), ) app.config["JSON_AS_ASCII"] = False app.config["JSON_SORT_KEYS"] = False app.secret_key = os.environ.get("DRIFT_BOTTLE_SECRET") or secrets.token_hex(24) # Only allow cross-origin requests for the API endpoints so the React app # running on Vite's dev server can communicate with Flask during local dev. CORS(app, resources={f"{API_PREFIX}/*": {"origins": "*"}}) last_operation_time: Dict[str, Dict[str, float]] = defaultdict( lambda: {"throw": 0.0, "pickup": 0.0} ) last_picked_bottle: Dict[str, str | None] = defaultdict(lambda: None) DEFAULT_CONFIG = { "name_limit": 7, "message_limit": 100, "admin_username": "shumengya", "admin_password": hashlib.sha256("tyh@19900420".encode()).hexdigest(), } SIMPLE_ADMIN_TOKEN = os.environ.get("DRIFT_BOTTLE_ADMIN_TOKEN", "shumengya520") # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def ensure_file(path: Path, default_content: Any) -> None: path.parent.mkdir(parents=True, exist_ok=True) if path.exists(): return with path.open("w", encoding="utf-8") as fh: json.dump(default_content, fh, indent=4, ensure_ascii=False) def ensure_config_file() -> Dict[str, Any]: ensure_file(CONFIG_FILE, DEFAULT_CONFIG) return read_config() def read_config() -> Dict[str, Any]: if CONFIG_FILE.exists(): try: with CONFIG_FILE.open("r", encoding="utf-8") as fh: config = json.load(fh) except json.JSONDecodeError: config = DEFAULT_CONFIG.copy() else: config = DEFAULT_CONFIG.copy() for key, value in DEFAULT_CONFIG.items(): config.setdefault(key, value) return config def save_config(config: Dict[str, Any]) -> bool: try: with CONFIG_FILE.open("w", encoding="utf-8") as fh: json.dump(config, fh, indent=4, ensure_ascii=False) return True except OSError: return False def ensure_mottos_file() -> None: default_mottos = [ "良言一句三冬暖,恶语伤人六月寒", "己所不欲,勿施于人", "赠人玫瑰,手有余香", "海内存知己,天涯若比邻", "一期一会,珍惜每一次相遇", "星河滚烫,你是人间理想", ] ensure_file(MOTTOS_FILE, default_mottos) def read_mottos() -> Iterable[str]: ensure_mottos_file() try: with MOTTOS_FILE.open("r", encoding="utf-8") as fh: return json.load(fh) except json.JSONDecodeError: return ["良言一句三冬暖,恶语伤人六月寒"] def get_random_motto() -> str: mottos = list(read_mottos()) if not mottos: return "良言一句三冬暖,恶语伤人六月寒" return random.choice(mottos) def ensure_filter_words_file() -> None: ensure_file(FILTER_WORDS_FILE, ["敏感词1", "敏感词2"]) def read_filter_words() -> Iterable[str]: ensure_filter_words_file() try: with FILTER_WORDS_FILE.open("r", encoding="utf-8") as fh: return json.load(fh) except json.JSONDecodeError: return [] def filter_sensitive_words(text: str) -> str: filtered = text for word in read_filter_words(): filtered = re.sub(re.escape(word), "*" * len(word), filtered) return filtered def read_bottles() -> list[Dict[str, Any]]: if not BOTTLES_FILE.exists(): with BOTTLES_FILE.open("w", encoding="utf-8") as fh: json.dump([], fh) return [] try: with BOTTLES_FILE.open("r", encoding="utf-8") as fh: bottles = json.load(fh) except json.JSONDecodeError: return [] for bottle in bottles: bottle.setdefault("likes", 0) bottle.setdefault("dislikes", 0) write_bottles(bottles) return bottles def write_bottles(bottles: list[Dict[str, Any]]) -> None: BOTTLES_FILE.parent.mkdir(parents=True, exist_ok=True) with BOTTLES_FILE.open("w", encoding="utf-8") as fh: json.dump(bottles, fh, indent=4, ensure_ascii=False) def get_client_ip() -> str: forwarded = request.headers.get("X-Forwarded-For") if forwarded: return forwarded.split(",")[0].strip() real_ip = request.headers.get("X-Real-IP") if real_ip: return real_ip return request.remote_addr or "unknown" def check_operation_interval(operation_type: str) -> float: ip = get_client_ip() current_time = time.time() last_time = last_operation_time[ip][operation_type] remaining = OPERATION_INTERVAL - (current_time - last_time) if remaining > 0: return max(0.0, remaining) last_operation_time[ip][operation_type] = current_time return 0.0 def admin_required(func): @wraps(func) def decorated(*args, **kwargs): if not session.get("admin_logged_in"): return redirect(url_for("admin_login")) return func(*args, **kwargs) return decorated def get_request_payload() -> Dict[str, Any]: if request.is_json: payload = request.get_json(silent=True) if isinstance(payload, dict): return payload if request.form: return request.form.to_dict() return {} def normalize_name(value: str | None) -> str | None: if value is None: return None stripped = value.strip() return stripped or None def is_valid_simple_admin_token(token: str | None) -> bool: if not token: return False # compare_digest防止时间侧信道 return secrets.compare_digest(str(token), SIMPLE_ADMIN_TOKEN) def bootstrap_storage() -> None: ensure_filter_words_file() ensure_mottos_file() ensure_config_file() if not BOTTLES_FILE.exists(): write_bottles([]) bootstrap_storage() # ---------------------------------------------------------------------- # # ==============================后端公开API============================== # # ---------------------------------------------------------------------- # @app.get("/") def root() -> Any: return jsonify({ "message": "Mengya Drift Bottle后端API正在运行中...", "api_base": API_PREFIX, }) @app.get(f"{API_PREFIX}/health") def health_check() -> Any: return jsonify({"status": "ok", "timestamp": datetime.utcnow().isoformat()}) @app.get(f"{API_PREFIX}/motto") def random_motto() -> Any: return jsonify({"success": True, "motto": get_random_motto()}) @app.get(f"{API_PREFIX}/stats") def get_stats() -> Any: bottles = read_bottles() return jsonify({"success": True, "stats": {"total_bottles": len(bottles)}}) @app.get(f"{API_PREFIX}/config") def get_config() -> Any: config = read_config() return jsonify({ "success": True, "config": { "name_limit": config.get("name_limit", 7), "message_limit": config.get("message_limit", 100), }, }) @app.post(f"{API_PREFIX}/throw") def throw_bottle() -> Any: wait_time = check_operation_interval("throw") if wait_time > 0: return ( jsonify( { "success": False, "error": f"操作太频繁,请等待 {round(wait_time, 1)} 秒后再试。", "wait_time": round(wait_time, 1), } ), 429, ) data = get_request_payload() name = normalize_name(data.get("name")) message = normalize_name(data.get("message")) gender = data.get("gender") or "保密" qq_number = normalize_name(data.get("qq") or data.get("qq_number")) if not name or not message or not gender: return jsonify({"success": False, "error": "姓名、消息和性别是必需的"}), 400 config = read_config() name_limit = int(config.get("name_limit", 7)) message_limit = int(config.get("message_limit", 100)) if len(name) > name_limit: return jsonify({"success": False, "error": f"名字最多{name_limit}个字符"}), 400 if len(message) > message_limit: return jsonify({"success": False, "error": f"消息内容最多{message_limit}个字符"}), 400 filtered_name = filter_sensitive_words(name) filtered_message = filter_sensitive_words(message) timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") ip_address = get_client_ip() qq_avatar_url = None if qq_number and qq_number.isdigit(): qq_avatar_url = f"http://q1.qlogo.cn/g?b=qq&nk={qq_number}&s=100" new_bottle = { "id": f"{timestamp}_{random.randint(1000, 9999)}", "name": filtered_name, "message": filtered_message, "timestamp": timestamp, "ip_address": ip_address, "gender": gender, "qq_number": qq_number, "qq_avatar_url": qq_avatar_url, "likes": 0, "dislikes": 0, } bottles = read_bottles() bottles.append(new_bottle) write_bottles(bottles) return jsonify({"success": True, "message": "漂流瓶投放成功!"}) @app.get(f"{API_PREFIX}/pickup") def pickup_bottle() -> Any: wait_time = check_operation_interval("pickup") if wait_time > 0: return ( jsonify( { "success": False, "message": f"操作太频繁,请等待 {round(wait_time, 1)} 秒后再试。", "wait_time": round(wait_time, 1), } ), 429, ) bottles = read_bottles() if not bottles: return jsonify({"success": False, "message": "海里没有漂流瓶。"}) ip_address = get_client_ip() last_bottle_id = last_picked_bottle[ip_address] if len(bottles) == 1: selected_bottle = bottles[0] else: available = [b for b in bottles if b["id"] != last_bottle_id] available = available or bottles weights = [max(1, 10 - min(9, b.get("dislikes", 0))) for b in available] selected_bottle = random.choices(available, weights=weights, k=1)[0] last_picked_bottle[ip_address] = selected_bottle["id"] return jsonify({"success": True, "bottle": selected_bottle}) @app.post(f"{API_PREFIX}/react") def react_to_bottle() -> Any: data = request.get_json(silent=True) or {} bottle_id = data.get("bottle_id") reaction = data.get("reaction") if not bottle_id or reaction not in {"like", "dislike"}: return jsonify({"success": False, "error": "参数错误"}), 400 bottles = read_bottles() for bottle in bottles: if bottle["id"] == bottle_id: key = "likes" if reaction == "like" else "dislikes" bottle[key] = bottle.get(key, 0) + 1 write_bottles(bottles) return jsonify({"success": True, "message": "反馈已记录"}) return jsonify({"success": False, "error": "未找到漂流瓶"}), 404 # --------------------------------------------------------------------------- # 管理员相关操作API # --------------------------------------------------------------------------- def summarize_bottles(bottles: list[Dict[str, Any]]) -> Dict[str, Any]: return { "total": len(bottles), "likes": sum(int(b.get("likes", 0) or 0) for b in bottles), "dislikes": sum(int(b.get("dislikes", 0) or 0) for b in bottles), } @app.get("/admin") def admin_simple_portal(): token = request.args.get("token", "") status_key = request.args.get("status") status_messages = { "deleted": "漂流瓶已删除", "missing": "未找到指定漂流瓶,可能已经被删除", "unauthorized": "无权执行该操作,请检查token", } feedback = status_messages.get(status_key) authorized = is_valid_simple_admin_token(token) bottles_raw = read_bottles() if authorized else [] bottles = sorted( bottles_raw, key=lambda item: item.get("timestamp", ""), reverse=True, ) if authorized else [] stats = summarize_bottles(bottles) if authorized else None return render_template( "admin_simple.html", authorized=authorized, token=token, stats=stats, bottles=bottles, feedback=feedback, sample_token=SIMPLE_ADMIN_TOKEN if os.environ.get("DRIFT_BOTTLE_ADMIN_TOKEN") is None else "***", ) @app.post("/admin/simple/delete/") def admin_simple_delete(bottle_id: str): token = request.form.get("token") or request.args.get("token") if not is_valid_simple_admin_token(token): return redirect(url_for("admin_simple_portal", status="unauthorized")) bottles = read_bottles() new_bottles = [b for b in bottles if b["id"] != bottle_id] status = "missing" if len(new_bottles) != len(bottles): write_bottles(new_bottles) status = "deleted" return redirect(url_for("admin_simple_portal", token=token, status=status)) @app.route("/admin/login", methods=["GET", "POST"]) def admin_login(): if request.method == "POST": username = request.form.get("username", "") password = request.form.get("password", "") config = read_config() if ( username == config.get("admin_username") and hashlib.sha256(password.encode()).hexdigest() == config.get("admin_password") ): session["admin_logged_in"] = True return redirect(url_for("admin_dashboard")) flash("用户名或密码错误", "error") return render_template("admin_login.html") @app.get("/admin/logout") def admin_logout(): session.pop("admin_logged_in", None) return redirect(url_for("admin_login")) @app.get("/admin/dashboard") @admin_required def admin_dashboard(): bottles = read_bottles() return render_template("admin_dashboard.html", bottles=bottles) @app.post("/admin/delete_bottle/") @admin_required def delete_bottle(bottle_id: str): bottles = [b for b in read_bottles() if b["id"] != bottle_id] write_bottles(bottles) flash("漂流瓶已成功删除", "success") return redirect(url_for("admin_dashboard")) @app.route("/admin/settings", methods=["GET", "POST"]) @admin_required def admin_settings(): config = read_config() if request.method == "POST": try: name_limit = int(request.form.get("name_limit", 7)) message_limit = int(request.form.get("message_limit", 100)) if name_limit < 1 or message_limit < 1: raise ValueError config["name_limit"] = name_limit config["message_limit"] = message_limit if save_config(config): flash("设置已成功保存", "success") else: flash("保存设置时出错", "error") except ValueError: flash("请输入有效的数值", "error") return render_template("admin_settings.html", config=config) # ---------------------------------------------------------------------- # # ==============================后端公开API============================== # # ---------------------------------------------------------------------- # @app.route("/", defaults={"path": ""}) @app.route("/") def serve_frontend_app(path: str): """Serve the compiled React frontend (single-page app).""" if path.startswith("api/"): abort(404) if not FRONTEND_DIST.exists(): return ("Frontend build not found. Please run npm run build first.", 404) if path and (FRONTEND_DIST / path).is_file(): return send_from_directory(str(FRONTEND_DIST), path) return send_from_directory(str(FRONTEND_DIST), "index.html") if __name__ == "__main__": app.run(host="0.0.0.0", port=5002, debug=True)