Upload 3 files
Browse files- app.py +1053 -881
- templates/admin.html +314 -11
app.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
-
|
| 2 |
-
import atexit
|
| 3 |
-
import hashlib
|
| 4 |
import json
|
| 5 |
import logging
|
| 6 |
import os
|
|
@@ -15,20 +15,20 @@ 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"
|
|
@@ -47,180 +47,190 @@ DEFAULT_TIMEZONE = "Asia/Shanghai"
|
|
| 47 |
MAX_LOG_LINES = 1200
|
| 48 |
MAX_TEMPLATE_LENGTH = 2000
|
| 49 |
PASSWORD_ITERATIONS = 210000
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
"
|
| 55 |
-
"
|
| 56 |
-
"
|
| 57 |
-
"
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
"
|
| 61 |
-
"
|
| 62 |
-
"
|
| 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 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
self.
|
| 84 |
-
self.
|
| 85 |
-
self.
|
| 86 |
-
self.
|
| 87 |
-
self.
|
| 88 |
-
self.
|
| 89 |
-
self.
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
def
|
| 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 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
self.
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
self.
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
with self._state_lock:
|
| 175 |
-
self.
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
class
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
class
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
def _ensure_data_layout():
|
| 225 |
global db_initialized
|
| 226 |
if db_initialized:
|
|
@@ -231,22 +241,22 @@ def _ensure_data_layout():
|
|
| 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)
|
|
@@ -274,6 +284,35 @@ def _merge_config_with_defaults(raw_cfg: Any):
|
|
| 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:
|
|
@@ -331,8 +370,18 @@ def _build_mysql_conn_kwargs():
|
|
| 331 |
return kwargs
|
| 332 |
|
| 333 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
def _db_query_all(query: str, params=()):
|
| 335 |
-
conn =
|
| 336 |
try:
|
| 337 |
with conn.cursor() as cursor:
|
| 338 |
cursor.execute(query, params)
|
|
@@ -342,7 +391,7 @@ def _db_query_all(query: str, params=()):
|
|
| 342 |
|
| 343 |
|
| 344 |
def _db_query_one(query: str, params=()):
|
| 345 |
-
conn =
|
| 346 |
try:
|
| 347 |
with conn.cursor() as cursor:
|
| 348 |
cursor.execute(query, params)
|
|
@@ -352,7 +401,7 @@ def _db_query_one(query: str, params=()):
|
|
| 352 |
|
| 353 |
|
| 354 |
def _db_execute(query: str, params=()):
|
| 355 |
-
conn =
|
| 356 |
try:
|
| 357 |
with conn.cursor() as cursor:
|
| 358 |
cursor.execute(query, params)
|
|
@@ -362,6 +411,7 @@ def _db_execute(query: str, params=()):
|
|
| 362 |
|
| 363 |
|
| 364 |
def _init_db_schema():
|
|
|
|
| 365 |
_db_execute(
|
| 366 |
f"""
|
| 367 |
CREATE TABLE IF NOT EXISTS `{USERS_TABLE}` (
|
|
@@ -455,6 +505,7 @@ def _migrate_legacy_file_data_if_needed():
|
|
| 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
|
|
@@ -466,6 +517,7 @@ def _load_users_meta():
|
|
| 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
|
|
@@ -477,6 +529,7 @@ def _load_user_row(username: str):
|
|
| 477 |
|
| 478 |
|
| 479 |
def _user_exists(username: str):
|
|
|
|
| 480 |
row = _db_query_one(
|
| 481 |
f"SELECT 1 AS ok FROM `{USERS_TABLE}` WHERE username=%s",
|
| 482 |
(username,),
|
|
@@ -513,9 +566,10 @@ def _create_user_record(
|
|
| 513 |
|
| 514 |
|
| 515 |
def _delete_user_record(username: str):
|
|
|
|
| 516 |
return _db_execute(f"DELETE FROM `{USERS_TABLE}` WHERE username=%s", (username,))
|
| 517 |
-
|
| 518 |
-
|
| 519 |
def _get_user_meta_or_404(username: str):
|
| 520 |
users_map = _load_users_meta()
|
| 521 |
user = users_map.get(username)
|
|
@@ -576,387 +630,449 @@ def _save_user_users_data(username: str, users_data: list):
|
|
| 576 |
)
|
| 577 |
if changed == 0 and not _user_exists(username):
|
| 578 |
raise FileNotFoundError(f"用户 {username} 不存在")
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
def _sanitize_targets(values):
|
| 582 |
-
cleaned = []
|
| 583 |
-
seen = set()
|
| 584 |
-
for value in values or []:
|
| 585 |
-
text = str(value).strip()
|
| 586 |
-
if not text or text in seen:
|
| 587 |
-
continue
|
| 588 |
-
seen.add(text)
|
| 589 |
-
cleaned.append(text)
|
| 590 |
-
return cleaned
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
def _validate_and_normalize_users_data(raw_bytes: bytes):
|
| 594 |
-
try:
|
| 595 |
-
payload = json.loads(raw_bytes.decode("utf-8"))
|
| 596 |
-
except Exception as exc:
|
| 597 |
-
raise ValueError(f"上传文件不是合法 JSON:{exc}")
|
| 598 |
-
|
| 599 |
-
if not isinstance(payload, list) or not payload:
|
| 600 |
-
raise ValueError("usersData.json 必须是非空数组")
|
| 601 |
-
|
| 602 |
-
normalized = []
|
| 603 |
-
for idx, item in enumerate(payload):
|
| 604 |
-
if not isinstance(item, dict):
|
| 605 |
-
raise ValueError(f"第 {idx + 1} 条用户数据格式错误(必须是对象)")
|
| 606 |
-
|
| 607 |
-
unique_id = str(item.get("unique_id", "")).strip()
|
| 608 |
-
username = str(item.get("username", "")).strip()
|
| 609 |
-
cookies = item.get("cookies", [])
|
| 610 |
-
targets = item.get("targets", [])
|
| 611 |
-
|
| 612 |
-
if not unique_id:
|
| 613 |
-
raise ValueError(f"第 {idx + 1} 条缺少 unique_id")
|
| 614 |
-
if not username:
|
| 615 |
-
raise ValueError(f"第 {idx + 1} 条缺少 username")
|
| 616 |
-
if not isinstance(cookies, list) or not cookies:
|
| 617 |
-
raise ValueError(f"第 {idx + 1} 条 cookies 不能为空且必须是数组")
|
| 618 |
-
if not isinstance(targets, list):
|
| 619 |
-
raise ValueError(f"第 {idx + 1} 条 targets 必须是数组")
|
| 620 |
-
|
| 621 |
-
normalized.append(
|
| 622 |
-
{
|
| 623 |
-
"unique_id": unique_id,
|
| 624 |
-
"username": username,
|
| 625 |
-
"cookies": cookies,
|
| 626 |
-
"targets": _sanitize_targets(targets),
|
| 627 |
-
}
|
| 628 |
-
)
|
| 629 |
-
|
| 630 |
-
primary_username = normalized[0]["username"]
|
| 631 |
-
primary_unique_id = normalized[0]["unique_id"]
|
| 632 |
-
return normalized, primary_username, primary_unique_id
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
def _count_targets(users_data: list):
|
| 636 |
-
return sum(len(user.get("targets", [])) for user in users_data)
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
def _get_runtime(username: str):
|
| 640 |
-
with runtime_map_lock:
|
| 641 |
-
runtime = runtime_map.get(username)
|
| 642 |
-
if runtime is None:
|
| 643 |
-
runtime = UserRuntimeState(username=username)
|
| 644 |
-
runtime_map[username] = runtime
|
| 645 |
-
return runtime
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
def _delete_runtime(username: str):
|
| 649 |
-
with runtime_map_lock:
|
| 650 |
-
runtime_map.pop(username, None)
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
def _session_from_request(request: Request):
|
| 654 |
-
token = request.cookies.get(SESSION_COOKIE_NAME)
|
| 655 |
-
if not token:
|
| 656 |
-
return None
|
| 657 |
-
return AUTH_SESSIONS.get(token)
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
def _require_user_session(request: Request):
|
| 661 |
-
session = _session_from_request(request)
|
| 662 |
-
if not session or session.get("role") != "user":
|
| 663 |
-
raise HTTPException(
|
| 664 |
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 665 |
-
detail="未登录或登录已失效",
|
| 666 |
-
)
|
| 667 |
-
return session
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
def _require_admin_session(request: Request):
|
| 671 |
-
session = _session_from_request(request)
|
| 672 |
-
if not session or session.get("role") != "admin":
|
| 673 |
-
raise HTTPException(
|
| 674 |
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 675 |
-
detail="未登录或登录已失效",
|
| 676 |
-
)
|
| 677 |
-
return session
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
def _parse_time_string(value: str):
|
| 681 |
-
parts = value.strip().split(":")
|
| 682 |
-
if len(parts) not in (2, 3):
|
| 683 |
-
raise ValueError("时间格式错误,必须是 HH:MM")
|
| 684 |
-
hour = int(parts[0])
|
| 685 |
-
minute = int(parts[1])
|
| 686 |
-
if hour < 0 or hour > 23 or minute < 0 or minute > 59:
|
| 687 |
-
raise ValueError("时间范围错误,小时 0-23,分钟 0-59")
|
| 688 |
-
return hour, minute
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
def _build_editor_state(username: str):
|
| 692 |
-
cfg = _load_user_config(username)
|
| 693 |
-
users = _load_user_users_data(username)
|
| 694 |
-
return {
|
| 695 |
-
"message_template": str(cfg.get("messageTemplate", "")),
|
| 696 |
-
"users": [
|
| 697 |
-
{
|
| 698 |
-
"unique_id": str(user.get("unique_id", "")),
|
| 699 |
-
"username": str(user.get("username", "未知用户")),
|
| 700 |
-
"targets": _sanitize_targets(user.get("targets", [])),
|
| 701 |
-
}
|
| 702 |
-
for user in users
|
| 703 |
-
],
|
| 704 |
-
}
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
def _scheduler_job_id(username: str):
|
| 708 |
-
return f"daily_task::{username}"
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
def _run_scheduled_once(username: str):
|
| 712 |
-
runtime = _get_runtime(username)
|
| 713 |
-
runtime.run_once("schedule")
|
| 714 |
-
if scheduler:
|
| 715 |
-
job = scheduler.get_job(_scheduler_job_id(username))
|
| 716 |
-
runtime.update_next_run(job.next_run_time if job else None)
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
async def _run_user_tasks(username: str):
|
| 720 |
-
cfg = _load_user_config(username)
|
| 721 |
-
users_data = _load_user_users_data(username)
|
| 722 |
-
await runTasks(config=cfg, userData=users_data)
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
def
|
| 726 |
-
global
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
|
| 793 |
-
|
| 794 |
-
scheduler
|
| 795 |
-
|
| 796 |
-
|
| 797 |
-
|
| 798 |
-
|
| 799 |
-
|
| 800 |
-
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
|
| 834 |
-
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
|
| 844 |
-
|
| 845 |
-
|
| 846 |
-
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
|
| 850 |
-
|
| 851 |
-
|
| 852 |
-
|
| 853 |
-
|
| 854 |
-
|
| 855 |
-
|
| 856 |
-
|
| 857 |
-
|
| 858 |
-
|
| 859 |
-
|
| 860 |
-
|
| 861 |
-
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
|
| 866 |
-
|
| 867 |
-
|
| 868 |
-
|
| 869 |
-
|
| 870 |
-
|
| 871 |
-
|
| 872 |
-
|
| 873 |
-
|
| 874 |
-
|
| 875 |
-
|
| 876 |
-
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
|
| 882 |
-
|
| 883 |
-
|
| 884 |
-
|
| 885 |
-
|
| 886 |
-
|
| 887 |
-
|
| 888 |
-
|
| 889 |
-
|
| 890 |
-
|
| 891 |
-
|
| 892 |
-
|
| 893 |
-
|
| 894 |
-
|
| 895 |
-
|
| 896 |
-
if not
|
| 897 |
-
return
|
| 898 |
-
|
| 899 |
-
|
| 900 |
-
|
| 901 |
-
|
| 902 |
-
|
| 903 |
-
|
| 904 |
-
|
| 905 |
-
|
| 906 |
-
|
| 907 |
-
|
| 908 |
-
|
| 909 |
-
|
| 910 |
-
|
| 911 |
-
|
| 912 |
-
|
| 913 |
-
@app.
|
| 914 |
-
async def
|
| 915 |
-
|
| 916 |
-
if
|
| 917 |
-
|
| 918 |
-
|
| 919 |
-
|
| 920 |
-
|
| 921 |
-
|
| 922 |
-
|
| 923 |
-
|
| 924 |
-
|
| 925 |
-
|
| 926 |
-
|
| 927 |
-
|
| 928 |
-
|
| 929 |
-
|
| 930 |
-
|
| 931 |
-
|
| 932 |
-
|
| 933 |
-
|
| 934 |
-
|
| 935 |
-
|
| 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 |
default_config = _get_default_user_config()
|
| 961 |
default_config.setdefault("scheduler", {})
|
| 962 |
default_config["scheduler"].setdefault("enabled", True)
|
|
@@ -982,285 +1098,341 @@ async def api_register(password: str = Form(...), users_file: UploadFile = File(
|
|
| 982 |
|
| 983 |
_schedule_user_job(username)
|
| 984 |
_get_runtime(username).add_log("用户已注册并完成定时任务初始化")
|
| 985 |
-
|
| 986 |
-
return {
|
| 987 |
-
"ok": True,
|
| 988 |
-
"message": "注册成功,请使用用户名和密码登录。",
|
| 989 |
-
"username": username,
|
| 990 |
-
}
|
| 991 |
-
|
| 992 |
-
|
| 993 |
-
@app.post("/api/logout")
|
| 994 |
-
async def api_logout(request: Request):
|
| 995 |
-
token = request.cookies.get(SESSION_COOKIE_NAME)
|
| 996 |
-
if token:
|
| 997 |
-
AUTH_SESSIONS.pop(token, None)
|
| 998 |
-
response = JSONResponse({"ok": True})
|
| 999 |
-
response.delete_cookie(SESSION_COOKIE_NAME)
|
| 1000 |
-
return response
|
| 1001 |
-
|
| 1002 |
-
|
| 1003 |
-
@app.get("/api/status")
|
| 1004 |
-
async def api_status(request: Request):
|
| 1005 |
-
session = _require_user_session(request)
|
| 1006 |
-
username = session["username"]
|
| 1007 |
-
runtime = _get_runtime(username)
|
| 1008 |
-
users_data = _load_user_users_data(username)
|
| 1009 |
-
return {
|
| 1010 |
-
"ok": True,
|
| 1011 |
-
"runtime": runtime.snapshot(
|
| 1012 |
-
account_count=len(users_data),
|
| 1013 |
-
target_count=_count_targets(users_data),
|
| 1014 |
-
),
|
| 1015 |
-
"history": runtime.history_rows(),
|
| 1016 |
-
}
|
| 1017 |
-
|
| 1018 |
-
|
| 1019 |
-
@app.get("/api/logs")
|
| 1020 |
-
async def api_logs(request: Request, limit: int = MAX_LOG_LINES):
|
| 1021 |
-
session = _require_user_session(request)
|
| 1022 |
-
username = session["username"]
|
| 1023 |
-
runtime = _get_runtime(username)
|
| 1024 |
-
limit = min(max(100, limit), 3000)
|
| 1025 |
-
return {"ok": True, "logs": runtime.recent_logs(limit=limit)}
|
| 1026 |
-
|
| 1027 |
-
|
| 1028 |
-
@app.post("/api/run")
|
| 1029 |
-
async def api_run(request: Request):
|
| 1030 |
-
session = _require_user_session(request)
|
| 1031 |
-
username = session["username"]
|
| 1032 |
-
runtime = _get_runtime(username)
|
| 1033 |
-
|
| 1034 |
-
if runtime.is_running:
|
| 1035 |
-
return JSONResponse(
|
| 1036 |
-
status_code=409,
|
| 1037 |
-
content={"ok": False, "message": "已有任务正在执行,请稍后再试。"},
|
| 1038 |
-
)
|
| 1039 |
-
|
| 1040 |
-
_start_background_run(username, "manual")
|
| 1041 |
-
return {"ok": True, "message": "任务已开始执行。"}
|
| 1042 |
-
|
| 1043 |
-
|
| 1044 |
-
@app.post("/api/schedule")
|
| 1045 |
-
async def api_schedule(request: Request, payload: SchedulePayload):
|
| 1046 |
-
session = _require_user_session(request)
|
| 1047 |
-
username = session["username"]
|
| 1048 |
-
|
| 1049 |
-
try:
|
| 1050 |
-
hour, minute = _parse_time_string(payload.time)
|
| 1051 |
-
except Exception as exc:
|
| 1052 |
-
return JSONResponse(status_code=400, content={"ok": False, "message": str(exc)})
|
| 1053 |
-
|
| 1054 |
-
cfg = _load_user_config(username)
|
| 1055 |
-
scheduler_cfg = cfg.setdefault("scheduler", {})
|
| 1056 |
-
scheduler_cfg["enabled"] = True
|
| 1057 |
-
scheduler_cfg["hour"] = hour
|
| 1058 |
-
scheduler_cfg["minute"] = minute
|
| 1059 |
-
scheduler_cfg["timezone"] = str(scheduler_cfg.get("timezone", DEFAULT_TIMEZONE))
|
| 1060 |
-
scheduler_cfg["runOnStartup"] = bool(scheduler_cfg.get("runOnStartup", False))
|
| 1061 |
-
_save_user_config(username, cfg)
|
| 1062 |
-
|
| 1063 |
-
_schedule_user_job(username)
|
| 1064 |
-
runtime = _get_runtime(username)
|
| 1065 |
-
return {
|
| 1066 |
-
"ok": True,
|
| 1067 |
-
"message": f"定时任务已更新为每天 {hour:02d}:{minute:02d}。",
|
| 1068 |
-
"time": f"{hour:02d}:{minute:02d}",
|
| 1069 |
-
"next_run": runtime.snapshot(0, 0)["next_run"],
|
| 1070 |
-
}
|
| 1071 |
-
|
| 1072 |
-
|
| 1073 |
-
@app.get("/api/editor/state")
|
| 1074 |
-
async def api_editor_state(request: Request):
|
| 1075 |
-
session = _require_user_session(request)
|
| 1076 |
-
username = session["username"]
|
| 1077 |
-
return {"ok": True, **_build_editor_state(username)}
|
| 1078 |
-
|
| 1079 |
-
|
| 1080 |
-
@app.post("/api/editor/message")
|
| 1081 |
-
async def api_editor_message(request: Request, payload: MessageTemplatePayload):
|
| 1082 |
-
session = _require_user_session(request)
|
| 1083 |
-
username = session["username"]
|
| 1084 |
-
|
| 1085 |
-
message = payload.message.strip()
|
| 1086 |
-
if not message:
|
| 1087 |
-
return JSONResponse(status_code=400, content={"ok": False, "message": "消息内容不能为空。"})
|
| 1088 |
-
if len(message) > MAX_TEMPLATE_LENGTH:
|
| 1089 |
-
return JSONResponse(
|
| 1090 |
-
status_code=400,
|
| 1091 |
-
content={"ok": False, "message": f"消息内容过长,最多 {MAX_TEMPLATE_LENGTH} 字符。"},
|
| 1092 |
-
)
|
| 1093 |
-
|
| 1094 |
-
cfg = _load_user_config(username)
|
| 1095 |
-
cfg["messageTemplate"] = message
|
| 1096 |
-
_save_user_config(username, cfg)
|
| 1097 |
-
_get_runtime(username).add_log("消息模板已更新")
|
| 1098 |
-
return {"ok": True, "message": "消息模板已保存。"}
|
| 1099 |
-
|
| 1100 |
-
|
| 1101 |
-
@app.post("/api/editor/targets")
|
| 1102 |
-
async def api_editor_targets(request: Request, payload: UserTargetsPayload):
|
| 1103 |
-
session = _require_user_session(request)
|
| 1104 |
-
username = session["username"]
|
| 1105 |
-
|
| 1106 |
-
users_data = _load_user_users_data(username)
|
| 1107 |
-
updates = {item.unique_id: _sanitize_targets(item.targets) for item in payload.users}
|
| 1108 |
-
|
| 1109 |
-
updated = 0
|
| 1110 |
-
for user in users_data:
|
| 1111 |
-
uid = str(user.get("unique_id", ""))
|
| 1112 |
-
if uid in updates:
|
| 1113 |
-
user["targets"] = updates[uid]
|
| 1114 |
-
updated += 1
|
| 1115 |
-
|
| 1116 |
-
_save_user_users_data(username, users_data)
|
| 1117 |
-
_get_runtime(username).add_log(f"目标好友已更新,涉及账号数:{updated}")
|
| 1118 |
-
return {"ok": True, "message": f"目标好友已保存({updated} 个账号)。"}
|
| 1119 |
-
|
| 1120 |
-
|
| 1121 |
-
@app.get("/api/admin/overview")
|
| 1122 |
-
async def api_admin_overview(request: Request):
|
| 1123 |
-
_require_admin_session(request)
|
| 1124 |
-
|
| 1125 |
-
|
| 1126 |
-
|
| 1127 |
-
|
| 1128 |
-
|
| 1129 |
-
|
| 1130 |
-
|
| 1131 |
-
|
| 1132 |
-
|
| 1133 |
-
|
| 1134 |
-
|
| 1135 |
-
|
| 1136 |
-
|
| 1137 |
-
|
| 1138 |
-
|
| 1139 |
-
|
| 1140 |
-
|
| 1141 |
-
|
| 1142 |
-
|
| 1143 |
-
|
| 1144 |
-
|
| 1145 |
-
|
| 1146 |
-
|
| 1147 |
-
|
| 1148 |
-
|
| 1149 |
-
|
| 1150 |
-
|
| 1151 |
-
|
| 1152 |
-
|
| 1153 |
-
|
| 1154 |
-
|
| 1155 |
-
|
| 1156 |
-
|
| 1157 |
-
|
| 1158 |
-
|
| 1159 |
-
|
| 1160 |
-
|
| 1161 |
-
|
| 1162 |
-
|
| 1163 |
-
|
| 1164 |
-
|
| 1165 |
-
|
| 1166 |
-
|
| 1167 |
-
"
|
| 1168 |
-
|
| 1169 |
-
|
| 1170 |
-
|
| 1171 |
-
|
| 1172 |
-
|
| 1173 |
-
|
| 1174 |
-
|
| 1175 |
-
|
| 1176 |
-
|
| 1177 |
-
|
| 1178 |
-
|
| 1179 |
-
|
| 1180 |
-
|
| 1181 |
-
|
| 1182 |
-
|
| 1183 |
-
|
| 1184 |
-
|
| 1185 |
-
|
| 1186 |
-
|
| 1187 |
-
|
| 1188 |
-
|
| 1189 |
-
|
| 1190 |
-
|
| 1191 |
-
)
|
| 1192 |
-
|
| 1193 |
-
|
| 1194 |
-
|
| 1195 |
-
|
| 1196 |
-
|
| 1197 |
-
|
| 1198 |
-
|
| 1199 |
-
|
| 1200 |
-
|
| 1201 |
-
|
| 1202 |
-
|
| 1203 |
-
|
| 1204 |
-
|
| 1205 |
-
|
| 1206 |
-
|
| 1207 |
-
|
| 1208 |
-
|
| 1209 |
-
|
| 1210 |
-
|
| 1211 |
-
|
| 1212 |
-
|
| 1213 |
-
|
| 1214 |
-
|
| 1215 |
-
|
| 1216 |
-
|
| 1217 |
-
|
| 1218 |
-
|
| 1219 |
-
|
| 1220 |
-
|
| 1221 |
-
|
| 1222 |
-
|
| 1223 |
-
|
| 1224 |
-
|
| 1225 |
-
|
| 1226 |
-
|
| 1227 |
-
|
| 1228 |
-
|
| 1229 |
-
|
| 1230 |
-
|
| 1231 |
-
|
| 1232 |
-
|
| 1233 |
-
|
| 1234 |
-
|
| 1235 |
-
"
|
| 1236 |
-
|
| 1237 |
-
|
| 1238 |
-
|
| 1239 |
-
|
| 1240 |
-
|
| 1241 |
-
|
| 1242 |
-
|
| 1243 |
-
|
| 1244 |
-
|
| 1245 |
-
|
| 1246 |
-
|
| 1247 |
-
|
| 1248 |
-
|
| 1249 |
-
|
| 1250 |
-
|
| 1251 |
-
|
| 1252 |
-
|
| 1253 |
-
|
| 1254 |
-
|
| 1255 |
-
|
| 1256 |
-
|
| 1257 |
-
|
| 1258 |
-
|
| 1259 |
-
|
| 1260 |
-
|
| 1261 |
-
|
| 1262 |
-
|
| 1263 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1264 |
_get_user_meta_or_404(username)
|
| 1265 |
|
| 1266 |
_remove_user_schedule_job(username)
|
|
@@ -1268,17 +1440,17 @@ async def api_admin_delete_user(request: Request, username: str):
|
|
| 1268 |
_delete_runtime(username)
|
| 1269 |
|
| 1270 |
return {"ok": True, "message": f"用户 {username} 已删除。"}
|
| 1271 |
-
|
| 1272 |
-
|
| 1273 |
-
@app.get("/health")
|
| 1274 |
-
async def health():
|
| 1275 |
-
return {"ok": True, "status": "alive"}
|
| 1276 |
-
|
| 1277 |
-
|
| 1278 |
-
def run_server():
|
| 1279 |
-
port = int(os.getenv("PORT", "7860"))
|
| 1280 |
-
uvicorn.run("app:app", host="0.0.0.0", port=port, workers=1)
|
| 1281 |
-
|
| 1282 |
-
|
| 1283 |
-
if __name__ == "__main__":
|
| 1284 |
-
run_server()
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import atexit
|
| 3 |
+
import hashlib
|
| 4 |
import json
|
| 5 |
import logging
|
| 6 |
import os
|
|
|
|
| 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"
|
|
|
|
| 47 |
MAX_LOG_LINES = 1200
|
| 48 |
MAX_TEMPLATE_LENGTH = 2000
|
| 49 |
PASSWORD_ITERATIONS = 210000
|
| 50 |
+
FAILED_RETRY_JOB_ID = "_system_retry_failed_tasks"
|
| 51 |
+
FAILED_RETRY_INTERVAL_HOURS = 1
|
| 52 |
+
|
| 53 |
+
DEFAULT_USER_CONFIG = {
|
| 54 |
+
"multiTask": True,
|
| 55 |
+
"taskCount": 5,
|
| 56 |
+
"proxyAddress": "",
|
| 57 |
+
"messageTemplate": "[续火花]",
|
| 58 |
+
"hitokotoTypes": ["文学", "影视", "诗词", "哲学"],
|
| 59 |
+
"scheduler": {
|
| 60 |
+
"enabled": True,
|
| 61 |
+
"timezone": DEFAULT_TIMEZONE,
|
| 62 |
+
"hour": 9,
|
| 63 |
+
"minute": 0,
|
| 64 |
+
"runOnStartup": False,
|
| 65 |
+
},
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
AUTH_SESSIONS: dict[str, dict[str, str]] = {}
|
| 69 |
db_init_lock = threading.Lock()
|
| 70 |
scheduler_lock = threading.Lock()
|
| 71 |
runtime_map_lock = threading.Lock()
|
| 72 |
db_initialized = False
|
| 73 |
+
db_status_lock = threading.Lock()
|
| 74 |
+
db_status = {
|
| 75 |
+
"connected": None,
|
| 76 |
+
"last_checked_at": None,
|
| 77 |
+
"last_ok_at": None,
|
| 78 |
+
"last_error": "",
|
| 79 |
+
}
|
| 80 |
+
scheduler_bootstrapped = False
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class UserRuntimeState:
|
| 84 |
+
def __init__(self, username: str):
|
| 85 |
+
self.username = username
|
| 86 |
+
self._run_lock = threading.Lock()
|
| 87 |
+
self._state_lock = threading.Lock()
|
| 88 |
+
self.is_running = False
|
| 89 |
+
self.last_status = "未开始"
|
| 90 |
+
self.last_error = ""
|
| 91 |
+
self.last_trigger = "-"
|
| 92 |
+
self.last_start = None
|
| 93 |
+
self.last_end = None
|
| 94 |
+
self.next_run = None
|
| 95 |
+
self.schedule_hour = 9
|
| 96 |
+
self.schedule_minute = 0
|
| 97 |
+
self.schedule_timezone = DEFAULT_TIMEZONE
|
| 98 |
+
self.history = deque(maxlen=50)
|
| 99 |
+
self.logs = deque(maxlen=2000)
|
| 100 |
+
|
| 101 |
+
def _format_ts(self, value: Optional[datetime]):
|
| 102 |
+
if not value:
|
| 103 |
+
return "-"
|
| 104 |
+
return value.strftime("%Y-%m-%d %H:%M:%S")
|
| 105 |
+
|
| 106 |
+
def schedule_time(self):
|
| 107 |
+
return f"{self.schedule_hour:02d}:{self.schedule_minute:02d}"
|
| 108 |
+
|
| 109 |
+
def _set_running(self, value: bool):
|
| 110 |
+
with self._state_lock:
|
| 111 |
+
self.is_running = value
|
| 112 |
+
|
| 113 |
+
def add_log(self, message: str):
|
| 114 |
+
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 115 |
+
with self._state_lock:
|
| 116 |
+
self.logs.append(f"{ts} [{self.username}] {message}")
|
| 117 |
+
|
| 118 |
+
def update_schedule(self, hour: int, minute: int, timezone: str):
|
| 119 |
+
with self._state_lock:
|
| 120 |
+
self.schedule_hour = hour
|
| 121 |
+
self.schedule_minute = minute
|
| 122 |
+
self.schedule_timezone = timezone
|
| 123 |
+
|
| 124 |
+
def update_next_run(self, next_run):
|
| 125 |
+
with self._state_lock:
|
| 126 |
+
self.next_run = next_run
|
| 127 |
+
|
| 128 |
+
def snapshot(self, account_count: int, target_count: int):
|
| 129 |
+
with self._state_lock:
|
| 130 |
+
return {
|
| 131 |
+
"is_running": self.is_running,
|
| 132 |
+
"last_status": self.last_status,
|
| 133 |
+
"last_error": self.last_error,
|
| 134 |
+
"last_trigger": self.last_trigger,
|
| 135 |
+
"last_start": self._format_ts(self.last_start),
|
| 136 |
+
"last_end": self._format_ts(self.last_end),
|
| 137 |
+
"next_run": self._format_ts(self.next_run),
|
| 138 |
+
"account_count": account_count,
|
| 139 |
+
"target_count": target_count,
|
| 140 |
+
"schedule_time": self.schedule_time(),
|
| 141 |
+
"schedule_timezone": self.schedule_timezone,
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
def history_rows(self):
|
| 145 |
+
with self._state_lock:
|
| 146 |
+
return list(self.history)[::-1]
|
| 147 |
+
|
| 148 |
+
def recent_logs(self, limit=MAX_LOG_LINES):
|
| 149 |
+
with self._state_lock:
|
| 150 |
+
lines = list(self.logs)[-max(1, limit):]
|
| 151 |
+
return "\n".join(lines) if lines else "暂无日志。"
|
| 152 |
+
|
| 153 |
+
def run_once(self, trigger: str):
|
| 154 |
+
if not self._run_lock.acquire(blocking=False):
|
| 155 |
+
self.add_log(f"任务已在运行中,忽略触发:{trigger}")
|
| 156 |
+
return False, "已有任务在运行,本次触发已跳过。"
|
| 157 |
+
|
| 158 |
+
self._set_running(True)
|
| 159 |
+
with self._state_lock:
|
| 160 |
+
self.last_trigger = trigger
|
| 161 |
+
self.last_start = datetime.now()
|
| 162 |
+
self.last_end = None
|
| 163 |
+
self.last_error = ""
|
| 164 |
+
self.last_status = "运行中"
|
| 165 |
+
self.add_log(f"任务开始执行,触发方式:{trigger}")
|
| 166 |
+
|
| 167 |
+
ok = True
|
| 168 |
+
message = "任务执行完成。"
|
| 169 |
+
try:
|
| 170 |
+
asyncio.run(_run_user_tasks(self.username))
|
| 171 |
+
with self._state_lock:
|
| 172 |
+
self.last_status = "成功"
|
| 173 |
+
except Exception as exc:
|
| 174 |
+
ok = False
|
| 175 |
+
message = f"任务执行失败:{exc}"
|
| 176 |
+
with self._state_lock:
|
| 177 |
+
self.last_status = "失败"
|
| 178 |
+
self.last_error = repr(exc)
|
| 179 |
+
self.add_log(f"任务失败:{exc}")
|
| 180 |
+
logger.error("Task failed. user=%s trigger=%s error=%s", self.username, trigger, exc)
|
| 181 |
+
logger.debug("Task traceback:\n%s", traceback.format_exc())
|
| 182 |
+
finally:
|
| 183 |
+
end_at = datetime.now()
|
| 184 |
+
with self._state_lock:
|
| 185 |
+
self.last_end = end_at
|
| 186 |
+
duration = (self.last_end - self.last_start).total_seconds()
|
| 187 |
+
self.history.append(
|
| 188 |
+
{
|
| 189 |
+
"trigger": trigger,
|
| 190 |
+
"start": self._format_ts(self.last_start),
|
| 191 |
+
"end": self._format_ts(self.last_end),
|
| 192 |
+
"status": self.last_status,
|
| 193 |
+
"duration": f"{duration:.2f}s",
|
| 194 |
+
"message": self.last_error or "OK",
|
| 195 |
+
}
|
| 196 |
+
)
|
| 197 |
+
current_status = self.last_status
|
| 198 |
+
self.add_log(f"任务结束,状态={current_status},耗时={duration:.2f}s")
|
| 199 |
+
self._set_running(False)
|
| 200 |
+
self._run_lock.release()
|
| 201 |
+
return ok, message
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
runtime_map: dict[str, UserRuntimeState] = {}
|
| 205 |
+
scheduler = None
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
class UserLoginPayload(BaseModel):
|
| 209 |
+
username: str
|
| 210 |
+
password: str
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
class AdminLoginPayload(BaseModel):
|
| 214 |
+
password: str
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
class SchedulePayload(BaseModel):
|
| 218 |
+
time: str
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
class MessageTemplatePayload(BaseModel):
|
| 222 |
+
message: str
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
class UserTargetsItem(BaseModel):
|
| 226 |
+
unique_id: str
|
| 227 |
+
targets: list[str] = Field(default_factory=list)
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
class UserTargetsPayload(BaseModel):
|
| 231 |
+
users: list[UserTargetsItem]
|
| 232 |
+
|
| 233 |
+
|
| 234 |
def _ensure_data_layout():
|
| 235 |
global db_initialized
|
| 236 |
if db_initialized:
|
|
|
|
| 241 |
_init_db_schema()
|
| 242 |
_migrate_legacy_file_data_if_needed()
|
| 243 |
db_initialized = True
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def _hash_password(password: str, salt_hex: Optional[str] = None):
|
| 247 |
+
salt = bytes.fromhex(salt_hex) if salt_hex else secrets.token_bytes(16)
|
| 248 |
+
digest = hashlib.pbkdf2_hmac(
|
| 249 |
+
"sha256",
|
| 250 |
+
password.encode("utf-8"),
|
| 251 |
+
salt,
|
| 252 |
+
PASSWORD_ITERATIONS,
|
| 253 |
+
)
|
| 254 |
+
return {
|
| 255 |
+
"salt": salt.hex(),
|
| 256 |
+
"hash": digest.hex(),
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
|
| 260 |
def _verify_password(password: str, salt_hex: str, expected_hash: str):
|
| 261 |
data = _hash_password(password, salt_hex=salt_hex)
|
| 262 |
return secrets.compare_digest(data["hash"], expected_hash)
|
|
|
|
| 284 |
return merged
|
| 285 |
|
| 286 |
|
| 287 |
+
def _format_common_ts(value: Optional[datetime]):
|
| 288 |
+
if not value:
|
| 289 |
+
return "-"
|
| 290 |
+
return value.strftime("%Y-%m-%d %H:%M:%S")
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def _update_db_status(connected: bool, error: Optional[Exception] = None):
|
| 294 |
+
now = datetime.now()
|
| 295 |
+
with db_status_lock:
|
| 296 |
+
db_status["connected"] = connected
|
| 297 |
+
db_status["last_checked_at"] = now
|
| 298 |
+
if connected:
|
| 299 |
+
db_status["last_ok_at"] = now
|
| 300 |
+
db_status["last_error"] = ""
|
| 301 |
+
else:
|
| 302 |
+
db_status["last_error"] = str(error or "数据库连接失败")
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def _build_db_status_payload():
|
| 306 |
+
with db_status_lock:
|
| 307 |
+
connected = db_status.get("connected")
|
| 308 |
+
return {
|
| 309 |
+
"connected": connected,
|
| 310 |
+
"last_checked_at": _format_common_ts(db_status.get("last_checked_at")),
|
| 311 |
+
"last_ok_at": _format_common_ts(db_status.get("last_ok_at")),
|
| 312 |
+
"last_error": str(db_status.get("last_error") or ""),
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
|
| 316 |
def _resolve_mysql_dsn():
|
| 317 |
raw = os.getenv(MYSQL_DSN_ENV, MYSQL_DSN_TEMPLATE).strip()
|
| 318 |
if "SQL_PASSWORD" in raw:
|
|
|
|
| 370 |
return kwargs
|
| 371 |
|
| 372 |
|
| 373 |
+
def _db_connect():
|
| 374 |
+
try:
|
| 375 |
+
conn = pymysql.connect(**_build_mysql_conn_kwargs())
|
| 376 |
+
except Exception as exc:
|
| 377 |
+
_update_db_status(False, exc)
|
| 378 |
+
raise
|
| 379 |
+
_update_db_status(True)
|
| 380 |
+
return conn
|
| 381 |
+
|
| 382 |
+
|
| 383 |
def _db_query_all(query: str, params=()):
|
| 384 |
+
conn = _db_connect()
|
| 385 |
try:
|
| 386 |
with conn.cursor() as cursor:
|
| 387 |
cursor.execute(query, params)
|
|
|
|
| 391 |
|
| 392 |
|
| 393 |
def _db_query_one(query: str, params=()):
|
| 394 |
+
conn = _db_connect()
|
| 395 |
try:
|
| 396 |
with conn.cursor() as cursor:
|
| 397 |
cursor.execute(query, params)
|
|
|
|
| 401 |
|
| 402 |
|
| 403 |
def _db_execute(query: str, params=()):
|
| 404 |
+
conn = _db_connect()
|
| 405 |
try:
|
| 406 |
with conn.cursor() as cursor:
|
| 407 |
cursor.execute(query, params)
|
|
|
|
| 411 |
|
| 412 |
|
| 413 |
def _init_db_schema():
|
| 414 |
+
_ensure_data_layout()
|
| 415 |
_db_execute(
|
| 416 |
f"""
|
| 417 |
CREATE TABLE IF NOT EXISTS `{USERS_TABLE}` (
|
|
|
|
| 505 |
|
| 506 |
|
| 507 |
def _load_users_meta():
|
| 508 |
+
_ensure_data_layout()
|
| 509 |
rows = _db_query_all(
|
| 510 |
f"""
|
| 511 |
SELECT username, unique_id, password_hash, password_salt, created_at
|
|
|
|
| 517 |
|
| 518 |
|
| 519 |
def _load_user_row(username: str):
|
| 520 |
+
_ensure_data_layout()
|
| 521 |
return _db_query_one(
|
| 522 |
f"""
|
| 523 |
SELECT username, unique_id, password_hash, password_salt, created_at, config_json, users_data_json
|
|
|
|
| 529 |
|
| 530 |
|
| 531 |
def _user_exists(username: str):
|
| 532 |
+
_ensure_data_layout()
|
| 533 |
row = _db_query_one(
|
| 534 |
f"SELECT 1 AS ok FROM `{USERS_TABLE}` WHERE username=%s",
|
| 535 |
(username,),
|
|
|
|
| 566 |
|
| 567 |
|
| 568 |
def _delete_user_record(username: str):
|
| 569 |
+
_ensure_data_layout()
|
| 570 |
return _db_execute(f"DELETE FROM `{USERS_TABLE}` WHERE username=%s", (username,))
|
| 571 |
+
|
| 572 |
+
|
| 573 |
def _get_user_meta_or_404(username: str):
|
| 574 |
users_map = _load_users_meta()
|
| 575 |
user = users_map.get(username)
|
|
|
|
| 630 |
)
|
| 631 |
if changed == 0 and not _user_exists(username):
|
| 632 |
raise FileNotFoundError(f"用户 {username} 不存在")
|
| 633 |
+
|
| 634 |
+
|
| 635 |
+
def _sanitize_targets(values):
|
| 636 |
+
cleaned = []
|
| 637 |
+
seen = set()
|
| 638 |
+
for value in values or []:
|
| 639 |
+
text = str(value).strip()
|
| 640 |
+
if not text or text in seen:
|
| 641 |
+
continue
|
| 642 |
+
seen.add(text)
|
| 643 |
+
cleaned.append(text)
|
| 644 |
+
return cleaned
|
| 645 |
+
|
| 646 |
+
|
| 647 |
+
def _validate_and_normalize_users_data(raw_bytes: bytes):
|
| 648 |
+
try:
|
| 649 |
+
payload = json.loads(raw_bytes.decode("utf-8"))
|
| 650 |
+
except Exception as exc:
|
| 651 |
+
raise ValueError(f"上传文件不是合法 JSON:{exc}")
|
| 652 |
+
|
| 653 |
+
if not isinstance(payload, list) or not payload:
|
| 654 |
+
raise ValueError("usersData.json 必须是非空数组")
|
| 655 |
+
|
| 656 |
+
normalized = []
|
| 657 |
+
for idx, item in enumerate(payload):
|
| 658 |
+
if not isinstance(item, dict):
|
| 659 |
+
raise ValueError(f"第 {idx + 1} 条用户数据格式错误(必须是对象)")
|
| 660 |
+
|
| 661 |
+
unique_id = str(item.get("unique_id", "")).strip()
|
| 662 |
+
username = str(item.get("username", "")).strip()
|
| 663 |
+
cookies = item.get("cookies", [])
|
| 664 |
+
targets = item.get("targets", [])
|
| 665 |
+
|
| 666 |
+
if not unique_id:
|
| 667 |
+
raise ValueError(f"第 {idx + 1} 条缺少 unique_id")
|
| 668 |
+
if not username:
|
| 669 |
+
raise ValueError(f"第 {idx + 1} 条缺少 username")
|
| 670 |
+
if not isinstance(cookies, list) or not cookies:
|
| 671 |
+
raise ValueError(f"第 {idx + 1} 条 cookies 不能为空且必须是数组")
|
| 672 |
+
if not isinstance(targets, list):
|
| 673 |
+
raise ValueError(f"第 {idx + 1} 条 targets 必须是数组")
|
| 674 |
+
|
| 675 |
+
normalized.append(
|
| 676 |
+
{
|
| 677 |
+
"unique_id": unique_id,
|
| 678 |
+
"username": username,
|
| 679 |
+
"cookies": cookies,
|
| 680 |
+
"targets": _sanitize_targets(targets),
|
| 681 |
+
}
|
| 682 |
+
)
|
| 683 |
+
|
| 684 |
+
primary_username = normalized[0]["username"]
|
| 685 |
+
primary_unique_id = normalized[0]["unique_id"]
|
| 686 |
+
return normalized, primary_username, primary_unique_id
|
| 687 |
+
|
| 688 |
+
|
| 689 |
+
def _count_targets(users_data: list):
|
| 690 |
+
return sum(len(user.get("targets", [])) for user in users_data)
|
| 691 |
+
|
| 692 |
+
|
| 693 |
+
def _get_runtime(username: str):
|
| 694 |
+
with runtime_map_lock:
|
| 695 |
+
runtime = runtime_map.get(username)
|
| 696 |
+
if runtime is None:
|
| 697 |
+
runtime = UserRuntimeState(username=username)
|
| 698 |
+
runtime_map[username] = runtime
|
| 699 |
+
return runtime
|
| 700 |
+
|
| 701 |
+
|
| 702 |
+
def _delete_runtime(username: str):
|
| 703 |
+
with runtime_map_lock:
|
| 704 |
+
runtime_map.pop(username, None)
|
| 705 |
+
|
| 706 |
+
|
| 707 |
+
def _session_from_request(request: Request):
|
| 708 |
+
token = request.cookies.get(SESSION_COOKIE_NAME)
|
| 709 |
+
if not token:
|
| 710 |
+
return None
|
| 711 |
+
return AUTH_SESSIONS.get(token)
|
| 712 |
+
|
| 713 |
+
|
| 714 |
+
def _require_user_session(request: Request):
|
| 715 |
+
session = _session_from_request(request)
|
| 716 |
+
if not session or session.get("role") != "user":
|
| 717 |
+
raise HTTPException(
|
| 718 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 719 |
+
detail="未登录或登录已失效",
|
| 720 |
+
)
|
| 721 |
+
return session
|
| 722 |
+
|
| 723 |
+
|
| 724 |
+
def _require_admin_session(request: Request):
|
| 725 |
+
session = _session_from_request(request)
|
| 726 |
+
if not session or session.get("role") != "admin":
|
| 727 |
+
raise HTTPException(
|
| 728 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 729 |
+
detail="未登录或登录已失效",
|
| 730 |
+
)
|
| 731 |
+
return session
|
| 732 |
+
|
| 733 |
+
|
| 734 |
+
def _parse_time_string(value: str):
|
| 735 |
+
parts = value.strip().split(":")
|
| 736 |
+
if len(parts) not in (2, 3):
|
| 737 |
+
raise ValueError("时间格式错误,必须是 HH:MM")
|
| 738 |
+
hour = int(parts[0])
|
| 739 |
+
minute = int(parts[1])
|
| 740 |
+
if hour < 0 or hour > 23 or minute < 0 or minute > 59:
|
| 741 |
+
raise ValueError("时间范围错误,小时 0-23,分钟 0-59")
|
| 742 |
+
return hour, minute
|
| 743 |
+
|
| 744 |
+
|
| 745 |
+
def _build_editor_state(username: str):
|
| 746 |
+
cfg = _load_user_config(username)
|
| 747 |
+
users = _load_user_users_data(username)
|
| 748 |
+
return {
|
| 749 |
+
"message_template": str(cfg.get("messageTemplate", "")),
|
| 750 |
+
"users": [
|
| 751 |
+
{
|
| 752 |
+
"unique_id": str(user.get("unique_id", "")),
|
| 753 |
+
"username": str(user.get("username", "未知用户")),
|
| 754 |
+
"targets": _sanitize_targets(user.get("targets", [])),
|
| 755 |
+
}
|
| 756 |
+
for user in users
|
| 757 |
+
],
|
| 758 |
+
}
|
| 759 |
+
|
| 760 |
+
|
| 761 |
+
def _scheduler_job_id(username: str):
|
| 762 |
+
return f"daily_task::{username}"
|
| 763 |
+
|
| 764 |
+
|
| 765 |
+
def _run_scheduled_once(username: str):
|
| 766 |
+
runtime = _get_runtime(username)
|
| 767 |
+
runtime.run_once("schedule")
|
| 768 |
+
if scheduler:
|
| 769 |
+
job = scheduler.get_job(_scheduler_job_id(username))
|
| 770 |
+
runtime.update_next_run(job.next_run_time if job else None)
|
| 771 |
+
|
| 772 |
+
|
| 773 |
+
async def _run_user_tasks(username: str):
|
| 774 |
+
cfg = _load_user_config(username)
|
| 775 |
+
users_data = _load_user_users_data(username)
|
| 776 |
+
await runTasks(config=cfg, userData=users_data)
|
| 777 |
+
|
| 778 |
+
|
| 779 |
+
def _sync_user_jobs_from_meta(users_map: dict[str, Any], run_startup_tasks: bool = False):
|
| 780 |
+
global scheduler_bootstrapped
|
| 781 |
+
|
| 782 |
+
for username in users_map.keys():
|
| 783 |
+
_schedule_user_job(username)
|
| 784 |
+
if run_startup_tasks:
|
| 785 |
+
cfg = _load_user_config(username)
|
| 786 |
+
run_on_startup = bool(cfg.get("scheduler", {}).get("runOnStartup", False))
|
| 787 |
+
if run_on_startup:
|
| 788 |
+
_start_background_run(username, "startup")
|
| 789 |
+
|
| 790 |
+
scheduler_bootstrapped = True
|
| 791 |
+
|
| 792 |
+
|
| 793 |
+
def _retry_failed_tasks_once(trigger: str, *, raise_on_db_error: bool = False):
|
| 794 |
+
try:
|
| 795 |
+
users_map = _load_users_meta()
|
| 796 |
+
if not scheduler_bootstrapped:
|
| 797 |
+
_sync_user_jobs_from_meta(users_map, run_startup_tasks=False)
|
| 798 |
+
except Exception as exc:
|
| 799 |
+
logger.warning("Failed to load users for failed-task retry. error=%s", exc)
|
| 800 |
+
if raise_on_db_error:
|
| 801 |
+
raise
|
| 802 |
+
return []
|
| 803 |
+
|
| 804 |
+
triggered = []
|
| 805 |
+
for username in users_map.keys():
|
| 806 |
+
runtime = _get_runtime(username)
|
| 807 |
+
snapshot = runtime.snapshot(account_count=0, target_count=0)
|
| 808 |
+
if snapshot.get("is_running") or snapshot.get("last_status") != "失败":
|
| 809 |
+
continue
|
| 810 |
+
|
| 811 |
+
try:
|
| 812 |
+
cfg = _load_user_config(username)
|
| 813 |
+
except Exception as exc:
|
| 814 |
+
runtime.add_log(f"自动重试前加载配置失败:{exc}")
|
| 815 |
+
continue
|
| 816 |
+
|
| 817 |
+
if not bool(cfg.get("scheduler", {}).get("enabled", True)):
|
| 818 |
+
continue
|
| 819 |
+
|
| 820 |
+
runtime.add_log(f"检测到失败任务,准备执行自动重试:{trigger}")
|
| 821 |
+
_start_background_run(username, trigger)
|
| 822 |
+
triggered.append(username)
|
| 823 |
+
|
| 824 |
+
if triggered:
|
| 825 |
+
logger.info("Retried failed tasks for users: %s", ", ".join(triggered))
|
| 826 |
+
return triggered
|
| 827 |
+
|
| 828 |
+
|
| 829 |
+
def _retry_failed_tasks_job():
|
| 830 |
+
_retry_failed_tasks_once("hourly_retry")
|
| 831 |
+
|
| 832 |
+
|
| 833 |
+
def _schedule_user_job(username: str):
|
| 834 |
+
global scheduler
|
| 835 |
+
|
| 836 |
+
cfg = _load_user_config(username)
|
| 837 |
+
scheduler_cfg = cfg.get("scheduler", {}) if isinstance(cfg, dict) else {}
|
| 838 |
+
enabled = bool(scheduler_cfg.get("enabled", True))
|
| 839 |
+
timezone = str(scheduler_cfg.get("timezone", DEFAULT_TIMEZONE))
|
| 840 |
+
hour = int(scheduler_cfg.get("hour", 9))
|
| 841 |
+
minute = int(scheduler_cfg.get("minute", 0))
|
| 842 |
+
|
| 843 |
+
runtime = _get_runtime(username)
|
| 844 |
+
runtime.update_schedule(hour, minute, timezone)
|
| 845 |
+
|
| 846 |
+
with scheduler_lock:
|
| 847 |
+
if scheduler is None:
|
| 848 |
+
scheduler = BackgroundScheduler(timezone=timezone)
|
| 849 |
+
scheduler.start()
|
| 850 |
+
|
| 851 |
+
job_id = _scheduler_job_id(username)
|
| 852 |
+
if not enabled:
|
| 853 |
+
if scheduler.get_job(job_id):
|
| 854 |
+
scheduler.remove_job(job_id)
|
| 855 |
+
runtime.update_next_run(None)
|
| 856 |
+
runtime.add_log("定时任务已禁用")
|
| 857 |
+
return
|
| 858 |
+
|
| 859 |
+
scheduler.add_job(
|
| 860 |
+
_run_scheduled_once,
|
| 861 |
+
args=[username],
|
| 862 |
+
trigger=CronTrigger(hour=hour, minute=minute, timezone=timezone),
|
| 863 |
+
id=job_id,
|
| 864 |
+
replace_existing=True,
|
| 865 |
+
max_instances=1,
|
| 866 |
+
coalesce=True,
|
| 867 |
+
)
|
| 868 |
+
job = scheduler.get_job(job_id)
|
| 869 |
+
runtime.update_next_run(job.next_run_time if job else None)
|
| 870 |
+
runtime.add_log(f"定时任务更新为 {hour:02d}:{minute:02d} ({timezone})")
|
| 871 |
+
|
| 872 |
+
|
| 873 |
+
def _remove_user_schedule_job(username: str):
|
| 874 |
+
with scheduler_lock:
|
| 875 |
+
if scheduler is None:
|
| 876 |
+
return
|
| 877 |
+
job_id = _scheduler_job_id(username)
|
| 878 |
+
if scheduler.get_job(job_id):
|
| 879 |
+
scheduler.remove_job(job_id)
|
| 880 |
+
|
| 881 |
+
|
| 882 |
+
def _start_background_run(username: str, trigger: str):
|
| 883 |
+
runtime = _get_runtime(username)
|
| 884 |
+
|
| 885 |
+
def _worker():
|
| 886 |
+
runtime.run_once(trigger)
|
| 887 |
+
if scheduler:
|
| 888 |
+
job = scheduler.get_job(_scheduler_job_id(username))
|
| 889 |
+
runtime.update_next_run(job.next_run_time if job else None)
|
| 890 |
+
|
| 891 |
+
thread = threading.Thread(target=_worker, daemon=True)
|
| 892 |
+
thread.start()
|
| 893 |
+
return True
|
| 894 |
+
|
| 895 |
+
|
| 896 |
+
def _start_scheduler():
|
| 897 |
+
global scheduler, scheduler_bootstrapped
|
| 898 |
+
with scheduler_lock:
|
| 899 |
+
if scheduler is None:
|
| 900 |
+
scheduler = BackgroundScheduler(timezone=DEFAULT_TIMEZONE)
|
| 901 |
+
scheduler.start()
|
| 902 |
+
scheduler.add_job(
|
| 903 |
+
_retry_failed_tasks_job,
|
| 904 |
+
trigger="interval",
|
| 905 |
+
hours=FAILED_RETRY_INTERVAL_HOURS,
|
| 906 |
+
id=FAILED_RETRY_JOB_ID,
|
| 907 |
+
replace_existing=True,
|
| 908 |
+
max_instances=1,
|
| 909 |
+
coalesce=True,
|
| 910 |
+
)
|
| 911 |
+
|
| 912 |
+
try:
|
| 913 |
+
_ensure_data_layout()
|
| 914 |
+
users_map = _load_users_meta()
|
| 915 |
+
_sync_user_jobs_from_meta(users_map, run_startup_tasks=True)
|
| 916 |
+
except Exception as exc:
|
| 917 |
+
scheduler_bootstrapped = False
|
| 918 |
+
logger.warning("Scheduler bootstrap skipped, database unavailable. error=%s", exc)
|
| 919 |
+
|
| 920 |
+
|
| 921 |
+
def _stop_scheduler():
|
| 922 |
+
global scheduler, scheduler_bootstrapped
|
| 923 |
+
with scheduler_lock:
|
| 924 |
+
if scheduler and scheduler.running:
|
| 925 |
+
scheduler.shutdown(wait=False)
|
| 926 |
+
logger.info("Scheduler stopped.")
|
| 927 |
+
scheduler = None
|
| 928 |
+
scheduler_bootstrapped = False
|
| 929 |
+
|
| 930 |
+
|
| 931 |
+
app = FastAPI(title="DouYin Spark Flow Dashboard")
|
| 932 |
+
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
| 933 |
+
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
| 934 |
+
|
| 935 |
+
|
| 936 |
+
@app.on_event("startup")
|
| 937 |
+
async def on_startup():
|
| 938 |
+
_start_scheduler()
|
| 939 |
+
atexit.register(_stop_scheduler)
|
| 940 |
+
|
| 941 |
+
|
| 942 |
+
@app.on_event("shutdown")
|
| 943 |
+
async def on_shutdown():
|
| 944 |
+
_stop_scheduler()
|
| 945 |
+
|
| 946 |
+
|
| 947 |
+
@app.get("/", response_class=HTMLResponse)
|
| 948 |
+
async def dashboard(request: Request):
|
| 949 |
+
session = _session_from_request(request)
|
| 950 |
+
if not session:
|
| 951 |
+
return RedirectResponse(url="/login", status_code=303)
|
| 952 |
+
if session.get("role") == "admin":
|
| 953 |
+
return RedirectResponse(url="/admin", status_code=303)
|
| 954 |
+
|
| 955 |
+
username = session.get("username")
|
| 956 |
+
runtime = _get_runtime(username)
|
| 957 |
+
return templates.TemplateResponse(
|
| 958 |
+
"dashboard.html",
|
| 959 |
+
{
|
| 960 |
+
"request": request,
|
| 961 |
+
"default_time": runtime.schedule_time(),
|
| 962 |
+
"username": username,
|
| 963 |
+
},
|
| 964 |
+
)
|
| 965 |
+
|
| 966 |
+
|
| 967 |
+
@app.get("/login", response_class=HTMLResponse)
|
| 968 |
+
async def login_page(request: Request):
|
| 969 |
+
session = _session_from_request(request)
|
| 970 |
+
if session:
|
| 971 |
+
if session.get("role") == "admin":
|
| 972 |
+
return RedirectResponse(url="/admin", status_code=303)
|
| 973 |
+
return RedirectResponse(url="/", status_code=303)
|
| 974 |
+
return templates.TemplateResponse("login.html", {"request": request})
|
| 975 |
+
|
| 976 |
+
|
| 977 |
+
@app.get("/register", response_class=HTMLResponse)
|
| 978 |
+
async def register_page(request: Request):
|
| 979 |
+
session = _session_from_request(request)
|
| 980 |
+
if session:
|
| 981 |
+
if session.get("role") == "admin":
|
| 982 |
+
return RedirectResponse(url="/admin", status_code=303)
|
| 983 |
+
return RedirectResponse(url="/", status_code=303)
|
| 984 |
+
return templates.TemplateResponse("register.html", {"request": request})
|
| 985 |
+
|
| 986 |
+
|
| 987 |
+
@app.get("/admin", response_class=HTMLResponse)
|
| 988 |
+
async def admin_page(request: Request):
|
| 989 |
+
session = _session_from_request(request)
|
| 990 |
+
if not session or session.get("role") != "admin":
|
| 991 |
+
return templates.TemplateResponse(
|
| 992 |
+
"admin_login.html",
|
| 993 |
+
{
|
| 994 |
+
"request": request,
|
| 995 |
+
"password_missing": not bool(os.getenv("PASSWORD")),
|
| 996 |
+
},
|
| 997 |
+
)
|
| 998 |
+
return templates.TemplateResponse("admin.html", {"request": request})
|
| 999 |
+
|
| 1000 |
+
|
| 1001 |
+
@app.post("/api/login")
|
| 1002 |
+
async def api_login(payload: UserLoginPayload):
|
| 1003 |
+
username = payload.username.strip()
|
| 1004 |
+
if not username:
|
| 1005 |
+
return JSONResponse(status_code=400, content={"ok": False, "message": "用户名不能为空。"})
|
| 1006 |
+
|
| 1007 |
+
users_map = _load_users_meta()
|
| 1008 |
+
user = users_map.get(username)
|
| 1009 |
+
if not user:
|
| 1010 |
+
return JSONResponse(status_code=401, content={"ok": False, "message": "用户名或密码错误。"})
|
| 1011 |
+
|
| 1012 |
+
if not _verify_password(payload.password, user.get("password_salt", ""), user.get("password_hash", "")):
|
| 1013 |
+
return JSONResponse(status_code=401, content={"ok": False, "message": "用户名或密码错误。"})
|
| 1014 |
+
|
| 1015 |
+
token = secrets.token_urlsafe(32)
|
| 1016 |
+
AUTH_SESSIONS[token] = {"role": "user", "username": username}
|
| 1017 |
+
|
| 1018 |
+
response = JSONResponse({"ok": True, "message": "登录成功。"})
|
| 1019 |
+
response.set_cookie(
|
| 1020 |
+
key=SESSION_COOKIE_NAME,
|
| 1021 |
+
value=token,
|
| 1022 |
+
httponly=True,
|
| 1023 |
+
samesite="lax",
|
| 1024 |
+
max_age=7 * 24 * 3600,
|
| 1025 |
+
)
|
| 1026 |
+
return response
|
| 1027 |
+
|
| 1028 |
+
|
| 1029 |
+
@app.post("/api/admin/login")
|
| 1030 |
+
async def api_admin_login(payload: AdminLoginPayload):
|
| 1031 |
+
expected_password = os.getenv("PASSWORD")
|
| 1032 |
+
if not expected_password:
|
| 1033 |
+
return JSONResponse(
|
| 1034 |
+
status_code=500,
|
| 1035 |
+
content={"ok": False, "message": "服务端未配置 PASSWORD 环境变量。"},
|
| 1036 |
+
)
|
| 1037 |
+
|
| 1038 |
+
if payload.password != expected_password:
|
| 1039 |
+
return JSONResponse(status_code=401, content={"ok": False, "message": "密码错误。"})
|
| 1040 |
+
|
| 1041 |
+
token = secrets.token_urlsafe(32)
|
| 1042 |
+
AUTH_SESSIONS[token] = {"role": "admin", "username": "admin"}
|
| 1043 |
+
response = JSONResponse({"ok": True, "message": "登录成功。"})
|
| 1044 |
+
response.set_cookie(
|
| 1045 |
+
key=SESSION_COOKIE_NAME,
|
| 1046 |
+
value=token,
|
| 1047 |
+
httponly=True,
|
| 1048 |
+
samesite="lax",
|
| 1049 |
+
max_age=7 * 24 * 3600,
|
| 1050 |
+
)
|
| 1051 |
+
return response
|
| 1052 |
+
|
| 1053 |
+
|
| 1054 |
+
@app.post("/api/register")
|
| 1055 |
+
async def api_register(password: str = Form(...), users_file: UploadFile = File(...)):
|
| 1056 |
+
if len(password.strip()) < 4:
|
| 1057 |
+
return JSONResponse(status_code=400, content={"ok": False, "message": "密码至少 4 位。"})
|
| 1058 |
+
|
| 1059 |
+
if not users_file.filename.lower().endswith(".json"):
|
| 1060 |
+
return JSONResponse(status_code=400, content={"ok": False, "message": "请上传 usersData.json 文件。"})
|
| 1061 |
+
|
| 1062 |
+
try:
|
| 1063 |
+
raw = await users_file.read()
|
| 1064 |
+
users_data, username, unique_id = _validate_and_normalize_users_data(raw)
|
| 1065 |
+
except Exception as exc:
|
| 1066 |
+
return JSONResponse(status_code=400, content={"ok": False, "message": str(exc)})
|
| 1067 |
+
|
| 1068 |
+
users_map = _load_users_meta()
|
| 1069 |
+
if username in users_map:
|
| 1070 |
+
return JSONResponse(status_code=409, content={"ok": False, "message": f"用户名 {username} 已注册。"})
|
| 1071 |
+
|
| 1072 |
+
for existing in users_map.values():
|
| 1073 |
+
if str(existing.get("unique_id", "")).strip() == unique_id:
|
| 1074 |
+
return JSONResponse(status_code=409, content={"ok": False, "message": f"unique_id {unique_id} 已注册。"})
|
| 1075 |
+
|
| 1076 |
default_config = _get_default_user_config()
|
| 1077 |
default_config.setdefault("scheduler", {})
|
| 1078 |
default_config["scheduler"].setdefault("enabled", True)
|
|
|
|
| 1098 |
|
| 1099 |
_schedule_user_job(username)
|
| 1100 |
_get_runtime(username).add_log("用户已注册并完成定时任务初始化")
|
| 1101 |
+
|
| 1102 |
+
return {
|
| 1103 |
+
"ok": True,
|
| 1104 |
+
"message": "注册成功,请使用用户名和密码登录。",
|
| 1105 |
+
"username": username,
|
| 1106 |
+
}
|
| 1107 |
+
|
| 1108 |
+
|
| 1109 |
+
@app.post("/api/logout")
|
| 1110 |
+
async def api_logout(request: Request):
|
| 1111 |
+
token = request.cookies.get(SESSION_COOKIE_NAME)
|
| 1112 |
+
if token:
|
| 1113 |
+
AUTH_SESSIONS.pop(token, None)
|
| 1114 |
+
response = JSONResponse({"ok": True})
|
| 1115 |
+
response.delete_cookie(SESSION_COOKIE_NAME)
|
| 1116 |
+
return response
|
| 1117 |
+
|
| 1118 |
+
|
| 1119 |
+
@app.get("/api/status")
|
| 1120 |
+
async def api_status(request: Request):
|
| 1121 |
+
session = _require_user_session(request)
|
| 1122 |
+
username = session["username"]
|
| 1123 |
+
runtime = _get_runtime(username)
|
| 1124 |
+
users_data = _load_user_users_data(username)
|
| 1125 |
+
return {
|
| 1126 |
+
"ok": True,
|
| 1127 |
+
"runtime": runtime.snapshot(
|
| 1128 |
+
account_count=len(users_data),
|
| 1129 |
+
target_count=_count_targets(users_data),
|
| 1130 |
+
),
|
| 1131 |
+
"history": runtime.history_rows(),
|
| 1132 |
+
}
|
| 1133 |
+
|
| 1134 |
+
|
| 1135 |
+
@app.get("/api/logs")
|
| 1136 |
+
async def api_logs(request: Request, limit: int = MAX_LOG_LINES):
|
| 1137 |
+
session = _require_user_session(request)
|
| 1138 |
+
username = session["username"]
|
| 1139 |
+
runtime = _get_runtime(username)
|
| 1140 |
+
limit = min(max(100, limit), 3000)
|
| 1141 |
+
return {"ok": True, "logs": runtime.recent_logs(limit=limit)}
|
| 1142 |
+
|
| 1143 |
+
|
| 1144 |
+
@app.post("/api/run")
|
| 1145 |
+
async def api_run(request: Request):
|
| 1146 |
+
session = _require_user_session(request)
|
| 1147 |
+
username = session["username"]
|
| 1148 |
+
runtime = _get_runtime(username)
|
| 1149 |
+
|
| 1150 |
+
if runtime.is_running:
|
| 1151 |
+
return JSONResponse(
|
| 1152 |
+
status_code=409,
|
| 1153 |
+
content={"ok": False, "message": "已有任务正在执行,请稍后再试。"},
|
| 1154 |
+
)
|
| 1155 |
+
|
| 1156 |
+
_start_background_run(username, "manual")
|
| 1157 |
+
return {"ok": True, "message": "任务已开始执行。"}
|
| 1158 |
+
|
| 1159 |
+
|
| 1160 |
+
@app.post("/api/schedule")
|
| 1161 |
+
async def api_schedule(request: Request, payload: SchedulePayload):
|
| 1162 |
+
session = _require_user_session(request)
|
| 1163 |
+
username = session["username"]
|
| 1164 |
+
|
| 1165 |
+
try:
|
| 1166 |
+
hour, minute = _parse_time_string(payload.time)
|
| 1167 |
+
except Exception as exc:
|
| 1168 |
+
return JSONResponse(status_code=400, content={"ok": False, "message": str(exc)})
|
| 1169 |
+
|
| 1170 |
+
cfg = _load_user_config(username)
|
| 1171 |
+
scheduler_cfg = cfg.setdefault("scheduler", {})
|
| 1172 |
+
scheduler_cfg["enabled"] = True
|
| 1173 |
+
scheduler_cfg["hour"] = hour
|
| 1174 |
+
scheduler_cfg["minute"] = minute
|
| 1175 |
+
scheduler_cfg["timezone"] = str(scheduler_cfg.get("timezone", DEFAULT_TIMEZONE))
|
| 1176 |
+
scheduler_cfg["runOnStartup"] = bool(scheduler_cfg.get("runOnStartup", False))
|
| 1177 |
+
_save_user_config(username, cfg)
|
| 1178 |
+
|
| 1179 |
+
_schedule_user_job(username)
|
| 1180 |
+
runtime = _get_runtime(username)
|
| 1181 |
+
return {
|
| 1182 |
+
"ok": True,
|
| 1183 |
+
"message": f"定时任务已更新为每天 {hour:02d}:{minute:02d}。",
|
| 1184 |
+
"time": f"{hour:02d}:{minute:02d}",
|
| 1185 |
+
"next_run": runtime.snapshot(0, 0)["next_run"],
|
| 1186 |
+
}
|
| 1187 |
+
|
| 1188 |
+
|
| 1189 |
+
@app.get("/api/editor/state")
|
| 1190 |
+
async def api_editor_state(request: Request):
|
| 1191 |
+
session = _require_user_session(request)
|
| 1192 |
+
username = session["username"]
|
| 1193 |
+
return {"ok": True, **_build_editor_state(username)}
|
| 1194 |
+
|
| 1195 |
+
|
| 1196 |
+
@app.post("/api/editor/message")
|
| 1197 |
+
async def api_editor_message(request: Request, payload: MessageTemplatePayload):
|
| 1198 |
+
session = _require_user_session(request)
|
| 1199 |
+
username = session["username"]
|
| 1200 |
+
|
| 1201 |
+
message = payload.message.strip()
|
| 1202 |
+
if not message:
|
| 1203 |
+
return JSONResponse(status_code=400, content={"ok": False, "message": "消息内容不能为空。"})
|
| 1204 |
+
if len(message) > MAX_TEMPLATE_LENGTH:
|
| 1205 |
+
return JSONResponse(
|
| 1206 |
+
status_code=400,
|
| 1207 |
+
content={"ok": False, "message": f"消息内容过长,最多 {MAX_TEMPLATE_LENGTH} 字符。"},
|
| 1208 |
+
)
|
| 1209 |
+
|
| 1210 |
+
cfg = _load_user_config(username)
|
| 1211 |
+
cfg["messageTemplate"] = message
|
| 1212 |
+
_save_user_config(username, cfg)
|
| 1213 |
+
_get_runtime(username).add_log("消息模板已更新")
|
| 1214 |
+
return {"ok": True, "message": "消息模板已保存。"}
|
| 1215 |
+
|
| 1216 |
+
|
| 1217 |
+
@app.post("/api/editor/targets")
|
| 1218 |
+
async def api_editor_targets(request: Request, payload: UserTargetsPayload):
|
| 1219 |
+
session = _require_user_session(request)
|
| 1220 |
+
username = session["username"]
|
| 1221 |
+
|
| 1222 |
+
users_data = _load_user_users_data(username)
|
| 1223 |
+
updates = {item.unique_id: _sanitize_targets(item.targets) for item in payload.users}
|
| 1224 |
+
|
| 1225 |
+
updated = 0
|
| 1226 |
+
for user in users_data:
|
| 1227 |
+
uid = str(user.get("unique_id", ""))
|
| 1228 |
+
if uid in updates:
|
| 1229 |
+
user["targets"] = updates[uid]
|
| 1230 |
+
updated += 1
|
| 1231 |
+
|
| 1232 |
+
_save_user_users_data(username, users_data)
|
| 1233 |
+
_get_runtime(username).add_log(f"目标好友已更新,涉及账号数:{updated}")
|
| 1234 |
+
return {"ok": True, "message": f"目标好友已保存({updated} 个账号)。"}
|
| 1235 |
+
|
| 1236 |
+
|
| 1237 |
+
@app.get("/api/admin/overview")
|
| 1238 |
+
async def api_admin_overview(request: Request):
|
| 1239 |
+
_require_admin_session(request)
|
| 1240 |
+
try:
|
| 1241 |
+
users_map = _load_users_meta()
|
| 1242 |
+
if not scheduler_bootstrapped:
|
| 1243 |
+
_sync_user_jobs_from_meta(users_map, run_startup_tasks=False)
|
| 1244 |
+
except Exception as exc:
|
| 1245 |
+
logger.warning("Admin overview failed to reach database. error=%s", exc)
|
| 1246 |
+
return {
|
| 1247 |
+
"ok": True,
|
| 1248 |
+
"users": [],
|
| 1249 |
+
"task_count": 0,
|
| 1250 |
+
"db_status": _build_db_status_payload(),
|
| 1251 |
+
"message": f"无法连接 SQL 服务器:{exc}",
|
| 1252 |
+
}
|
| 1253 |
+
|
| 1254 |
+
rows = []
|
| 1255 |
+
for username, meta in sorted(users_map.items(), key=lambda x: x[0]):
|
| 1256 |
+
try:
|
| 1257 |
+
cfg = _load_user_config(username)
|
| 1258 |
+
users_data = _load_user_users_data(username)
|
| 1259 |
+
except Exception as exc:
|
| 1260 |
+
rows.append(
|
| 1261 |
+
{
|
| 1262 |
+
"username": username,
|
| 1263 |
+
"unique_id": meta.get("unique_id", ""),
|
| 1264 |
+
"created_at": meta.get("created_at", "-"),
|
| 1265 |
+
"error": str(exc),
|
| 1266 |
+
}
|
| 1267 |
+
)
|
| 1268 |
+
continue
|
| 1269 |
+
|
| 1270 |
+
scheduler_cfg = cfg.get("scheduler", {})
|
| 1271 |
+
runtime = _get_runtime(username)
|
| 1272 |
+
runtime_snapshot = runtime.snapshot(
|
| 1273 |
+
account_count=len(users_data),
|
| 1274 |
+
target_count=_count_targets(users_data),
|
| 1275 |
+
)
|
| 1276 |
+
|
| 1277 |
+
receivers = []
|
| 1278 |
+
for item in users_data:
|
| 1279 |
+
receivers.extend(item.get("targets", []))
|
| 1280 |
+
|
| 1281 |
+
rows.append(
|
| 1282 |
+
{
|
| 1283 |
+
"username": username,
|
| 1284 |
+
"unique_id": meta.get("unique_id", ""),
|
| 1285 |
+
"created_at": meta.get("created_at", "-"),
|
| 1286 |
+
"scheduler_enabled": bool(scheduler_cfg.get("enabled", True)),
|
| 1287 |
+
"schedule_time": f"{int(scheduler_cfg.get('hour', 9)):02d}:{int(scheduler_cfg.get('minute', 0)):02d}",
|
| 1288 |
+
"schedule_timezone": str(scheduler_cfg.get("timezone", DEFAULT_TIMEZONE)),
|
| 1289 |
+
"message_template": str(cfg.get("messageTemplate", "")),
|
| 1290 |
+
"targets": receivers,
|
| 1291 |
+
"target_count": len(receivers),
|
| 1292 |
+
"next_run": runtime_snapshot.get("next_run", "-"),
|
| 1293 |
+
"last_status": runtime_snapshot.get("last_status", "-"),
|
| 1294 |
+
"last_start": runtime_snapshot.get("last_start", "-"),
|
| 1295 |
+
"is_running": runtime_snapshot.get("is_running", False),
|
| 1296 |
+
"can_retry": bool(
|
| 1297 |
+
not runtime_snapshot.get("is_running", False)
|
| 1298 |
+
and runtime_snapshot.get("last_status") == "失败"
|
| 1299 |
+
),
|
| 1300 |
+
}
|
| 1301 |
+
)
|
| 1302 |
+
|
| 1303 |
+
return {
|
| 1304 |
+
"ok": True,
|
| 1305 |
+
"users": rows,
|
| 1306 |
+
"task_count": len(rows),
|
| 1307 |
+
"db_status": _build_db_status_payload(),
|
| 1308 |
+
}
|
| 1309 |
+
|
| 1310 |
+
|
| 1311 |
+
@app.get("/api/admin/tasks/{username}")
|
| 1312 |
+
async def api_admin_task_detail(request: Request, username: str, log_limit: int = MAX_LOG_LINES):
|
| 1313 |
+
_require_admin_session(request)
|
| 1314 |
+
username = username.strip()
|
| 1315 |
+
user_meta = _get_user_meta_or_404(username)
|
| 1316 |
+
|
| 1317 |
+
try:
|
| 1318 |
+
cfg = _load_user_config(username)
|
| 1319 |
+
users_data = _load_user_users_data(username)
|
| 1320 |
+
except Exception as exc:
|
| 1321 |
+
return JSONResponse(
|
| 1322 |
+
status_code=500,
|
| 1323 |
+
content={"ok": False, "message": f"加载任务详情失败:{exc}"},
|
| 1324 |
+
)
|
| 1325 |
+
|
| 1326 |
+
scheduler_cfg = cfg.get("scheduler", {})
|
| 1327 |
+
runtime = _get_runtime(username)
|
| 1328 |
+
target_count = _count_targets(users_data)
|
| 1329 |
+
snapshot = runtime.snapshot(account_count=len(users_data), target_count=target_count)
|
| 1330 |
+
|
| 1331 |
+
accounts = []
|
| 1332 |
+
all_targets = []
|
| 1333 |
+
for item in users_data:
|
| 1334 |
+
targets = _sanitize_targets(item.get("targets", []))
|
| 1335 |
+
all_targets.extend(targets)
|
| 1336 |
+
accounts.append(
|
| 1337 |
+
{
|
| 1338 |
+
"username": str(item.get("username", "未知用户")),
|
| 1339 |
+
"unique_id": str(item.get("unique_id", "")),
|
| 1340 |
+
"target_count": len(targets),
|
| 1341 |
+
"targets": targets,
|
| 1342 |
+
"cookie_count": len(item.get("cookies", [])) if isinstance(item.get("cookies", []), list) else 0,
|
| 1343 |
+
}
|
| 1344 |
+
)
|
| 1345 |
+
|
| 1346 |
+
log_limit = min(max(100, log_limit), 3000)
|
| 1347 |
+
return {
|
| 1348 |
+
"ok": True,
|
| 1349 |
+
"task": {
|
| 1350 |
+
"username": username,
|
| 1351 |
+
"unique_id": user_meta.get("unique_id", ""),
|
| 1352 |
+
"created_at": user_meta.get("created_at", "-"),
|
| 1353 |
+
"scheduler_enabled": bool(scheduler_cfg.get("enabled", True)),
|
| 1354 |
+
"schedule_time": f"{int(scheduler_cfg.get('hour', 9)):02d}:{int(scheduler_cfg.get('minute', 0)):02d}",
|
| 1355 |
+
"schedule_timezone": str(scheduler_cfg.get("timezone", DEFAULT_TIMEZONE)),
|
| 1356 |
+
"message_template": str(cfg.get("messageTemplate", "")),
|
| 1357 |
+
"targets": all_targets,
|
| 1358 |
+
"target_count": len(all_targets),
|
| 1359 |
+
"runtime": snapshot,
|
| 1360 |
+
"can_retry": bool(not snapshot.get("is_running", False) and snapshot.get("last_status") == "失败"),
|
| 1361 |
+
"history": runtime.history_rows(),
|
| 1362 |
+
"logs": runtime.recent_logs(limit=log_limit),
|
| 1363 |
+
"config": {
|
| 1364 |
+
"multiTask": bool(cfg.get("multiTask", True)),
|
| 1365 |
+
"taskCount": int(cfg.get("taskCount", 1) or 1),
|
| 1366 |
+
"hitokotoTypes": cfg.get("hitokotoTypes", []),
|
| 1367 |
+
"proxyAddress": str(cfg.get("proxyAddress", "")),
|
| 1368 |
+
},
|
| 1369 |
+
"accounts": accounts,
|
| 1370 |
+
},
|
| 1371 |
+
}
|
| 1372 |
+
|
| 1373 |
+
|
| 1374 |
+
@app.post("/api/admin/tasks/{username}/retry")
|
| 1375 |
+
async def api_admin_retry_task(request: Request, username: str):
|
| 1376 |
+
_require_admin_session(request)
|
| 1377 |
+
username = username.strip()
|
| 1378 |
+
_get_user_meta_or_404(username)
|
| 1379 |
+
|
| 1380 |
+
runtime = _get_runtime(username)
|
| 1381 |
+
snapshot = runtime.snapshot(account_count=0, target_count=0)
|
| 1382 |
+
if snapshot.get("is_running"):
|
| 1383 |
+
return JSONResponse(status_code=409, content={"ok": False, "message": "任务正在运行中,请稍后再试。"})
|
| 1384 |
+
if snapshot.get("last_status") != "失败":
|
| 1385 |
+
return JSONResponse(status_code=409, content={"ok": False, "message": "当前任务不是失败状态,无需重试。"})
|
| 1386 |
+
|
| 1387 |
+
runtime.add_log("管理员手动触发失败任务重试")
|
| 1388 |
+
_start_background_run(username, "admin_retry")
|
| 1389 |
+
return {"ok": True, "message": f"已开始重试 {username} 的失败任务。"}
|
| 1390 |
+
|
| 1391 |
+
|
| 1392 |
+
@app.post("/api/admin/tasks/retry-failed")
|
| 1393 |
+
async def api_admin_retry_all_failed_tasks(request: Request):
|
| 1394 |
+
_require_admin_session(request)
|
| 1395 |
+
|
| 1396 |
+
try:
|
| 1397 |
+
usernames = _retry_failed_tasks_once("admin_bulk_retry", raise_on_db_error=True)
|
| 1398 |
+
except Exception as exc:
|
| 1399 |
+
return JSONResponse(status_code=503, content={"ok": False, "message": f"???? SQL ????{exc}"})
|
| 1400 |
+
|
| 1401 |
+
if not usernames:
|
| 1402 |
+
return {"ok": True, "message": "?????????????", "count": 0, "usernames": []}
|
| 1403 |
+
|
| 1404 |
+
return {
|
| 1405 |
+
"ok": True,
|
| 1406 |
+
"message": f"??????? {len(usernames)} ??????",
|
| 1407 |
+
"count": len(usernames),
|
| 1408 |
+
"usernames": usernames,
|
| 1409 |
+
}
|
| 1410 |
+
|
| 1411 |
+
|
| 1412 |
+
@app.post("/api/admin/tasks/{username}/delete")
|
| 1413 |
+
async def api_admin_delete_task(request: Request, username: str):
|
| 1414 |
+
_require_admin_session(request)
|
| 1415 |
+
username = username.strip()
|
| 1416 |
+
_get_user_meta_or_404(username)
|
| 1417 |
+
|
| 1418 |
+
cfg = _load_user_config(username)
|
| 1419 |
+
scheduler_cfg = cfg.setdefault("scheduler", {})
|
| 1420 |
+
scheduler_cfg["enabled"] = False
|
| 1421 |
+
_save_user_config(username, cfg)
|
| 1422 |
+
|
| 1423 |
+
_remove_user_schedule_job(username)
|
| 1424 |
+
runtime = _get_runtime(username)
|
| 1425 |
+
runtime.update_next_run(None)
|
| 1426 |
+
runtime.add_log("管理员已删除(禁用)该用户定时任务")
|
| 1427 |
+
|
| 1428 |
+
return {"ok": True, "message": f"已删除用户 {username} 的定时任务。"}
|
| 1429 |
+
|
| 1430 |
+
|
| 1431 |
+
@app.delete("/api/admin/users/{username}")
|
| 1432 |
+
async def api_admin_delete_user(request: Request, username: str):
|
| 1433 |
+
_require_admin_session(request)
|
| 1434 |
+
username = username.strip()
|
| 1435 |
+
|
| 1436 |
_get_user_meta_or_404(username)
|
| 1437 |
|
| 1438 |
_remove_user_schedule_job(username)
|
|
|
|
| 1440 |
_delete_runtime(username)
|
| 1441 |
|
| 1442 |
return {"ok": True, "message": f"用户 {username} 已删除。"}
|
| 1443 |
+
|
| 1444 |
+
|
| 1445 |
+
@app.get("/health")
|
| 1446 |
+
async def health():
|
| 1447 |
+
return {"ok": True, "status": "alive"}
|
| 1448 |
+
|
| 1449 |
+
|
| 1450 |
+
def run_server():
|
| 1451 |
+
port = int(os.getenv("PORT", "7860"))
|
| 1452 |
+
uvicorn.run("app:app", host="0.0.0.0", port=port, workers=1)
|
| 1453 |
+
|
| 1454 |
+
|
| 1455 |
+
if __name__ == "__main__":
|
| 1456 |
+
run_server()
|
templates/admin.html
CHANGED
|
@@ -1,9 +1,9 @@
|
|
| 1 |
-
|
| 2 |
<html lang="zh-CN">
|
| 3 |
<head>
|
| 4 |
<meta charset="utf-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
-
<title>DouYin Spark
|
| 7 |
<link rel="stylesheet" href="/static/style.css">
|
| 8 |
</head>
|
| 9 |
<body class="dash-body">
|
|
@@ -13,6 +13,7 @@
|
|
| 13 |
<p>用户管理 + 定时任务总览</p>
|
| 14 |
</div>
|
| 15 |
<div class="top-actions">
|
|
|
|
| 16 |
<button id="refreshBtn" class="btn ghost">刷新</button>
|
| 17 |
<button id="logoutBtn" class="btn ghost">退出登录</button>
|
| 18 |
</div>
|
|
@@ -22,8 +23,9 @@
|
|
| 22 |
<section class="panel">
|
| 23 |
<h2>用户与任务总览</h2>
|
| 24 |
<p id="summary" class="muted">加载中...</p>
|
|
|
|
| 25 |
<div class="table-wrap">
|
| 26 |
-
<table>
|
| 27 |
<thead>
|
| 28 |
<tr>
|
| 29 |
<th>发起用户</th>
|
|
@@ -45,12 +47,94 @@
|
|
| 45 |
</div>
|
| 46 |
<p id="adminMsg" class="msg"></p>
|
| 47 |
</section>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
</main>
|
| 49 |
|
| 50 |
<script>
|
| 51 |
const adminBody = document.getElementById("adminBody");
|
| 52 |
const summary = document.getElementById("summary");
|
|
|
|
|
|
|
| 53 |
const adminMsg = document.getElementById("adminMsg");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
function setMsg(msg, isError = false) {
|
| 56 |
adminMsg.textContent = msg || "";
|
|
@@ -85,9 +169,49 @@
|
|
| 85 |
.replace(/'/g, "'");
|
| 86 |
}
|
| 87 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
function renderRows(users) {
|
| 89 |
if (!users || users.length === 0) {
|
| 90 |
adminBody.innerHTML = '<tr><td colspan="10">暂无用户</td></tr>';
|
|
|
|
| 91 |
return;
|
| 92 |
}
|
| 93 |
|
|
@@ -100,7 +224,9 @@
|
|
| 100 |
<td>${escapeHtml(u.created_at || "-")}</td>
|
| 101 |
<td colspan="6" style="color:#c0392b;">数据异常:${escapeHtml(u.error)}</td>
|
| 102 |
<td>
|
| 103 |
-
<
|
|
|
|
|
|
|
| 104 |
</td>
|
| 105 |
</tr>
|
| 106 |
`;
|
|
@@ -108,6 +234,10 @@
|
|
| 108 |
|
| 109 |
const targets = Array.isArray(u.targets) ? u.targets : [];
|
| 110 |
const targetText = targets.length ? targets.join(" / ") : "-";
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
return `
|
| 113 |
<tr>
|
|
@@ -116,12 +246,14 @@
|
|
| 116 |
<td>${escapeHtml(u.created_at)}</td>
|
| 117 |
<td>${u.scheduler_enabled ? "启用" : "禁用"}</td>
|
| 118 |
<td>${escapeHtml(u.schedule_time)} (${escapeHtml(u.schedule_timezone)})</td>
|
| 119 |
-
<td class="
|
| 120 |
-
<td class="
|
| 121 |
<td>${escapeHtml(u.next_run || "-")}</td>
|
| 122 |
-
<td>${
|
| 123 |
<td>
|
| 124 |
<div class="admin-actions">
|
|
|
|
|
|
|
| 125 |
<button class="btn admin-del-task" data-user="${escapeHtml(u.username)}">删任务</button>
|
| 126 |
<button class="btn admin-delete-user" data-user="${escapeHtml(u.username)}">删用户</button>
|
| 127 |
</div>
|
|
@@ -130,6 +262,13 @@
|
|
| 130 |
`;
|
| 131 |
}).join("");
|
| 132 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
document.querySelectorAll(".admin-del-task").forEach((btn) => {
|
| 134 |
btn.addEventListener("click", async () => {
|
| 135 |
const username = btn.dataset.user;
|
|
@@ -142,12 +281,22 @@
|
|
| 142 |
});
|
| 143 |
setMsg(data.message || "已删除任务");
|
| 144 |
await loadOverview();
|
|
|
|
|
|
|
|
|
|
| 145 |
} catch (err) {
|
| 146 |
setMsg(err.message, true);
|
| 147 |
}
|
| 148 |
});
|
| 149 |
});
|
| 150 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
document.querySelectorAll(".admin-delete-user").forEach((btn) => {
|
| 152 |
btn.addEventListener("click", async () => {
|
| 153 |
const username = btn.dataset.user;
|
|
@@ -158,6 +307,9 @@
|
|
| 158 |
method: "DELETE",
|
| 159 |
});
|
| 160 |
setMsg(data.message || "用户已删除");
|
|
|
|
|
|
|
|
|
|
| 161 |
await loadOverview();
|
| 162 |
} catch (err) {
|
| 163 |
setMsg(err.message, true);
|
|
@@ -166,29 +318,180 @@
|
|
| 166 |
});
|
| 167 |
}
|
| 168 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
async function loadOverview() {
|
| 170 |
const data = await requestJSON("/api/admin/overview");
|
| 171 |
-
|
|
|
|
|
|
|
|
|
|
| 172 |
renderRows(data.users || []);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
}
|
| 174 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
document.getElementById("refreshBtn").addEventListener("click", async () => {
|
| 176 |
try {
|
| 177 |
await loadOverview();
|
|
|
|
|
|
|
|
|
|
| 178 |
} catch (err) {
|
| 179 |
setMsg(err.message, true);
|
| 180 |
}
|
| 181 |
});
|
| 182 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
document.getElementById("logoutBtn").addEventListener("click", async () => {
|
| 184 |
await fetch("/api/logout", { method: "POST", credentials: "same-origin" });
|
| 185 |
window.location.href = "/admin";
|
| 186 |
});
|
| 187 |
|
| 188 |
loadOverview().catch((err) => setMsg(err.message, true));
|
| 189 |
-
setInterval(() => {
|
| 190 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
}, 8000);
|
| 192 |
</script>
|
| 193 |
</body>
|
| 194 |
-
</html>
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
<html lang="zh-CN">
|
| 3 |
<head>
|
| 4 |
<meta charset="utf-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>DouYin Spark Helper - Admin 控制台</title>
|
| 7 |
<link rel="stylesheet" href="/static/style.css">
|
| 8 |
</head>
|
| 9 |
<body class="dash-body">
|
|
|
|
| 13 |
<p>用户管理 + 定时任务总览</p>
|
| 14 |
</div>
|
| 15 |
<div class="top-actions">
|
| 16 |
+
<button id="retryAllFailedBtn" class="btn ghost">一键重试失败任务</button>
|
| 17 |
<button id="refreshBtn" class="btn ghost">刷新</button>
|
| 18 |
<button id="logoutBtn" class="btn ghost">退出登录</button>
|
| 19 |
</div>
|
|
|
|
| 23 |
<section class="panel">
|
| 24 |
<h2>用户与任务总览</h2>
|
| 25 |
<p id="summary" class="muted">加载中...</p>
|
| 26 |
+
<div id="dbAlert" class="alert warning" style="display:none;"></div>
|
| 27 |
<div class="table-wrap">
|
| 28 |
+
<table class="admin-table">
|
| 29 |
<thead>
|
| 30 |
<tr>
|
| 31 |
<th>发起用户</th>
|
|
|
|
| 47 |
</div>
|
| 48 |
<p id="adminMsg" class="msg"></p>
|
| 49 |
</section>
|
| 50 |
+
|
| 51 |
+
<section id="detailPanel" class="panel" style="display:none;">
|
| 52 |
+
<div class="panel-header">
|
| 53 |
+
<h2 id="detailTitle">任务详情</h2>
|
| 54 |
+
<div class="top-actions">
|
| 55 |
+
<button id="detailRetryBtn" class="btn" style="display:none;">重试失败任务</button>
|
| 56 |
+
<button id="detailRefreshBtn" class="btn">刷新详情</button>
|
| 57 |
+
</div>
|
| 58 |
+
</div>
|
| 59 |
+
|
| 60 |
+
<div id="detailMeta" class="detail-grid"></div>
|
| 61 |
+
|
| 62 |
+
<div class="detail-split">
|
| 63 |
+
<article class="detail-card">
|
| 64 |
+
<h3>消息内容</h3>
|
| 65 |
+
<pre id="detailMessage" class="detail-message">-</pre>
|
| 66 |
+
</article>
|
| 67 |
+
<article class="detail-card">
|
| 68 |
+
<h3>接收方(去重后)</h3>
|
| 69 |
+
<pre id="detailTargets" class="detail-message">-</pre>
|
| 70 |
+
</article>
|
| 71 |
+
</div>
|
| 72 |
+
|
| 73 |
+
<article class="detail-card">
|
| 74 |
+
<h3>账号明细</h3>
|
| 75 |
+
<div class="table-wrap">
|
| 76 |
+
<table class="admin-table detail-history">
|
| 77 |
+
<thead>
|
| 78 |
+
<tr>
|
| 79 |
+
<th>账号昵称</th>
|
| 80 |
+
<th>unique_id</th>
|
| 81 |
+
<th>目标数</th>
|
| 82 |
+
<th>cookies 数</th>
|
| 83 |
+
<th>目标列表</th>
|
| 84 |
+
</tr>
|
| 85 |
+
</thead>
|
| 86 |
+
<tbody id="detailAccountsBody">
|
| 87 |
+
<tr><td colspan="5">暂无数据</td></tr>
|
| 88 |
+
</tbody>
|
| 89 |
+
</table>
|
| 90 |
+
</div>
|
| 91 |
+
</article>
|
| 92 |
+
|
| 93 |
+
<article class="detail-card">
|
| 94 |
+
<h3>执行历史</h3>
|
| 95 |
+
<div class="table-wrap">
|
| 96 |
+
<table class="admin-table detail-history">
|
| 97 |
+
<thead>
|
| 98 |
+
<tr>
|
| 99 |
+
<th>触发方式</th>
|
| 100 |
+
<th>开始时间</th>
|
| 101 |
+
<th>结束时间</th>
|
| 102 |
+
<th>状态</th>
|
| 103 |
+
<th>耗时</th>
|
| 104 |
+
<th>信息</th>
|
| 105 |
+
</tr>
|
| 106 |
+
</thead>
|
| 107 |
+
<tbody id="detailHistoryBody">
|
| 108 |
+
<tr><td colspan="6">暂无记录</td></tr>
|
| 109 |
+
</tbody>
|
| 110 |
+
</table>
|
| 111 |
+
</div>
|
| 112 |
+
</article>
|
| 113 |
+
|
| 114 |
+
<article class="detail-card">
|
| 115 |
+
<h3>任务日志</h3>
|
| 116 |
+
<pre id="detailLogs" class="detail-logbox">暂无日志。</pre>
|
| 117 |
+
</article>
|
| 118 |
+
</section>
|
| 119 |
</main>
|
| 120 |
|
| 121 |
<script>
|
| 122 |
const adminBody = document.getElementById("adminBody");
|
| 123 |
const summary = document.getElementById("summary");
|
| 124 |
+
const retryAllFailedBtn = document.getElementById("retryAllFailedBtn");
|
| 125 |
+
const dbAlert = document.getElementById("dbAlert");
|
| 126 |
const adminMsg = document.getElementById("adminMsg");
|
| 127 |
+
const detailPanel = document.getElementById("detailPanel");
|
| 128 |
+
const detailTitle = document.getElementById("detailTitle");
|
| 129 |
+
const detailMeta = document.getElementById("detailMeta");
|
| 130 |
+
const detailMessage = document.getElementById("detailMessage");
|
| 131 |
+
const detailTargets = document.getElementById("detailTargets");
|
| 132 |
+
const detailAccountsBody = document.getElementById("detailAccountsBody");
|
| 133 |
+
const detailHistoryBody = document.getElementById("detailHistoryBody");
|
| 134 |
+
const detailLogs = document.getElementById("detailLogs");
|
| 135 |
+
const detailRetryBtn = document.getElementById("detailRetryBtn");
|
| 136 |
+
let currentDetailUser = null;
|
| 137 |
+
let dbConnected = true;
|
| 138 |
|
| 139 |
function setMsg(msg, isError = false) {
|
| 140 |
adminMsg.textContent = msg || "";
|
|
|
|
| 169 |
.replace(/'/g, "'");
|
| 170 |
}
|
| 171 |
|
| 172 |
+
function renderDbStatus(status, fallbackMessage = "") {
|
| 173 |
+
dbConnected = !status || status.connected !== false;
|
| 174 |
+
if (dbConnected) {
|
| 175 |
+
dbAlert.style.display = "none";
|
| 176 |
+
dbAlert.textContent = "";
|
| 177 |
+
return;
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
const parts = ["当前无法连接 SQL 服务器。"];
|
| 181 |
+
if (fallbackMessage) {
|
| 182 |
+
parts.push(fallbackMessage);
|
| 183 |
+
} else if (status && status.last_error) {
|
| 184 |
+
parts.push(status.last_error);
|
| 185 |
+
}
|
| 186 |
+
if (status && status.last_checked_at && status.last_checked_at !== "-") {
|
| 187 |
+
parts.push(`最近检查:${status.last_checked_at}`);
|
| 188 |
+
}
|
| 189 |
+
if (status && status.last_ok_at && status.last_ok_at !== "-") {
|
| 190 |
+
parts.push(`最近成功连接:${status.last_ok_at}`);
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
dbAlert.textContent = parts.join(" ");
|
| 194 |
+
dbAlert.style.display = "block";
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
function clearDetailPanel() {
|
| 198 |
+
currentDetailUser = null;
|
| 199 |
+
detailPanel.style.display = "none";
|
| 200 |
+
detailRetryBtn.style.display = "none";
|
| 201 |
+
detailRetryBtn.dataset.user = "";
|
| 202 |
+
detailTitle.textContent = "任务详情";
|
| 203 |
+
detailMeta.innerHTML = "";
|
| 204 |
+
detailMessage.textContent = "-";
|
| 205 |
+
detailTargets.textContent = "-";
|
| 206 |
+
detailLogs.textContent = "暂无日志。";
|
| 207 |
+
detailAccountsBody.innerHTML = '<tr><td colspan="5">暂无数据</td></tr>';
|
| 208 |
+
detailHistoryBody.innerHTML = '<tr><td colspan="6">暂无记录</td></tr>';
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
function renderRows(users) {
|
| 212 |
if (!users || users.length === 0) {
|
| 213 |
adminBody.innerHTML = '<tr><td colspan="10">暂无用户</td></tr>';
|
| 214 |
+
clearDetailPanel();
|
| 215 |
return;
|
| 216 |
}
|
| 217 |
|
|
|
|
| 224 |
<td>${escapeHtml(u.created_at || "-")}</td>
|
| 225 |
<td colspan="6" style="color:#c0392b;">数据异常:${escapeHtml(u.error)}</td>
|
| 226 |
<td>
|
| 227 |
+
<div class="admin-actions">
|
| 228 |
+
<button class="btn admin-delete-user" data-user="${escapeHtml(u.username)}">删用户</button>
|
| 229 |
+
</div>
|
| 230 |
</td>
|
| 231 |
</tr>
|
| 232 |
`;
|
|
|
|
| 234 |
|
| 235 |
const targets = Array.isArray(u.targets) ? u.targets : [];
|
| 236 |
const targetText = targets.length ? targets.join(" / ") : "-";
|
| 237 |
+
const statusText = u.is_running ? "运行中" : (u.last_status || "-");
|
| 238 |
+
const retryAction = u.can_retry
|
| 239 |
+
? `<button class="btn admin-retry-task" data-user="${escapeHtml(u.username)}">重试失败</button>`
|
| 240 |
+
: "";
|
| 241 |
|
| 242 |
return `
|
| 243 |
<tr>
|
|
|
|
| 246 |
<td>${escapeHtml(u.created_at)}</td>
|
| 247 |
<td>${u.scheduler_enabled ? "启用" : "禁用"}</td>
|
| 248 |
<td>${escapeHtml(u.schedule_time)} (${escapeHtml(u.schedule_timezone)})</td>
|
| 249 |
+
<td class="ellipsis-cell" title="${escapeHtml(u.message_template || "")}">${escapeHtml(u.message_template || "-")}</td>
|
| 250 |
+
<td class="ellipsis-cell" title="${escapeHtml(targetText)}">${escapeHtml(targetText)}</td>
|
| 251 |
<td>${escapeHtml(u.next_run || "-")}</td>
|
| 252 |
+
<td>${escapeHtml(statusText)}</td>
|
| 253 |
<td>
|
| 254 |
<div class="admin-actions">
|
| 255 |
+
<button class="btn admin-view-task" data-user="${escapeHtml(u.username)}">详情/日志</button>
|
| 256 |
+
${retryAction}
|
| 257 |
<button class="btn admin-del-task" data-user="${escapeHtml(u.username)}">删任务</button>
|
| 258 |
<button class="btn admin-delete-user" data-user="${escapeHtml(u.username)}">删用户</button>
|
| 259 |
</div>
|
|
|
|
| 262 |
`;
|
| 263 |
}).join("");
|
| 264 |
|
| 265 |
+
document.querySelectorAll(".admin-view-task").forEach((btn) => {
|
| 266 |
+
btn.addEventListener("click", async () => {
|
| 267 |
+
const username = btn.dataset.user;
|
| 268 |
+
await loadTaskDetail(username, true);
|
| 269 |
+
});
|
| 270 |
+
});
|
| 271 |
+
|
| 272 |
document.querySelectorAll(".admin-del-task").forEach((btn) => {
|
| 273 |
btn.addEventListener("click", async () => {
|
| 274 |
const username = btn.dataset.user;
|
|
|
|
| 281 |
});
|
| 282 |
setMsg(data.message || "已删除任务");
|
| 283 |
await loadOverview();
|
| 284 |
+
if (currentDetailUser === username) {
|
| 285 |
+
await loadTaskDetail(username, false);
|
| 286 |
+
}
|
| 287 |
} catch (err) {
|
| 288 |
setMsg(err.message, true);
|
| 289 |
}
|
| 290 |
});
|
| 291 |
});
|
| 292 |
|
| 293 |
+
document.querySelectorAll(".admin-retry-task").forEach((btn) => {
|
| 294 |
+
btn.addEventListener("click", async () => {
|
| 295 |
+
const username = btn.dataset.user;
|
| 296 |
+
await retryFailedTask(username);
|
| 297 |
+
});
|
| 298 |
+
});
|
| 299 |
+
|
| 300 |
document.querySelectorAll(".admin-delete-user").forEach((btn) => {
|
| 301 |
btn.addEventListener("click", async () => {
|
| 302 |
const username = btn.dataset.user;
|
|
|
|
| 307 |
method: "DELETE",
|
| 308 |
});
|
| 309 |
setMsg(data.message || "用户已删除");
|
| 310 |
+
if (currentDetailUser === username) {
|
| 311 |
+
clearDetailPanel();
|
| 312 |
+
}
|
| 313 |
await loadOverview();
|
| 314 |
} catch (err) {
|
| 315 |
setMsg(err.message, true);
|
|
|
|
| 318 |
});
|
| 319 |
}
|
| 320 |
|
| 321 |
+
function renderDetail(task) {
|
| 322 |
+
currentDetailUser = task.username;
|
| 323 |
+
detailPanel.style.display = "block";
|
| 324 |
+
detailTitle.textContent = `任务详情 · ${task.username}`;
|
| 325 |
+
|
| 326 |
+
const runtime = task.runtime || {};
|
| 327 |
+
const config = task.config || {};
|
| 328 |
+
const targets = Array.isArray(task.targets) ? task.targets : [];
|
| 329 |
+
|
| 330 |
+
detailMeta.innerHTML = `
|
| 331 |
+
<div class="detail-item"><span>发起用户</span><strong>${escapeHtml(task.username || "-")}</strong></div>
|
| 332 |
+
<div class="detail-item"><span>唯一标识</span><strong>${escapeHtml(task.unique_id || "-")}</strong></div>
|
| 333 |
+
<div class="detail-item"><span>注册时间</span><strong>${escapeHtml(task.created_at || "-")}</strong></div>
|
| 334 |
+
<div class="detail-item"><span>定时状态</span><strong>${task.scheduler_enabled ? "启用" : "禁用"}</strong></div>
|
| 335 |
+
<div class="detail-item"><span>发送时间</span><strong>${escapeHtml(task.schedule_time || "-")} (${escapeHtml(task.schedule_timezone || "-")})</strong></div>
|
| 336 |
+
<div class="detail-item"><span>下一次执行</span><strong>${escapeHtml(runtime.next_run || "-")}</strong></div>
|
| 337 |
+
<div class="detail-item"><span>最近状态</span><strong>${runtime.is_running ? "运行中" : escapeHtml(runtime.last_status || "-")}</strong></div>
|
| 338 |
+
<div class="detail-item"><span>最近开始</span><strong>${escapeHtml(runtime.last_start || "-")}</strong></div>
|
| 339 |
+
<div class="detail-item"><span>账号数 / 目标数</span><strong>${runtime.account_count || 0} / ${runtime.target_count || 0}</strong></div>
|
| 340 |
+
<div class="detail-item"><span>并发设置</span><strong>multiTask=${config.multiTask ? "true" : "false"}, taskCount=${config.taskCount || 1}</strong></div>
|
| 341 |
+
`;
|
| 342 |
+
|
| 343 |
+
detailMessage.textContent = task.message_template || "-";
|
| 344 |
+
detailTargets.textContent = targets.length ? targets.join("\n") : "-";
|
| 345 |
+
detailLogs.textContent = task.logs || "暂无日志。";
|
| 346 |
+
detailRetryBtn.dataset.user = task.username || "";
|
| 347 |
+
detailRetryBtn.style.display = task.can_retry ? "inline-flex" : "none";
|
| 348 |
+
|
| 349 |
+
const accounts = Array.isArray(task.accounts) ? task.accounts : [];
|
| 350 |
+
if (!accounts.length) {
|
| 351 |
+
detailAccountsBody.innerHTML = '<tr><td colspan="5">暂无账号明细</td></tr>';
|
| 352 |
+
} else {
|
| 353 |
+
detailAccountsBody.innerHTML = accounts.map((item) => {
|
| 354 |
+
const t = Array.isArray(item.targets) ? item.targets : [];
|
| 355 |
+
return `
|
| 356 |
+
<tr>
|
| 357 |
+
<td>${escapeHtml(item.username || "-")}</td>
|
| 358 |
+
<td>${escapeHtml(item.unique_id || "-")}</td>
|
| 359 |
+
<td>${item.target_count || 0}</td>
|
| 360 |
+
<td>${item.cookie_count || 0}</td>
|
| 361 |
+
<td>${escapeHtml(t.join(" / ") || "-")}</td>
|
| 362 |
+
</tr>
|
| 363 |
+
`;
|
| 364 |
+
}).join("");
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
const history = Array.isArray(task.history) ? task.history : [];
|
| 368 |
+
if (!history.length) {
|
| 369 |
+
detailHistoryBody.innerHTML = '<tr><td colspan="6">暂无记录</td></tr>';
|
| 370 |
+
} else {
|
| 371 |
+
detailHistoryBody.innerHTML = history.map((row) => `
|
| 372 |
+
<tr>
|
| 373 |
+
<td>${escapeHtml(row.trigger || "-")}</td>
|
| 374 |
+
<td>${escapeHtml(row.start || "-")}</td>
|
| 375 |
+
<td>${escapeHtml(row.end || "-")}</td>
|
| 376 |
+
<td>${escapeHtml(row.status || "-")}</td>
|
| 377 |
+
<td>${escapeHtml(row.duration || "-")}</td>
|
| 378 |
+
<td>${escapeHtml(row.message || "-")}</td>
|
| 379 |
+
</tr>
|
| 380 |
+
`).join("");
|
| 381 |
+
}
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
async function loadTaskDetail(username, showMessage = false) {
|
| 385 |
+
try {
|
| 386 |
+
const data = await requestJSON(`/api/admin/tasks/${encodeURIComponent(username)}?log_limit=1200`);
|
| 387 |
+
renderDetail(data.task || {});
|
| 388 |
+
if (showMessage) {
|
| 389 |
+
setMsg(`已加载 ${username} 的任务详情与日志。`);
|
| 390 |
+
}
|
| 391 |
+
} catch (err) {
|
| 392 |
+
setMsg(err.message, true);
|
| 393 |
+
}
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
async function retryAllFailedTasks() {
|
| 397 |
+
if (!dbConnected) {
|
| 398 |
+
setMsg("SQL 连接异常,暂时无法批量重试失败任务。", true);
|
| 399 |
+
return;
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
setMsg("正在批量重试失败任务...");
|
| 403 |
+
try {
|
| 404 |
+
const data = await requestJSON("/api/admin/tasks/retry-failed", {
|
| 405 |
+
method: "POST",
|
| 406 |
+
body: "{}",
|
| 407 |
+
});
|
| 408 |
+
setMsg(data.message || "已开始批量重试失败任务");
|
| 409 |
+
await loadOverview();
|
| 410 |
+
if (currentDetailUser && dbConnected) {
|
| 411 |
+
await loadTaskDetail(currentDetailUser, false);
|
| 412 |
+
}
|
| 413 |
+
} catch (err) {
|
| 414 |
+
setMsg(err.message, true);
|
| 415 |
+
}
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
async function retryFailedTask(username) {
|
| 419 |
+
if (!username) {
|
| 420 |
+
setMsg("未找到可重试的任务用户。", true);
|
| 421 |
+
return;
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
setMsg("正在重试失败任务...");
|
| 425 |
+
try {
|
| 426 |
+
const data = await requestJSON(`/api/admin/tasks/${encodeURIComponent(username)}/retry`, {
|
| 427 |
+
method: "POST",
|
| 428 |
+
body: "{}",
|
| 429 |
+
});
|
| 430 |
+
setMsg(data.message || "已开始重试失败任务");
|
| 431 |
+
await loadOverview();
|
| 432 |
+
if (currentDetailUser === username && dbConnected) {
|
| 433 |
+
await loadTaskDetail(username, false);
|
| 434 |
+
}
|
| 435 |
+
} catch (err) {
|
| 436 |
+
setMsg(err.message, true);
|
| 437 |
+
}
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
async function loadOverview() {
|
| 441 |
const data = await requestJSON("/api/admin/overview");
|
| 442 |
+
renderDbStatus(data.db_status || null, data.message || "");
|
| 443 |
+
summary.textContent = dbConnected
|
| 444 |
+
? `共 ${data.task_count || 0} 个用户任务。`
|
| 445 |
+
: "SQL 连接异常,当前未能加载用户任务。";
|
| 446 |
renderRows(data.users || []);
|
| 447 |
+
if (!dbConnected) {
|
| 448 |
+
clearDetailPanel();
|
| 449 |
+
}
|
| 450 |
+
return data;
|
| 451 |
}
|
| 452 |
|
| 453 |
+
retryAllFailedBtn.addEventListener("click", async () => {
|
| 454 |
+
await retryAllFailedTasks();
|
| 455 |
+
});
|
| 456 |
+
|
| 457 |
document.getElementById("refreshBtn").addEventListener("click", async () => {
|
| 458 |
try {
|
| 459 |
await loadOverview();
|
| 460 |
+
if (dbConnected && currentDetailUser) {
|
| 461 |
+
await loadTaskDetail(currentDetailUser, false);
|
| 462 |
+
}
|
| 463 |
} catch (err) {
|
| 464 |
setMsg(err.message, true);
|
| 465 |
}
|
| 466 |
});
|
| 467 |
|
| 468 |
+
document.getElementById("detailRefreshBtn").addEventListener("click", async () => {
|
| 469 |
+
if (!currentDetailUser) {
|
| 470 |
+
setMsg("请先在上方选择一个任务。", true);
|
| 471 |
+
return;
|
| 472 |
+
}
|
| 473 |
+
await loadTaskDetail(currentDetailUser, true);
|
| 474 |
+
});
|
| 475 |
+
|
| 476 |
+
detailRetryBtn.addEventListener("click", async () => {
|
| 477 |
+
await retryFailedTask(detailRetryBtn.dataset.user || currentDetailUser);
|
| 478 |
+
});
|
| 479 |
+
|
| 480 |
document.getElementById("logoutBtn").addEventListener("click", async () => {
|
| 481 |
await fetch("/api/logout", { method: "POST", credentials: "same-origin" });
|
| 482 |
window.location.href = "/admin";
|
| 483 |
});
|
| 484 |
|
| 485 |
loadOverview().catch((err) => setMsg(err.message, true));
|
| 486 |
+
setInterval(async () => {
|
| 487 |
+
try {
|
| 488 |
+
await loadOverview();
|
| 489 |
+
if (dbConnected && currentDetailUser) {
|
| 490 |
+
await loadTaskDetail(currentDetailUser, false);
|
| 491 |
+
}
|
| 492 |
+
} catch (_) {
|
| 493 |
+
}
|
| 494 |
}, 8000);
|
| 495 |
</script>
|
| 496 |
</body>
|
| 497 |
+
</html>
|