cacode commited on
Commit
dac5e28
·
verified ·
1 Parent(s): 8f943b1

Upload 3 files

Browse files
Files changed (2) hide show
  1. app.py +1053 -881
  2. templates/admin.html +314 -11
app.py CHANGED
@@ -1,6 +1,6 @@
1
- import asyncio
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
- DEFAULT_USER_CONFIG = {
52
- "multiTask": True,
53
- "taskCount": 5,
54
- "proxyAddress": "",
55
- "messageTemplate": "[续火花]",
56
- "hitokotoTypes": ["文学", "影视", "诗词", "哲学"],
57
- "scheduler": {
58
- "enabled": True,
59
- "timezone": DEFAULT_TIMEZONE,
60
- "hour": 9,
61
- "minute": 0,
62
- "runOnStartup": False,
63
- },
64
- }
65
-
 
 
66
  AUTH_SESSIONS: dict[str, dict[str, str]] = {}
67
  db_init_lock = threading.Lock()
68
  scheduler_lock = threading.Lock()
69
  runtime_map_lock = threading.Lock()
70
  db_initialized = False
71
-
72
-
73
- class UserRuntimeState:
74
- def __init__(self, username: str):
75
- self.username = username
76
- self._run_lock = threading.Lock()
77
- self._state_lock = threading.Lock()
78
- self.is_running = False
79
- self.last_status = "未开始"
80
- self.last_error = ""
81
- self.last_trigger = "-"
82
- self.last_start = None
83
- self.last_end = None
84
- self.next_run = None
85
- self.schedule_hour = 9
86
- self.schedule_minute = 0
87
- self.schedule_timezone = DEFAULT_TIMEZONE
88
- self.history = deque(maxlen=50)
89
- self.logs = deque(maxlen=2000)
90
-
91
- def _format_ts(self, value: Optional[datetime]):
92
- if not value:
93
- return "-"
94
- return value.strftime("%Y-%m-%d %H:%M:%S")
95
-
96
- def schedule_time(self):
97
- return f"{self.schedule_hour:02d}:{self.schedule_minute:02d}"
98
-
99
- def _set_running(self, value: bool):
100
- with self._state_lock:
101
- self.is_running = value
102
-
103
- def add_log(self, message: str):
104
- ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
105
- with self._state_lock:
106
- self.logs.append(f"{ts} [{self.username}] {message}")
107
-
108
- def update_schedule(self, hour: int, minute: int, timezone: str):
109
- with self._state_lock:
110
- self.schedule_hour = hour
111
- self.schedule_minute = minute
112
- self.schedule_timezone = timezone
113
-
114
- def update_next_run(self, next_run):
115
- with self._state_lock:
116
- self.next_run = next_run
117
-
118
- def snapshot(self, account_count: int, target_count: int):
119
- with self._state_lock:
120
- return {
121
- "is_running": self.is_running,
122
- "last_status": self.last_status,
123
- "last_error": self.last_error,
124
- "last_trigger": self.last_trigger,
125
- "last_start": self._format_ts(self.last_start),
126
- "last_end": self._format_ts(self.last_end),
127
- "next_run": self._format_ts(self.next_run),
128
- "account_count": account_count,
129
- "target_count": target_count,
130
- "schedule_time": self.schedule_time(),
131
- "schedule_timezone": self.schedule_timezone,
132
- }
133
-
134
- def history_rows(self):
135
- with self._state_lock:
136
- return list(self.history)[::-1]
137
-
138
- def recent_logs(self, limit=MAX_LOG_LINES):
139
- with self._state_lock:
140
- lines = list(self.logs)[-max(1, limit):]
141
- return "\n".join(lines) if lines else "暂无日志。"
142
-
143
- def run_once(self, trigger: str):
144
- if not self._run_lock.acquire(blocking=False):
145
- self.add_log(f"任务已在运行中,忽略触发:{trigger}")
146
- return False, "已有任务在运行,本次触发已跳过。"
147
-
148
- self._set_running(True)
149
- with self._state_lock:
150
- self.last_trigger = trigger
151
- self.last_start = datetime.now()
152
- self.last_end = None
153
- self.last_error = ""
154
- self.last_status = "运行"
155
- self.add_log(f"任务开始执行,触发方式:{trigger}")
156
-
157
- ok = True
158
- message = "任务执行完成。"
159
- try:
160
- asyncio.run(_run_user_tasks(self.username))
161
- with self._state_lock:
162
- self.last_status = "成功"
163
- except Exception as exc:
164
- ok = False
165
- message = f"任务执行失败:{exc}"
166
- with self._state_lock:
167
- self.last_status = "失败"
168
- self.last_error = repr(exc)
169
- self.add_log(f"任务失败:{exc}")
170
- logger.error("Task failed. user=%s trigger=%s error=%s", self.username, trigger, exc)
171
- logger.debug("Task traceback:\n%s", traceback.format_exc())
172
- finally:
173
- end_at = datetime.now()
174
- with self._state_lock:
175
- self.last_end = end_at
176
- duration = (self.last_end - self.last_start).total_seconds()
177
- self.history.append(
178
- {
179
- "trigger": trigger,
180
- "start": self._format_ts(self.last_start),
181
- "end": self._format_ts(self.last_end),
182
- "status": self.last_status,
183
- "duration": f"{duration:.2f}s",
184
- "message": self.last_error or "OK",
185
- }
186
- )
187
- current_status = self.last_status
188
- self.add_log(f"任务结束,状态={current_status},耗时={duration:.2f}s")
189
- self._set_running(False)
190
- self._run_lock.release()
191
- return ok, message
192
-
193
-
194
- runtime_map: dict[str, UserRuntimeState] = {}
195
- scheduler = None
196
-
197
-
198
- class UserLoginPayload(BaseModel):
199
- username: str
200
- password: str
201
-
202
-
203
- class AdminLoginPayload(BaseModel):
204
- password: str
205
-
206
-
207
- class SchedulePayload(BaseModel):
208
- time: str
209
-
210
-
211
- class MessageTemplatePayload(BaseModel):
212
- message: str
213
-
214
-
215
- class UserTargetsItem(BaseModel):
216
- unique_id: str
217
- targets: list[str] = Field(default_factory=list)
218
-
219
-
220
- class UserTargetsPayload(BaseModel):
221
- users: list[UserTargetsItem]
222
-
223
-
 
 
 
 
 
 
 
 
224
  def _ensure_data_layout():
