cacode commited on
Commit
de6b777
·
verified ·
1 Parent(s): 77c8ac9

Upload 3 files

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