继续提交

This commit is contained in:
2025-12-13 21:33:26 +08:00
parent 7a731d44e3
commit fa77e0a65f
2215 changed files with 392858 additions and 2 deletions

View File

@@ -0,0 +1,35 @@
# Mengya Drift Bottle Backend
Flask API that powers the React frontend located in `../mengyadriftbottle-frontend`.
## Features
- JSON-first `/api` endpoints for throwing, picking up, and reacting to bottles
- Rate limiting per IP (5 seconds) for throw/pickup actions
- File-backed persistence with automatic schema upgrades
- Lightweight token portal via `/admin?token=...` for quick moderation
- Full legacy dashboard (`/admin/login`) preserved for session-based moderation
- CORS enabled for local development via Vite
## Quick Start
```bash
cd mengyadriftbottle-backend
python -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txt
python app.py
```
The server listens on `http://localhost:5002` by default and exposes the API under `http://localhost:5002/api`.
## Environment Variables
| Name | Description | Default |
| ---- | ----------- | ------- |
| `DRIFT_BOTTLE_SECRET` | Secret key for Flask sessions | Random value generated at runtime |
| `DRIFT_BOTTLE_ADMIN_TOKEN` | Token required for `/admin?token=...` | `shumengya520` |
## File Storage
Bottle data, config, mottos, and filter words continue to use the JSON files located at the repository root (`../bottles.json`, etc.). The backend automatically creates them if missing.

View File

@@ -0,0 +1,572 @@
"""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/<bottle_id>")
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/<bottle_id>")
@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("/<path:path>")
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)

View File

@@ -0,0 +1,14 @@
[
{
"id": "2025-11-16 19:00:54_5309",
"name": "树萌芽",
"message": "Hello World",
"timestamp": "2025-11-16 19:00:54",
"ip_address": "127.0.0.1",
"gender": "男",
"qq_number": "3205788256",
"qq_avatar_url": "http://q1.qlogo.cn/g?b=qq&nk=3205788256&s=100",
"likes": 1,
"dislikes": 1
}
]

View File

@@ -0,0 +1,6 @@
{
"name_limit": 7,
"message_limit": 150,
"admin_username": "shumengya",
"admin_password": "06dc4b37c16a43fa94a3191e3be039ab6b05dbecc1c55e7860cf9911c72e71f8"
}

View File

@@ -0,0 +1,19 @@
[
"你TM",
"唐伟",
"糖伟",
"糖萎",
"月抛",
"约炮",
"cnm",
"傻逼",
"狗日的",
"妈卖批",
"nmsl",
"杂种",
"操你妈",
"草你妈",
"日你妈",
"你他妈",
"超你妈"
]

View File

@@ -0,0 +1,8 @@
[
"良言一句三冬暖,恶语伤人六月寒",
"己所不欲,勿施于人",
"赠人玫瑰,手有余香",
"海内存知己,天涯若比邻",
"一期一会,珍惜每一次相遇",
"星河滚烫,你是人间理想"
]

View File

@@ -0,0 +1,2 @@
Flask==3.0.3
flask-cors==4.0.0