225
  global db_initialized
226
  if db_initialized:
@@ -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 = pymysql.connect(**_build_mysql_conn_kwargs())
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 = pymysql.connect(**_build_mysql_conn_kwargs())
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 = pymysql.connect(**_build_mysql_conn_kwargs())
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 _schedule_user_job(username: str):
726
- global scheduler
727
-
728
- cfg = _load_user_config(username)
729
- scheduler_cfg = cfg.get("scheduler", {}) if isinstance(cfg, dict) else {}
730
- enabled = bool(scheduler_cfg.get("enabled", True))
731
- timezone = str(scheduler_cfg.get("timezone", DEFAULT_TIMEZONE))
732
- hour = int(scheduler_cfg.get("hour", 9))
733
- minute = int(scheduler_cfg.get("minute", 0))
734
-
735
- runtime = _get_runtime(username)
736
- runtime.update_schedule(hour, minute, timezone)
737
-
738
- with scheduler_lock:
739
- if scheduler is None:
740
- scheduler = BackgroundScheduler(timezone=timezone)
741
- scheduler.start()
742
-
743
- job_id = _scheduler_job_id(username)
744
- if not enabled:
745
- if scheduler.get_job(job_id):
746
- scheduler.remove_job(job_id)
747
- runtime.update_next_run(None)
748
- runtime.add_log("定时任务已禁用")
749
- return
750
-
751
- scheduler.add_job(
752
- _run_scheduled_once,
753
- args=[username],
754
- trigger=CronTrigger(hour=hour, minute=minute, timezone=timezone),
755
- id=job_id,
756
- replace_existing=True,
757
- max_instances=1,
758
- coalesce=True,
759
- )
760
- job = scheduler.get_job(job_id)
761
- runtime.update_next_run(job.next_run_time if job else None)
762
- runtime.add_log(f"定时任务更新为 {hour:02d}:{minute:02d} ({timezone})")
763
-
764
-
765
- def _remove_user_schedule_job(username: str):
766
- with scheduler_lock:
767
- if scheduler is None:
768
- return
769
- job_id = _scheduler_job_id(username)
770
- if scheduler.get_job(job_id):
771
- scheduler.remove_job(job_id)
772
-
773
-
774
- def _start_background_run(username: str, trigger: str):
775
- runtime = _get_runtime(username)
776
-
777
- def _worker():
778
- runtime.run_once(trigger)
779
- if scheduler:
780
- job = scheduler.get_job(_scheduler_job_id(username))
781
- runtime.update_next_run(job.next_run_time if job else None)
782
-
783
- thread = threading.Thread(target=_worker, daemon=True)
784
- thread.start()
785
- return True
786
-
787
-
788
- def _start_scheduler():
789
- global scheduler
790
- _ensure_data_layout()
791
- with scheduler_lock:
792
- if scheduler is None:
793
- scheduler = BackgroundScheduler(timezone=DEFAULT_TIMEZONE)
794
- scheduler.start()
795
-
796
- users_map = _load_users_meta()
797
- for username in users_map.keys():
798
- _schedule_user_job(username)
799
- cfg = _load_user_config(username)
800
- run_on_startup = bool(cfg.get("scheduler", {}).get("runOnStartup", False))
801
- if run_on_startup:
802
- _start_background_run(username, "startup")
803
-
804
-
805
- def _stop_scheduler():
806
- global scheduler
807
- with scheduler_lock:
808
- if scheduler and scheduler.running:
809
- scheduler.shutdown(wait=False)
810
- logger.info("Scheduler stopped.")
811
- scheduler = None
812
-
813
-
814
- app = FastAPI(title="DouYin Spark Flow Dashboard")
815
- app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
816
- templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
817
-
818
-
819
- @app.on_event("startup")
820
- async def on_startup():
821
- _ensure_data_layout()
822
- _start_scheduler()
823
- atexit.register(_stop_scheduler)
824
-
825
-
826
- @app.on_event("shutdown")
827
- async def on_shutdown():
828
- _stop_scheduler()
829
-
830
-
831
- @app.get("/", response_class=HTMLResponse)
832
- async def dashboard(request: Request):
833
- session = _session_from_request(request)
834
- if not session:
835
- return RedirectResponse(url="/login", status_code=303)
836
- if session.get("role") == "admin":
837
- return RedirectResponse(url="/admin", status_code=303)
838
-
839
- username = session.get("username")
840
- runtime = _get_runtime(username)
841
- return templates.TemplateResponse(
842
- "dashboard.html",
843
- {
844
- "request": request,
845
- "default_time": runtime.schedule_time(),
846
- "username": username,
847
- },
848
- )
849
-
850
-
851
- @app.get("/login", response_class=HTMLResponse)
852
- async def login_page(request: Request):
853
- session = _session_from_request(request)
854
- if session:
855
- if session.get("role") == "admin":
856
- return RedirectResponse(url="/admin", status_code=303)
857
- return RedirectResponse(url="/", status_code=303)
858
- return templates.TemplateResponse("login.html", {"request": request})
859
-
860
-
861
- @app.get("/register", response_class=HTMLResponse)
862
- async def register_page(request: Request):
863
- session = _session_from_request(request)
864
- if session:
865
- if session.get("role") == "admin":
866
- return RedirectResponse(url="/admin", status_code=303)
867
- return RedirectResponse(url="/", status_code=303)
868
- return templates.TemplateResponse("register.html", {"request": request})
869
-
870
-
871
- @app.get("/admin", response_class=HTMLResponse)
872
- async def admin_page(request: Request):
873
- session = _session_from_request(request)
874
- if not session or session.get("role") != "admin":
875
- return templates.TemplateResponse(
876
- "admin_login.html",
877
- {
878
- "request": request,
879
- "password_missing": not bool(os.getenv("PASSWORD")),
880
- },
881
- )
882
- return templates.TemplateResponse("admin.html", {"request": request})
883
-
884
-
885
- @app.post("/api/login")
886
- async def api_login(payload: UserLoginPayload):
887
- username = payload.username.strip()
888
- if not username:
889
- return JSONResponse(status_code=400, content={"ok": False, "message": "用户名不能为空。"})
890
-
891
- users_map = _load_users_meta()
892
- user = users_map.get(username)
893
- if not user:
894
- return JSONResponse(status_code=401, content={"ok": False, "message": "用户名或密码错误。"})
895
-
896
- if not _verify_password(payload.password, user.get("password_salt", ""), user.get("password_hash", "")):
897
- return JSONResponse(status_code=401, content={"ok": False, "message": "用户名或密码错误。"})
898
-
899
- token = secrets.token_urlsafe(32)
900
- AUTH_SESSIONS[token] = {"role": "user", "username": username}
901
-
902
- response = JSONResponse({"ok": True, "message": "登录成功。"})
903
- response.set_cookie(
904
- key=SESSION_COOKIE_NAME,
905
- value=token,
906
- httponly=True,
907
- samesite="lax",
908
- max_age=7 * 24 * 3600,
909
- )
910
- return response
911
-
912
-
913
- @app.post("/api/admin/login")
914
- async def api_admin_login(payload: AdminLoginPayload):
915
- expected_password = os.getenv("PASSWORD")
916
- if not expected_password:
917
- return JSONResponse(
918
- status_code=500,
919
- content={"ok": False, "message": "服务端未配置 PASSWORD 环境变量。"},
920
- )
921
-
922
- if payload.password != expected_password:
923
- return JSONResponse(status_code=401, content={"ok": False, "message": "密码错误。"})
924
-
925
- token = secrets.token_urlsafe(32)
926
- AUTH_SESSIONS[token] = {"role": "admin", "username": "admin"}
927
- response = JSONResponse({"ok": True, "message": "登录成功。"})
928
- response.set_cookie(
929
- key=SESSION_COOKIE_NAME,
930
- value=token,
931
- httponly=True,
932
- samesite="lax",
933
- max_age=7 * 24 * 3600,
934
- )
935
- return response
936
-
937
-
938
- @app.post("/api/register")
939
- async def api_register(password: str = Form(...), users_file: UploadFile = File(...)):
940
- if len(password.strip()) < 4:
941
- return JSONResponse(status_code=400, content={"ok": False, "message": "密码至少 4 位。"})
942
-
943
- if not users_file.filename.lower().endswith(".json"):
944
- return JSONResponse(status_code=400, content={"ok": False, "message": "请上传 usersData.json 文件。"})
945
-
946
- try:
947
- raw = await users_file.read()
948
- users_data, username, unique_id = _validate_and_normalize_users_data(raw)
949
- except Exception as exc:
950
- return JSONResponse(status_code=400, content={"ok": False, "message": str(exc)})
951
-
952
- users_map = _load_users_meta()
953
- if username in users_map:
954
- return JSONResponse(status_code=409, content={"ok": False, "message": f"用户名 {username} 已注册。"})
955
-
956
- for existing in users_map.values():
957
- if str(existing.get("unique_id", "")).strip() == unique_id:
958
- return JSONResponse(status_code=409, content={"ok": False, "message": f"unique_id {unique_id} 已注册。"})
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
- users_map = _load_users_meta()
1125
-
1126
- rows = []
1127
- for username, meta in sorted(users_map.items(), key=lambda x: x[0]):
1128
- try:
1129
- cfg = _load_user_config(username)
1130
- users_data = _load_user_users_data(username)
1131
- except Exception as exc:
1132
- rows.append(
1133
- {
1134
- "username": username,
1135
- "unique_id": meta.get("unique_id", ""),
1136
- "created_at": meta.get("created_at", "-"),
1137
- "error": str(exc),
1138
- }
1139
- )
1140
- continue
1141
-
1142
- scheduler_cfg = cfg.get("scheduler", {})
1143
- runtime = _get_runtime(username)
1144
- runtime_snapshot = runtime.snapshot(
1145
- account_count=len(users_data),
1146
- target_count=_count_targets(users_data),
1147
- )
1148
-
1149
- receivers = []
1150
- for item in users_data:
1151
- receivers.extend(item.get("targets", []))
1152
-
1153
- rows.append(
1154
- {
1155
- "username": username,
1156
- "unique_id": meta.get("unique_id", ""),
1157
- "created_at": meta.get("created_at", "-"),
1158
- "scheduler_enabled": bool(scheduler_cfg.get("enabled", True)),
1159
- "schedule_time": f"{int(scheduler_cfg.get('hour', 9)):02d}:{int(scheduler_cfg.get('minute', 0)):02d}",
1160
- "schedule_timezone": str(scheduler_cfg.get("timezone", DEFAULT_TIMEZONE)),
1161
- "message_template": str(cfg.get("messageTemplate", "")),
1162
- "targets": receivers,
1163
- "target_count": len(receivers),
1164
- "next_run": runtime_snapshot.get("next_run", "-"),
1165
- "last_status": runtime_snapshot.get("last_status", "-"),
1166
- "last_start": runtime_snapshot.get("last_start", "-"),
1167
- "is_running": runtime_snapshot.get("is_running", False),
1168
- }
1169
- )
1170
-
1171
- return {
1172
- "ok": True,
1173
- "users": rows,
1174
- "task_count": len(rows),
1175
- }
1176
-
1177
-
1178
- @app.get("/api/admin/tasks/{username}")
1179
- async def api_admin_task_detail(request: Request, username: str, log_limit: int = MAX_LOG_LINES):
1180
- _require_admin_session(request)
1181
- username = username.strip()
1182
- user_meta = _get_user_meta_or_404(username)
1183
-
1184
- try:
1185
- cfg = _load_user_config(username)
1186
- users_data = _load_user_users_data(username)
1187
- except Exception as exc:
1188
- return JSONResponse(
1189
- status_code=500,
1190
- content={"ok": False, "message": f"加载任务详情失败:{exc}"},
1191
- )
1192
-
1193
- scheduler_cfg = cfg.get("scheduler", {})
1194
- runtime = _get_runtime(username)
1195
- target_count = _count_targets(users_data)
1196
- snapshot = runtime.snapshot(account_count=len(users_data), target_count=target_count)
1197
-
1198
- accounts = []
1199
- all_targets = []
1200
- for item in users_data:
1201
- targets = _sanitize_targets(item.get("targets", []))
1202
- all_targets.extend(targets)
1203
- accounts.append(
1204
- {
1205
- "username": str(item.get("username", "未知用户")),
1206
- "unique_id": str(item.get("unique_id", "")),
1207
- "target_count": len(targets),
1208
- "targets": targets,
1209
- "cookie_count": len(item.get("cookies", [])) if isinstance(item.get("cookies", []), list) else 0,
1210
- }
1211
- )
1212
-
1213
- log_limit = min(max(100, log_limit), 3000)
1214
- return {
1215
- "ok": True,
1216
- "task": {
1217
- "username": username,
1218
- "unique_id": user_meta.get("unique_id", ""),
1219
- "created_at": user_meta.get("created_at", "-"),
1220
- "scheduler_enabled": bool(scheduler_cfg.get("enabled", True)),
1221
- "schedule_time": f"{int(scheduler_cfg.get('hour', 9)):02d}:{int(scheduler_cfg.get('minute', 0)):02d}",
1222
- "schedule_timezone": str(scheduler_cfg.get("timezone", DEFAULT_TIMEZONE)),
1223
- "message_template": str(cfg.get("messageTemplate", "")),
1224
- "targets": all_targets,
1225
- "target_count": len(all_targets),
1226
- "runtime": snapshot,
1227
- "history": runtime.history_rows(),
1228
- "logs": runtime.recent_logs(limit=log_limit),
1229
- "config": {
1230
- "multiTask": bool(cfg.get("multiTask", True)),
1231
- "taskCount": int(cfg.get("taskCount", 1) or 1),
1232
- "hitokotoTypes": cfg.get("hitokotoTypes", []),
1233
- "proxyAddress": str(cfg.get("proxyAddress", "")),
1234
- },
1235
- "accounts": accounts,
1236
- },
1237
- }
1238
-
1239
-
1240
- @app.post("/api/admin/tasks/{username}/delete")
1241
- async def api_admin_delete_task(request: Request, username: str):
1242
- _require_admin_session(request)
1243
- username = username.strip()
1244
- _get_user_meta_or_404(username)
1245
-
1246
- cfg = _load_user_config(username)
1247
- scheduler_cfg = cfg.setdefault("scheduler", {})
1248
- scheduler_cfg["enabled"] = False
1249
- _save_user_config(username, cfg)
1250
-
1251
- _remove_user_schedule_job(username)
1252
- runtime = _get_runtime(username)
1253
- runtime.update_next_run(None)
1254
- runtime.add_log("管理员已删除(禁用)该用户定时任务")
1255
-
1256
- return {"ok": True, "message": f"已删除用户 {username} 的定时任务。"}
1257
-
1258
-
1259
- @app.delete("/api/admin/users/{username}")
1260
- async def api_admin_delete_user(request: Request, username: str):
1261
- _require_admin_session(request)
1262
- username = username.strip()
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
- <!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 Flow - Admin 控制台</title>
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, "&#39;");
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
- <button class="btn admin-delete-user" data-user="${escapeHtml(u.username)}">删除用户</button>
 
 
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="clamp-cell">${escapeHtml(u.message_template || "")}</td>
120
- <td class="clamp-cell">${escapeHtml(targetText)}</td>
121
  <td>${escapeHtml(u.next_run || "-")}</td>
122
- <td>${u.is_running ? "运行中" : escapeHtml(u.last_status || "-")}</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
- summary.textContent = `共 ${data.task_count || 0} 个用户任务。`;
 
 
 
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
- loadOverview().catch(() => {});
 
 
 
 
 
 
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, "&#39;");
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>