- 修复Redis认证 (配置密码) - 启动Python管理后台 (端口9531, 15个功能开关) - 统一版本号 0.2.7 - 更新docker-compose.yml (镜像版本/Redis URL/Admin服务)
207 lines
8.3 KiB
Python
207 lines
8.3 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
CloudSearch 管理后台 v2.2.0
|
||
功能开关一键管理 — 支持本地 SQLite + MySQL 双向同步
|
||
"""
|
||
|
||
import os
|
||
import json
|
||
import sqlite3
|
||
import logging
|
||
from datetime import datetime
|
||
from typing import Dict, Optional
|
||
|
||
from flask import Flask, render_template, request, jsonify
|
||
|
||
# ── 日志 ──────────────────────────────────────────────────
|
||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||
log = logging.getLogger("admin")
|
||
|
||
app = Flask(__name__)
|
||
|
||
# ── 配置 ──────────────────────────────────────────────────
|
||
ADMIN_PORT = int(os.getenv("ADMIN_PORT", "9531"))
|
||
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin123")
|
||
DB_PATH = os.getenv("ADMIN_DB_PATH", "/data/admin_flags.sqlite")
|
||
|
||
# MySQL(主应用 system_configs 表,可选)
|
||
MYSQL_HOST = os.getenv("MYSQL_HOST", "")
|
||
MYSQL_PORT = int(os.getenv("MYSQL_PORT", "3306"))
|
||
MYSQL_USER = os.getenv("MYSQL_USER", "")
|
||
MYSQL_PASS = os.getenv("MYSQL_PASS", "")
|
||
MYSQL_DB = os.getenv("MYSQL_DB", "cloudsearch")
|
||
|
||
# ── 功能开关定义 ──────────────────────────────────────────
|
||
FEATURES: Dict[str, dict] = {
|
||
"feature_quark_pid": {"name": "夸克推广PID", "group": "核心", "default": True},
|
||
"feature_seo": {"name": "SEO / Sitemap", "group": "核心", "default": True},
|
||
"feature_link_monitor": {"name": "失效链接监控", "group": "核心", "default": True},
|
||
"feature_tmdb": {"name": "TMDB影视刮削", "group": "增强", "default": True},
|
||
"feature_telegram_bot": {"name": "Telegram Bot", "group": "增强", "default": False},
|
||
"feature_subscription": {"name": "关键词订阅通知", "group": "增强", "default": False},
|
||
"feature_alist": {"name": "AList打通", "group": "增强", "default": False},
|
||
"feature_transfer_quark": {"name": "夸克转存", "group": "转存", "default": True},
|
||
"feature_transfer_baidu": {"name": "百度转存", "group": "转存", "default": False},
|
||
"feature_transfer_aliyun": {"name": "阿里转存", "group": "转存", "default": False},
|
||
"feature_transfer_uc": {"name": "UC转存", "group": "转存", "default": False},
|
||
"feature_transfer_xunlei": {"name": "迅雷转存", "group": "转存", "default": False},
|
||
"feature_transfer_115": {"name": "115转存", "group": "转存", "default": False},
|
||
"feature_transfer_123": {"name": "123转存", "group": "转存", "default": False},
|
||
"feature_transfer_cloud189":{"name": "天翼转存", "group": "转存", "default": False},
|
||
}
|
||
|
||
# ── SQLite ────────────────────────────────────────────────
|
||
def get_db():
|
||
conn = sqlite3.connect(DB_PATH)
|
||
conn.row_factory = sqlite3.Row
|
||
conn.execute("PRAGMA journal_mode=WAL")
|
||
conn.execute("""
|
||
CREATE TABLE IF NOT EXISTS flags (
|
||
key TEXT PRIMARY KEY,
|
||
value INTEGER NOT NULL DEFAULT 0,
|
||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||
)
|
||
""")
|
||
# 初始化默认值
|
||
for key in FEATURES:
|
||
conn.execute(
|
||
"INSERT OR IGNORE INTO flags(key, value) VALUES(?, ?)",
|
||
(key, int(FEATURES[key]["default"]))
|
||
)
|
||
conn.commit()
|
||
return conn
|
||
|
||
def read_flags_sqlite() -> Dict[str, bool]:
|
||
conn = get_db()
|
||
rows = conn.execute("SELECT key, value, updated_at FROM flags ORDER BY key").fetchall()
|
||
conn.close()
|
||
return {r["key"]: bool(r["value"]) for r in rows}, {r["key"]: r["updated_at"] for r in rows}
|
||
|
||
def write_flag_sqlite(key: str, value: bool):
|
||
conn = get_db()
|
||
conn.execute(
|
||
"INSERT INTO flags(key, value, updated_at) VALUES(?, ?, datetime('now')) "
|
||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=datetime('now')",
|
||
(key, int(value))
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
# ── MySQL 同步 ────────────────────────────────────────────
|
||
def sync_to_mysql(key: str, value: bool):
|
||
"""将开关状态同步到主应用的 system_configs 表"""
|
||
if not MYSQL_HOST:
|
||
return # MySQL 未配置,跳过
|
||
try:
|
||
import pymysql
|
||
conn = pymysql.connect(
|
||
host=MYSQL_HOST, port=MYSQL_PORT,
|
||
user=MYSQL_USER, password=MYSQL_PASS,
|
||
database=MYSQL_DB, charset="utf8mb4",
|
||
connect_timeout=5
|
||
)
|
||
with conn.cursor() as cur:
|
||
cur.execute("""
|
||
INSERT INTO system_configs (config_key, config_value, updated_at)
|
||
VALUES (%s, %s, NOW())
|
||
ON DUPLICATE KEY UPDATE config_value=VALUES(config_value), updated_at=NOW()
|
||
""", (key, "true" if value else "false"))
|
||
conn.commit()
|
||
conn.close()
|
||
log.info(f"MySQL 同步成功: {key} = {value}")
|
||
except Exception as e:
|
||
log.warning(f"MySQL 同步失败 ({key}): {e}")
|
||
|
||
def read_flags_mysql() -> Optional[Dict[str, bool]]:
|
||
if not MYSQL_HOST:
|
||
return None
|
||
try:
|
||
import pymysql
|
||
conn = pymysql.connect(
|
||
host=MYSQL_HOST, port=MYSQL_PORT,
|
||
user=MYSQL_USER, password=MYSQL_PASS,
|
||
database=MYSQL_DB, charset="utf8mb4",
|
||
connect_timeout=5
|
||
)
|
||
with conn.cursor(pymysql.cursors.DictCursor) as cur:
|
||
cur.execute("SELECT config_key, config_value FROM system_configs WHERE config_key LIKE 'feature_%'")
|
||
rows = cur.fetchall()
|
||
conn.close()
|
||
return {r["config_key"]: r["config_value"].lower() == "true" for r in rows}
|
||
except Exception as e:
|
||
log.warning(f"MySQL 读取失败: {e}")
|
||
return None
|
||
|
||
# ── 路由 ──────────────────────────────────────────────────
|
||
@app.route("/")
|
||
def index():
|
||
"""管理后台首页"""
|
||
return render_template("admin.html", features=FEATURES)
|
||
|
||
@app.route("/health")
|
||
def health():
|
||
return jsonify({"status": "ok", "service": "cloudsearch-admin"})
|
||
|
||
@app.route("/api/flags", methods=["GET"])
|
||
def api_list_flags():
|
||
"""列出所有开关"""
|
||
flags_sqlite, updated = read_flags_sqlite()
|
||
flags_mysql = read_flags_mysql()
|
||
|
||
result = {}
|
||
for key, meta in FEATURES.items():
|
||
result[key] = {
|
||
"name": meta["name"],
|
||
"group": meta["group"],
|
||
"value": flags_sqlite.get(key, meta["default"]),
|
||
"mysql_value": flags_mysql.get(key) if flags_mysql else None,
|
||
"synced": (flags_mysql is None) or (flags_sqlite.get(key) == flags_mysql.get(key)),
|
||
"updated_at": updated.get(key, ""),
|
||
}
|
||
return jsonify(result)
|
||
|
||
@app.route("/api/flags/<key>", methods=["PUT"])
|
||
def api_set_flag(key):
|
||
"""设置单个开关"""
|
||
if key not in FEATURES:
|
||
return jsonify({"error": f"未知开关: {key}"}), 404
|
||
|
||
data = request.get_json(force=True)
|
||
value = bool(data.get("value", False))
|
||
|
||
# 写本地 SQLite
|
||
write_flag_sqlite(key, value)
|
||
|
||
# 同步到 MySQL
|
||
sync_to_mysql(key, value)
|
||
|
||
log.info(f"开关切换: {key} = {value}")
|
||
return jsonify({"ok": True, "key": key, "value": value})
|
||
|
||
@app.route("/api/flags/batch", methods=["PUT"])
|
||
def api_batch_set_flags():
|
||
"""批量设置开关"""
|
||
data = request.get_json(force=True)
|
||
if not isinstance(data, dict):
|
||
return jsonify({"error": "请求体需为 {key: value} 字典"}), 400
|
||
|
||
results = {}
|
||
for key, value in data.items():
|
||
if key not in FEATURES:
|
||
results[key] = {"error": "未知"}
|
||
continue
|
||
val = bool(value)
|
||
write_flag_sqlite(key, val)
|
||
sync_to_mysql(key, val)
|
||
results[key] = val
|
||
log.info(f"批量切换: {key} = {val}")
|
||
|
||
return jsonify({"ok": True, "results": results})
|
||
|
||
|
||
if __name__ == "__main__":
|
||
from waitress import serve
|
||
log.info(f"管理后台启动: http://0.0.0.0:{ADMIN_PORT}")
|
||
log.info(f"MySQL 同步: {'已配置' if MYSQL_HOST else '未配置 (仅使用本地 SQLite)'}")
|
||
serve(app, host="0.0.0.0", port=ADMIN_PORT, threads=4)
|