Upload 3 files
Browse files- app.py +1173 -957
- camysql2.pem +25 -0
- requirements.txt +7 -6
app.py
CHANGED
|
@@ -1,286 +1,513 @@
|
|
| 1 |
-
import asyncio
|
| 2 |
-
import atexit
|
| 3 |
-
import hashlib
|
| 4 |
import json
|
| 5 |
import logging
|
| 6 |
import os
|
| 7 |
import secrets
|
| 8 |
-
import shutil
|
| 9 |
import threading
|
| 10 |
import traceback
|
| 11 |
from collections import deque
|
| 12 |
from datetime import datetime
|
| 13 |
from pathlib import Path
|
| 14 |
from typing import Any, Optional
|
|
|
|
| 15 |
|
|
|
|
| 16 |
import uvicorn
|
| 17 |
-
from apscheduler.schedulers.background import BackgroundScheduler
|
| 18 |
-
from apscheduler.triggers.cron import CronTrigger
|
| 19 |
-
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile, status
|
| 20 |
-
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
| 21 |
-
from fastapi.staticfiles import StaticFiles
|
| 22 |
-
from fastapi.templating import Jinja2Templates
|
| 23 |
-
from pydantic import BaseModel, Field
|
| 24 |
-
|
| 25 |
-
from core.tasks import runTasks
|
| 26 |
-
from utils.logger import setup_logger
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
logger = setup_logger(level=logging.DEBUG)
|
| 30 |
-
|
| 31 |
BASE_DIR = Path(__file__).resolve().parent
|
| 32 |
TEMPLATES_DIR = BASE_DIR / "templates"
|
| 33 |
STATIC_DIR = BASE_DIR / "static"
|
| 34 |
ROOT_CONFIG_PATH = BASE_DIR / "config.json"
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
SESSION_COOKIE_NAME = "sparkflow_auth"
|
| 39 |
DEFAULT_TIMEZONE = "Asia/Shanghai"
|
| 40 |
MAX_LOG_LINES = 1200
|
| 41 |
MAX_TEMPLATE_LENGTH = 2000
|
| 42 |
PASSWORD_ITERATIONS = 210000
|
| 43 |
-
|
| 44 |
-
DEFAULT_USER_CONFIG = {
|
| 45 |
-
"multiTask": True,
|
| 46 |
-
"taskCount": 5,
|
| 47 |
-
"proxyAddress": "",
|
| 48 |
-
"messageTemplate": "续火花
|
| 49 |
-
"hitokotoTypes": ["文学", "影视", "诗词", "哲学"],
|
| 50 |
-
"scheduler": {
|
| 51 |
-
"enabled": True,
|
| 52 |
-
"timezone": DEFAULT_TIMEZONE,
|
| 53 |
-
"hour": 9,
|
| 54 |
-
"minute": 0,
|
| 55 |
-
"runOnStartup": False,
|
| 56 |
-
},
|
| 57 |
-
}
|
| 58 |
-
|
| 59 |
AUTH_SESSIONS: dict[str, dict[str, str]] = {}
|
| 60 |
-
|
| 61 |
scheduler_lock = threading.Lock()
|
| 62 |
runtime_map_lock = threading.Lock()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
def run_once(self, trigger: str):
|
| 136 |
-
if not self._run_lock.acquire(blocking=False):
|
| 137 |
-
self.add_log(f"任务已在运行中,忽略触发:{trigger}")
|
| 138 |
-
return False, "已有任务在运行,本次触发已跳过。"
|
| 139 |
-
|
| 140 |
-
self._set_running(True)
|
| 141 |
-
with self._state_lock:
|
| 142 |
-
self.last_trigger = trigger
|
| 143 |
-
self.last_start = datetime.now()
|
| 144 |
-
self.last_end = None
|
| 145 |
-
self.last_error = ""
|
| 146 |
-
self.last_status = "运行中"
|
| 147 |
-
self.add_log(f"任务开始执行,触发方式:{trigger}")
|
| 148 |
-
|
| 149 |
-
ok = True
|
| 150 |
-
message = "任务执行完成。"
|
| 151 |
-
try:
|
| 152 |
-
asyncio.run(_run_user_tasks(self.username))
|
| 153 |
-
with self._state_lock:
|
| 154 |
-
self.last_status = "成功"
|
| 155 |
-
except Exception as exc:
|
| 156 |
-
ok = False
|
| 157 |
-
message = f"任务执行失败:{exc}"
|
| 158 |
-
with self._state_lock:
|
| 159 |
-
self.last_status = "失败"
|
| 160 |
-
self.last_error = repr(exc)
|
| 161 |
-
self.add_log(f"任务失败:{exc}")
|
| 162 |
-
logger.error("Task failed. user=%s trigger=%s error=%s", self.username, trigger, exc)
|
| 163 |
-
logger.debug("Task traceback:\n%s", traceback.format_exc())
|
| 164 |
-
finally:
|
| 165 |
-
end_at = datetime.now()
|
| 166 |
-
with self._state_lock:
|
| 167 |
-
self.last_end = end_at
|
| 168 |
-
duration = (self.last_end - self.last_start).total_seconds()
|
| 169 |
-
self.history.append(
|
| 170 |
-
{
|
| 171 |
-
"trigger": trigger,
|
| 172 |
-
"start": self._format_ts(self.last_start),
|
| 173 |
-
"end": self._format_ts(self.last_end),
|
| 174 |
-
"status": self.last_status,
|
| 175 |
-
"duration": f"{duration:.2f}s",
|
| 176 |
-
"message": self.last_error or "OK",
|
| 177 |
-
}
|
| 178 |
-
)
|
| 179 |
-
current_status = self.last_status
|
| 180 |
-
self.add_log(f"任务结束,状态={current_status},耗时={duration:.2f}s")
|
| 181 |
-
self._set_running(False)
|
| 182 |
-
self._run_lock.release()
|
| 183 |
-
return ok, message
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
runtime_map: dict[str, UserRuntimeState] = {}
|
| 187 |
-
scheduler = None
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
class UserLoginPayload(BaseModel):
|
| 191 |
-
username: str
|
| 192 |
-
password: str
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
class AdminLoginPayload(BaseModel):
|
| 196 |
-
password: str
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
class SchedulePayload(BaseModel):
|
| 200 |
-
time: str
|
| 201 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
|
| 203 |
-
|
| 204 |
-
message: str
|
| 205 |
|
| 206 |
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
|
| 211 |
|
| 212 |
-
|
| 213 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
|
| 215 |
|
| 216 |
-
def
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
|
| 222 |
|
| 223 |
-
def
|
| 224 |
if not path.exists():
|
| 225 |
return default
|
| 226 |
with path.open("r", encoding="utf-8") as f:
|
| 227 |
return json.load(f)
|
| 228 |
|
| 229 |
|
| 230 |
-
def
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
with path.open("w", encoding="utf-8") as f:
|
| 234 |
-
json.dump(payload, f, ensure_ascii=False, indent=2)
|
| 235 |
|
|
|
|
|
|
|
|
|
|
| 236 |
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
else:
|
| 243 |
-
allowed.append("_")
|
| 244 |
-
slug = "".join(allowed).strip("_")
|
| 245 |
-
return slug or "user"
|
| 246 |
|
|
|
|
|
|
|
|
|
|
| 247 |
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
"
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
)
|
| 256 |
-
return {
|
| 257 |
-
"salt": salt.hex(),
|
| 258 |
-
"hash": digest.hex(),
|
| 259 |
-
}
|
| 260 |
|
|
|
|
|
|
|
|
|
|
| 261 |
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
|
| 266 |
|
| 267 |
def _load_users_meta():
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
|
| 278 |
|
| 279 |
-
def
|
| 280 |
-
|
| 281 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
|
| 283 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
def _get_user_meta_or_404(username: str):
|
| 285 |
users_map = _load_users_meta()
|
| 286 |
user = users_map.get(username)
|
|
@@ -289,445 +516,439 @@ def _get_user_meta_or_404(username: str):
|
|
| 289 |
return user
|
| 290 |
|
| 291 |
|
| 292 |
-
def _get_tenant_dir(user_meta: dict[str, Any]):
|
| 293 |
-
tenant_rel = user_meta.get("tenant_dir", "")
|
| 294 |
-
if not tenant_rel:
|
| 295 |
-
raise RuntimeError(f"用户 {user_meta.get('username')} 缺少 tenant_dir")
|
| 296 |
-
return (BASE_DIR / tenant_rel).resolve()
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
def _get_user_config_path(username: str):
|
| 300 |
-
user_meta = _get_user_meta_or_404(username)
|
| 301 |
-
return _get_tenant_dir(user_meta) / "config.json"
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
def _get_user_data_path(username: str):
|
| 305 |
-
user_meta = _get_user_meta_or_404(username)
|
| 306 |
-
return _get_tenant_dir(user_meta) / "usersData.json"
|
| 307 |
-
|
| 308 |
-
|
| 309 |
def _get_default_user_config():
|
| 310 |
if ROOT_CONFIG_PATH.exists():
|
| 311 |
try:
|
| 312 |
-
|
|
|
|
|
|
|
| 313 |
except Exception:
|
| 314 |
logger.warning("Failed to read root config.json. fallback to DEFAULT_USER_CONFIG")
|
| 315 |
-
return
|
| 316 |
|
| 317 |
|
| 318 |
def _load_user_config(username: str):
|
| 319 |
-
|
| 320 |
-
if not
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
|
|
|
|
|
|
| 325 |
|
| 326 |
|
| 327 |
def _save_user_config(username: str, cfg: dict):
|
| 328 |
-
|
| 329 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 330 |
|
| 331 |
|
| 332 |
def _load_user_users_data(username: str):
|
| 333 |
-
|
| 334 |
-
if not
|
| 335 |
-
raise FileNotFoundError(f"用户 {username}
|
| 336 |
-
|
|
|
|
|
|
|
|
|
|
| 337 |
if not isinstance(data, list):
|
| 338 |
raise ValueError("usersData.json 必须是数组")
|
| 339 |
return data
|
| 340 |
|
| 341 |
|
| 342 |
def _save_user_users_data(username: str, users_data: list):
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
def _sanitize_targets(values):
|
| 348 |
-
cleaned = []
|
| 349 |
-
seen = set()
|
| 350 |
-
for value in values or []:
|
| 351 |
-
text = str(value).strip()
|
| 352 |
-
if not text or text in seen:
|
| 353 |
-
continue
|
| 354 |
-
seen.add(text)
|
| 355 |
-
cleaned.append(text)
|
| 356 |
-
return cleaned
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
def _validate_and_normalize_users_data(raw_bytes: bytes):
|
| 360 |
-
try:
|
| 361 |
-
payload = json.loads(raw_bytes.decode("utf-8"))
|
| 362 |
-
except Exception as exc:
|
| 363 |
-
raise ValueError(f"上传文件不是合法 JSON:{exc}")
|
| 364 |
-
|
| 365 |
-
if not isinstance(payload, list) or not payload:
|
| 366 |
-
raise ValueError("usersData.json 必须是非空数组")
|
| 367 |
-
|
| 368 |
-
normalized = []
|
| 369 |
-
for idx, item in enumerate(payload):
|
| 370 |
-
if not isinstance(item, dict):
|
| 371 |
-
raise ValueError(f"第 {idx + 1} 条用户数据格式错误(必须是对象)")
|
| 372 |
-
|
| 373 |
-
unique_id = str(item.get("unique_id", "")).strip()
|
| 374 |
-
username = str(item.get("username", "")).strip()
|
| 375 |
-
cookies = item.get("cookies", [])
|
| 376 |
-
targets = item.get("targets", [])
|
| 377 |
-
|
| 378 |
-
if not unique_id:
|
| 379 |
-
raise ValueError(f"第 {idx + 1} 条缺少 unique_id")
|
| 380 |
-
if not username:
|
| 381 |
-
raise ValueError(f"第 {idx + 1} 条缺少 username")
|
| 382 |
-
if not isinstance(cookies, list) or not cookies:
|
| 383 |
-
raise ValueError(f"第 {idx + 1} 条 cookies 不能为空且必须是数组")
|
| 384 |
-
if not isinstance(targets, list):
|
| 385 |
-
raise ValueError(f"第 {idx + 1} 条 targets 必须是数组")
|
| 386 |
-
|
| 387 |
-
normalized.append(
|
| 388 |
-
{
|
| 389 |
-
"unique_id": unique_id,
|
| 390 |
-
"username": username,
|
| 391 |
-
"cookies": cookies,
|
| 392 |
-
"targets": _sanitize_targets(targets),
|
| 393 |
-
}
|
| 394 |
-
)
|
| 395 |
-
|
| 396 |
-
primary_username = normalized[0]["username"]
|
| 397 |
-
primary_unique_id = normalized[0]["unique_id"]
|
| 398 |
-
return normalized, primary_username, primary_unique_id
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
def _count_targets(users_data: list):
|
| 402 |
-
return sum(len(user.get("targets", [])) for user in users_data)
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
def _get_runtime(username: str):
|
| 406 |
-
with runtime_map_lock:
|
| 407 |
-
runtime = runtime_map.get(username)
|
| 408 |
-
if runtime is None:
|
| 409 |
-
runtime = UserRuntimeState(username=username)
|
| 410 |
-
runtime_map[username] = runtime
|
| 411 |
-
return runtime
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
def _delete_runtime(username: str):
|
| 415 |
-
with runtime_map_lock:
|
| 416 |
-
runtime_map.pop(username, None)
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
def _session_from_request(request: Request):
|
| 420 |
-
token = request.cookies.get(SESSION_COOKIE_NAME)
|
| 421 |
-
if not token:
|
| 422 |
-
return None
|
| 423 |
-
return AUTH_SESSIONS.get(token)
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
def _require_user_session(request: Request):
|
| 427 |
-
session = _session_from_request(request)
|
| 428 |
-
if not session or session.get("role") != "user":
|
| 429 |
-
raise HTTPException(
|
| 430 |
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 431 |
-
detail="未登录或登录已失效",
|
| 432 |
-
)
|
| 433 |
-
return session
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
def _require_admin_session(request: Request):
|
| 437 |
-
session = _session_from_request(request)
|
| 438 |
-
if not session or session.get("role") != "admin":
|
| 439 |
-
raise HTTPException(
|
| 440 |
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 441 |
-
detail="未登录或登录已失效",
|
| 442 |
-
)
|
| 443 |
-
return session
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
def _parse_time_string(value: str):
|
| 447 |
-
parts = value.strip().split(":")
|
| 448 |
-
if len(parts) not in (2, 3):
|
| 449 |
-
raise ValueError("时间格式错误,必须是 HH:MM")
|
| 450 |
-
hour = int(parts[0])
|
| 451 |
-
minute = int(parts[1])
|
| 452 |
-
if hour < 0 or hour > 23 or minute < 0 or minute > 59:
|
| 453 |
-
raise ValueError("时间范围错误,小时 0-23,分钟 0-59")
|
| 454 |
-
return hour, minute
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
def _build_editor_state(username: str):
|
| 458 |
-
cfg = _load_user_config(username)
|
| 459 |
-
users = _load_user_users_data(username)
|
| 460 |
-
return {
|
| 461 |
-
"message_template": str(cfg.get("messageTemplate", "")),
|
| 462 |
-
"users": [
|
| 463 |
-
{
|
| 464 |
-
"unique_id": str(user.get("unique_id", "")),
|
| 465 |
-
"username": str(user.get("username", "未知用户")),
|
| 466 |
-
"targets": _sanitize_targets(user.get("targets", [])),
|
| 467 |
-
}
|
| 468 |
-
for user in users
|
| 469 |
-
],
|
| 470 |
-
}
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
def _scheduler_job_id(username: str):
|
| 474 |
-
return f"daily_task::{username}"
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
def _run_scheduled_once(username: str):
|
| 478 |
-
runtime = _get_runtime(username)
|
| 479 |
-
runtime.run_once("schedule")
|
| 480 |
-
if scheduler:
|
| 481 |
-
job = scheduler.get_job(_scheduler_job_id(username))
|
| 482 |
-
runtime.update_next_run(job.next_run_time if job else None)
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
async def _run_user_tasks(username: str):
|
| 486 |
-
cfg = _load_user_config(username)
|
| 487 |
-
users_data = _load_user_users_data(username)
|
| 488 |
-
await runTasks(config=cfg, userData=users_data)
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
def _schedule_user_job(username: str):
|
| 492 |
-
global scheduler
|
| 493 |
-
|
| 494 |
-
cfg = _load_user_config(username)
|
| 495 |
-
scheduler_cfg = cfg.get("scheduler", {}) if isinstance(cfg, dict) else {}
|
| 496 |
-
enabled = bool(scheduler_cfg.get("enabled", True))
|
| 497 |
-
timezone = str(scheduler_cfg.get("timezone", DEFAULT_TIMEZONE))
|
| 498 |
-
hour = int(scheduler_cfg.get("hour", 9))
|
| 499 |
-
minute = int(scheduler_cfg.get("minute", 0))
|
| 500 |
-
|
| 501 |
-
runtime = _get_runtime(username)
|
| 502 |
-
runtime.update_schedule(hour, minute, timezone)
|
| 503 |
-
|
| 504 |
-
with scheduler_lock:
|
| 505 |
-
if scheduler is None:
|
| 506 |
-
scheduler = BackgroundScheduler(timezone=timezone)
|
| 507 |
-
scheduler.start()
|
| 508 |
-
|
| 509 |
-
job_id = _scheduler_job_id(username)
|
| 510 |
-
if not enabled:
|
| 511 |
-
if scheduler.get_job(job_id):
|
| 512 |
-
scheduler.remove_job(job_id)
|
| 513 |
-
runtime.update_next_run(None)
|
| 514 |
-
runtime.add_log("定时任务已禁用")
|
| 515 |
-
return
|
| 516 |
-
|
| 517 |
-
scheduler.add_job(
|
| 518 |
-
_run_scheduled_once,
|
| 519 |
-
args=[username],
|
| 520 |
-
trigger=CronTrigger(hour=hour, minute=minute, timezone=timezone),
|
| 521 |
-
id=job_id,
|
| 522 |
-
replace_existing=True,
|
| 523 |
-
max_instances=1,
|
| 524 |
-
coalesce=True,
|
| 525 |
-
)
|
| 526 |
-
job = scheduler.get_job(job_id)
|
| 527 |
-
runtime.update_next_run(job.next_run_time if job else None)
|
| 528 |
-
runtime.add_log(f"定时任务更新为 {hour:02d}:{minute:02d} ({timezone})")
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
def _remove_user_schedule_job(username: str):
|
| 532 |
-
with scheduler_lock:
|
| 533 |
-
if scheduler is None:
|
| 534 |
-
return
|
| 535 |
-
job_id = _scheduler_job_id(username)
|
| 536 |
-
if scheduler.get_job(job_id):
|
| 537 |
-
scheduler.remove_job(job_id)
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
def _start_background_run(username: str, trigger: str):
|
| 541 |
-
runtime = _get_runtime(username)
|
| 542 |
-
|
| 543 |
-
def _worker():
|
| 544 |
-
runtime.run_once(trigger)
|
| 545 |
-
if scheduler:
|
| 546 |
-
job = scheduler.get_job(_scheduler_job_id(username))
|
| 547 |
-
runtime.update_next_run(job.next_run_time if job else None)
|
| 548 |
-
|
| 549 |
-
thread = threading.Thread(target=_worker, daemon=True)
|
| 550 |
-
thread.start()
|
| 551 |
-
return True
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
def _start_scheduler():
|
| 555 |
-
global scheduler
|
| 556 |
-
_ensure_data_layout()
|
| 557 |
-
with scheduler_lock:
|
| 558 |
-
if scheduler is None:
|
| 559 |
-
scheduler = BackgroundScheduler(timezone=DEFAULT_TIMEZONE)
|
| 560 |
-
scheduler.start()
|
| 561 |
-
|
| 562 |
-
users_map = _load_users_meta()
|
| 563 |
-
for username in users_map.keys():
|
| 564 |
-
_schedule_user_job(username)
|
| 565 |
-
cfg = _load_user_config(username)
|
| 566 |
-
run_on_startup = bool(cfg.get("scheduler", {}).get("runOnStartup", False))
|
| 567 |
-
if run_on_startup:
|
| 568 |
-
_start_background_run(username, "startup")
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
def _stop_scheduler():
|
| 572 |
-
global scheduler
|
| 573 |
-
with scheduler_lock:
|
| 574 |
-
if scheduler and scheduler.running:
|
| 575 |
-
scheduler.shutdown(wait=False)
|
| 576 |
-
logger.info("Scheduler stopped.")
|
| 577 |
-
scheduler = None
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
app = FastAPI(title="DouYin Spark Flow Dashboard")
|
| 581 |
-
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
| 582 |
-
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
@app.on_event("startup")
|
| 586 |
-
async def on_startup():
|
| 587 |
-
_ensure_data_layout()
|
| 588 |
-
_start_scheduler()
|
| 589 |
-
atexit.register(_stop_scheduler)
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
@app.on_event("shutdown")
|
| 593 |
-
async def on_shutdown():
|
| 594 |
-
_stop_scheduler()
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
@app.get("/", response_class=HTMLResponse)
|
| 598 |
-
async def dashboard(request: Request):
|
| 599 |
-
session = _session_from_request(request)
|
| 600 |
-
if not session:
|
| 601 |
-
return RedirectResponse(url="/login", status_code=303)
|
| 602 |
-
if session.get("role") == "admin":
|
| 603 |
-
return RedirectResponse(url="/admin", status_code=303)
|
| 604 |
-
|
| 605 |
-
username = session.get("username")
|
| 606 |
-
runtime = _get_runtime(username)
|
| 607 |
-
return templates.TemplateResponse(
|
| 608 |
-
"dashboard.html",
|
| 609 |
-
{
|
| 610 |
-
"request": request,
|
| 611 |
-
"default_time": runtime.schedule_time(),
|
| 612 |
-
"username": username,
|
| 613 |
-
},
|
| 614 |
-
)
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
@app.get("/login", response_class=HTMLResponse)
|
| 618 |
-
async def login_page(request: Request):
|
| 619 |
-
session = _session_from_request(request)
|
| 620 |
-
if session:
|
| 621 |
-
if session.get("role") == "admin":
|
| 622 |
-
return RedirectResponse(url="/admin", status_code=303)
|
| 623 |
-
return RedirectResponse(url="/", status_code=303)
|
| 624 |
-
return templates.TemplateResponse("login.html", {"request": request})
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
@app.get("/register", response_class=HTMLResponse)
|
| 628 |
-
async def register_page(request: Request):
|
| 629 |
-
session = _session_from_request(request)
|
| 630 |
-
if session:
|
| 631 |
-
if session.get("role") == "admin":
|
| 632 |
-
return RedirectResponse(url="/admin", status_code=303)
|
| 633 |
-
return RedirectResponse(url="/", status_code=303)
|
| 634 |
-
return templates.TemplateResponse("register.html", {"request": request})
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
@app.get("/admin", response_class=HTMLResponse)
|
| 638 |
-
async def admin_page(request: Request):
|
| 639 |
-
session = _session_from_request(request)
|
| 640 |
-
if not session or session.get("role") != "admin":
|
| 641 |
-
return templates.TemplateResponse(
|
| 642 |
-
"admin_login.html",
|
| 643 |
-
{
|
| 644 |
-
"request": request,
|
| 645 |
-
"password_missing": not bool(os.getenv("PASSWORD")),
|
| 646 |
-
},
|
| 647 |
-
)
|
| 648 |
-
return templates.TemplateResponse("admin.html", {"request": request})
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
@app.post("/api/login")
|
| 652 |
-
async def api_login(payload: UserLoginPayload):
|
| 653 |
-
username = payload.username.strip()
|
| 654 |
-
if not username:
|
| 655 |
-
return JSONResponse(status_code=400, content={"ok": False, "message": "用户名不能为空。"})
|
| 656 |
-
|
| 657 |
-
users_map = _load_users_meta()
|
| 658 |
-
user = users_map.get(username)
|
| 659 |
-
if not user:
|
| 660 |
-
return JSONResponse(status_code=401, content={"ok": False, "message": "用户名或密码错误。"})
|
| 661 |
-
|
| 662 |
-
if not _verify_password(payload.password, user.get("password_salt", ""), user.get("password_hash", "")):
|
| 663 |
-
return JSONResponse(status_code=401, content={"ok": False, "message": "用户名或密码错误。"})
|
| 664 |
-
|
| 665 |
-
token = secrets.token_urlsafe(32)
|
| 666 |
-
AUTH_SESSIONS[token] = {"role": "user", "username": username}
|
| 667 |
-
|
| 668 |
-
response = JSONResponse({"ok": True, "message": "登录成功。"})
|
| 669 |
-
response.set_cookie(
|
| 670 |
-
key=SESSION_COOKIE_NAME,
|
| 671 |
-
value=token,
|
| 672 |
-
httponly=True,
|
| 673 |
-
samesite="lax",
|
| 674 |
-
max_age=7 * 24 * 3600,
|
| 675 |
)
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 731 |
default_config = _get_default_user_config()
|
| 732 |
default_config.setdefault("scheduler", {})
|
| 733 |
default_config["scheduler"].setdefault("enabled", True)
|
|
@@ -735,326 +956,321 @@ async def api_register(password: str = Form(...), users_file: UploadFile = File(
|
|
| 735 |
default_config["scheduler"].setdefault("hour", 9)
|
| 736 |
default_config["scheduler"].setdefault("minute", 0)
|
| 737 |
default_config["scheduler"].setdefault("runOnStartup", False)
|
| 738 |
-
_save_json(tenant_dir / "config.json", default_config)
|
| 739 |
|
| 740 |
hash_data = _hash_password(password.strip())
|
| 741 |
-
|
| 742 |
-
"username": username,
|
| 743 |
-
"unique_id": unique_id,
|
| 744 |
-
"password_hash": hash_data["hash"],
|
| 745 |
-
"password_salt": hash_data["salt"],
|
| 746 |
-
"tenant_dir": str(tenant_dir.relative_to(BASE_DIR)).replace("\\", "/"),
|
| 747 |
-
"created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
| 748 |
-
}
|
| 749 |
-
_save_users_meta(users_map)
|
| 750 |
-
|
| 751 |
-
_schedule_user_job(username)
|
| 752 |
-
_get_runtime(username).add_log("用户已注册并完成定时任务初始化")
|
| 753 |
-
|
| 754 |
-
return {
|
| 755 |
-
"ok": True,
|
| 756 |
-
"message": "注册成功,请使用用户名和密码登录。",
|
| 757 |
-
"username": username,
|
| 758 |
-
}
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
@app.post("/api/logout")
|
| 762 |
-
async def api_logout(request: Request):
|
| 763 |
-
token = request.cookies.get(SESSION_COOKIE_NAME)
|
| 764 |
-
if token:
|
| 765 |
-
AUTH_SESSIONS.pop(token, None)
|
| 766 |
-
response = JSONResponse({"ok": True})
|
| 767 |
-
response.delete_cookie(SESSION_COOKIE_NAME)
|
| 768 |
-
return response
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
@app.get("/api/status")
|
| 772 |
-
async def api_status(request: Request):
|
| 773 |
-
session = _require_user_session(request)
|
| 774 |
-
username = session["username"]
|
| 775 |
-
runtime = _get_runtime(username)
|
| 776 |
-
users_data = _load_user_users_data(username)
|
| 777 |
-
return {
|
| 778 |
-
"ok": True,
|
| 779 |
-
"runtime": runtime.snapshot(
|
| 780 |
-
account_count=len(users_data),
|
| 781 |
-
target_count=_count_targets(users_data),
|
| 782 |
-
),
|
| 783 |
-
"history": runtime.history_rows(),
|
| 784 |
-
}
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
@app.get("/api/logs")
|
| 788 |
-
async def api_logs(request: Request, limit: int = MAX_LOG_LINES):
|
| 789 |
-
session = _require_user_session(request)
|
| 790 |
-
username = session["username"]
|
| 791 |
-
runtime = _get_runtime(username)
|
| 792 |
-
limit = min(max(100, limit), 3000)
|
| 793 |
-
return {"ok": True, "logs": runtime.recent_logs(limit=limit)}
|
| 794 |
-
|
| 795 |
-
|
| 796 |
-
@app.post("/api/run")
|
| 797 |
-
async def api_run(request: Request):
|
| 798 |
-
session = _require_user_session(request)
|
| 799 |
-
username = session["username"]
|
| 800 |
-
runtime = _get_runtime(username)
|
| 801 |
-
|
| 802 |
-
if runtime.is_running:
|
| 803 |
-
return JSONResponse(
|
| 804 |
-
status_code=409,
|
| 805 |
-
content={"ok": False, "message": "已有任务正在执行,请稍后再试。"},
|
| 806 |
-
)
|
| 807 |
-
|
| 808 |
-
_start_background_run(username, "manual")
|
| 809 |
-
return {"ok": True, "message": "任务已开始执行。"}
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
@app.post("/api/schedule")
|
| 813 |
-
async def api_schedule(request: Request, payload: SchedulePayload):
|
| 814 |
-
session = _require_user_session(request)
|
| 815 |
-
username = session["username"]
|
| 816 |
-
|
| 817 |
try:
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
scheduler_cfg["minute"] = minute
|
| 827 |
-
scheduler_cfg["timezone"] = str(scheduler_cfg.get("timezone", DEFAULT_TIMEZONE))
|
| 828 |
-
scheduler_cfg["runOnStartup"] = bool(scheduler_cfg.get("runOnStartup", False))
|
| 829 |
-
_save_user_config(username, cfg)
|
| 830 |
-
|
| 831 |
-
_schedule_user_job(username)
|
| 832 |
-
runtime = _get_runtime(username)
|
| 833 |
-
return {
|
| 834 |
-
"ok": True,
|
| 835 |
-
"message": f"定时任务已更新为每天 {hour:02d}:{minute:02d}。",
|
| 836 |
-
"time": f"{hour:02d}:{minute:02d}",
|
| 837 |
-
"next_run": runtime.snapshot(0, 0)["next_run"],
|
| 838 |
-
}
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
@app.get("/api/editor/state")
|
| 842 |
-
async def api_editor_state(request: Request):
|
| 843 |
-
session = _require_user_session(request)
|
| 844 |
-
username = session["username"]
|
| 845 |
-
return {"ok": True, **_build_editor_state(username)}
|
| 846 |
-
|
| 847 |
-
|
| 848 |
-
@app.post("/api/editor/message")
|
| 849 |
-
async def api_editor_message(request: Request, payload: MessageTemplatePayload):
|
| 850 |
-
session = _require_user_session(request)
|
| 851 |
-
username = session["username"]
|
| 852 |
-
|
| 853 |
-
message = payload.message.strip()
|
| 854 |
-
if not message:
|
| 855 |
-
return JSONResponse(status_code=400, content={"ok": False, "message": "消息内容不能为空。"})
|
| 856 |
-
if len(message) > MAX_TEMPLATE_LENGTH:
|
| 857 |
-
return JSONResponse(
|
| 858 |
-
status_code=400,
|
| 859 |
-
content={"ok": False, "message": f"消息内容过长,最多 {MAX_TEMPLATE_LENGTH} 字符。"},
|
| 860 |
-
)
|
| 861 |
-
|
| 862 |
-
cfg = _load_user_config(username)
|
| 863 |
-
cfg["messageTemplate"] = message
|
| 864 |
-
_save_user_config(username, cfg)
|
| 865 |
-
_get_runtime(username).add_log("消息模板已更新")
|
| 866 |
-
return {"ok": True, "message": "消息模板已保存。"}
|
| 867 |
-
|
| 868 |
-
|
| 869 |
-
@app.post("/api/editor/targets")
|
| 870 |
-
async def api_editor_targets(request: Request, payload: UserTargetsPayload):
|
| 871 |
-
session = _require_user_session(request)
|
| 872 |
-
username = session["username"]
|
| 873 |
-
|
| 874 |
-
users_data = _load_user_users_data(username)
|
| 875 |
-
updates = {item.unique_id: _sanitize_targets(item.targets) for item in payload.users}
|
| 876 |
-
|
| 877 |
-
updated = 0
|
| 878 |
-
for user in users_data:
|
| 879 |
-
uid = str(user.get("unique_id", ""))
|
| 880 |
-
if uid in updates:
|
| 881 |
-
user["targets"] = updates[uid]
|
| 882 |
-
updated += 1
|
| 883 |
-
|
| 884 |
-
_save_user_users_data(username, users_data)
|
| 885 |
-
_get_runtime(username).add_log(f"目标好友已更新,涉及账号数:{updated}")
|
| 886 |
-
return {"ok": True, "message": f"目标好友已保存({updated} 个账号)。"}
|
| 887 |
-
|
| 888 |
-
|
| 889 |
-
@app.get("/api/admin/overview")
|
| 890 |
-
async def api_admin_overview(request: Request):
|
| 891 |
-
_require_admin_session(request)
|
| 892 |
-
users_map = _load_users_meta()
|
| 893 |
-
|
| 894 |
-
rows = []
|
| 895 |
-
for username, meta in sorted(users_map.items(), key=lambda x: x[0]):
|
| 896 |
-
try:
|
| 897 |
-
cfg = _load_user_config(username)
|
| 898 |
-
users_data = _load_user_users_data(username)
|
| 899 |
-
except Exception as exc:
|
| 900 |
-
rows.append(
|
| 901 |
-
{
|
| 902 |
-
"username": username,
|
| 903 |
-
"unique_id": meta.get("unique_id", ""),
|
| 904 |
-
"created_at": meta.get("created_at", "-"),
|
| 905 |
-
"error": str(exc),
|
| 906 |
-
}
|
| 907 |
-
)
|
| 908 |
-
continue
|
| 909 |
-
|
| 910 |
-
scheduler_cfg = cfg.get("scheduler", {})
|
| 911 |
-
runtime = _get_runtime(username)
|
| 912 |
-
runtime_snapshot = runtime.snapshot(
|
| 913 |
-
account_count=len(users_data),
|
| 914 |
-
target_count=_count_targets(users_data),
|
| 915 |
-
)
|
| 916 |
-
|
| 917 |
-
receivers = []
|
| 918 |
-
for item in users_data:
|
| 919 |
-
receivers.extend(item.get("targets", []))
|
| 920 |
-
|
| 921 |
-
rows.append(
|
| 922 |
-
{
|
| 923 |
-
"username": username,
|
| 924 |
-
"unique_id": meta.get("unique_id", ""),
|
| 925 |
-
"created_at": meta.get("created_at", "-"),
|
| 926 |
-
"scheduler_enabled": bool(scheduler_cfg.get("enabled", True)),
|
| 927 |
-
"schedule_time": f"{int(scheduler_cfg.get('hour', 9)):02d}:{int(scheduler_cfg.get('minute', 0)):02d}",
|
| 928 |
-
"schedule_timezone": str(scheduler_cfg.get("timezone", DEFAULT_TIMEZONE)),
|
| 929 |
-
"message_template": str(cfg.get("messageTemplate", "")),
|
| 930 |
-
"targets": receivers,
|
| 931 |
-
"target_count": len(receivers),
|
| 932 |
-
"next_run": runtime_snapshot.get("next_run", "-"),
|
| 933 |
-
"last_status": runtime_snapshot.get("last_status", "-"),
|
| 934 |
-
"last_start": runtime_snapshot.get("last_start", "-"),
|
| 935 |
-
"is_running": runtime_snapshot.get("is_running", False),
|
| 936 |
-
}
|
| 937 |
)
|
|
|
|
|
|
|
| 938 |
|
| 939 |
-
|
| 940 |
-
|
| 941 |
-
|
| 942 |
-
|
| 943 |
-
|
| 944 |
-
|
| 945 |
-
|
| 946 |
-
|
| 947 |
-
|
| 948 |
-
|
| 949 |
-
|
| 950 |
-
|
| 951 |
-
|
| 952 |
-
|
| 953 |
-
|
| 954 |
-
|
| 955 |
-
|
| 956 |
-
|
| 957 |
-
|
| 958 |
-
|
| 959 |
-
|
| 960 |
-
|
| 961 |
-
|
| 962 |
-
|
| 963 |
-
|
| 964 |
-
|
| 965 |
-
|
| 966 |
-
|
| 967 |
-
|
| 968 |
-
|
| 969 |
-
|
| 970 |
-
|
| 971 |
-
|
| 972 |
-
|
| 973 |
-
|
| 974 |
-
|
| 975 |
-
|
| 976 |
-
|
| 977 |
-
|
| 978 |
-
|
| 979 |
-
|
| 980 |
-
|
| 981 |
-
|
| 982 |
-
|
| 983 |
-
|
| 984 |
-
|
| 985 |
-
|
| 986 |
-
|
| 987 |
-
|
| 988 |
-
|
| 989 |
-
|
| 990 |
-
|
| 991 |
-
|
| 992 |
-
|
| 993 |
-
"
|
| 994 |
-
|
| 995 |
-
|
| 996 |
-
|
| 997 |
-
|
| 998 |
-
|
| 999 |
-
|
| 1000 |
-
|
| 1001 |
-
|
| 1002 |
-
|
| 1003 |
-
|
| 1004 |
-
|
| 1005 |
-
|
| 1006 |
-
|
| 1007 |
-
|
| 1008 |
-
|
| 1009 |
-
|
| 1010 |
-
|
| 1011 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1012 |
_get_user_meta_or_404(username)
|
| 1013 |
|
| 1014 |
-
cfg = _load_user_config(username)
|
| 1015 |
-
scheduler_cfg = cfg.setdefault("scheduler", {})
|
| 1016 |
-
scheduler_cfg["enabled"] = False
|
| 1017 |
-
_save_user_config(username, cfg)
|
| 1018 |
-
|
| 1019 |
_remove_user_schedule_job(username)
|
| 1020 |
-
|
| 1021 |
-
runtime.update_next_run(None)
|
| 1022 |
-
runtime.add_log("管理员已删除(禁用)该用户定时任务")
|
| 1023 |
-
|
| 1024 |
-
return {"ok": True, "message": f"已删除用户 {username} 的定时任务。"}
|
| 1025 |
-
|
| 1026 |
-
|
| 1027 |
-
@app.delete("/api/admin/users/{username}")
|
| 1028 |
-
async def api_admin_delete_user(request: Request, username: str):
|
| 1029 |
-
_require_admin_session(request)
|
| 1030 |
-
username = username.strip()
|
| 1031 |
-
|
| 1032 |
-
users_map = _load_users_meta()
|
| 1033 |
-
user = users_map.get(username)
|
| 1034 |
-
if not user:
|
| 1035 |
-
return JSONResponse(status_code=404, content={"ok": False, "message": "用户不存在。"})
|
| 1036 |
-
|
| 1037 |
-
_remove_user_schedule_job(username)
|
| 1038 |
-
tenant_dir = _get_tenant_dir(user)
|
| 1039 |
-
if tenant_dir.exists():
|
| 1040 |
-
shutil.rmtree(tenant_dir, ignore_errors=True)
|
| 1041 |
-
|
| 1042 |
-
users_map.pop(username, None)
|
| 1043 |
-
_save_users_meta(users_map)
|
| 1044 |
_delete_runtime(username)
|
| 1045 |
|
| 1046 |
return {"ok": True, "message": f"用户 {username} 已删除。"}
|
| 1047 |
-
|
| 1048 |
-
|
| 1049 |
-
@app.get("/health")
|
| 1050 |
-
async def health():
|
| 1051 |
-
return {"ok": True, "status": "alive"}
|
| 1052 |
-
|
| 1053 |
-
|
| 1054 |
-
def run_server():
|
| 1055 |
-
port = int(os.getenv("PORT", "7860"))
|
| 1056 |
-
uvicorn.run("app:app", host="0.0.0.0", port=port, workers=1)
|
| 1057 |
-
|
| 1058 |
-
|
| 1059 |
-
if __name__ == "__main__":
|
| 1060 |
run_server()
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import atexit
|
| 3 |
+
import hashlib
|
| 4 |
import json
|
| 5 |
import logging
|
| 6 |
import os
|
| 7 |
import secrets
|
|
|
|
| 8 |
import threading
|
| 9 |
import traceback
|
| 10 |
from collections import deque
|
| 11 |
from datetime import datetime
|
| 12 |
from pathlib import Path
|
| 13 |
from typing import Any, Optional
|
| 14 |
+
from urllib.parse import parse_qsl, unquote, urlsplit
|
| 15 |
|
| 16 |
+
import pymysql
|
| 17 |
import uvicorn
|
| 18 |
+
from apscheduler.schedulers.background import BackgroundScheduler
|
| 19 |
+
from apscheduler.triggers.cron import CronTrigger
|
| 20 |
+
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile, status
|
| 21 |
+
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
| 22 |
+
from fastapi.staticfiles import StaticFiles
|
| 23 |
+
from fastapi.templating import Jinja2Templates
|
| 24 |
+
from pydantic import BaseModel, Field
|
| 25 |
+
|
| 26 |
+
from core.tasks import runTasks
|
| 27 |
+
from utils.logger import setup_logger
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
logger = setup_logger(level=logging.DEBUG)
|
| 31 |
+
|
| 32 |
BASE_DIR = Path(__file__).resolve().parent
|
| 33 |
TEMPLATES_DIR = BASE_DIR / "templates"
|
| 34 |
STATIC_DIR = BASE_DIR / "static"
|
| 35 |
ROOT_CONFIG_PATH = BASE_DIR / "config.json"
|
| 36 |
+
LEGACY_DATA_DIR = BASE_DIR / "data"
|
| 37 |
+
LEGACY_USERS_META_PATH = LEGACY_DATA_DIR / "users.json"
|
| 38 |
+
MYSQL_DSN_TEMPLATE = "mysql://SQL_PASSWORD@mysql-2bace9cd-cacode.i.aivencloud.com:21260/defaultdb?ssl-mode=REQUIRED"
|
| 39 |
+
MYSQL_DSN_ENV = "MYSQL_DSN_TEMPLATE"
|
| 40 |
+
MYSQL_PASSWORD_ENV = "SQL_PASSWORD"
|
| 41 |
+
MYSQL_USER_ENV = "MYSQL_USER"
|
| 42 |
+
MYSQL_CA_CERT_ENV = "MYSQL_CA_CERT_PATH"
|
| 43 |
+
MYSQL_DEFAULT_USER = "avnadmin"
|
| 44 |
+
USERS_TABLE = "app_users"
|
| 45 |
SESSION_COOKIE_NAME = "sparkflow_auth"
|
| 46 |
DEFAULT_TIMEZONE = "Asia/Shanghai"
|
| 47 |
MAX_LOG_LINES = 1200
|
| 48 |
MAX_TEMPLATE_LENGTH = 2000
|
| 49 |
PASSWORD_ITERATIONS = 210000
|
| 50 |
+
|
| 51 |
+
DEFAULT_USER_CONFIG = {
|
| 52 |
+
"multiTask": True,
|
| 53 |
+
"taskCount": 5,
|
| 54 |
+
"proxyAddress": "",
|
| 55 |
+
"messageTemplate": "[续火花]",
|
| 56 |
+
"hitokotoTypes": ["文学", "影视", "诗词", "哲学"],
|
| 57 |
+
"scheduler": {
|
| 58 |
+
"enabled": True,
|
| 59 |
+
"timezone": DEFAULT_TIMEZONE,
|
| 60 |
+
"hour": 9,
|
| 61 |
+
"minute": 0,
|
| 62 |
+
"runOnStartup": False,
|
| 63 |
+
},
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
AUTH_SESSIONS: dict[str, dict[str, str]] = {}
|
| 67 |
+
db_init_lock = threading.Lock()
|
| 68 |
scheduler_lock = threading.Lock()
|
| 69 |
runtime_map_lock = threading.Lock()
|
| 70 |
+
db_initialized = False
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
class UserRuntimeState:
|
| 74 |
+
def __init__(self, username: str):
|
| 75 |
+
self.username = username
|
| 76 |
+
self._run_lock = threading.Lock()
|
| 77 |
+
self._state_lock = threading.Lock()
|
| 78 |
+
self.is_running = False
|
| 79 |
+
self.last_status = "未开始"
|
| 80 |
+
self.last_error = ""
|
| 81 |
+
self.last_trigger = "-"
|
| 82 |
+
self.last_start = None
|
| 83 |
+
self.last_end = None
|
| 84 |
+
self.next_run = None
|
| 85 |
+
self.schedule_hour = 9
|
| 86 |
+
self.schedule_minute = 0
|
| 87 |
+
self.schedule_timezone = DEFAULT_TIMEZONE
|
| 88 |
+
self.history = deque(maxlen=50)
|
| 89 |
+
self.logs = deque(maxlen=2000)
|
| 90 |
+
|
| 91 |
+
def _format_ts(self, value: Optional[datetime]):
|
| 92 |
+
if not value:
|
| 93 |
+
return "-"
|
| 94 |
+
return value.strftime("%Y-%m-%d %H:%M:%S")
|
| 95 |
+
|
| 96 |
+
def schedule_time(self):
|
| 97 |
+
return f"{self.schedule_hour:02d}:{self.schedule_minute:02d}"
|
| 98 |
+
|
| 99 |
+
def _set_running(self, value: bool):
|
| 100 |
+
with self._state_lock:
|
| 101 |
+
self.is_running = value
|
| 102 |
+
|
| 103 |
+
def add_log(self, message: str):
|
| 104 |
+
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 105 |
+
with self._state_lock:
|
| 106 |
+
self.logs.append(f"{ts} [{self.username}] {message}")
|
| 107 |
+
|
| 108 |
+
def update_schedule(self, hour: int, minute: int, timezone: str):
|
| 109 |
+
with self._state_lock:
|
| 110 |
+
self.schedule_hour = hour
|
| 111 |
+
self.schedule_minute = minute
|
| 112 |
+
self.schedule_timezone = timezone
|
| 113 |
+
|
| 114 |
+
def update_next_run(self, next_run):
|
| 115 |
+
with self._state_lock:
|
| 116 |
+
self.next_run = next_run
|
| 117 |
+
|
| 118 |
+
def snapshot(self, account_count: int, target_count: int):
|
| 119 |
+
with self._state_lock:
|
| 120 |
+
return {
|
| 121 |
+
"is_running": self.is_running,
|
| 122 |
+
"last_status": self.last_status,
|
| 123 |
+
"last_error": self.last_error,
|
| 124 |
+
"last_trigger": self.last_trigger,
|
| 125 |
+
"last_start": self._format_ts(self.last_start),
|
| 126 |
+
"last_end": self._format_ts(self.last_end),
|
| 127 |
+
"next_run": self._format_ts(self.next_run),
|
| 128 |
+
"account_count": account_count,
|
| 129 |
+
"target_count": target_count,
|
| 130 |
+
"schedule_time": self.schedule_time(),
|
| 131 |
+
"schedule_timezone": self.schedule_timezone,
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
def history_rows(self):
|
| 135 |
+
with self._state_lock:
|
| 136 |
+
return list(self.history)[::-1]
|
| 137 |
+
|
| 138 |
+
def recent_logs(self, limit=MAX_LOG_LINES):
|
| 139 |
+
with self._state_lock:
|
| 140 |
+
lines = list(self.logs)[-max(1, limit):]
|
| 141 |
+
return "\n".join(lines) if lines else "暂无日志。"
|
| 142 |
+
|
| 143 |
+
def run_once(self, trigger: str):
|
| 144 |
+
if not self._run_lock.acquire(blocking=False):
|
| 145 |
+
self.add_log(f"任务已在运行中,忽略触发:{trigger}")
|
| 146 |
+
return False, "已有任务在运行,本次触发已跳过。"
|
| 147 |
+
|
| 148 |
+
self._set_running(True)
|
| 149 |
+
with self._state_lock:
|
| 150 |
+
self.last_trigger = trigger
|
| 151 |
+
self.last_start = datetime.now()
|
| 152 |
+
self.last_end = None
|
| 153 |
+
self.last_error = ""
|
| 154 |
+
self.last_status = "运行中"
|
| 155 |
+
self.add_log(f"任务开始执行,触发方式:{trigger}")
|
| 156 |
+
|
| 157 |
+
ok = True
|
| 158 |
+
message = "任务执行完成。"
|
| 159 |
+
try:
|
| 160 |
+
asyncio.run(_run_user_tasks(self.username))
|
| 161 |
+
with self._state_lock:
|
| 162 |
+
self.last_status = "成功"
|
| 163 |
+
except Exception as exc:
|
| 164 |
+
ok = False
|
| 165 |
+
message = f"任务执行失败:{exc}"
|
| 166 |
+
with self._state_lock:
|
| 167 |
+
self.last_status = "失败"
|
| 168 |
+
self.last_error = repr(exc)
|
| 169 |
+
self.add_log(f"任务失败:{exc}")
|
| 170 |
+
logger.error("Task failed. user=%s trigger=%s error=%s", self.username, trigger, exc)
|
| 171 |
+
logger.debug("Task traceback:\n%s", traceback.format_exc())
|
| 172 |
+
finally:
|
| 173 |
+
end_at = datetime.now()
|
| 174 |
+
with self._state_lock:
|
| 175 |
+
self.last_end = end_at
|
| 176 |
+
duration = (self.last_end - self.last_start).total_seconds()
|
| 177 |
+
self.history.append(
|
| 178 |
+
{
|
| 179 |
+
"trigger": trigger,
|
| 180 |
+
"start": self._format_ts(self.last_start),
|
| 181 |
+
"end": self._format_ts(self.last_end),
|
| 182 |
+
"status": self.last_status,
|
| 183 |
+
"duration": f"{duration:.2f}s",
|
| 184 |
+
"message": self.last_error or "OK",
|
| 185 |
+
}
|
| 186 |
+
)
|
| 187 |
+
current_status = self.last_status
|
| 188 |
+
self.add_log(f"任务结束,状态={current_status},耗时={duration:.2f}s")
|
| 189 |
+
self._set_running(False)
|
| 190 |
+
self._run_lock.release()
|
| 191 |
+
return ok, message
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
runtime_map: dict[str, UserRuntimeState] = {}
|
| 195 |
+
scheduler = None
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
class UserLoginPayload(BaseModel):
|
| 199 |
+
username: str
|
| 200 |
+
password: str
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
class AdminLoginPayload(BaseModel):
|
| 204 |
+
password: str
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
class SchedulePayload(BaseModel):
|
| 208 |
+
time: str
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
class MessageTemplatePayload(BaseModel):
|
| 212 |
+
message: str
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
class UserTargetsItem(BaseModel):
|
| 216 |
+
unique_id: str
|
| 217 |
+
targets: list[str] = Field(default_factory=list)
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
class UserTargetsPayload(BaseModel):
|
| 221 |
+
users: list[UserTargetsItem]
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def _ensure_data_layout():
|
| 225 |
+
global db_initialized
|
| 226 |
+
if db_initialized:
|
| 227 |
+
return
|
| 228 |
+
with db_init_lock:
|
| 229 |
+
if db_initialized:
|
| 230 |
+
return
|
| 231 |
+
_init_db_schema()
|
| 232 |
+
_migrate_legacy_file_data_if_needed()
|
| 233 |
+
db_initialized = True
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def _hash_password(password: str, salt_hex: Optional[str] = None):
|
| 237 |
+
salt = bytes.fromhex(salt_hex) if salt_hex else secrets.token_bytes(16)
|
| 238 |
+
digest = hashlib.pbkdf2_hmac(
|
| 239 |
+
"sha256",
|
| 240 |
+
password.encode("utf-8"),
|
| 241 |
+
salt,
|
| 242 |
+
PASSWORD_ITERATIONS,
|
| 243 |
+
)
|
| 244 |
+
return {
|
| 245 |
+
"salt": salt.hex(),
|
| 246 |
+
"hash": digest.hex(),
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def _verify_password(password: str, salt_hex: str, expected_hash: str):
|
| 251 |
+
data = _hash_password(password, salt_hex=salt_hex)
|
| 252 |
+
return secrets.compare_digest(data["hash"], expected_hash)
|
| 253 |
|
| 254 |
|
| 255 |
+
def _deep_copy_json(value):
|
| 256 |
+
return json.loads(json.dumps(value, ensure_ascii=False))
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def _merge_config_with_defaults(raw_cfg: Any):
|
| 260 |
+
base = _deep_copy_json(DEFAULT_USER_CONFIG)
|
| 261 |
+
if not isinstance(raw_cfg, dict):
|
| 262 |
+
return base
|
| 263 |
+
|
| 264 |
+
merged = _deep_copy_json(base)
|
| 265 |
+
merged.update(raw_cfg)
|
| 266 |
+
base_scheduler = base.get("scheduler", {})
|
| 267 |
+
merged_scheduler = raw_cfg.get("scheduler", {})
|
| 268 |
+
if isinstance(merged_scheduler, dict):
|
| 269 |
+
scheduler = _deep_copy_json(base_scheduler)
|
| 270 |
+
scheduler.update(merged_scheduler)
|
| 271 |
+
merged["scheduler"] = scheduler
|
| 272 |
+
else:
|
| 273 |
+
merged["scheduler"] = _deep_copy_json(base_scheduler)
|
| 274 |
+
return merged
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def _resolve_mysql_dsn():
|
| 278 |
+
raw = os.getenv(MYSQL_DSN_ENV, MYSQL_DSN_TEMPLATE).strip()
|
| 279 |
+
if "SQL_PASSWORD" in raw:
|
| 280 |
+
secret = os.getenv(MYSQL_PASSWORD_ENV, "").strip()
|
| 281 |
+
if not secret:
|
| 282 |
+
raise RuntimeError(f"环境变量 {MYSQL_PASSWORD_ENV} 未设置,无法连接 MySQL。")
|
| 283 |
+
raw = raw.replace("SQL_PASSWORD", secret, 1)
|
| 284 |
+
return raw
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def _build_mysql_conn_kwargs():
|
| 288 |
+
dsn = _resolve_mysql_dsn()
|
| 289 |
+
parsed = urlsplit(dsn)
|
| 290 |
+
if parsed.scheme not in ("mysql", "mysql+pymysql"):
|
| 291 |
+
raise RuntimeError(f"不支持的 MySQL DSN 协议:{parsed.scheme}")
|
| 292 |
+
|
| 293 |
+
host = parsed.hostname
|
| 294 |
+
if not host:
|
| 295 |
+
raise RuntimeError("MySQL DSN 缺少主机地址。")
|
| 296 |
+
|
| 297 |
+
user = unquote(parsed.username or "")
|
| 298 |
+
password = unquote(parsed.password) if parsed.password is not None else None
|
| 299 |
+
if user and password is None:
|
| 300 |
+
password = user
|
| 301 |
+
user = os.getenv(MYSQL_USER_ENV, MYSQL_DEFAULT_USER).strip() or MYSQL_DEFAULT_USER
|
| 302 |
+
if not user:
|
| 303 |
+
user = os.getenv(MYSQL_USER_ENV, MYSQL_DEFAULT_USER).strip() or MYSQL_DEFAULT_USER
|
| 304 |
+
if not password:
|
| 305 |
+
password = os.getenv(MYSQL_PASSWORD_ENV, "").strip()
|
| 306 |
+
if not password:
|
| 307 |
+
raise RuntimeError("MySQL 密码为空,请检查 SQL_PASSWORD 环境变量。")
|
| 308 |
+
|
| 309 |
+
db_name = parsed.path.lstrip("/") or "defaultdb"
|
| 310 |
+
query = {k.lower(): v for k, v in parse_qsl(parsed.query, keep_blank_values=True)}
|
| 311 |
+
ssl_mode = str(query.get("ssl-mode", query.get("ssl_mode", ""))).upper()
|
| 312 |
+
|
| 313 |
+
kwargs = {
|
| 314 |
+
"host": host,
|
| 315 |
+
"port": parsed.port or 3306,
|
| 316 |
+
"user": user,
|
| 317 |
+
"password": password,
|
| 318 |
+
"database": db_name,
|
| 319 |
+
"charset": "utf8mb4",
|
| 320 |
+
"autocommit": True,
|
| 321 |
+
"connect_timeout": int(os.getenv("MYSQL_CONNECT_TIMEOUT", "10")),
|
| 322 |
+
"cursorclass": pymysql.cursors.DictCursor,
|
| 323 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
|
| 325 |
+
if ssl_mode in {"REQUIRED", "VERIFY_CA", "VERIFY_IDENTITY"}:
|
| 326 |
+
ca_file = Path(os.getenv(MYSQL_CA_CERT_ENV, str(BASE_DIR / "camysql2.pem"))).resolve()
|
| 327 |
+
if not ca_file.exists():
|
| 328 |
+
raise RuntimeError(f"MySQL CA 证书不存在:{ca_file}")
|
| 329 |
+
kwargs["ssl"] = {"ca": str(ca_file)}
|
| 330 |
|
| 331 |
+
return kwargs
|
|
|
|
| 332 |
|
| 333 |
|
| 334 |
+
def _db_query_all(query: str, params=()):
|
| 335 |
+
conn = pymysql.connect(**_build_mysql_conn_kwargs())
|
| 336 |
+
try:
|
| 337 |
+
with conn.cursor() as cursor:
|
| 338 |
+
cursor.execute(query, params)
|
| 339 |
+
return cursor.fetchall()
|
| 340 |
+
finally:
|
| 341 |
+
conn.close()
|
| 342 |
|
| 343 |
|
| 344 |
+
def _db_query_one(query: str, params=()):
|
| 345 |
+
conn = pymysql.connect(**_build_mysql_conn_kwargs())
|
| 346 |
+
try:
|
| 347 |
+
with conn.cursor() as cursor:
|
| 348 |
+
cursor.execute(query, params)
|
| 349 |
+
return cursor.fetchone()
|
| 350 |
+
finally:
|
| 351 |
+
conn.close()
|
| 352 |
|
| 353 |
|
| 354 |
+
def _db_execute(query: str, params=()):
|
| 355 |
+
conn = pymysql.connect(**_build_mysql_conn_kwargs())
|
| 356 |
+
try:
|
| 357 |
+
with conn.cursor() as cursor:
|
| 358 |
+
cursor.execute(query, params)
|
| 359 |
+
return cursor.rowcount
|
| 360 |
+
finally:
|
| 361 |
+
conn.close()
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
def _init_db_schema():
|
| 365 |
+
_db_execute(
|
| 366 |
+
f"""
|
| 367 |
+
CREATE TABLE IF NOT EXISTS `{USERS_TABLE}` (
|
| 368 |
+
`username` VARCHAR(128) NOT NULL,
|
| 369 |
+
`unique_id` VARCHAR(255) NOT NULL,
|
| 370 |
+
`password_hash` VARCHAR(128) NOT NULL,
|
| 371 |
+
`password_salt` VARCHAR(64) NOT NULL,
|
| 372 |
+
`created_at` VARCHAR(32) NOT NULL,
|
| 373 |
+
`config_json` LONGTEXT NOT NULL,
|
| 374 |
+
`users_data_json` LONGTEXT NOT NULL,
|
| 375 |
+
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
| 376 |
+
PRIMARY KEY (`username`),
|
| 377 |
+
UNIQUE KEY `uniq_unique_id` (`unique_id`)
|
| 378 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
| 379 |
+
"""
|
| 380 |
+
)
|
| 381 |
|
| 382 |
|
| 383 |
+
def _legacy_load_json(path: Path, default):
|
| 384 |
if not path.exists():
|
| 385 |
return default
|
| 386 |
with path.open("r", encoding="utf-8") as f:
|
| 387 |
return json.load(f)
|
| 388 |
|
| 389 |
|
| 390 |
+
def _migrate_legacy_file_data_if_needed():
|
| 391 |
+
if not LEGACY_USERS_META_PATH.exists():
|
| 392 |
+
return
|
|
|
|
|
|
|
| 393 |
|
| 394 |
+
row = _db_query_one(f"SELECT COUNT(*) AS cnt FROM `{USERS_TABLE}`")
|
| 395 |
+
if row and int(row.get("cnt", 0)) > 0:
|
| 396 |
+
return
|
| 397 |
|
| 398 |
+
try:
|
| 399 |
+
raw = _legacy_load_json(LEGACY_USERS_META_PATH, {"users": []})
|
| 400 |
+
except Exception as exc:
|
| 401 |
+
logger.warning("读取旧版 users.json 失败,跳过迁移:%s", exc)
|
| 402 |
+
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
|
| 404 |
+
users = raw.get("users", []) if isinstance(raw, dict) else []
|
| 405 |
+
if not users:
|
| 406 |
+
return
|
| 407 |
|
| 408 |
+
migrated = 0
|
| 409 |
+
for item in users:
|
| 410 |
+
username = str(item.get("username", "")).strip()
|
| 411 |
+
unique_id = str(item.get("unique_id", "")).strip()
|
| 412 |
+
password_hash = str(item.get("password_hash", "")).strip()
|
| 413 |
+
password_salt = str(item.get("password_salt", "")).strip()
|
| 414 |
+
created_at = str(item.get("created_at", "")).strip() or datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
|
| 416 |
+
if not (username and unique_id and password_hash and password_salt):
|
| 417 |
+
logger.warning("旧版用户数据不完整,跳过:%s", username or "<empty>")
|
| 418 |
+
continue
|
| 419 |
|
| 420 |
+
cfg = _get_default_user_config()
|
| 421 |
+
users_data = []
|
| 422 |
+
tenant_rel = str(item.get("tenant_dir", "")).strip()
|
| 423 |
+
if tenant_rel:
|
| 424 |
+
tenant_dir = (BASE_DIR / tenant_rel).resolve()
|
| 425 |
+
cfg_path = tenant_dir / "config.json"
|
| 426 |
+
users_data_path = tenant_dir / "usersData.json"
|
| 427 |
+
try:
|
| 428 |
+
cfg = _merge_config_with_defaults(_legacy_load_json(cfg_path, cfg))
|
| 429 |
+
except Exception as exc:
|
| 430 |
+
logger.warning("读取旧版配置失败,使用默认配置。user=%s error=%s", username, exc)
|
| 431 |
+
try:
|
| 432 |
+
users_data = _legacy_load_json(users_data_path, [])
|
| 433 |
+
except Exception as exc:
|
| 434 |
+
logger.warning("读取旧版 usersData 失败。user=%s error=%s", username, exc)
|
| 435 |
+
|
| 436 |
+
if not isinstance(users_data, list):
|
| 437 |
+
users_data = []
|
| 438 |
+
|
| 439 |
+
try:
|
| 440 |
+
_create_user_record(
|
| 441 |
+
username=username,
|
| 442 |
+
unique_id=unique_id,
|
| 443 |
+
password_hash=password_hash,
|
| 444 |
+
password_salt=password_salt,
|
| 445 |
+
created_at=created_at,
|
| 446 |
+
config_payload=cfg,
|
| 447 |
+
users_data_payload=users_data,
|
| 448 |
+
)
|
| 449 |
+
migrated += 1
|
| 450 |
+
except Exception as exc:
|
| 451 |
+
logger.warning("迁移用户失败。user=%s error=%s", username, exc)
|
| 452 |
+
|
| 453 |
+
if migrated > 0:
|
| 454 |
+
logger.info("已完成旧版文件数据迁移,共迁移 %s 个用户。", migrated)
|
| 455 |
|
| 456 |
|
| 457 |
def _load_users_meta():
|
| 458 |
+
rows = _db_query_all(
|
| 459 |
+
f"""
|
| 460 |
+
SELECT username, unique_id, password_hash, password_salt, created_at
|
| 461 |
+
FROM `{USERS_TABLE}`
|
| 462 |
+
ORDER BY username ASC
|
| 463 |
+
"""
|
| 464 |
+
)
|
| 465 |
+
return {str(row["username"]): row for row in rows}
|
| 466 |
+
|
| 467 |
+
|
| 468 |
+
def _load_user_row(username: str):
|
| 469 |
+
return _db_query_one(
|
| 470 |
+
f"""
|
| 471 |
+
SELECT username, unique_id, password_hash, password_salt, created_at, config_json, users_data_json
|
| 472 |
+
FROM `{USERS_TABLE}`
|
| 473 |
+
WHERE username=%s
|
| 474 |
+
""",
|
| 475 |
+
(username,),
|
| 476 |
+
)
|
| 477 |
|
| 478 |
|
| 479 |
+
def _create_user_record(
|
| 480 |
+
*,
|
| 481 |
+
username: str,
|
| 482 |
+
unique_id: str,
|
| 483 |
+
password_hash: str,
|
| 484 |
+
password_salt: str,
|
| 485 |
+
created_at: str,
|
| 486 |
+
config_payload: dict[str, Any],
|
| 487 |
+
users_data_payload: list[dict[str, Any]],
|
| 488 |
+
):
|
| 489 |
+
_db_execute(
|
| 490 |
+
f"""
|
| 491 |
+
INSERT INTO `{USERS_TABLE}`
|
| 492 |
+
(username, unique_id, password_hash, password_salt, created_at, config_json, users_data_json)
|
| 493 |
+
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
| 494 |
+
""",
|
| 495 |
+
(
|
| 496 |
+
username,
|
| 497 |
+
unique_id,
|
| 498 |
+
password_hash,
|
| 499 |
+
password_salt,
|
| 500 |
+
created_at,
|
| 501 |
+
json.dumps(config_payload, ensure_ascii=False),
|
| 502 |
+
json.dumps(users_data_payload, ensure_ascii=False),
|
| 503 |
+
),
|
| 504 |
+
)
|
| 505 |
|
| 506 |
|
| 507 |
+
def _delete_user_record(username: str):
|
| 508 |
+
return _db_execute(f"DELETE FROM `{USERS_TABLE}` WHERE username=%s", (username,))
|
| 509 |
+
|
| 510 |
+
|
| 511 |
def _get_user_meta_or_404(username: str):
|
| 512 |
users_map = _load_users_meta()
|
| 513 |
user = users_map.get(username)
|
|
|
|
| 516 |
return user
|
| 517 |
|
| 518 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 519 |
def _get_default_user_config():
|
| 520 |
if ROOT_CONFIG_PATH.exists():
|
| 521 |
try:
|
| 522 |
+
with ROOT_CONFIG_PATH.open("r", encoding="utf-8") as f:
|
| 523 |
+
root_cfg = json.load(f)
|
| 524 |
+
return _merge_config_with_defaults(root_cfg)
|
| 525 |
except Exception:
|
| 526 |
logger.warning("Failed to read root config.json. fallback to DEFAULT_USER_CONFIG")
|
| 527 |
+
return _deep_copy_json(DEFAULT_USER_CONFIG)
|
| 528 |
|
| 529 |
|
| 530 |
def _load_user_config(username: str):
|
| 531 |
+
row = _load_user_row(username)
|
| 532 |
+
if not row:
|
| 533 |
+
raise FileNotFoundError(f"用户 {username} 不存在")
|
| 534 |
+
try:
|
| 535 |
+
payload = json.loads(row.get("config_json", "{}"))
|
| 536 |
+
except Exception as exc:
|
| 537 |
+
raise ValueError(f"用户 {username} 的配置数据损坏:{exc}")
|
| 538 |
+
return _merge_config_with_defaults(payload)
|
| 539 |
|
| 540 |
|
| 541 |
def _save_user_config(username: str, cfg: dict):
|
| 542 |
+
normalized = _merge_config_with_defaults(cfg)
|
| 543 |
+
changed = _db_execute(
|
| 544 |
+
f"UPDATE `{USERS_TABLE}` SET config_json=%s WHERE username=%s",
|
| 545 |
+
(json.dumps(normalized, ensure_ascii=False), username),
|
| 546 |
+
)
|
| 547 |
+
if changed == 0:
|
| 548 |
+
raise FileNotFoundError(f"用户 {username} 不存在")
|
| 549 |
|
| 550 |
|
| 551 |
def _load_user_users_data(username: str):
|
| 552 |
+
row = _load_user_row(username)
|
| 553 |
+
if not row:
|
| 554 |
+
raise FileNotFoundError(f"用户 {username} 不存在")
|
| 555 |
+
try:
|
| 556 |
+
data = json.loads(row.get("users_data_json", "[]"))
|
| 557 |
+
except Exception as exc:
|
| 558 |
+
raise ValueError(f"用户 {username} 的 usersData 数据损坏:{exc}")
|
| 559 |
if not isinstance(data, list):
|
| 560 |
raise ValueError("usersData.json 必须是数组")
|
| 561 |
return data
|
| 562 |
|
| 563 |
|
| 564 |
def _save_user_users_data(username: str, users_data: list):
|
| 565 |
+
changed = _db_execute(
|
| 566 |
+
f"UPDATE `{USERS_TABLE}` SET users_data_json=%s WHERE username=%s",
|
| 567 |
+
(json.dumps(users_data, ensure_ascii=False), username),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 568 |
)
|
| 569 |
+
if changed == 0:
|
| 570 |
+
raise FileNotFoundError(f"用户 {username} 不存在")
|
| 571 |
+
|
| 572 |
+
|
| 573 |
+
def _sanitize_targets(values):
|
| 574 |
+
cleaned = []
|
| 575 |
+
seen = set()
|
| 576 |
+
for value in values or []:
|
| 577 |
+
text = str(value).strip()
|
| 578 |
+
if not text or text in seen:
|
| 579 |
+
continue
|
| 580 |
+
seen.add(text)
|
| 581 |
+
cleaned.append(text)
|
| 582 |
+
return cleaned
|
| 583 |
+
|
| 584 |
+
|
| 585 |
+
def _validate_and_normalize_users_data(raw_bytes: bytes):
|
| 586 |
+
try:
|
| 587 |
+
payload = json.loads(raw_bytes.decode("utf-8"))
|
| 588 |
+
except Exception as exc:
|
| 589 |
+
raise ValueError(f"上传文件不是合法 JSON:{exc}")
|
| 590 |
+
|
| 591 |
+
if not isinstance(payload, list) or not payload:
|
| 592 |
+
raise ValueError("usersData.json 必须是非空数组")
|
| 593 |
+
|
| 594 |
+
normalized = []
|
| 595 |
+
for idx, item in enumerate(payload):
|
| 596 |
+
if not isinstance(item, dict):
|
| 597 |
+
raise ValueError(f"第 {idx + 1} 条用户数据格式错误(必须是对象)")
|
| 598 |
+
|
| 599 |
+
unique_id = str(item.get("unique_id", "")).strip()
|
| 600 |
+
username = str(item.get("username", "")).strip()
|
| 601 |
+
cookies = item.get("cookies", [])
|
| 602 |
+
targets = item.get("targets", [])
|
| 603 |
+
|
| 604 |
+
if not unique_id:
|
| 605 |
+
raise ValueError(f"第 {idx + 1} 条缺少 unique_id")
|
| 606 |
+
if not username:
|
| 607 |
+
raise ValueError(f"第 {idx + 1} 条缺少 username")
|
| 608 |
+
if not isinstance(cookies, list) or not cookies:
|
| 609 |
+
raise ValueError(f"第 {idx + 1} 条 cookies 不能为空且必须是数组")
|
| 610 |
+
if not isinstance(targets, list):
|
| 611 |
+
raise ValueError(f"第 {idx + 1} 条 targets 必须是数组")
|
| 612 |
+
|
| 613 |
+
normalized.append(
|
| 614 |
+
{
|
| 615 |
+
"unique_id": unique_id,
|
| 616 |
+
"username": username,
|
| 617 |
+
"cookies": cookies,
|
| 618 |
+
"targets": _sanitize_targets(targets),
|
| 619 |
+
}
|
| 620 |
+
)
|
| 621 |
+
|
| 622 |
+
primary_username = normalized[0]["username"]
|
| 623 |
+
primary_unique_id = normalized[0]["unique_id"]
|
| 624 |
+
return normalized, primary_username, primary_unique_id
|
| 625 |
+
|
| 626 |
+
|
| 627 |
+
def _count_targets(users_data: list):
|
| 628 |
+
return sum(len(user.get("targets", [])) for user in users_data)
|
| 629 |
+
|
| 630 |
+
|
| 631 |
+
def _get_runtime(username: str):
|
| 632 |
+
with runtime_map_lock:
|
| 633 |
+
runtime = runtime_map.get(username)
|
| 634 |
+
if runtime is None:
|
| 635 |
+
runtime = UserRuntimeState(username=username)
|
| 636 |
+
runtime_map[username] = runtime
|
| 637 |
+
return runtime
|
| 638 |
+
|
| 639 |
+
|
| 640 |
+
def _delete_runtime(username: str):
|
| 641 |
+
with runtime_map_lock:
|
| 642 |
+
runtime_map.pop(username, None)
|
| 643 |
+
|
| 644 |
+
|
| 645 |
+
def _session_from_request(request: Request):
|
| 646 |
+
token = request.cookies.get(SESSION_COOKIE_NAME)
|
| 647 |
+
if not token:
|
| 648 |
+
return None
|
| 649 |
+
return AUTH_SESSIONS.get(token)
|
| 650 |
+
|
| 651 |
+
|
| 652 |
+
def _require_user_session(request: Request):
|
| 653 |
+
session = _session_from_request(request)
|
| 654 |
+
if not session or session.get("role") != "user":
|
| 655 |
+
raise HTTPException(
|
| 656 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 657 |
+
detail="未登录或登录已失效",
|
| 658 |
+
)
|
| 659 |
+
return session
|
| 660 |
+
|
| 661 |
+
|
| 662 |
+
def _require_admin_session(request: Request):
|
| 663 |
+
session = _session_from_request(request)
|
| 664 |
+
if not session or session.get("role") != "admin":
|
| 665 |
+
raise HTTPException(
|
| 666 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 667 |
+
detail="未登录或登录已失效",
|
| 668 |
+
)
|
| 669 |
+
return session
|
| 670 |
+
|
| 671 |
+
|
| 672 |
+
def _parse_time_string(value: str):
|
| 673 |
+
parts = value.strip().split(":")
|
| 674 |
+
if len(parts) not in (2, 3):
|
| 675 |
+
raise ValueError("时间格式错误,必须是 HH:MM")
|
| 676 |
+
hour = int(parts[0])
|
| 677 |
+
minute = int(parts[1])
|
| 678 |
+
if hour < 0 or hour > 23 or minute < 0 or minute > 59:
|
| 679 |
+
raise ValueError("时间范围错误,小时 0-23,分钟 0-59")
|
| 680 |
+
return hour, minute
|
| 681 |
+
|
| 682 |
+
|
| 683 |
+
def _build_editor_state(username: str):
|
| 684 |
+
cfg = _load_user_config(username)
|
| 685 |
+
users = _load_user_users_data(username)
|
| 686 |
+
return {
|
| 687 |
+
"message_template": str(cfg.get("messageTemplate", "")),
|
| 688 |
+
"users": [
|
| 689 |
+
{
|
| 690 |
+
"unique_id": str(user.get("unique_id", "")),
|
| 691 |
+
"username": str(user.get("username", "未知用户")),
|
| 692 |
+
"targets": _sanitize_targets(user.get("targets", [])),
|
| 693 |
+
}
|
| 694 |
+
for user in users
|
| 695 |
+
],
|
| 696 |
+
}
|
| 697 |
+
|
| 698 |
+
|
| 699 |
+
def _scheduler_job_id(username: str):
|
| 700 |
+
return f"daily_task::{username}"
|
| 701 |
+
|
| 702 |
+
|
| 703 |
+
def _run_scheduled_once(username: str):
|
| 704 |
+
runtime = _get_runtime(username)
|
| 705 |
+
runtime.run_once("schedule")
|
| 706 |
+
if scheduler:
|
| 707 |
+
job = scheduler.get_job(_scheduler_job_id(username))
|
| 708 |
+
runtime.update_next_run(job.next_run_time if job else None)
|
| 709 |
+
|
| 710 |
+
|
| 711 |
+
async def _run_user_tasks(username: str):
|
| 712 |
+
cfg = _load_user_config(username)
|
| 713 |
+
users_data = _load_user_users_data(username)
|
| 714 |
+
await runTasks(config=cfg, userData=users_data)
|
| 715 |
+
|
| 716 |
+
|
| 717 |
+
def _schedule_user_job(username: str):
|
| 718 |
+
global scheduler
|
| 719 |
+
|
| 720 |
+
cfg = _load_user_config(username)
|
| 721 |
+
scheduler_cfg = cfg.get("scheduler", {}) if isinstance(cfg, dict) else {}
|
| 722 |
+
enabled = bool(scheduler_cfg.get("enabled", True))
|
| 723 |
+
timezone = str(scheduler_cfg.get("timezone", DEFAULT_TIMEZONE))
|
| 724 |
+
hour = int(scheduler_cfg.get("hour", 9))
|
| 725 |
+
minute = int(scheduler_cfg.get("minute", 0))
|
| 726 |
+
|
| 727 |
+
runtime = _get_runtime(username)
|
| 728 |
+
runtime.update_schedule(hour, minute, timezone)
|
| 729 |
+
|
| 730 |
+
with scheduler_lock:
|
| 731 |
+
if scheduler is None:
|
| 732 |
+
scheduler = BackgroundScheduler(timezone=timezone)
|
| 733 |
+
scheduler.start()
|
| 734 |
+
|
| 735 |
+
job_id = _scheduler_job_id(username)
|
| 736 |
+
if not enabled:
|
| 737 |
+
if scheduler.get_job(job_id):
|
| 738 |
+
scheduler.remove_job(job_id)
|
| 739 |
+
runtime.update_next_run(None)
|
| 740 |
+
runtime.add_log("定时任务已禁用")
|
| 741 |
+
return
|
| 742 |
+
|
| 743 |
+
scheduler.add_job(
|
| 744 |
+
_run_scheduled_once,
|
| 745 |
+
args=[username],
|
| 746 |
+
trigger=CronTrigger(hour=hour, minute=minute, timezone=timezone),
|
| 747 |
+
id=job_id,
|
| 748 |
+
replace_existing=True,
|
| 749 |
+
max_instances=1,
|
| 750 |
+
coalesce=True,
|
| 751 |
+
)
|
| 752 |
+
job = scheduler.get_job(job_id)
|
| 753 |
+
runtime.update_next_run(job.next_run_time if job else None)
|
| 754 |
+
runtime.add_log(f"定时任务更新为 {hour:02d}:{minute:02d} ({timezone})")
|
| 755 |
+
|
| 756 |
+
|
| 757 |
+
def _remove_user_schedule_job(username: str):
|
| 758 |
+
with scheduler_lock:
|
| 759 |
+
if scheduler is None:
|
| 760 |
+
return
|
| 761 |
+
job_id = _scheduler_job_id(username)
|
| 762 |
+
if scheduler.get_job(job_id):
|
| 763 |
+
scheduler.remove_job(job_id)
|
| 764 |
+
|
| 765 |
+
|
| 766 |
+
def _start_background_run(username: str, trigger: str):
|
| 767 |
+
runtime = _get_runtime(username)
|
| 768 |
+
|
| 769 |
+
def _worker():
|
| 770 |
+
runtime.run_once(trigger)
|
| 771 |
+
if scheduler:
|
| 772 |
+
job = scheduler.get_job(_scheduler_job_id(username))
|
| 773 |
+
runtime.update_next_run(job.next_run_time if job else None)
|
| 774 |
+
|
| 775 |
+
thread = threading.Thread(target=_worker, daemon=True)
|
| 776 |
+
thread.start()
|
| 777 |
+
return True
|
| 778 |
+
|
| 779 |
+
|
| 780 |
+
def _start_scheduler():
|
| 781 |
+
global scheduler
|
| 782 |
+
_ensure_data_layout()
|
| 783 |
+
with scheduler_lock:
|
| 784 |
+
if scheduler is None:
|
| 785 |
+
scheduler = BackgroundScheduler(timezone=DEFAULT_TIMEZONE)
|
| 786 |
+
scheduler.start()
|
| 787 |
+
|
| 788 |
+
users_map = _load_users_meta()
|
| 789 |
+
for username in users_map.keys():
|
| 790 |
+
_schedule_user_job(username)
|
| 791 |
+
cfg = _load_user_config(username)
|
| 792 |
+
run_on_startup = bool(cfg.get("scheduler", {}).get("runOnStartup", False))
|
| 793 |
+
if run_on_startup:
|
| 794 |
+
_start_background_run(username, "startup")
|
| 795 |
+
|
| 796 |
+
|
| 797 |
+
def _stop_scheduler():
|
| 798 |
+
global scheduler
|
| 799 |
+
with scheduler_lock:
|
| 800 |
+
if scheduler and scheduler.running:
|
| 801 |
+
scheduler.shutdown(wait=False)
|
| 802 |
+
logger.info("Scheduler stopped.")
|
| 803 |
+
scheduler = None
|
| 804 |
+
|
| 805 |
+
|
| 806 |
+
app = FastAPI(title="DouYin Spark Flow Dashboard")
|
| 807 |
+
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
| 808 |
+
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
| 809 |
+
|
| 810 |
+
|
| 811 |
+
@app.on_event("startup")
|
| 812 |
+
async def on_startup():
|
| 813 |
+
_ensure_data_layout()
|
| 814 |
+
_start_scheduler()
|
| 815 |
+
atexit.register(_stop_scheduler)
|
| 816 |
+
|
| 817 |
+
|
| 818 |
+
@app.on_event("shutdown")
|
| 819 |
+
async def on_shutdown():
|
| 820 |
+
_stop_scheduler()
|
| 821 |
+
|
| 822 |
+
|
| 823 |
+
@app.get("/", response_class=HTMLResponse)
|
| 824 |
+
async def dashboard(request: Request):
|
| 825 |
+
session = _session_from_request(request)
|
| 826 |
+
if not session:
|
| 827 |
+
return RedirectResponse(url="/login", status_code=303)
|
| 828 |
+
if session.get("role") == "admin":
|
| 829 |
+
return RedirectResponse(url="/admin", status_code=303)
|
| 830 |
+
|
| 831 |
+
username = session.get("username")
|
| 832 |
+
runtime = _get_runtime(username)
|
| 833 |
+
return templates.TemplateResponse(
|
| 834 |
+
"dashboard.html",
|
| 835 |
+
{
|
| 836 |
+
"request": request,
|
| 837 |
+
"default_time": runtime.schedule_time(),
|
| 838 |
+
"username": username,
|
| 839 |
+
},
|
| 840 |
+
)
|
| 841 |
+
|
| 842 |
+
|
| 843 |
+
@app.get("/login", response_class=HTMLResponse)
|
| 844 |
+
async def login_page(request: Request):
|
| 845 |
+
session = _session_from_request(request)
|
| 846 |
+
if session:
|
| 847 |
+
if session.get("role") == "admin":
|
| 848 |
+
return RedirectResponse(url="/admin", status_code=303)
|
| 849 |
+
return RedirectResponse(url="/", status_code=303)
|
| 850 |
+
return templates.TemplateResponse("login.html", {"request": request})
|
| 851 |
+
|
| 852 |
+
|
| 853 |
+
@app.get("/register", response_class=HTMLResponse)
|
| 854 |
+
async def register_page(request: Request):
|
| 855 |
+
session = _session_from_request(request)
|
| 856 |
+
if session:
|
| 857 |
+
if session.get("role") == "admin":
|
| 858 |
+
return RedirectResponse(url="/admin", status_code=303)
|
| 859 |
+
return RedirectResponse(url="/", status_code=303)
|
| 860 |
+
return templates.TemplateResponse("register.html", {"request": request})
|
| 861 |
+
|
| 862 |
+
|
| 863 |
+
@app.get("/admin", response_class=HTMLResponse)
|
| 864 |
+
async def admin_page(request: Request):
|
| 865 |
+
session = _session_from_request(request)
|
| 866 |
+
if not session or session.get("role") != "admin":
|
| 867 |
+
return templates.TemplateResponse(
|
| 868 |
+
"admin_login.html",
|
| 869 |
+
{
|
| 870 |
+
"request": request,
|
| 871 |
+
"password_missing": not bool(os.getenv("PASSWORD")),
|
| 872 |
+
},
|
| 873 |
+
)
|
| 874 |
+
return templates.TemplateResponse("admin.html", {"request": request})
|
| 875 |
+
|
| 876 |
+
|
| 877 |
+
@app.post("/api/login")
|
| 878 |
+
async def api_login(payload: UserLoginPayload):
|
| 879 |
+
username = payload.username.strip()
|
| 880 |
+
if not username:
|
| 881 |
+
return JSONResponse(status_code=400, content={"ok": False, "message": "用户名不能为空。"})
|
| 882 |
+
|
| 883 |
+
users_map = _load_users_meta()
|
| 884 |
+
user = users_map.get(username)
|
| 885 |
+
if not user:
|
| 886 |
+
return JSONResponse(status_code=401, content={"ok": False, "message": "用户名或密码错误。"})
|
| 887 |
+
|
| 888 |
+
if not _verify_password(payload.password, user.get("password_salt", ""), user.get("password_hash", "")):
|
| 889 |
+
return JSONResponse(status_code=401, content={"ok": False, "message": "用户名或密码错误。"})
|
| 890 |
+
|
| 891 |
+
token = secrets.token_urlsafe(32)
|
| 892 |
+
AUTH_SESSIONS[token] = {"role": "user", "username": username}
|
| 893 |
+
|
| 894 |
+
response = JSONResponse({"ok": True, "message": "登录成功。"})
|
| 895 |
+
response.set_cookie(
|
| 896 |
+
key=SESSION_COOKIE_NAME,
|
| 897 |
+
value=token,
|
| 898 |
+
httponly=True,
|
| 899 |
+
samesite="lax",
|
| 900 |
+
max_age=7 * 24 * 3600,
|
| 901 |
+
)
|
| 902 |
+
return response
|
| 903 |
+
|
| 904 |
+
|
| 905 |
+
@app.post("/api/admin/login")
|
| 906 |
+
async def api_admin_login(payload: AdminLoginPayload):
|
| 907 |
+
expected_password = os.getenv("PASSWORD")
|
| 908 |
+
if not expected_password:
|
| 909 |
+
return JSONResponse(
|
| 910 |
+
status_code=500,
|
| 911 |
+
content={"ok": False, "message": "服务端未配置 PASSWORD 环境变量。"},
|
| 912 |
+
)
|
| 913 |
+
|
| 914 |
+
if payload.password != expected_password:
|
| 915 |
+
return JSONResponse(status_code=401, content={"ok": False, "message": "密码错误。"})
|
| 916 |
+
|
| 917 |
+
token = secrets.token_urlsafe(32)
|
| 918 |
+
AUTH_SESSIONS[token] = {"role": "admin", "username": "admin"}
|
| 919 |
+
response = JSONResponse({"ok": True, "message": "登录成功。"})
|
| 920 |
+
response.set_cookie(
|
| 921 |
+
key=SESSION_COOKIE_NAME,
|
| 922 |
+
value=token,
|
| 923 |
+
httponly=True,
|
| 924 |
+
samesite="lax",
|
| 925 |
+
max_age=7 * 24 * 3600,
|
| 926 |
+
)
|
| 927 |
+
return response
|
| 928 |
+
|
| 929 |
+
|
| 930 |
+
@app.post("/api/register")
|
| 931 |
+
async def api_register(password: str = Form(...), users_file: UploadFile = File(...)):
|
| 932 |
+
if len(password.strip()) < 4:
|
| 933 |
+
return JSONResponse(status_code=400, content={"ok": False, "message": "密码至少 4 位。"})
|
| 934 |
+
|
| 935 |
+
if not users_file.filename.lower().endswith(".json"):
|
| 936 |
+
return JSONResponse(status_code=400, content={"ok": False, "message": "请上传 usersData.json 文件。"})
|
| 937 |
+
|
| 938 |
+
try:
|
| 939 |
+
raw = await users_file.read()
|
| 940 |
+
users_data, username, unique_id = _validate_and_normalize_users_data(raw)
|
| 941 |
+
except Exception as exc:
|
| 942 |
+
return JSONResponse(status_code=400, content={"ok": False, "message": str(exc)})
|
| 943 |
+
|
| 944 |
+
users_map = _load_users_meta()
|
| 945 |
+
if username in users_map:
|
| 946 |
+
return JSONResponse(status_code=409, content={"ok": False, "message": f"用户名 {username} 已注册。"})
|
| 947 |
+
|
| 948 |
+
for existing in users_map.values():
|
| 949 |
+
if str(existing.get("unique_id", "")).strip() == unique_id:
|
| 950 |
+
return JSONResponse(status_code=409, content={"ok": False, "message": f"unique_id {unique_id} 已注册。"})
|
| 951 |
+
|
| 952 |
default_config = _get_default_user_config()
|
| 953 |
default_config.setdefault("scheduler", {})
|
| 954 |
default_config["scheduler"].setdefault("enabled", True)
|
|
|
|
| 956 |
default_config["scheduler"].setdefault("hour", 9)
|
| 957 |
default_config["scheduler"].setdefault("minute", 0)
|
| 958 |
default_config["scheduler"].setdefault("runOnStartup", False)
|
|
|
|
| 959 |
|
| 960 |
hash_data = _hash_password(password.strip())
|
| 961 |
+
created_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 962 |
try:
|
| 963 |
+
_create_user_record(
|
| 964 |
+
username=username,
|
| 965 |
+
unique_id=unique_id,
|
| 966 |
+
password_hash=hash_data["hash"],
|
| 967 |
+
password_salt=hash_data["salt"],
|
| 968 |
+
created_at=created_at,
|
| 969 |
+
config_payload=default_config,
|
| 970 |
+
users_data_payload=users_data,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 971 |
)
|
| 972 |
+
except pymysql.err.IntegrityError:
|
| 973 |
+
return JSONResponse(status_code=409, content={"ok": False, "message": "用户名或 unique_id 已注册。"})
|
| 974 |
|
| 975 |
+
_schedule_user_job(username)
|
| 976 |
+
_get_runtime(username).add_log("用户已注册并完成定时任务初始化")
|
| 977 |
+
|
| 978 |
+
return {
|
| 979 |
+
"ok": True,
|
| 980 |
+
"message": "注册成功,请使用用户名和密码登录。",
|
| 981 |
+
"username": username,
|
| 982 |
+
}
|
| 983 |
+
|
| 984 |
+
|
| 985 |
+
@app.post("/api/logout")
|
| 986 |
+
async def api_logout(request: Request):
|
| 987 |
+
token = request.cookies.get(SESSION_COOKIE_NAME)
|
| 988 |
+
if token:
|
| 989 |
+
AUTH_SESSIONS.pop(token, None)
|
| 990 |
+
response = JSONResponse({"ok": True})
|
| 991 |
+
response.delete_cookie(SESSION_COOKIE_NAME)
|
| 992 |
+
return response
|
| 993 |
+
|
| 994 |
+
|
| 995 |
+
@app.get("/api/status")
|
| 996 |
+
async def api_status(request: Request):
|
| 997 |
+
session = _require_user_session(request)
|
| 998 |
+
username = session["username"]
|
| 999 |
+
runtime = _get_runtime(username)
|
| 1000 |
+
users_data = _load_user_users_data(username)
|
| 1001 |
+
return {
|
| 1002 |
+
"ok": True,
|
| 1003 |
+
"runtime": runtime.snapshot(
|
| 1004 |
+
account_count=len(users_data),
|
| 1005 |
+
target_count=_count_targets(users_data),
|
| 1006 |
+
),
|
| 1007 |
+
"history": runtime.history_rows(),
|
| 1008 |
+
}
|
| 1009 |
+
|
| 1010 |
+
|
| 1011 |
+
@app.get("/api/logs")
|
| 1012 |
+
async def api_logs(request: Request, limit: int = MAX_LOG_LINES):
|
| 1013 |
+
session = _require_user_session(request)
|
| 1014 |
+
username = session["username"]
|
| 1015 |
+
runtime = _get_runtime(username)
|
| 1016 |
+
limit = min(max(100, limit), 3000)
|
| 1017 |
+
return {"ok": True, "logs": runtime.recent_logs(limit=limit)}
|
| 1018 |
+
|
| 1019 |
+
|
| 1020 |
+
@app.post("/api/run")
|
| 1021 |
+
async def api_run(request: Request):
|
| 1022 |
+
session = _require_user_session(request)
|
| 1023 |
+
username = session["username"]
|
| 1024 |
+
runtime = _get_runtime(username)
|
| 1025 |
+
|
| 1026 |
+
if runtime.is_running:
|
| 1027 |
+
return JSONResponse(
|
| 1028 |
+
status_code=409,
|
| 1029 |
+
content={"ok": False, "message": "已有任务正在执行,请稍后再试。"},
|
| 1030 |
+
)
|
| 1031 |
+
|
| 1032 |
+
_start_background_run(username, "manual")
|
| 1033 |
+
return {"ok": True, "message": "任务已开始执行。"}
|
| 1034 |
+
|
| 1035 |
+
|
| 1036 |
+
@app.post("/api/schedule")
|
| 1037 |
+
async def api_schedule(request: Request, payload: SchedulePayload):
|
| 1038 |
+
session = _require_user_session(request)
|
| 1039 |
+
username = session["username"]
|
| 1040 |
+
|
| 1041 |
+
try:
|
| 1042 |
+
hour, minute = _parse_time_string(payload.time)
|
| 1043 |
+
except Exception as exc:
|
| 1044 |
+
return JSONResponse(status_code=400, content={"ok": False, "message": str(exc)})
|
| 1045 |
+
|
| 1046 |
+
cfg = _load_user_config(username)
|
| 1047 |
+
scheduler_cfg = cfg.setdefault("scheduler", {})
|
| 1048 |
+
scheduler_cfg["enabled"] = True
|
| 1049 |
+
scheduler_cfg["hour"] = hour
|
| 1050 |
+
scheduler_cfg["minute"] = minute
|
| 1051 |
+
scheduler_cfg["timezone"] = str(scheduler_cfg.get("timezone", DEFAULT_TIMEZONE))
|
| 1052 |
+
scheduler_cfg["runOnStartup"] = bool(scheduler_cfg.get("runOnStartup", False))
|
| 1053 |
+
_save_user_config(username, cfg)
|
| 1054 |
+
|
| 1055 |
+
_schedule_user_job(username)
|
| 1056 |
+
runtime = _get_runtime(username)
|
| 1057 |
+
return {
|
| 1058 |
+
"ok": True,
|
| 1059 |
+
"message": f"定时任务已更新为每天 {hour:02d}:{minute:02d}。",
|
| 1060 |
+
"time": f"{hour:02d}:{minute:02d}",
|
| 1061 |
+
"next_run": runtime.snapshot(0, 0)["next_run"],
|
| 1062 |
+
}
|
| 1063 |
+
|
| 1064 |
+
|
| 1065 |
+
@app.get("/api/editor/state")
|
| 1066 |
+
async def api_editor_state(request: Request):
|
| 1067 |
+
session = _require_user_session(request)
|
| 1068 |
+
username = session["username"]
|
| 1069 |
+
return {"ok": True, **_build_editor_state(username)}
|
| 1070 |
+
|
| 1071 |
+
|
| 1072 |
+
@app.post("/api/editor/message")
|
| 1073 |
+
async def api_editor_message(request: Request, payload: MessageTemplatePayload):
|
| 1074 |
+
session = _require_user_session(request)
|
| 1075 |
+
username = session["username"]
|
| 1076 |
+
|
| 1077 |
+
message = payload.message.strip()
|
| 1078 |
+
if not message:
|
| 1079 |
+
return JSONResponse(status_code=400, content={"ok": False, "message": "消息内容不能为空。"})
|
| 1080 |
+
if len(message) > MAX_TEMPLATE_LENGTH:
|
| 1081 |
+
return JSONResponse(
|
| 1082 |
+
status_code=400,
|
| 1083 |
+
content={"ok": False, "message": f"消息内容过长,最多 {MAX_TEMPLATE_LENGTH} 字符。"},
|
| 1084 |
+
)
|
| 1085 |
+
|
| 1086 |
+
cfg = _load_user_config(username)
|
| 1087 |
+
cfg["messageTemplate"] = message
|
| 1088 |
+
_save_user_config(username, cfg)
|
| 1089 |
+
_get_runtime(username).add_log("消息模板已更新")
|
| 1090 |
+
return {"ok": True, "message": "消息模板已保存。"}
|
| 1091 |
+
|
| 1092 |
+
|
| 1093 |
+
@app.post("/api/editor/targets")
|
| 1094 |
+
async def api_editor_targets(request: Request, payload: UserTargetsPayload):
|
| 1095 |
+
session = _require_user_session(request)
|
| 1096 |
+
username = session["username"]
|
| 1097 |
+
|
| 1098 |
+
users_data = _load_user_users_data(username)
|
| 1099 |
+
updates = {item.unique_id: _sanitize_targets(item.targets) for item in payload.users}
|
| 1100 |
+
|
| 1101 |
+
updated = 0
|
| 1102 |
+
for user in users_data:
|
| 1103 |
+
uid = str(user.get("unique_id", ""))
|
| 1104 |
+
if uid in updates:
|
| 1105 |
+
user["targets"] = updates[uid]
|
| 1106 |
+
updated += 1
|
| 1107 |
+
|
| 1108 |
+
_save_user_users_data(username, users_data)
|
| 1109 |
+
_get_runtime(username).add_log(f"目标好友已更新,涉及账号数:{updated}")
|
| 1110 |
+
return {"ok": True, "message": f"目标好友已保存({updated} 个账号)。"}
|
| 1111 |
+
|
| 1112 |
+
|
| 1113 |
+
@app.get("/api/admin/overview")
|
| 1114 |
+
async def api_admin_overview(request: Request):
|
| 1115 |
+
_require_admin_session(request)
|
| 1116 |
+
users_map = _load_users_meta()
|
| 1117 |
+
|
| 1118 |
+
rows = []
|
| 1119 |
+
for username, meta in sorted(users_map.items(), key=lambda x: x[0]):
|
| 1120 |
+
try:
|
| 1121 |
+
cfg = _load_user_config(username)
|
| 1122 |
+
users_data = _load_user_users_data(username)
|
| 1123 |
+
except Exception as exc:
|
| 1124 |
+
rows.append(
|
| 1125 |
+
{
|
| 1126 |
+
"username": username,
|
| 1127 |
+
"unique_id": meta.get("unique_id", ""),
|
| 1128 |
+
"created_at": meta.get("created_at", "-"),
|
| 1129 |
+
"error": str(exc),
|
| 1130 |
+
}
|
| 1131 |
+
)
|
| 1132 |
+
continue
|
| 1133 |
+
|
| 1134 |
+
scheduler_cfg = cfg.get("scheduler", {})
|
| 1135 |
+
runtime = _get_runtime(username)
|
| 1136 |
+
runtime_snapshot = runtime.snapshot(
|
| 1137 |
+
account_count=len(users_data),
|
| 1138 |
+
target_count=_count_targets(users_data),
|
| 1139 |
+
)
|
| 1140 |
+
|
| 1141 |
+
receivers = []
|
| 1142 |
+
for item in users_data:
|
| 1143 |
+
receivers.extend(item.get("targets", []))
|
| 1144 |
+
|
| 1145 |
+
rows.append(
|
| 1146 |
+
{
|
| 1147 |
+
"username": username,
|
| 1148 |
+
"unique_id": meta.get("unique_id", ""),
|
| 1149 |
+
"created_at": meta.get("created_at", "-"),
|
| 1150 |
+
"scheduler_enabled": bool(scheduler_cfg.get("enabled", True)),
|
| 1151 |
+
"schedule_time": f"{int(scheduler_cfg.get('hour', 9)):02d}:{int(scheduler_cfg.get('minute', 0)):02d}",
|
| 1152 |
+
"schedule_timezone": str(scheduler_cfg.get("timezone", DEFAULT_TIMEZONE)),
|
| 1153 |
+
"message_template": str(cfg.get("messageTemplate", "")),
|
| 1154 |
+
"targets": receivers,
|
| 1155 |
+
"target_count": len(receivers),
|
| 1156 |
+
"next_run": runtime_snapshot.get("next_run", "-"),
|
| 1157 |
+
"last_status": runtime_snapshot.get("last_status", "-"),
|
| 1158 |
+
"last_start": runtime_snapshot.get("last_start", "-"),
|
| 1159 |
+
"is_running": runtime_snapshot.get("is_running", False),
|
| 1160 |
+
}
|
| 1161 |
+
)
|
| 1162 |
+
|
| 1163 |
+
return {
|
| 1164 |
+
"ok": True,
|
| 1165 |
+
"users": rows,
|
| 1166 |
+
"task_count": len(rows),
|
| 1167 |
+
}
|
| 1168 |
+
|
| 1169 |
+
|
| 1170 |
+
@app.get("/api/admin/tasks/{username}")
|
| 1171 |
+
async def api_admin_task_detail(request: Request, username: str, log_limit: int = MAX_LOG_LINES):
|
| 1172 |
+
_require_admin_session(request)
|
| 1173 |
+
username = username.strip()
|
| 1174 |
+
user_meta = _get_user_meta_or_404(username)
|
| 1175 |
+
|
| 1176 |
+
try:
|
| 1177 |
+
cfg = _load_user_config(username)
|
| 1178 |
+
users_data = _load_user_users_data(username)
|
| 1179 |
+
except Exception as exc:
|
| 1180 |
+
return JSONResponse(
|
| 1181 |
+
status_code=500,
|
| 1182 |
+
content={"ok": False, "message": f"加载任务详情失败:{exc}"},
|
| 1183 |
+
)
|
| 1184 |
+
|
| 1185 |
+
scheduler_cfg = cfg.get("scheduler", {})
|
| 1186 |
+
runtime = _get_runtime(username)
|
| 1187 |
+
target_count = _count_targets(users_data)
|
| 1188 |
+
snapshot = runtime.snapshot(account_count=len(users_data), target_count=target_count)
|
| 1189 |
+
|
| 1190 |
+
accounts = []
|
| 1191 |
+
all_targets = []
|
| 1192 |
+
for item in users_data:
|
| 1193 |
+
targets = _sanitize_targets(item.get("targets", []))
|
| 1194 |
+
all_targets.extend(targets)
|
| 1195 |
+
accounts.append(
|
| 1196 |
+
{
|
| 1197 |
+
"username": str(item.get("username", "未知用户")),
|
| 1198 |
+
"unique_id": str(item.get("unique_id", "")),
|
| 1199 |
+
"target_count": len(targets),
|
| 1200 |
+
"targets": targets,
|
| 1201 |
+
"cookie_count": len(item.get("cookies", [])) if isinstance(item.get("cookies", []), list) else 0,
|
| 1202 |
+
}
|
| 1203 |
+
)
|
| 1204 |
+
|
| 1205 |
+
log_limit = min(max(100, log_limit), 3000)
|
| 1206 |
+
return {
|
| 1207 |
+
"ok": True,
|
| 1208 |
+
"task": {
|
| 1209 |
+
"username": username,
|
| 1210 |
+
"unique_id": user_meta.get("unique_id", ""),
|
| 1211 |
+
"created_at": user_meta.get("created_at", "-"),
|
| 1212 |
+
"scheduler_enabled": bool(scheduler_cfg.get("enabled", True)),
|
| 1213 |
+
"schedule_time": f"{int(scheduler_cfg.get('hour', 9)):02d}:{int(scheduler_cfg.get('minute', 0)):02d}",
|
| 1214 |
+
"schedule_timezone": str(scheduler_cfg.get("timezone", DEFAULT_TIMEZONE)),
|
| 1215 |
+
"message_template": str(cfg.get("messageTemplate", "")),
|
| 1216 |
+
"targets": all_targets,
|
| 1217 |
+
"target_count": len(all_targets),
|
| 1218 |
+
"runtime": snapshot,
|
| 1219 |
+
"history": runtime.history_rows(),
|
| 1220 |
+
"logs": runtime.recent_logs(limit=log_limit),
|
| 1221 |
+
"config": {
|
| 1222 |
+
"multiTask": bool(cfg.get("multiTask", True)),
|
| 1223 |
+
"taskCount": int(cfg.get("taskCount", 1) or 1),
|
| 1224 |
+
"hitokotoTypes": cfg.get("hitokotoTypes", []),
|
| 1225 |
+
"proxyAddress": str(cfg.get("proxyAddress", "")),
|
| 1226 |
+
},
|
| 1227 |
+
"accounts": accounts,
|
| 1228 |
+
},
|
| 1229 |
+
}
|
| 1230 |
+
|
| 1231 |
+
|
| 1232 |
+
@app.post("/api/admin/tasks/{username}/delete")
|
| 1233 |
+
async def api_admin_delete_task(request: Request, username: str):
|
| 1234 |
+
_require_admin_session(request)
|
| 1235 |
+
username = username.strip()
|
| 1236 |
+
_get_user_meta_or_404(username)
|
| 1237 |
+
|
| 1238 |
+
cfg = _load_user_config(username)
|
| 1239 |
+
scheduler_cfg = cfg.setdefault("scheduler", {})
|
| 1240 |
+
scheduler_cfg["enabled"] = False
|
| 1241 |
+
_save_user_config(username, cfg)
|
| 1242 |
+
|
| 1243 |
+
_remove_user_schedule_job(username)
|
| 1244 |
+
runtime = _get_runtime(username)
|
| 1245 |
+
runtime.update_next_run(None)
|
| 1246 |
+
runtime.add_log("管理员已删除(禁用)该用户���时任务")
|
| 1247 |
+
|
| 1248 |
+
return {"ok": True, "message": f"已删除用户 {username} 的定时任务。"}
|
| 1249 |
+
|
| 1250 |
+
|
| 1251 |
+
@app.delete("/api/admin/users/{username}")
|
| 1252 |
+
async def api_admin_delete_user(request: Request, username: str):
|
| 1253 |
+
_require_admin_session(request)
|
| 1254 |
+
username = username.strip()
|
| 1255 |
+
|
| 1256 |
_get_user_meta_or_404(username)
|
| 1257 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1258 |
_remove_user_schedule_job(username)
|
| 1259 |
+
_delete_user_record(username)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1260 |
_delete_runtime(username)
|
| 1261 |
|
| 1262 |
return {"ok": True, "message": f"用户 {username} 已删除。"}
|
| 1263 |
+
|
| 1264 |
+
|
| 1265 |
+
@app.get("/health")
|
| 1266 |
+
async def health():
|
| 1267 |
+
return {"ok": True, "status": "alive"}
|
| 1268 |
+
|
| 1269 |
+
|
| 1270 |
+
def run_server():
|
| 1271 |
+
port = int(os.getenv("PORT", "7860"))
|
| 1272 |
+
uvicorn.run("app:app", host="0.0.0.0", port=port, workers=1)
|
| 1273 |
+
|
| 1274 |
+
|
| 1275 |
+
if __name__ == "__main__":
|
| 1276 |
run_server()
|
camysql2.pem
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-----BEGIN CERTIFICATE-----
|
| 2 |
+
MIIERDCCAqygAwIBAgIUIZeUQD0xvEAJNu72uWDNezVfx/cwDQYJKoZIhvcNAQEM
|
| 3 |
+
BQAwOjE4MDYGA1UEAwwvOTYzMmFjZDktZjBhOC00NjQ4LTg3M2QtNTRkYTAxNWEz
|
| 4 |
+
NzllIFByb2plY3QgQ0EwHhcNMjYwMzA1MDgzMTU3WhcNMzYwMzAyMDgzMTU3WjA6
|
| 5 |
+
MTgwNgYDVQQDDC85NjMyYWNkOS1mMGE4LTQ2NDgtODczZC01NGRhMDE1YTM3OWUg
|
| 6 |
+
UHJvamVjdCBDQTCCAaIwDQYJKoZIhvcNAQEBBQADggGPADCCAYoCggGBAMSAckGu
|
| 7 |
+
d3mDTOTdgrL/mCfF7WkNWx6CKNGU/R5WaKdJ0Ub0Fbsu3erzQ7Qi877qcPSQg06G
|
| 8 |
+
qH9umxOvq6hZG2lrNXg+8AJihezMME1zmODu+OEOX96jsUn5Pr8OSa68zzRkoiOl
|
| 9 |
+
fXtVDYwTaIhexLJT/U6ELKqUxkBIVHTSmY/hp0SFOuEtMdra6dbJdMOXrGhI+IQK
|
| 10 |
+
KmbRh9H208NpfzjQBos1g27D4YHBe1p55CfihDFEso22i98Wxu2kRqv6hz2n35Qi
|
| 11 |
+
PNdJEi38ascZCGjx24VuuhXGcghHC1GxuIcArxWExNt880HtGztSIu6mBeSW+k9m
|
| 12 |
+
HQiORu9TGuaErD/Xa33wM+sLKFbjBspCjkejwWWp7Kz4T+yup1lOxBDZlqaBgUdD
|
| 13 |
+
MqIcIwBlG/kEUqTPHHKiTcGzg/8KUVDtTTofEAEi5MSiu/7EBQkN/jcAmEfwm4E4
|
| 14 |
+
eOCINwkv53IL5ZRKVg7+sPg0a3mFe2nC0jpO8SskAe00ny3glN+uVzG+2wIDAQAB
|
| 15 |
+
o0IwQDAdBgNVHQ4EFgQUecg1zbigo7JiWmgY17t7E4L8cFIwEgYDVR0TAQH/BAgw
|
| 16 |
+
BgEB/wIBADALBgNVHQ8EBAMCAQYwDQYJKoZIhvcNAQEMBQADggGBAFSdhJQ5fCO7
|
| 17 |
+
FC4I7ri5Gh93iBzjFplqTpbKYZft1RCRZy/ddvwSh4RMylb4MAQbDNs5D/c45E0u
|
| 18 |
+
CUSc449KkVmoZsrtRwQY7Z9BLGaSDlWNca8FO+cGNqrM7vhXOeMYiGADIeg2M/yU
|
| 19 |
+
QMmNxR+Y6nQO6wHP9r3BG1rNMBrjNcOjIXoo65qXv3PGLmNnjlLWyHwTmkfm6E4o
|
| 20 |
+
RIPPWQNo7zVzUnxNiGEDUOpMpeA/JxK4BY44JZTRy4TNVCoM+Qn6PoY4IYyFVjFo
|
| 21 |
+
CG0dBZPHdSeeJGgqJjOVhQQj2hq7BMVdgMSIv1jhUezHQeyfW8XF42C80qG09psI
|
| 22 |
+
CLuxu2geZC/4Y+I5NR21EecUf8nDHNcBHObAOsrPM7oOd7iPpiEMKh2s6kRa4yGt
|
| 23 |
+
LWOL391h5HgNnNcancVe8fXuZ4q7ul4NmQYYt8vtj9e82VpppKPwCPtIZGi4XEv5
|
| 24 |
+
qrYctjxBQUnw+Wp/aV+Q4MN1CD188XEJyo0re0n5Lzi/VBj8suXzcg==
|
| 25 |
+
-----END CERTIFICATE-----
|
requirements.txt
CHANGED
|
@@ -1,14 +1,15 @@
|
|
| 1 |
certifi==2025.11.12
|
| 2 |
charset-normalizer==3.4.4
|
| 3 |
colorama==0.4.6
|
| 4 |
-
APScheduler>=3.10,<4
|
| 5 |
fastapi>=0.115,<1
|
| 6 |
python-multipart>=0.0.9,<1
|
| 7 |
uvicorn[standard]>=0.30,<1
|
| 8 |
-
jinja2>=3.1,<4
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
| 12 |
mdurl==0.1.2
|
| 13 |
playwright==1.56.0
|
| 14 |
pyee==13.0.0
|
|
@@ -18,4 +19,4 @@ qrcode==8.2
|
|
| 18 |
requests==2.32.5
|
| 19 |
rich==14.2.0
|
| 20 |
typing_extensions==4.15.0
|
| 21 |
-
urllib3==2.5.0
|
|
|
|
| 1 |
certifi==2025.11.12
|
| 2 |
charset-normalizer==3.4.4
|
| 3 |
colorama==0.4.6
|
| 4 |
+
APScheduler>=3.10,<4
|
| 5 |
fastapi>=0.115,<1
|
| 6 |
python-multipart>=0.0.9,<1
|
| 7 |
uvicorn[standard]>=0.30,<1
|
| 8 |
+
jinja2>=3.1,<4
|
| 9 |
+
PyMySQL>=1.1,<2
|
| 10 |
+
greenlet==3.2.4
|
| 11 |
+
idna==3.11
|
| 12 |
+
markdown-it-py==4.0.0
|
| 13 |
mdurl==0.1.2
|
| 14 |
playwright==1.56.0
|
| 15 |
pyee==13.0.0
|
|
|
|
| 19 |
requests==2.32.5
|
| 20 |
rich==14.2.0
|
| 21 |
typing_extensions==4.15.0
|
| 22 |
+
urllib3==2.5.0
|