同步更新:重构路由、服务模块,更新前端构建
This commit is contained in:
@@ -39,6 +39,10 @@ COPY app_logger.py .
|
|||||||
COPY app_security.py .
|
COPY app_security.py .
|
||||||
COPY app_state.py .
|
COPY app_state.py .
|
||||||
COPY app_utils.py .
|
COPY app_utils.py .
|
||||||
|
COPY routes/ ./routes/
|
||||||
|
COPY services/ ./services/
|
||||||
|
COPY realtime/ ./realtime/
|
||||||
|
COPY db/ ./db/
|
||||||
|
|
||||||
COPY templates/ ./templates/
|
COPY templates/ ./templates/
|
||||||
COPY static/ ./static/
|
COPY static/ ./static/
|
||||||
|
|||||||
@@ -25,7 +25,11 @@ api.interceptors.response.use(
|
|||||||
const payload = error?.response?.data
|
const payload = error?.response?.data
|
||||||
const message = payload?.error || payload?.message || error?.message || '请求失败'
|
const message = payload?.error || payload?.message || error?.message || '请求失败'
|
||||||
|
|
||||||
if (status === 403) {
|
if (status === 401) {
|
||||||
|
toastErrorOnce('401', message || '登录已过期,请重新登录', 3000)
|
||||||
|
const pathname = window.location?.pathname || ''
|
||||||
|
if (!pathname.startsWith('/yuyx')) window.location.href = '/yuyx'
|
||||||
|
} else if (status === 403) {
|
||||||
toastErrorOnce('403', message || '需要管理员权限', 5000)
|
toastErrorOnce('403', message || '需要管理员权限', 5000)
|
||||||
} else if (status) {
|
} else if (status) {
|
||||||
toastErrorOnce(`http:${status}:${message}`, message)
|
toastErrorOnce(`http:${status}:${message}`, message)
|
||||||
|
|||||||
+17
-10
@@ -13,9 +13,18 @@ import atexit
|
|||||||
import weakref
|
import weakref
|
||||||
from typing import Optional, Callable
|
from typing import Optional, Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from app_config import get_config
|
||||||
|
|
||||||
BASE_URL = "https://postoa.aidunsoft.com"
|
config = get_config()
|
||||||
|
|
||||||
|
BASE_URL = getattr(config, "ZSGL_BASE_URL", "https://postoa.aidunsoft.com")
|
||||||
|
LOGIN_URL = getattr(config, "ZSGL_LOGIN_URL", f"{BASE_URL}/admin/login.aspx")
|
||||||
|
INDEX_URL_PATTERN = getattr(config, "ZSGL_INDEX_URL_PATTERN", "index.aspx")
|
||||||
|
COOKIES_DIR = getattr(config, "COOKIES_DIR", "data/cookies")
|
||||||
|
|
||||||
|
_cookie_domain_fallback = urlsplit(BASE_URL).hostname or "postoa.aidunsoft.com"
|
||||||
|
|
||||||
_api_browser_instances: "weakref.WeakSet[APIBrowser]" = weakref.WeakSet()
|
_api_browser_instances: "weakref.WeakSet[APIBrowser]" = weakref.WeakSet()
|
||||||
|
|
||||||
@@ -79,12 +88,11 @@ class APIBrowser:
|
|||||||
import json
|
import json
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
cookies_dir = '/app/data/cookies'
|
os.makedirs(COOKIES_DIR, exist_ok=True)
|
||||||
os.makedirs(cookies_dir, exist_ok=True)
|
|
||||||
|
|
||||||
# 安全修复:使用SHA256代替MD5作为文件名哈希
|
# 安全修复:使用SHA256代替MD5作为文件名哈希
|
||||||
filename = hashlib.sha256(username.encode()).hexdigest()[:32] + '.json'
|
filename = hashlib.sha256(username.encode()).hexdigest()[:32] + '.json'
|
||||||
cookies_path = os.path.join(cookies_dir, filename)
|
cookies_path = os.path.join(COOKIES_DIR, filename)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 获取requests session的cookies
|
# 获取requests session的cookies
|
||||||
@@ -93,7 +101,7 @@ class APIBrowser:
|
|||||||
cookies_list.append({
|
cookies_list.append({
|
||||||
'name': cookie.name,
|
'name': cookie.name,
|
||||||
'value': cookie.value,
|
'value': cookie.value,
|
||||||
'domain': cookie.domain or 'postoa.aidunsoft.com',
|
'domain': cookie.domain or _cookie_domain_fallback,
|
||||||
'path': cookie.path or '/',
|
'path': cookie.path or '/',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -182,8 +190,7 @@ class APIBrowser:
|
|||||||
self.log(f"[API] 登录: {username}")
|
self.log(f"[API] 登录: {username}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
login_url = f"{BASE_URL}/admin/login.aspx"
|
resp = self._request_with_retry('get', LOGIN_URL)
|
||||||
resp = self._request_with_retry('get', login_url)
|
|
||||||
|
|
||||||
soup = BeautifulSoup(resp.text, 'html.parser')
|
soup = BeautifulSoup(resp.text, 'html.parser')
|
||||||
fields = self._get_aspnet_fields(soup)
|
fields = self._get_aspnet_fields(soup)
|
||||||
@@ -195,17 +202,17 @@ class APIBrowser:
|
|||||||
|
|
||||||
resp = self._request_with_retry(
|
resp = self._request_with_retry(
|
||||||
'post',
|
'post',
|
||||||
login_url,
|
LOGIN_URL,
|
||||||
data=data,
|
data=data,
|
||||||
headers={
|
headers={
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
'Origin': BASE_URL,
|
'Origin': BASE_URL,
|
||||||
'Referer': login_url,
|
'Referer': LOGIN_URL,
|
||||||
},
|
},
|
||||||
allow_redirects=True
|
allow_redirects=True
|
||||||
)
|
)
|
||||||
|
|
||||||
if 'index.aspx' in resp.url:
|
if INDEX_URL_PATTERN in resp.url:
|
||||||
self.logged_in = True
|
self.logged_in = True
|
||||||
self.log(f"[API] 登录成功")
|
self.log(f"[API] 登录成功")
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -1,4 +1,16 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
let lastToastKey = ''
|
||||||
|
let lastToastAt = 0
|
||||||
|
|
||||||
|
function toastErrorOnce(key, message, minIntervalMs = 1500) {
|
||||||
|
const now = Date.now()
|
||||||
|
if (key === lastToastKey && now - lastToastAt < minIntervalMs) return
|
||||||
|
lastToastKey = key
|
||||||
|
lastToastAt = now
|
||||||
|
ElMessage.error(message)
|
||||||
|
}
|
||||||
|
|
||||||
export const publicApi = axios.create({
|
export const publicApi = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: '/api',
|
||||||
@@ -6,3 +18,25 @@ export const publicApi = axios.create({
|
|||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
publicApi.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
const status = error?.response?.status
|
||||||
|
const payload = error?.response?.data
|
||||||
|
const message = payload?.error || payload?.message || error?.message || '请求失败'
|
||||||
|
|
||||||
|
if (status === 401) {
|
||||||
|
toastErrorOnce('401', message || '登录已过期,请重新登录', 3000)
|
||||||
|
const pathname = window.location?.pathname || ''
|
||||||
|
if (!pathname.startsWith('/login')) window.location.href = '/login'
|
||||||
|
} else if (status === 403) {
|
||||||
|
toastErrorOnce('403', message || '无权限', 5000)
|
||||||
|
} else if (error?.code === 'ECONNABORTED') {
|
||||||
|
toastErrorOnce('timeout', '请求超时', 3000)
|
||||||
|
} else if (!status) {
|
||||||
|
toastErrorOnce(`net:${message}`, message, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@
|
|||||||
import os
|
import os
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlsplit, urlunsplit
|
||||||
|
|
||||||
# 尝试加载.env文件(如果存在)
|
# 尝试加载.env文件(如果存在)
|
||||||
# Bug fix: 添加警告日志,避免静默失败
|
# Bug fix: 添加警告日志,避免静默失败
|
||||||
@@ -48,6 +49,33 @@ def get_secret_key():
|
|||||||
return new_key
|
return new_key
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_base_url_from_full_url(url: str, fallback: str) -> str:
|
||||||
|
"""从完整 URL 推导出 base_url(scheme://netloc)。"""
|
||||||
|
try:
|
||||||
|
parsed = urlsplit(str(url or "").strip())
|
||||||
|
if parsed.scheme and parsed.netloc:
|
||||||
|
return f"{parsed.scheme}://{parsed.netloc}"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_sibling_url(full_url: str, filename: str, fallback: str) -> str:
|
||||||
|
"""把 full_url 的最后路径段替换为 filename(忽略 query/fragment)。"""
|
||||||
|
try:
|
||||||
|
parsed = urlsplit(str(full_url or "").strip())
|
||||||
|
if not parsed.scheme or not parsed.netloc:
|
||||||
|
return fallback
|
||||||
|
path = parsed.path or "/"
|
||||||
|
if path.endswith("/"):
|
||||||
|
new_path = path + filename
|
||||||
|
else:
|
||||||
|
new_path = path.rsplit("/", 1)[0] + "/" + filename
|
||||||
|
return urlunsplit((parsed.scheme, parsed.netloc, new_path, "", ""))
|
||||||
|
except Exception:
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
"""应用配置基类"""
|
"""应用配置基类"""
|
||||||
|
|
||||||
@@ -93,6 +121,7 @@ class Config:
|
|||||||
|
|
||||||
# ==================== 浏览器配置 ====================
|
# ==================== 浏览器配置 ====================
|
||||||
SCREENSHOTS_DIR = os.environ.get('SCREENSHOTS_DIR', '截图')
|
SCREENSHOTS_DIR = os.environ.get('SCREENSHOTS_DIR', '截图')
|
||||||
|
COOKIES_DIR = os.environ.get('COOKIES_DIR', 'data/cookies')
|
||||||
|
|
||||||
# ==================== 并发控制配置 ====================
|
# ==================== 并发控制配置 ====================
|
||||||
MAX_CONCURRENT_GLOBAL = int(os.environ.get('MAX_CONCURRENT_GLOBAL', '2'))
|
MAX_CONCURRENT_GLOBAL = int(os.environ.get('MAX_CONCURRENT_GLOBAL', '2'))
|
||||||
@@ -102,6 +131,11 @@ class Config:
|
|||||||
MAX_LOGS_PER_USER = int(os.environ.get('MAX_LOGS_PER_USER', '100'))
|
MAX_LOGS_PER_USER = int(os.environ.get('MAX_LOGS_PER_USER', '100'))
|
||||||
MAX_TOTAL_LOGS = int(os.environ.get('MAX_TOTAL_LOGS', '1000'))
|
MAX_TOTAL_LOGS = int(os.environ.get('MAX_TOTAL_LOGS', '1000'))
|
||||||
|
|
||||||
|
# ==================== 内存/缓存清理配置 ====================
|
||||||
|
USER_ACCOUNTS_EXPIRE_SECONDS = int(os.environ.get('USER_ACCOUNTS_EXPIRE_SECONDS', '3600'))
|
||||||
|
BATCH_TASK_EXPIRE_SECONDS = int(os.environ.get('BATCH_TASK_EXPIRE_SECONDS', '21600')) # 默认6小时
|
||||||
|
PENDING_RANDOM_EXPIRE_SECONDS = int(os.environ.get('PENDING_RANDOM_EXPIRE_SECONDS', '7200')) # 默认2小时
|
||||||
|
|
||||||
# ==================== 验证码配置 ====================
|
# ==================== 验证码配置 ====================
|
||||||
MAX_CAPTCHA_ATTEMPTS = int(os.environ.get('MAX_CAPTCHA_ATTEMPTS', '5'))
|
MAX_CAPTCHA_ATTEMPTS = int(os.environ.get('MAX_CAPTCHA_ATTEMPTS', '5'))
|
||||||
CAPTCHA_EXPIRE_SECONDS = int(os.environ.get('CAPTCHA_EXPIRE_SECONDS', '300'))
|
CAPTCHA_EXPIRE_SECONDS = int(os.environ.get('CAPTCHA_EXPIRE_SECONDS', '300'))
|
||||||
@@ -117,6 +151,12 @@ class Config:
|
|||||||
# ==================== 知识管理平台配置 ====================
|
# ==================== 知识管理平台配置 ====================
|
||||||
ZSGL_LOGIN_URL = os.environ.get('ZSGL_LOGIN_URL', 'https://postoa.aidunsoft.com/admin/login.aspx')
|
ZSGL_LOGIN_URL = os.environ.get('ZSGL_LOGIN_URL', 'https://postoa.aidunsoft.com/admin/login.aspx')
|
||||||
ZSGL_INDEX_URL_PATTERN = os.environ.get('ZSGL_INDEX_URL_PATTERN', 'index.aspx')
|
ZSGL_INDEX_URL_PATTERN = os.environ.get('ZSGL_INDEX_URL_PATTERN', 'index.aspx')
|
||||||
|
ZSGL_BASE_URL = os.environ.get('ZSGL_BASE_URL') or _derive_base_url_from_full_url(ZSGL_LOGIN_URL, 'https://postoa.aidunsoft.com')
|
||||||
|
ZSGL_INDEX_URL = os.environ.get('ZSGL_INDEX_URL') or _derive_sibling_url(
|
||||||
|
ZSGL_LOGIN_URL,
|
||||||
|
ZSGL_INDEX_URL_PATTERN,
|
||||||
|
f"{ZSGL_BASE_URL}/admin/{ZSGL_INDEX_URL_PATTERN}",
|
||||||
|
)
|
||||||
MAX_CONCURRENT_CONTEXTS = int(os.environ.get('MAX_CONCURRENT_CONTEXTS', '100'))
|
MAX_CONCURRENT_CONTEXTS = int(os.environ.get('MAX_CONCURRENT_CONTEXTS', '100'))
|
||||||
|
|
||||||
# ==================== 服务器配置 ====================
|
# ==================== 服务器配置 ====================
|
||||||
|
|||||||
+4
-1
@@ -280,7 +280,10 @@ def init_logging(log_level='INFO', log_file='logs/app.log'):
|
|||||||
|
|
||||||
# 创建审计日志器(已在AuditLogger中创建)
|
# 创建审计日志器(已在AuditLogger中创建)
|
||||||
|
|
||||||
print("✓ 日志系统初始化完成")
|
try:
|
||||||
|
get_logger('app').info("✓ 日志系统初始化完成")
|
||||||
|
except Exception:
|
||||||
|
print("✓ 日志系统初始化完成")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
+27
-5
@@ -201,11 +201,33 @@ def require_ip_not_locked(f):
|
|||||||
def decorated_function(*args, **kwargs):
|
def decorated_function(*args, **kwargs):
|
||||||
ip_address = request.remote_addr
|
ip_address = request.remote_addr
|
||||||
|
|
||||||
if ip_rate_limiter.is_locked(ip_address):
|
# P0 / O-01:统一使用 services.state 的线程安全限流状态
|
||||||
return jsonify({
|
try:
|
||||||
"error": "由于多次失败尝试,您的IP已被临时锁定",
|
from services.state import check_ip_rate_limit, safe_get_ip_lock_until
|
||||||
"locked_until": ip_rate_limiter._locked.get(ip_address, 0)
|
|
||||||
}), 429
|
allowed, error_msg = check_ip_rate_limit(ip_address)
|
||||||
|
if not allowed:
|
||||||
|
return (
|
||||||
|
jsonify(
|
||||||
|
{
|
||||||
|
"error": error_msg or "由于多次失败尝试,您的IP已被临时锁定",
|
||||||
|
"locked_until": safe_get_ip_lock_until(ip_address),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
429,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# 兜底:沿用旧实现(避免极端情况下阻断业务)
|
||||||
|
if ip_rate_limiter.is_locked(ip_address):
|
||||||
|
return (
|
||||||
|
jsonify(
|
||||||
|
{
|
||||||
|
"error": "由于多次失败尝试,您的IP已被临时锁定",
|
||||||
|
"locked_until": ip_rate_limiter._locked.get(ip_address, 0),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
429,
|
||||||
|
)
|
||||||
|
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
"""
|
"""
|
||||||
应用状态管理模块
|
应用状态管理模块
|
||||||
提供线程安全的全局状态管理
|
提供线程安全的全局状态管理
|
||||||
|
|
||||||
|
说明(P0–P3 优化后):
|
||||||
|
- 该模块为历史遗留实现,保留用于兼容与参考
|
||||||
|
- 当前实际生效的全局状态入口为 `services/state.py`(safe_* API)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
|
|||||||
+6
-1
@@ -1,6 +1,11 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""浏览器池管理 - 线程本地存储,每个线程复用自己的浏览器"""
|
"""浏览器池管理 - 线程本地存储,每个线程复用自己的浏览器
|
||||||
|
|
||||||
|
说明(P0–P3 优化后):
|
||||||
|
- 该实现为遗留版本,当前截图并发与浏览器复用已迁移到 `browser_pool_worker.py` 的 WorkerPool 方案
|
||||||
|
- 本文件保留用于兼容/回滚参考(当前主流程不再依赖)
|
||||||
|
"""
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|||||||
Executable → Regular
+182
-2189
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
DB 包:按领域拆分的 DAO + schema/migrations。
|
||||||
|
|
||||||
|
约束:
|
||||||
|
- 外部仍通过 `import database` 访问稳定 API
|
||||||
|
- 本包仅提供内部实现与组织结构(P2 / O-07)
|
||||||
|
"""
|
||||||
|
|
||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import db_pool
|
||||||
|
from crypto_utils import decrypt_password, encrypt_password
|
||||||
|
from db.utils import get_cst_now_str
|
||||||
|
|
||||||
|
|
||||||
|
def create_account(user_id, account_id, username, password, remember=True, remark=""):
|
||||||
|
"""创建账号(密码加密存储)"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cst_time = get_cst_now_str()
|
||||||
|
encrypted_password = encrypt_password(password)
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO accounts (id, user_id, username, password, remember, remark, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(account_id, user_id, username, encrypted_password, 1 if remember else 0, remark, cst_time),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_accounts(user_id):
|
||||||
|
"""获取用户的所有账号(自动解密密码)"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT * FROM accounts WHERE user_id = ? ORDER BY created_at DESC", (user_id,))
|
||||||
|
accounts = []
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
account = dict(row)
|
||||||
|
account["password"] = decrypt_password(account.get("password", ""))
|
||||||
|
accounts.append(account)
|
||||||
|
return accounts
|
||||||
|
|
||||||
|
|
||||||
|
def get_account(account_id):
|
||||||
|
"""获取单个账号(自动解密密码)"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT * FROM accounts WHERE id = ?", (account_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
account = dict(row)
|
||||||
|
account["password"] = decrypt_password(account.get("password", ""))
|
||||||
|
return account
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def update_account_remark(account_id, remark):
|
||||||
|
"""更新账号备注"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("UPDATE accounts SET remark = ? WHERE id = ?", (remark, account_id))
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def delete_account(account_id):
|
||||||
|
"""删除账号"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("DELETE FROM accounts WHERE id = ?", (account_id,))
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def increment_account_login_fail(account_id, error_message):
|
||||||
|
"""增加账号登录失败次数,如果达到3次则暂停账号"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("SELECT login_fail_count FROM accounts WHERE id = ?", (account_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
return False
|
||||||
|
|
||||||
|
fail_count = (row["login_fail_count"] or 0) + 1
|
||||||
|
|
||||||
|
if fail_count >= 3:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE accounts
|
||||||
|
SET login_fail_count = ?,
|
||||||
|
last_login_error = ?,
|
||||||
|
status = 'suspended'
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(fail_count, error_message, account_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE accounts
|
||||||
|
SET login_fail_count = ?,
|
||||||
|
last_login_error = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(fail_count, error_message, account_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def reset_account_login_status(account_id):
|
||||||
|
"""重置账号登录状态(修改密码后调用)"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE accounts
|
||||||
|
SET login_fail_count = 0,
|
||||||
|
last_login_error = NULL,
|
||||||
|
status = 'active'
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(account_id,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_account_status(account_id):
|
||||||
|
"""获取账号状态信息"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT status, login_fail_count, last_login_error
|
||||||
|
FROM accounts
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(account_id,),
|
||||||
|
)
|
||||||
|
return cursor.fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_user_accounts(user_id):
|
||||||
|
"""删除用户的所有账号"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("DELETE FROM accounts WHERE user_id = ?", (user_id,))
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount
|
||||||
|
|
||||||
+419
@@ -0,0 +1,419 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
import db_pool
|
||||||
|
from db.utils import get_cst_now_str
|
||||||
|
from password_utils import (
|
||||||
|
hash_password_bcrypt,
|
||||||
|
is_sha256_hash,
|
||||||
|
verify_password_bcrypt,
|
||||||
|
verify_password_sha256,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_default_admin() -> bool:
|
||||||
|
"""确保存在默认管理员账号(行为保持不变)。"""
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT COUNT(*) as count FROM admins")
|
||||||
|
result = cursor.fetchone()
|
||||||
|
|
||||||
|
if result["count"] == 0:
|
||||||
|
alphabet = string.ascii_letters + string.digits
|
||||||
|
random_password = "".join(secrets.choice(alphabet) for _ in range(12))
|
||||||
|
|
||||||
|
default_password_hash = hash_password_bcrypt(random_password)
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO admins (username, password_hash, created_at) VALUES (?, ?, ?)",
|
||||||
|
("admin", default_password_hash, get_cst_now_str()),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
print("=" * 60)
|
||||||
|
print("安全提醒:已创建默认管理员账号")
|
||||||
|
print("用户名: admin")
|
||||||
|
print(f"密码: {random_password}")
|
||||||
|
print("请立即登录后修改密码!")
|
||||||
|
print("=" * 60)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def verify_admin(username: str, password: str):
|
||||||
|
"""验证管理员登录 - 自动从SHA256升级到bcrypt(行为保持不变)。"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT * FROM admins WHERE username = ?", (username,))
|
||||||
|
admin = cursor.fetchone()
|
||||||
|
|
||||||
|
if not admin:
|
||||||
|
return None
|
||||||
|
|
||||||
|
admin_dict = dict(admin)
|
||||||
|
password_hash = admin_dict["password_hash"]
|
||||||
|
|
||||||
|
if is_sha256_hash(password_hash):
|
||||||
|
if verify_password_sha256(password, password_hash):
|
||||||
|
new_hash = hash_password_bcrypt(password)
|
||||||
|
cursor.execute("UPDATE admins SET password_hash = ? WHERE username = ?", (new_hash, username))
|
||||||
|
conn.commit()
|
||||||
|
print(f"管理员 {username} 密码已自动升级到bcrypt")
|
||||||
|
return admin_dict
|
||||||
|
return None
|
||||||
|
|
||||||
|
if verify_password_bcrypt(password, password_hash):
|
||||||
|
return admin_dict
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def update_admin_password(username: str, new_password: str) -> bool:
|
||||||
|
"""更新管理员密码"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
password_hash = hash_password_bcrypt(new_password)
|
||||||
|
cursor.execute("UPDATE admins SET password_hash = ? WHERE username = ?", (password_hash, username))
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def update_admin_username(old_username: str, new_username: str) -> bool:
|
||||||
|
"""更新管理员用户名"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
try:
|
||||||
|
cursor.execute("UPDATE admins SET username = ? WHERE username = ?", (new_username, old_username))
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_system_stats() -> dict:
|
||||||
|
"""获取系统统计信息"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("SELECT COUNT(*) as count FROM users")
|
||||||
|
total_users = cursor.fetchone()["count"]
|
||||||
|
|
||||||
|
cursor.execute("SELECT COUNT(*) as count FROM users WHERE status = 'approved'")
|
||||||
|
approved_users = cursor.fetchone()["count"]
|
||||||
|
|
||||||
|
cursor.execute("SELECT COUNT(*) as count FROM users WHERE status = 'pending'")
|
||||||
|
pending_users = cursor.fetchone()["count"]
|
||||||
|
|
||||||
|
cursor.execute("SELECT COUNT(*) as count FROM accounts")
|
||||||
|
total_accounts = cursor.fetchone()["count"]
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) as count FROM users
|
||||||
|
WHERE vip_expire_time IS NOT NULL
|
||||||
|
AND datetime(vip_expire_time) > datetime('now', 'localtime')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
vip_users = cursor.fetchone()["count"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_users": total_users,
|
||||||
|
"approved_users": approved_users,
|
||||||
|
"pending_users": pending_users,
|
||||||
|
"total_accounts": total_accounts,
|
||||||
|
"vip_users": vip_users,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_system_config_raw() -> dict:
|
||||||
|
"""获取系统配置(无缓存,供 facade 做缓存/失效)。"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT * FROM system_config WHERE id = 1")
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
|
if row:
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"max_concurrent_global": 2,
|
||||||
|
"max_concurrent_per_account": 1,
|
||||||
|
"max_screenshot_concurrent": 3,
|
||||||
|
"schedule_enabled": 0,
|
||||||
|
"schedule_time": "02:00",
|
||||||
|
"schedule_browse_type": "应读",
|
||||||
|
"schedule_weekdays": "1,2,3,4,5,6,7",
|
||||||
|
"proxy_enabled": 0,
|
||||||
|
"proxy_api_url": "",
|
||||||
|
"proxy_expire_minutes": 3,
|
||||||
|
"enable_screenshot": 1,
|
||||||
|
"auto_approve_enabled": 0,
|
||||||
|
"auto_approve_hourly_limit": 10,
|
||||||
|
"auto_approve_vip_days": 7,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def update_system_config(
|
||||||
|
*,
|
||||||
|
max_concurrent=None,
|
||||||
|
schedule_enabled=None,
|
||||||
|
schedule_time=None,
|
||||||
|
schedule_browse_type=None,
|
||||||
|
schedule_weekdays=None,
|
||||||
|
max_concurrent_per_account=None,
|
||||||
|
max_screenshot_concurrent=None,
|
||||||
|
proxy_enabled=None,
|
||||||
|
proxy_api_url=None,
|
||||||
|
proxy_expire_minutes=None,
|
||||||
|
auto_approve_enabled=None,
|
||||||
|
auto_approve_hourly_limit=None,
|
||||||
|
auto_approve_vip_days=None,
|
||||||
|
) -> bool:
|
||||||
|
"""更新系统配置(仅更新DB,不做缓存处理)。"""
|
||||||
|
allowed_fields = {
|
||||||
|
"max_concurrent_global",
|
||||||
|
"schedule_enabled",
|
||||||
|
"schedule_time",
|
||||||
|
"schedule_browse_type",
|
||||||
|
"schedule_weekdays",
|
||||||
|
"max_concurrent_per_account",
|
||||||
|
"max_screenshot_concurrent",
|
||||||
|
"proxy_enabled",
|
||||||
|
"proxy_api_url",
|
||||||
|
"proxy_expire_minutes",
|
||||||
|
"auto_approve_enabled",
|
||||||
|
"auto_approve_hourly_limit",
|
||||||
|
"auto_approve_vip_days",
|
||||||
|
"updated_at",
|
||||||
|
}
|
||||||
|
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
updates = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if max_concurrent is not None:
|
||||||
|
updates.append("max_concurrent_global = ?")
|
||||||
|
params.append(max_concurrent)
|
||||||
|
if schedule_enabled is not None:
|
||||||
|
updates.append("schedule_enabled = ?")
|
||||||
|
params.append(schedule_enabled)
|
||||||
|
if schedule_time is not None:
|
||||||
|
updates.append("schedule_time = ?")
|
||||||
|
params.append(schedule_time)
|
||||||
|
if schedule_browse_type is not None:
|
||||||
|
updates.append("schedule_browse_type = ?")
|
||||||
|
params.append(schedule_browse_type)
|
||||||
|
if max_concurrent_per_account is not None:
|
||||||
|
updates.append("max_concurrent_per_account = ?")
|
||||||
|
params.append(max_concurrent_per_account)
|
||||||
|
if max_screenshot_concurrent is not None:
|
||||||
|
updates.append("max_screenshot_concurrent = ?")
|
||||||
|
params.append(max_screenshot_concurrent)
|
||||||
|
if schedule_weekdays is not None:
|
||||||
|
updates.append("schedule_weekdays = ?")
|
||||||
|
params.append(schedule_weekdays)
|
||||||
|
if proxy_enabled is not None:
|
||||||
|
updates.append("proxy_enabled = ?")
|
||||||
|
params.append(proxy_enabled)
|
||||||
|
if proxy_api_url is not None:
|
||||||
|
updates.append("proxy_api_url = ?")
|
||||||
|
params.append(proxy_api_url)
|
||||||
|
if proxy_expire_minutes is not None:
|
||||||
|
updates.append("proxy_expire_minutes = ?")
|
||||||
|
params.append(proxy_expire_minutes)
|
||||||
|
if auto_approve_enabled is not None:
|
||||||
|
updates.append("auto_approve_enabled = ?")
|
||||||
|
params.append(auto_approve_enabled)
|
||||||
|
if auto_approve_hourly_limit is not None:
|
||||||
|
updates.append("auto_approve_hourly_limit = ?")
|
||||||
|
params.append(auto_approve_hourly_limit)
|
||||||
|
if auto_approve_vip_days is not None:
|
||||||
|
updates.append("auto_approve_vip_days = ?")
|
||||||
|
params.append(auto_approve_vip_days)
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
return False
|
||||||
|
|
||||||
|
updates.append("updated_at = ?")
|
||||||
|
params.append(get_cst_now_str())
|
||||||
|
|
||||||
|
for update_clause in updates:
|
||||||
|
field_name = update_clause.split("=")[0].strip()
|
||||||
|
if field_name not in allowed_fields:
|
||||||
|
raise ValueError(f"非法字段名: {field_name}")
|
||||||
|
|
||||||
|
sql = f"UPDATE system_config SET {', '.join(updates)} WHERE id = 1"
|
||||||
|
cursor.execute(sql, params)
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_hourly_registration_count() -> int:
|
||||||
|
"""获取最近一小时内的注册用户数"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) FROM users
|
||||||
|
WHERE created_at >= datetime('now', 'localtime', '-1 hour')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return cursor.fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 密码重置(管理员) ====================
|
||||||
|
|
||||||
|
|
||||||
|
def create_password_reset_request(user_id: int, new_password: str):
|
||||||
|
"""创建密码重置申请(存储哈希)"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
password_hash = hash_password_bcrypt(new_password)
|
||||||
|
cst_time = get_cst_now_str()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO password_reset_requests (user_id, new_password_hash, status, created_at)
|
||||||
|
VALUES (?, ?, 'pending', ?)
|
||||||
|
""",
|
||||||
|
(user_id, password_hash, cst_time),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.lastrowid
|
||||||
|
except Exception as e:
|
||||||
|
print(f"创建密码重置申请失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_pending_password_resets():
|
||||||
|
"""获取待审核的密码重置申请列表"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT r.id, r.user_id, r.created_at, r.status,
|
||||||
|
u.username, u.email
|
||||||
|
FROM password_reset_requests r
|
||||||
|
JOIN users u ON r.user_id = u.id
|
||||||
|
WHERE r.status = 'pending'
|
||||||
|
ORDER BY r.created_at DESC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return [dict(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def approve_password_reset(request_id: int) -> bool:
|
||||||
|
"""批准密码重置申请"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cst_time = get_cst_now_str()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT user_id, new_password_hash
|
||||||
|
FROM password_reset_requests
|
||||||
|
WHERE id = ? AND status = 'pending'
|
||||||
|
""",
|
||||||
|
(request_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = cursor.fetchone()
|
||||||
|
if not result:
|
||||||
|
return False
|
||||||
|
|
||||||
|
user_id = result["user_id"]
|
||||||
|
new_password_hash = result["new_password_hash"]
|
||||||
|
|
||||||
|
cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_password_hash, user_id))
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE password_reset_requests
|
||||||
|
SET status = 'approved', processed_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(cst_time, request_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"批准密码重置失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def reject_password_reset(request_id: int) -> bool:
|
||||||
|
"""拒绝密码重置申请"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cst_time = get_cst_now_str()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE password_reset_requests
|
||||||
|
SET status = 'rejected', processed_at = ?
|
||||||
|
WHERE id = ? AND status = 'pending'
|
||||||
|
""",
|
||||||
|
(cst_time, request_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
except Exception as e:
|
||||||
|
print(f"拒绝密码重置失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def admin_reset_user_password(user_id: int, new_password: str) -> bool:
|
||||||
|
"""管理员直接重置用户密码"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
password_hash = hash_password_bcrypt(new_password)
|
||||||
|
try:
|
||||||
|
cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (password_hash, user_id))
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
except Exception as e:
|
||||||
|
print(f"管理员重置密码失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def clean_old_operation_logs(days: int = 30) -> int:
|
||||||
|
"""清理指定天数前的操作日志(如果存在operation_logs表)"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT name FROM sqlite_master
|
||||||
|
WHERE type='table' AND name='operation_logs'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
if not cursor.fetchone():
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM operation_logs
|
||||||
|
WHERE created_at < datetime('now', 'localtime', '-' || ? || ' days')
|
||||||
|
""",
|
||||||
|
(days,),
|
||||||
|
)
|
||||||
|
deleted_count = cursor.rowcount
|
||||||
|
conn.commit()
|
||||||
|
print(f"已清理 {deleted_count} 条旧操作日志 (>{days}天)")
|
||||||
|
return deleted_count
|
||||||
|
except Exception as e:
|
||||||
|
print(f"清理旧操作日志失败: {e}")
|
||||||
|
return 0
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import db_pool
|
||||||
|
from db.utils import get_cst_now_str
|
||||||
|
|
||||||
|
|
||||||
|
def create_announcement(title, content, is_active=True):
|
||||||
|
"""创建公告(默认启用;启用时会自动停用其他公告)"""
|
||||||
|
title = (title or "").strip()
|
||||||
|
content = (content or "").strip()
|
||||||
|
if not title or not content:
|
||||||
|
return None
|
||||||
|
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cst_time = get_cst_now_str()
|
||||||
|
|
||||||
|
if is_active:
|
||||||
|
cursor.execute("UPDATE announcements SET is_active = 0, updated_at = ? WHERE is_active = 1", (cst_time,))
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO announcements (title, content, is_active, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(title, content, 1 if is_active else 0, cst_time, cst_time),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def get_announcement_by_id(announcement_id):
|
||||||
|
"""根据ID获取公告"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT * FROM announcements WHERE id = ?", (announcement_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_announcements(limit=50, offset=0):
|
||||||
|
"""获取公告列表(管理员用)"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM announcements
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
""",
|
||||||
|
(limit, offset),
|
||||||
|
)
|
||||||
|
return [dict(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def set_announcement_active(announcement_id, is_active):
|
||||||
|
"""启用/停用公告;启用时会自动停用其他公告"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cst_time = get_cst_now_str()
|
||||||
|
|
||||||
|
if is_active:
|
||||||
|
cursor.execute("UPDATE announcements SET is_active = 0, updated_at = ? WHERE is_active = 1", (cst_time,))
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE announcements
|
||||||
|
SET is_active = 1, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(cst_time, announcement_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE announcements
|
||||||
|
SET is_active = 0, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(cst_time, announcement_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def delete_announcement(announcement_id):
|
||||||
|
"""删除公告(同时清理用户关闭记录)"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("DELETE FROM announcement_dismissals WHERE announcement_id = ?", (announcement_id,))
|
||||||
|
cursor.execute("DELETE FROM announcements WHERE id = ?", (announcement_id,))
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_announcement_for_user(user_id):
|
||||||
|
"""获取当前用户应展示的启用公告(已永久关闭的不再返回)"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT a.*
|
||||||
|
FROM announcements a
|
||||||
|
LEFT JOIN announcement_dismissals d
|
||||||
|
ON d.announcement_id = a.id AND d.user_id = ?
|
||||||
|
WHERE a.is_active = 1 AND d.announcement_id IS NULL
|
||||||
|
ORDER BY a.created_at DESC, a.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def dismiss_announcement_for_user(user_id, announcement_id):
|
||||||
|
"""用户永久关闭某条公告(幂等)"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cst_time = get_cst_now_str()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR IGNORE INTO announcement_dismissals (user_id, announcement_id, dismissed_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
""",
|
||||||
|
(user_id, announcement_id, cst_time),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount >= 0
|
||||||
|
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import db_pool
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_email(email):
|
||||||
|
"""根据邮箱获取用户"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT * FROM users WHERE email = ?", (email,))
|
||||||
|
user = cursor.fetchone()
|
||||||
|
return dict(user) if user else None
|
||||||
|
|
||||||
|
|
||||||
|
def update_user_email(user_id, email, verified=False):
|
||||||
|
"""更新用户邮箱"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET email = ?, email_verified = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(email, int(verified), user_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def update_user_email_notify(user_id, enabled):
|
||||||
|
"""更新用户邮件通知偏好"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET email_notify_enabled = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(int(enabled), user_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_email_notify(user_id):
|
||||||
|
"""获取用户邮件通知偏好(默认开启)"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
try:
|
||||||
|
cursor.execute("SELECT email_notify_enabled FROM users WHERE id = ?", (user_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row is None:
|
||||||
|
return True
|
||||||
|
return bool(row[0]) if row[0] is not None else True
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
import db_pool
|
||||||
|
from db.utils import escape_html
|
||||||
|
|
||||||
|
|
||||||
|
def create_bug_feedback(user_id, username, title, description, contact=""):
|
||||||
|
"""创建Bug反馈(带XSS防护)"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cst_tz = pytz.timezone("Asia/Shanghai")
|
||||||
|
cst_time = datetime.now(cst_tz).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
safe_title = escape_html(title) if title else ""
|
||||||
|
safe_description = escape_html(description) if description else ""
|
||||||
|
safe_contact = escape_html(contact) if contact else ""
|
||||||
|
safe_username = escape_html(username) if username else ""
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO bug_feedbacks (user_id, username, title, description, contact, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(user_id, safe_username, safe_title, safe_description, safe_contact, cst_time),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def get_bug_feedbacks(limit=100, offset=0, status_filter=None):
|
||||||
|
"""获取Bug反馈列表(管理员用)"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
sql = "SELECT * FROM bug_feedbacks WHERE 1=1"
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if status_filter:
|
||||||
|
sql += " AND status = ?"
|
||||||
|
params.append(status_filter)
|
||||||
|
|
||||||
|
sql += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
||||||
|
params.extend([limit, offset])
|
||||||
|
|
||||||
|
cursor.execute(sql, params)
|
||||||
|
return [dict(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_feedbacks(user_id, limit=50):
|
||||||
|
"""获取用户自己的反馈列表"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM bug_feedbacks
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(user_id, limit),
|
||||||
|
)
|
||||||
|
return [dict(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def get_feedback_by_id(feedback_id):
|
||||||
|
"""根据ID获取反馈详情"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT * FROM bug_feedbacks WHERE id = ?", (feedback_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def reply_feedback(feedback_id, admin_reply):
|
||||||
|
"""管理员回复反馈(带XSS防护)"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cst_tz = pytz.timezone("Asia/Shanghai")
|
||||||
|
cst_time = datetime.now(cst_tz).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
safe_reply = escape_html(admin_reply) if admin_reply else ""
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE bug_feedbacks
|
||||||
|
SET admin_reply = ?, status = 'replied', replied_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(safe_reply, cst_time, feedback_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def close_feedback(feedback_id):
|
||||||
|
"""关闭反馈"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE bug_feedbacks
|
||||||
|
SET status = 'closed'
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(feedback_id,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def delete_feedback(feedback_id):
|
||||||
|
"""删除反馈"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("DELETE FROM bug_feedbacks WHERE id = ?", (feedback_id,))
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_feedback_stats():
|
||||||
|
"""获取反馈统计"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
|
||||||
|
SUM(CASE WHEN status = 'replied' THEN 1 ELSE 0 END) as replied,
|
||||||
|
SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END) as closed
|
||||||
|
FROM bug_feedbacks
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return dict(row) if row else {"total": 0, "pending": 0, "replied": 0, "closed": 0}
|
||||||
|
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from db.utils import get_cst_now_str
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_version(conn) -> int:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT version FROM db_version WHERE id = 1")
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
return int(row["version"])
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
return int(row[0])
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def set_current_version(conn, version: int) -> None:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("UPDATE db_version SET version = ?, updated_at = ? WHERE id = 1", (int(version), get_cst_now_str()))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_database(conn, target_version: int) -> None:
|
||||||
|
"""数据库迁移:按版本增量升级(向前兼容)。"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("INSERT OR IGNORE INTO db_version (id, version, updated_at) VALUES (1, 0, ?)", (get_cst_now_str(),))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
current_version = get_current_version(conn)
|
||||||
|
|
||||||
|
if current_version < 1:
|
||||||
|
_migrate_to_v1(conn)
|
||||||
|
current_version = 1
|
||||||
|
if current_version < 2:
|
||||||
|
_migrate_to_v2(conn)
|
||||||
|
current_version = 2
|
||||||
|
if current_version < 3:
|
||||||
|
_migrate_to_v3(conn)
|
||||||
|
current_version = 3
|
||||||
|
if current_version < 4:
|
||||||
|
_migrate_to_v4(conn)
|
||||||
|
current_version = 4
|
||||||
|
if current_version < 5:
|
||||||
|
_migrate_to_v5(conn)
|
||||||
|
current_version = 5
|
||||||
|
if current_version < 6:
|
||||||
|
_migrate_to_v6(conn)
|
||||||
|
current_version = 6
|
||||||
|
if current_version < 7:
|
||||||
|
_migrate_to_v7(conn)
|
||||||
|
current_version = 7
|
||||||
|
if current_version < 8:
|
||||||
|
_migrate_to_v8(conn)
|
||||||
|
current_version = 8
|
||||||
|
if current_version < 9:
|
||||||
|
_migrate_to_v9(conn)
|
||||||
|
current_version = 9
|
||||||
|
if current_version < 10:
|
||||||
|
_migrate_to_v10(conn)
|
||||||
|
current_version = 10
|
||||||
|
|
||||||
|
if current_version != int(target_version):
|
||||||
|
set_current_version(conn, int(target_version))
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_to_v1(conn):
|
||||||
|
"""迁移到版本1 - 添加缺失字段"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("PRAGMA table_info(system_config)")
|
||||||
|
columns = [col[1] for col in cursor.fetchall()]
|
||||||
|
|
||||||
|
if "schedule_weekdays" not in columns:
|
||||||
|
cursor.execute('ALTER TABLE system_config ADD COLUMN schedule_weekdays TEXT DEFAULT "1,2,3,4,5,6,7"')
|
||||||
|
print(" ✓ 添加 schedule_weekdays 字段")
|
||||||
|
|
||||||
|
if "max_screenshot_concurrent" not in columns:
|
||||||
|
cursor.execute("ALTER TABLE system_config ADD COLUMN max_screenshot_concurrent INTEGER DEFAULT 3")
|
||||||
|
print(" ✓ 添加 max_screenshot_concurrent 字段")
|
||||||
|
if "max_concurrent_per_account" not in columns:
|
||||||
|
cursor.execute("ALTER TABLE system_config ADD COLUMN max_concurrent_per_account INTEGER DEFAULT 1")
|
||||||
|
print(" ✓ 添加 max_concurrent_per_account 字段")
|
||||||
|
if "auto_approve_enabled" not in columns:
|
||||||
|
cursor.execute("ALTER TABLE system_config ADD COLUMN auto_approve_enabled INTEGER DEFAULT 0")
|
||||||
|
print(" ✓ 添加 auto_approve_enabled 字段")
|
||||||
|
if "auto_approve_hourly_limit" not in columns:
|
||||||
|
cursor.execute("ALTER TABLE system_config ADD COLUMN auto_approve_hourly_limit INTEGER DEFAULT 10")
|
||||||
|
print(" ✓ 添加 auto_approve_hourly_limit 字段")
|
||||||
|
if "auto_approve_vip_days" not in columns:
|
||||||
|
cursor.execute("ALTER TABLE system_config ADD COLUMN auto_approve_vip_days INTEGER DEFAULT 7")
|
||||||
|
print(" ✓ 添加 auto_approve_vip_days 字段")
|
||||||
|
|
||||||
|
cursor.execute("PRAGMA table_info(task_logs)")
|
||||||
|
columns = [col[1] for col in cursor.fetchall()]
|
||||||
|
if "duration" not in columns:
|
||||||
|
cursor.execute("ALTER TABLE task_logs ADD COLUMN duration INTEGER")
|
||||||
|
print(" ✓ 添加 duration 字段到 task_logs")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_to_v2(conn):
|
||||||
|
"""迁移到版本2 - 添加代理配置字段"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("PRAGMA table_info(system_config)")
|
||||||
|
columns = [col[1] for col in cursor.fetchall()]
|
||||||
|
|
||||||
|
if "proxy_enabled" not in columns:
|
||||||
|
cursor.execute("ALTER TABLE system_config ADD COLUMN proxy_enabled INTEGER DEFAULT 0")
|
||||||
|
print(" ✓ 添加 proxy_enabled 字段")
|
||||||
|
|
||||||
|
if "proxy_api_url" not in columns:
|
||||||
|
cursor.execute('ALTER TABLE system_config ADD COLUMN proxy_api_url TEXT DEFAULT ""')
|
||||||
|
print(" ✓ 添加 proxy_api_url 字段")
|
||||||
|
|
||||||
|
if "proxy_expire_minutes" not in columns:
|
||||||
|
cursor.execute("ALTER TABLE system_config ADD COLUMN proxy_expire_minutes INTEGER DEFAULT 3")
|
||||||
|
print(" ✓ 添加 proxy_expire_minutes 字段")
|
||||||
|
|
||||||
|
if "enable_screenshot" not in columns:
|
||||||
|
cursor.execute("ALTER TABLE system_config ADD COLUMN enable_screenshot INTEGER DEFAULT 1")
|
||||||
|
print(" ✓ 添加 enable_screenshot 字段")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_to_v3(conn):
|
||||||
|
"""迁移到版本3 - 添加账号状态和登录失败计数字段"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("PRAGMA table_info(accounts)")
|
||||||
|
columns = [col[1] for col in cursor.fetchall()]
|
||||||
|
|
||||||
|
if "status" not in columns:
|
||||||
|
cursor.execute('ALTER TABLE accounts ADD COLUMN status TEXT DEFAULT "active"')
|
||||||
|
print(" ✓ 添加 accounts.status 字段 (账号状态)")
|
||||||
|
|
||||||
|
if "login_fail_count" not in columns:
|
||||||
|
cursor.execute("ALTER TABLE accounts ADD COLUMN login_fail_count INTEGER DEFAULT 0")
|
||||||
|
print(" ✓ 添加 accounts.login_fail_count 字段 (登录失败计数)")
|
||||||
|
|
||||||
|
if "last_login_error" not in columns:
|
||||||
|
cursor.execute("ALTER TABLE accounts ADD COLUMN last_login_error TEXT")
|
||||||
|
print(" ✓ 添加 accounts.last_login_error 字段 (最后登录错误)")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_to_v4(conn):
|
||||||
|
"""迁移到版本4 - 添加任务来源字段"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("PRAGMA table_info(task_logs)")
|
||||||
|
columns = [col[1] for col in cursor.fetchall()]
|
||||||
|
|
||||||
|
if "source" not in columns:
|
||||||
|
cursor.execute('ALTER TABLE task_logs ADD COLUMN source TEXT DEFAULT "manual"')
|
||||||
|
print(" ✓ 添加 task_logs.source 字段 (任务来源: manual/scheduled/immediate)")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_to_v5(conn):
|
||||||
|
"""迁移到版本5 - 添加用户定时任务表"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='user_schedules'")
|
||||||
|
if not cursor.fetchone():
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS user_schedules (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
name TEXT DEFAULT '我的定时任务',
|
||||||
|
enabled INTEGER DEFAULT 0,
|
||||||
|
schedule_time TEXT NOT NULL DEFAULT '08:00',
|
||||||
|
weekdays TEXT NOT NULL DEFAULT '1,2,3,4,5',
|
||||||
|
browse_type TEXT NOT NULL DEFAULT '应读',
|
||||||
|
enable_screenshot INTEGER DEFAULT 1,
|
||||||
|
account_ids TEXT,
|
||||||
|
last_run_at TIMESTAMP,
|
||||||
|
next_run_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
print(" ✓ 创建 user_schedules 表 (用户定时任务)")
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS schedule_execution_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
schedule_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
schedule_name TEXT,
|
||||||
|
execute_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
total_accounts INTEGER DEFAULT 0,
|
||||||
|
success_accounts INTEGER DEFAULT 0,
|
||||||
|
failed_accounts INTEGER DEFAULT 0,
|
||||||
|
total_items INTEGER DEFAULT 0,
|
||||||
|
total_attachments INTEGER DEFAULT 0,
|
||||||
|
total_screenshots INTEGER DEFAULT 0,
|
||||||
|
duration_seconds INTEGER DEFAULT 0,
|
||||||
|
status TEXT DEFAULT 'running',
|
||||||
|
error_message TEXT,
|
||||||
|
FOREIGN KEY (schedule_id) REFERENCES user_schedules (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
print(" ✓ 创建 schedule_execution_logs 表 (定时任务执行日志)")
|
||||||
|
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_schedules_user_id ON user_schedules(user_id)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_schedules_enabled ON user_schedules(enabled)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_schedules_next_run ON user_schedules(next_run_at)")
|
||||||
|
print(" ✓ 创建 user_schedules 表索引")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_to_v6(conn):
|
||||||
|
"""迁移到版本6 - 添加公告功能相关表"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='announcements'")
|
||||||
|
if not cursor.fetchone():
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS announcements (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
is_active INTEGER DEFAULT 1,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
print(" ✓ 创建 announcements 表 (公告)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_announcements_active ON announcements(is_active)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_announcements_created_at ON announcements(created_at)")
|
||||||
|
print(" ✓ 创建 announcements 表索引")
|
||||||
|
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='announcement_dismissals'")
|
||||||
|
if not cursor.fetchone():
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS announcement_dismissals (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
announcement_id INTEGER NOT NULL,
|
||||||
|
dismissed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (user_id, announcement_id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (announcement_id) REFERENCES announcements (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
print(" ✓ 创建 announcement_dismissals 表 (公告永久关闭记录)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_announcement_dismissals_user ON announcement_dismissals(user_id)")
|
||||||
|
print(" ✓ 创建 announcement_dismissals 表索引")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_to_v7(conn):
|
||||||
|
"""迁移到版本7 - 统一存储北京时间(将历史UTC时间字段整体+8小时)"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
def table_exists(table_name: str) -> bool:
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table_name,))
|
||||||
|
return cursor.fetchone() is not None
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
cursor.execute(f"PRAGMA table_info({table_name})")
|
||||||
|
return any(row[1] == column_name for row in cursor.fetchall())
|
||||||
|
|
||||||
|
def shift_utc_to_cst(table_name: str, column_name: str) -> None:
|
||||||
|
if not table_exists(table_name):
|
||||||
|
return
|
||||||
|
if not column_exists(table_name, column_name):
|
||||||
|
return
|
||||||
|
cursor.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE {table_name}
|
||||||
|
SET {column_name} = datetime({column_name}, '+8 hours')
|
||||||
|
WHERE {column_name} IS NOT NULL AND {column_name} != ''
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
for table, col in [
|
||||||
|
("users", "created_at"),
|
||||||
|
("users", "approved_at"),
|
||||||
|
("admins", "created_at"),
|
||||||
|
("accounts", "created_at"),
|
||||||
|
("password_reset_requests", "created_at"),
|
||||||
|
("password_reset_requests", "processed_at"),
|
||||||
|
]:
|
||||||
|
shift_utc_to_cst(table, col)
|
||||||
|
|
||||||
|
for table, col in [
|
||||||
|
("smtp_configs", "created_at"),
|
||||||
|
("smtp_configs", "updated_at"),
|
||||||
|
("smtp_configs", "last_success_at"),
|
||||||
|
("email_settings", "updated_at"),
|
||||||
|
("email_tokens", "created_at"),
|
||||||
|
("email_logs", "created_at"),
|
||||||
|
("email_stats", "last_updated"),
|
||||||
|
]:
|
||||||
|
shift_utc_to_cst(table, col)
|
||||||
|
|
||||||
|
for table, col in [
|
||||||
|
("task_checkpoints", "created_at"),
|
||||||
|
("task_checkpoints", "updated_at"),
|
||||||
|
("task_checkpoints", "completed_at"),
|
||||||
|
]:
|
||||||
|
shift_utc_to_cst(table, col)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print(" ✓ 时区迁移:历史UTC时间已转换为北京时间")
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_to_v8(conn):
|
||||||
|
"""迁移到版本8 - 用户定时 next_run_at 随机延迟落库(O-08)"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# 1) 增量字段:random_delay(旧库可能不存在)
|
||||||
|
cursor.execute("PRAGMA table_info(user_schedules)")
|
||||||
|
columns = [col[1] for col in cursor.fetchall()]
|
||||||
|
if "random_delay" not in columns:
|
||||||
|
cursor.execute("ALTER TABLE user_schedules ADD COLUMN random_delay INTEGER DEFAULT 0")
|
||||||
|
print(" ✓ 添加 user_schedules.random_delay 字段")
|
||||||
|
|
||||||
|
if "next_run_at" not in columns:
|
||||||
|
cursor.execute("ALTER TABLE user_schedules ADD COLUMN next_run_at TIMESTAMP")
|
||||||
|
print(" ✓ 添加 user_schedules.next_run_at 字段")
|
||||||
|
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_schedules_next_run ON user_schedules(next_run_at)")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# 2) 为历史 enabled schedule 补算 next_run_at(保证索引驱动可用)
|
||||||
|
try:
|
||||||
|
from services.schedule_utils import compute_next_run_at, format_cst
|
||||||
|
from services.time_utils import get_beijing_now
|
||||||
|
|
||||||
|
now_dt = get_beijing_now()
|
||||||
|
now_str = format_cst(now_dt)
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, schedule_time, weekdays, random_delay, last_run_at, next_run_at
|
||||||
|
FROM user_schedules
|
||||||
|
WHERE enabled = 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rows = cursor.fetchall() or []
|
||||||
|
|
||||||
|
fixed = 0
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
schedule_id = row["id"] if isinstance(row, sqlite3.Row) else row[0]
|
||||||
|
schedule_time = row["schedule_time"] if isinstance(row, sqlite3.Row) else row[1]
|
||||||
|
weekdays = row["weekdays"] if isinstance(row, sqlite3.Row) else row[2]
|
||||||
|
random_delay = row["random_delay"] if isinstance(row, sqlite3.Row) else row[3]
|
||||||
|
last_run_at = row["last_run_at"] if isinstance(row, sqlite3.Row) else row[4]
|
||||||
|
next_run_at = row["next_run_at"] if isinstance(row, sqlite3.Row) else row[5]
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
next_run_text = str(next_run_at or "").strip()
|
||||||
|
# 若 next_run_at 为空/非法/已过期,则重算
|
||||||
|
if (not next_run_text) or (next_run_text <= now_str):
|
||||||
|
next_dt = compute_next_run_at(
|
||||||
|
now=now_dt,
|
||||||
|
schedule_time=str(schedule_time or "08:00"),
|
||||||
|
weekdays=str(weekdays or "1,2,3,4,5"),
|
||||||
|
random_delay=int(random_delay or 0),
|
||||||
|
last_run_at=str(last_run_at or "") if last_run_at else None,
|
||||||
|
)
|
||||||
|
next_run_text = format_cst(next_dt)
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE user_schedules SET next_run_at = ?, updated_at = ? WHERE id = ?",
|
||||||
|
(next_run_text, get_cst_now_str(), int(schedule_id)),
|
||||||
|
)
|
||||||
|
fixed += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
if fixed:
|
||||||
|
print(f" ✓ 已为 {fixed} 条启用定时任务补算 next_run_at")
|
||||||
|
except Exception as e:
|
||||||
|
# 迁移过程中不阻断主流程;上线后由 worker 兜底补算
|
||||||
|
print(f" ⚠ v8 迁移补算 next_run_at 失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_to_v9(conn):
|
||||||
|
"""迁移到版本9 - 邮件设置字段迁移(清理 email_service scattered ALTER TABLE)"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='email_settings'")
|
||||||
|
if not cursor.fetchone():
|
||||||
|
# 邮件表由 email_service.init_email_tables 创建;此处仅做增量字段迁移
|
||||||
|
return
|
||||||
|
|
||||||
|
cursor.execute("PRAGMA table_info(email_settings)")
|
||||||
|
columns = [col[1] for col in cursor.fetchall()]
|
||||||
|
|
||||||
|
changed = False
|
||||||
|
if "register_verify_enabled" not in columns:
|
||||||
|
cursor.execute("ALTER TABLE email_settings ADD COLUMN register_verify_enabled INTEGER DEFAULT 0")
|
||||||
|
print(" ✓ 添加 email_settings.register_verify_enabled 字段")
|
||||||
|
changed = True
|
||||||
|
if "base_url" not in columns:
|
||||||
|
cursor.execute("ALTER TABLE email_settings ADD COLUMN base_url TEXT DEFAULT ''")
|
||||||
|
print(" ✓ 添加 email_settings.base_url 字段")
|
||||||
|
changed = True
|
||||||
|
if "task_notify_enabled" not in columns:
|
||||||
|
cursor.execute("ALTER TABLE email_settings ADD COLUMN task_notify_enabled INTEGER DEFAULT 0")
|
||||||
|
print(" ✓ 添加 email_settings.task_notify_enabled 字段")
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_to_v10(conn):
|
||||||
|
"""迁移到版本10 - users 邮箱字段迁移(避免运行时 ALTER TABLE)"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("PRAGMA table_info(users)")
|
||||||
|
columns = [col[1] for col in cursor.fetchall()]
|
||||||
|
|
||||||
|
changed = False
|
||||||
|
if "email_verified" not in columns:
|
||||||
|
cursor.execute("ALTER TABLE users ADD COLUMN email_verified INTEGER DEFAULT 0")
|
||||||
|
print(" ✓ 添加 users.email_verified 字段")
|
||||||
|
changed = True
|
||||||
|
if "email_notify_enabled" not in columns:
|
||||||
|
cursor.execute("ALTER TABLE users ADD COLUMN email_notify_enabled INTEGER DEFAULT 1")
|
||||||
|
print(" ✓ 添加 users.email_notify_enabled 字段")
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
conn.commit()
|
||||||
+485
@@ -0,0 +1,485 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import db_pool
|
||||||
|
from services.schedule_utils import compute_next_run_at, format_cst
|
||||||
|
from services.time_utils import get_beijing_now
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_schedules(user_id):
|
||||||
|
"""获取用户的所有定时任务"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM user_schedules
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
return [dict(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def get_schedule_by_id(schedule_id):
|
||||||
|
"""根据ID获取定时任务"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT * FROM user_schedules WHERE id = ?", (schedule_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def create_user_schedule(
|
||||||
|
user_id,
|
||||||
|
name="我的定时任务",
|
||||||
|
schedule_time="08:00",
|
||||||
|
weekdays="1,2,3,4,5",
|
||||||
|
browse_type="应读",
|
||||||
|
enable_screenshot=1,
|
||||||
|
random_delay=0,
|
||||||
|
account_ids=None,
|
||||||
|
):
|
||||||
|
"""创建用户定时任务"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cst_time = format_cst(get_beijing_now())
|
||||||
|
|
||||||
|
account_ids_str = json.dumps(account_ids) if account_ids else "[]"
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO user_schedules (
|
||||||
|
user_id, name, enabled, schedule_time, weekdays,
|
||||||
|
browse_type, enable_screenshot, random_delay, account_ids, created_at, updated_at
|
||||||
|
) VALUES (?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
user_id,
|
||||||
|
name,
|
||||||
|
schedule_time,
|
||||||
|
weekdays,
|
||||||
|
browse_type,
|
||||||
|
enable_screenshot,
|
||||||
|
int(random_delay or 0),
|
||||||
|
account_ids_str,
|
||||||
|
cst_time,
|
||||||
|
cst_time,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def update_user_schedule(schedule_id, **kwargs):
|
||||||
|
"""更新用户定时任务"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
now_dt = get_beijing_now()
|
||||||
|
now_str = format_cst(now_dt)
|
||||||
|
|
||||||
|
updates = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
allowed_fields = [
|
||||||
|
"name",
|
||||||
|
"enabled",
|
||||||
|
"schedule_time",
|
||||||
|
"weekdays",
|
||||||
|
"browse_type",
|
||||||
|
"enable_screenshot",
|
||||||
|
"random_delay",
|
||||||
|
"account_ids",
|
||||||
|
]
|
||||||
|
|
||||||
|
# 读取旧值,用于决定是否需要重算 next_run_at
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT enabled, schedule_time, weekdays, random_delay, last_run_at
|
||||||
|
FROM user_schedules
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(schedule_id,),
|
||||||
|
)
|
||||||
|
current = cursor.fetchone()
|
||||||
|
if not current:
|
||||||
|
return False
|
||||||
|
current_enabled = int(current[0] or 0)
|
||||||
|
current_time = current[1]
|
||||||
|
current_weekdays = current[2]
|
||||||
|
current_random_delay = int(current[3] or 0)
|
||||||
|
current_last_run_at = current[4]
|
||||||
|
|
||||||
|
will_enabled = current_enabled
|
||||||
|
next_time = current_time
|
||||||
|
next_weekdays = current_weekdays
|
||||||
|
next_random_delay = current_random_delay
|
||||||
|
|
||||||
|
for field in allowed_fields:
|
||||||
|
if field in kwargs:
|
||||||
|
value = kwargs[field]
|
||||||
|
if field == "account_ids" and isinstance(value, list):
|
||||||
|
value = json.dumps(value)
|
||||||
|
if field == "enabled":
|
||||||
|
will_enabled = 1 if value else 0
|
||||||
|
if field == "schedule_time":
|
||||||
|
next_time = value
|
||||||
|
if field == "weekdays":
|
||||||
|
next_weekdays = value
|
||||||
|
if field == "random_delay":
|
||||||
|
next_random_delay = int(value or 0)
|
||||||
|
updates.append(f"{field} = ?")
|
||||||
|
params.append(value)
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
return False
|
||||||
|
|
||||||
|
updates.append("updated_at = ?")
|
||||||
|
params.append(now_str)
|
||||||
|
|
||||||
|
# enabled 状态下,关键字段变更后要重算 next_run_at,确保索引驱动不会跑偏
|
||||||
|
should_recompute_next = will_enabled == 1 and any(
|
||||||
|
key in kwargs for key in ["enabled", "schedule_time", "weekdays", "random_delay"]
|
||||||
|
)
|
||||||
|
if should_recompute_next:
|
||||||
|
next_dt = compute_next_run_at(
|
||||||
|
now=now_dt,
|
||||||
|
schedule_time=str(next_time or "08:00"),
|
||||||
|
weekdays=str(next_weekdays or "1,2,3,4,5"),
|
||||||
|
random_delay=int(next_random_delay or 0),
|
||||||
|
last_run_at=str(current_last_run_at or "") if current_last_run_at else None,
|
||||||
|
)
|
||||||
|
updates.append("next_run_at = ?")
|
||||||
|
params.append(format_cst(next_dt))
|
||||||
|
params.append(schedule_id)
|
||||||
|
|
||||||
|
sql = f"UPDATE user_schedules SET {', '.join(updates)} WHERE id = ?"
|
||||||
|
cursor.execute(sql, params)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def delete_user_schedule(schedule_id):
|
||||||
|
"""删除用户定时任务"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("DELETE FROM user_schedules WHERE id = ?", (schedule_id,))
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def toggle_user_schedule(schedule_id, enabled):
|
||||||
|
"""启用/禁用用户定时任务"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
now_dt = get_beijing_now()
|
||||||
|
now_str = format_cst(now_dt)
|
||||||
|
|
||||||
|
next_run_at = None
|
||||||
|
if enabled:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT schedule_time, weekdays, random_delay, last_run_at
|
||||||
|
FROM user_schedules
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(schedule_id,),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
schedule_time, weekdays, random_delay, last_run_at = row[0], row[1], row[2], row[3]
|
||||||
|
next_dt = compute_next_run_at(
|
||||||
|
now=now_dt,
|
||||||
|
schedule_time=str(schedule_time or "08:00"),
|
||||||
|
weekdays=str(weekdays or "1,2,3,4,5"),
|
||||||
|
random_delay=int(random_delay or 0),
|
||||||
|
last_run_at=str(last_run_at or "") if last_run_at else None,
|
||||||
|
)
|
||||||
|
next_run_at = format_cst(next_dt)
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE user_schedules
|
||||||
|
SET enabled = ?, next_run_at = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(1 if enabled else 0, next_run_at, now_str, schedule_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_enabled_user_schedules():
|
||||||
|
"""获取所有启用的用户定时任务"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT us.*, u.username as user_username
|
||||||
|
FROM user_schedules us
|
||||||
|
JOIN users u ON us.user_id = u.id
|
||||||
|
WHERE us.enabled = 1
|
||||||
|
ORDER BY us.schedule_time
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return [dict(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def update_schedule_last_run(schedule_id):
|
||||||
|
"""更新定时任务最后运行时间,并推进 next_run_at(O-08)"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
now_dt = get_beijing_now()
|
||||||
|
now_str = format_cst(now_dt)
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT schedule_time, weekdays, random_delay
|
||||||
|
FROM user_schedules
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(schedule_id,),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
return False
|
||||||
|
schedule_time, weekdays, random_delay = row[0], row[1], row[2]
|
||||||
|
|
||||||
|
next_dt = compute_next_run_at(
|
||||||
|
now=now_dt,
|
||||||
|
schedule_time=str(schedule_time or "08:00"),
|
||||||
|
weekdays=str(weekdays or "1,2,3,4,5"),
|
||||||
|
random_delay=int(random_delay or 0),
|
||||||
|
last_run_at=now_str,
|
||||||
|
)
|
||||||
|
next_run_at = format_cst(next_dt)
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE user_schedules
|
||||||
|
SET last_run_at = ?, next_run_at = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(now_str, next_run_at, now_str, schedule_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def update_schedule_next_run(schedule_id: int, next_run_at: str) -> bool:
|
||||||
|
"""仅更新 next_run_at(不改变 last_run_at),用于跳过执行时推进。"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE user_schedules
|
||||||
|
SET next_run_at = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(str(next_run_at or "").strip() or None, format_cst(get_beijing_now()), int(schedule_id)),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def recompute_schedule_next_run(schedule_id: int, *, now_dt=None) -> bool:
|
||||||
|
"""按当前配置重算 next_run_at(不改变 last_run_at)。"""
|
||||||
|
now_dt = now_dt or get_beijing_now()
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT schedule_time, weekdays, random_delay, last_run_at
|
||||||
|
FROM user_schedules
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(int(schedule_id),),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
return False
|
||||||
|
|
||||||
|
schedule_time, weekdays, random_delay, last_run_at = row[0], row[1], row[2], row[3]
|
||||||
|
next_dt = compute_next_run_at(
|
||||||
|
now=now_dt,
|
||||||
|
schedule_time=str(schedule_time or "08:00"),
|
||||||
|
weekdays=str(weekdays or "1,2,3,4,5"),
|
||||||
|
random_delay=int(random_delay or 0),
|
||||||
|
last_run_at=str(last_run_at or "") if last_run_at else None,
|
||||||
|
)
|
||||||
|
return update_schedule_next_run(int(schedule_id), format_cst(next_dt))
|
||||||
|
|
||||||
|
|
||||||
|
def get_due_user_schedules(now_cst: str, limit: int = 50):
|
||||||
|
"""获取到期需要执行的用户定时任务(索引驱动)。"""
|
||||||
|
now_cst = str(now_cst or "").strip()
|
||||||
|
if not now_cst:
|
||||||
|
now_cst = format_cst(get_beijing_now())
|
||||||
|
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT us.*, u.username as user_username
|
||||||
|
FROM user_schedules us
|
||||||
|
JOIN users u ON us.user_id = u.id
|
||||||
|
WHERE us.enabled = 1
|
||||||
|
AND us.next_run_at IS NOT NULL
|
||||||
|
AND us.next_run_at <= ?
|
||||||
|
ORDER BY us.next_run_at ASC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(now_cst, int(limit)),
|
||||||
|
)
|
||||||
|
return [dict(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 定时任务执行日志 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def create_schedule_execution_log(schedule_id, user_id, schedule_name):
|
||||||
|
"""创建定时任务执行日志"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
execute_time = format_cst(get_beijing_now())
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO schedule_execution_logs (
|
||||||
|
schedule_id, user_id, schedule_name, execute_time, status
|
||||||
|
) VALUES (?, ?, ?, ?, 'running')
|
||||||
|
""",
|
||||||
|
(schedule_id, user_id, schedule_name, execute_time),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def update_schedule_execution_log(log_id, **kwargs):
|
||||||
|
"""更新定时任务执行日志"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
updates = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
allowed_fields = [
|
||||||
|
"total_accounts",
|
||||||
|
"success_accounts",
|
||||||
|
"failed_accounts",
|
||||||
|
"total_items",
|
||||||
|
"total_attachments",
|
||||||
|
"total_screenshots",
|
||||||
|
"duration_seconds",
|
||||||
|
"status",
|
||||||
|
"error_message",
|
||||||
|
]
|
||||||
|
|
||||||
|
for field in allowed_fields:
|
||||||
|
if field in kwargs:
|
||||||
|
updates.append(f"{field} = ?")
|
||||||
|
params.append(kwargs[field])
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
return False
|
||||||
|
|
||||||
|
params.append(log_id)
|
||||||
|
sql = f"UPDATE schedule_execution_logs SET {', '.join(updates)} WHERE id = ?"
|
||||||
|
|
||||||
|
cursor.execute(sql, params)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_schedule_execution_logs(schedule_id, limit=10):
|
||||||
|
"""获取定时任务执行日志"""
|
||||||
|
try:
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM schedule_execution_logs
|
||||||
|
WHERE schedule_id = ?
|
||||||
|
ORDER BY execute_time DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(schedule_id, limit),
|
||||||
|
)
|
||||||
|
|
||||||
|
logs = []
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
log = dict(row)
|
||||||
|
log["created_at"] = log.get("execute_time")
|
||||||
|
log["success_count"] = log.get("success_accounts", 0)
|
||||||
|
log["failed_count"] = log.get("failed_accounts", 0)
|
||||||
|
log["duration"] = log.get("duration_seconds", 0)
|
||||||
|
logs.append(log)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[数据库] 处理日志行时出错: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return logs
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[数据库] 查询定时任务日志时出错: {e}")
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_all_schedule_logs(user_id, limit=50):
|
||||||
|
"""获取用户所有定时任务的执行日志"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM schedule_execution_logs
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY execute_time DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(user_id, limit),
|
||||||
|
)
|
||||||
|
return [dict(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def delete_schedule_logs(schedule_id, user_id):
|
||||||
|
"""删除指定定时任务的所有执行日志(需验证用户权限)"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM schedule_execution_logs
|
||||||
|
WHERE schedule_id = ? AND user_id = ?
|
||||||
|
""",
|
||||||
|
(schedule_id, user_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount
|
||||||
|
|
||||||
|
|
||||||
|
def clean_old_schedule_logs(days=30):
|
||||||
|
"""清理指定天数前的定时任务执行日志"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM schedule_execution_logs
|
||||||
|
WHERE execute_time < datetime('now', 'localtime', '-' || ? || ' days')
|
||||||
|
""",
|
||||||
|
(days,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount
|
||||||
+288
@@ -0,0 +1,288 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from db.utils import get_cst_now_str
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_schema(conn) -> None:
|
||||||
|
"""创建当前版本所需的所有表与索引(新库可直接得到完整 schema)。"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# 管理员表
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS admins (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# 用户表
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
email TEXT,
|
||||||
|
email_verified INTEGER DEFAULT 0,
|
||||||
|
email_notify_enabled INTEGER DEFAULT 1,
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
vip_expire_time TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
approved_at TIMESTAMP
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# 账号表(关联用户)
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS accounts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
remember INTEGER DEFAULT 1,
|
||||||
|
remark TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# VIP配置表
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS vip_config (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
default_vip_days INTEGER DEFAULT 0,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# 系统配置表
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS system_config (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
max_concurrent_global INTEGER DEFAULT 2,
|
||||||
|
schedule_enabled INTEGER DEFAULT 0,
|
||||||
|
schedule_time TEXT DEFAULT '02:00',
|
||||||
|
schedule_browse_type TEXT DEFAULT '应读',
|
||||||
|
proxy_enabled INTEGER DEFAULT 0,
|
||||||
|
proxy_api_url TEXT DEFAULT '',
|
||||||
|
proxy_expire_minutes INTEGER DEFAULT 3,
|
||||||
|
max_screenshot_concurrent INTEGER DEFAULT 3,
|
||||||
|
max_concurrent_per_account INTEGER DEFAULT 1,
|
||||||
|
schedule_weekdays TEXT DEFAULT '1,2,3,4,5,6,7',
|
||||||
|
enable_screenshot INTEGER DEFAULT 1,
|
||||||
|
auto_approve_enabled INTEGER DEFAULT 0,
|
||||||
|
auto_approve_hourly_limit INTEGER DEFAULT 10,
|
||||||
|
auto_approve_vip_days INTEGER DEFAULT 7,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# 任务日志表
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS task_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
account_id TEXT NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
browse_type TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
total_items INTEGER DEFAULT 0,
|
||||||
|
total_attachments INTEGER DEFAULT 0,
|
||||||
|
error_message TEXT,
|
||||||
|
duration INTEGER,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
source TEXT DEFAULT 'manual',
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# 密码重置申请表
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS password_reset_requests (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
new_password_hash TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
processed_at TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# 数据库版本表
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS db_version (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
version INTEGER NOT NULL,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Bug反馈表
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS bug_feedbacks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
contact TEXT,
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
admin_reply TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
replied_at TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# 公告表
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS announcements (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
is_active INTEGER DEFAULT 1,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# 公告永久关闭记录表(用户维度)
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS announcement_dismissals (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
announcement_id INTEGER NOT NULL,
|
||||||
|
dismissed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (user_id, announcement_id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (announcement_id) REFERENCES announcements (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# 用户定时任务表
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS user_schedules (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
name TEXT DEFAULT '我的定时任务',
|
||||||
|
enabled INTEGER DEFAULT 0,
|
||||||
|
schedule_time TEXT NOT NULL DEFAULT '08:00',
|
||||||
|
weekdays TEXT NOT NULL DEFAULT '1,2,3,4,5',
|
||||||
|
browse_type TEXT NOT NULL DEFAULT '应读',
|
||||||
|
enable_screenshot INTEGER DEFAULT 1,
|
||||||
|
random_delay INTEGER DEFAULT 0,
|
||||||
|
account_ids TEXT,
|
||||||
|
last_run_at TIMESTAMP,
|
||||||
|
next_run_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# 定时任务执行日志表(历史上在迁移中创建;这里补齐,避免新库缺表)
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS schedule_execution_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
schedule_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
schedule_name TEXT,
|
||||||
|
execute_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
total_accounts INTEGER DEFAULT 0,
|
||||||
|
success_accounts INTEGER DEFAULT 0,
|
||||||
|
failed_accounts INTEGER DEFAULT 0,
|
||||||
|
total_items INTEGER DEFAULT 0,
|
||||||
|
total_attachments INTEGER DEFAULT 0,
|
||||||
|
total_screenshots INTEGER DEFAULT 0,
|
||||||
|
duration_seconds INTEGER DEFAULT 0,
|
||||||
|
status TEXT DEFAULT 'running',
|
||||||
|
error_message TEXT,
|
||||||
|
FOREIGN KEY (schedule_id) REFERENCES user_schedules (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# ========== 创建索引 ==========
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_status ON users(status)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_vip_expire ON users(vip_expire_time)")
|
||||||
|
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_accounts_user_id ON accounts(user_id)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_accounts_username ON accounts(username)")
|
||||||
|
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_task_logs_user_id ON task_logs(user_id)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_task_logs_status ON task_logs(status)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_task_logs_created_at ON task_logs(created_at)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_task_logs_user_date ON task_logs(user_id, created_at)")
|
||||||
|
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_password_reset_status ON password_reset_requests(status)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_password_reset_user_id ON password_reset_requests(user_id)")
|
||||||
|
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_bug_feedbacks_user_id ON bug_feedbacks(user_id)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_bug_feedbacks_status ON bug_feedbacks(status)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_bug_feedbacks_created_at ON bug_feedbacks(created_at)")
|
||||||
|
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_announcements_active ON announcements(is_active)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_announcements_created_at ON announcements(created_at)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_announcement_dismissals_user ON announcement_dismissals(user_id)")
|
||||||
|
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_schedules_user_id ON user_schedules(user_id)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_schedules_enabled ON user_schedules(enabled)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_schedules_next_run ON user_schedules(next_run_at)")
|
||||||
|
|
||||||
|
# 初始化VIP配置(幂等)
|
||||||
|
try:
|
||||||
|
cursor.execute("INSERT INTO vip_config (id, default_vip_days) VALUES (1, 0)")
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 初始化系统配置(幂等)
|
||||||
|
try:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO system_config (
|
||||||
|
id, max_concurrent_global, max_concurrent_per_account, max_screenshot_concurrent,
|
||||||
|
schedule_enabled, schedule_time, schedule_browse_type, schedule_weekdays,
|
||||||
|
proxy_enabled, proxy_api_url, proxy_expire_minutes, enable_screenshot,
|
||||||
|
auto_approve_enabled, auto_approve_hourly_limit, auto_approve_vip_days
|
||||||
|
) VALUES (1, 2, 1, 3, 0, '02:00', '应读', '1,2,3,4,5,6,7', 0, '', 3, 1, 0, 10, 7)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 确保 db_version 记录存在(默认 0,由迁移统一更新)
|
||||||
|
cursor.execute("INSERT OR IGNORE INTO db_version (id, version, updated_at) VALUES (1, 0, ?)", (get_cst_now_str(),))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
+235
@@ -0,0 +1,235 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
import db_pool
|
||||||
|
from db.utils import sanitize_sql_like_pattern
|
||||||
|
|
||||||
|
|
||||||
|
def create_task_log(
|
||||||
|
user_id,
|
||||||
|
account_id,
|
||||||
|
username,
|
||||||
|
browse_type,
|
||||||
|
status,
|
||||||
|
total_items=0,
|
||||||
|
total_attachments=0,
|
||||||
|
error_message="",
|
||||||
|
duration=None,
|
||||||
|
source="manual",
|
||||||
|
):
|
||||||
|
"""创建任务日志记录"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cst_tz = pytz.timezone("Asia/Shanghai")
|
||||||
|
cst_time = datetime.now(cst_tz).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO task_logs (
|
||||||
|
user_id, account_id, username, browse_type, status,
|
||||||
|
total_items, total_attachments, error_message, duration, created_at, source
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
user_id,
|
||||||
|
account_id,
|
||||||
|
username,
|
||||||
|
browse_type,
|
||||||
|
status,
|
||||||
|
total_items,
|
||||||
|
total_attachments,
|
||||||
|
error_message,
|
||||||
|
duration,
|
||||||
|
cst_time,
|
||||||
|
source,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def get_task_logs(
|
||||||
|
limit=100,
|
||||||
|
offset=0,
|
||||||
|
date_filter=None,
|
||||||
|
status_filter=None,
|
||||||
|
source_filter=None,
|
||||||
|
user_id_filter=None,
|
||||||
|
account_filter=None,
|
||||||
|
):
|
||||||
|
"""获取任务日志列表(支持分页和多种筛选)"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
where_clauses = ["1=1"]
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if date_filter:
|
||||||
|
where_clauses.append("date(tl.created_at) = ?")
|
||||||
|
params.append(date_filter)
|
||||||
|
|
||||||
|
if status_filter:
|
||||||
|
where_clauses.append("tl.status = ?")
|
||||||
|
params.append(status_filter)
|
||||||
|
|
||||||
|
if source_filter:
|
||||||
|
where_clauses.append("tl.source = ?")
|
||||||
|
params.append(source_filter)
|
||||||
|
|
||||||
|
if user_id_filter:
|
||||||
|
where_clauses.append("tl.user_id = ?")
|
||||||
|
params.append(user_id_filter)
|
||||||
|
|
||||||
|
if account_filter:
|
||||||
|
safe_filter = sanitize_sql_like_pattern(account_filter)
|
||||||
|
where_clauses.append("tl.username LIKE ? ESCAPE '\\\\'")
|
||||||
|
params.append(f"%{safe_filter}%")
|
||||||
|
|
||||||
|
where_sql = " AND ".join(where_clauses)
|
||||||
|
|
||||||
|
count_sql = f"""
|
||||||
|
SELECT COUNT(*) as total
|
||||||
|
FROM task_logs tl
|
||||||
|
LEFT JOIN users u ON tl.user_id = u.id
|
||||||
|
WHERE {where_sql}
|
||||||
|
"""
|
||||||
|
cursor.execute(count_sql, params)
|
||||||
|
total = cursor.fetchone()["total"]
|
||||||
|
|
||||||
|
data_sql = f"""
|
||||||
|
SELECT
|
||||||
|
tl.*,
|
||||||
|
u.username as user_username
|
||||||
|
FROM task_logs tl
|
||||||
|
LEFT JOIN users u ON tl.user_id = u.id
|
||||||
|
WHERE {where_sql}
|
||||||
|
ORDER BY tl.created_at DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
"""
|
||||||
|
params.extend([limit, offset])
|
||||||
|
|
||||||
|
cursor.execute(data_sql, params)
|
||||||
|
logs = [dict(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
return {"logs": logs, "total": total}
|
||||||
|
|
||||||
|
|
||||||
|
def get_task_stats(date_filter=None):
|
||||||
|
"""获取任务统计信息"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cst_tz = pytz.timezone("Asia/Shanghai")
|
||||||
|
|
||||||
|
if date_filter is None:
|
||||||
|
date_filter = datetime.now(cst_tz).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_tasks,
|
||||||
|
SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success_tasks,
|
||||||
|
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed_tasks,
|
||||||
|
SUM(total_items) as total_items,
|
||||||
|
SUM(total_attachments) as total_attachments
|
||||||
|
FROM task_logs
|
||||||
|
WHERE date(created_at) = ?
|
||||||
|
""",
|
||||||
|
(date_filter,),
|
||||||
|
)
|
||||||
|
today_stats = cursor.fetchone()
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_tasks,
|
||||||
|
SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success_tasks,
|
||||||
|
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed_tasks,
|
||||||
|
SUM(total_items) as total_items,
|
||||||
|
SUM(total_attachments) as total_attachments
|
||||||
|
FROM task_logs
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
total_stats = cursor.fetchone()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"today": {
|
||||||
|
"total_tasks": today_stats["total_tasks"] or 0,
|
||||||
|
"success_tasks": today_stats["success_tasks"] or 0,
|
||||||
|
"failed_tasks": today_stats["failed_tasks"] or 0,
|
||||||
|
"total_items": today_stats["total_items"] or 0,
|
||||||
|
"total_attachments": today_stats["total_attachments"] or 0,
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"total_tasks": total_stats["total_tasks"] or 0,
|
||||||
|
"success_tasks": total_stats["success_tasks"] or 0,
|
||||||
|
"failed_tasks": total_stats["failed_tasks"] or 0,
|
||||||
|
"total_items": total_stats["total_items"] or 0,
|
||||||
|
"total_attachments": total_stats["total_attachments"] or 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def delete_old_task_logs(days=30, batch_size=1000):
|
||||||
|
"""删除N天前的任务日志(分批删除,避免长时间锁表)"""
|
||||||
|
total_deleted = 0
|
||||||
|
while True:
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM task_logs
|
||||||
|
WHERE rowid IN (
|
||||||
|
SELECT rowid FROM task_logs
|
||||||
|
WHERE created_at < datetime('now', 'localtime', '-' || ? || ' days')
|
||||||
|
LIMIT ?
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(days, batch_size),
|
||||||
|
)
|
||||||
|
deleted = cursor.rowcount
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
if deleted == 0:
|
||||||
|
break
|
||||||
|
total_deleted += deleted
|
||||||
|
|
||||||
|
return total_deleted
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_run_stats(user_id, date_filter=None):
|
||||||
|
"""获取用户的运行统计信息"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cst_tz = pytz.timezone("Asia/Shanghai")
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
if date_filter is None:
|
||||||
|
date_filter = datetime.now(cst_tz).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as completed,
|
||||||
|
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed,
|
||||||
|
SUM(total_items) as total_items,
|
||||||
|
SUM(total_attachments) as total_attachments
|
||||||
|
FROM task_logs
|
||||||
|
WHERE user_id = ? AND date(created_at) = ?
|
||||||
|
""",
|
||||||
|
(user_id, date_filter),
|
||||||
|
)
|
||||||
|
|
||||||
|
stats = cursor.fetchone()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"completed": stats["completed"] or 0,
|
||||||
|
"failed": stats["failed"] or 0,
|
||||||
|
"total_items": stats["total_items"] or 0,
|
||||||
|
"total_attachments": stats["total_attachments"] or 0,
|
||||||
|
}
|
||||||
|
|
||||||
+284
@@ -0,0 +1,284 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
import db_pool
|
||||||
|
from db.utils import get_cst_now_str
|
||||||
|
from password_utils import (
|
||||||
|
hash_password_bcrypt,
|
||||||
|
is_sha256_hash,
|
||||||
|
verify_password_bcrypt,
|
||||||
|
verify_password_sha256,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_vip_config():
|
||||||
|
"""获取VIP配置"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT * FROM vip_config WHERE id = 1")
|
||||||
|
config = cursor.fetchone()
|
||||||
|
return dict(config) if config else {"default_vip_days": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def set_default_vip_days(days):
|
||||||
|
"""设置默认VIP天数"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cst_time = get_cst_now_str()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR REPLACE INTO vip_config (id, default_vip_days, updated_at)
|
||||||
|
VALUES (1, ?, ?)
|
||||||
|
""",
|
||||||
|
(days, cst_time),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def set_user_vip(user_id, days):
|
||||||
|
"""设置用户VIP - days: 7=一周, 30=一个月, 365=一年, 999999=永久"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cst_tz = pytz.timezone("Asia/Shanghai")
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
if days == 999999:
|
||||||
|
expire_time = "2099-12-31 23:59:59"
|
||||||
|
else:
|
||||||
|
expire_time = (datetime.now(cst_tz) + timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
cursor.execute("UPDATE users SET vip_expire_time = ? WHERE id = ?", (expire_time, user_id))
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def extend_user_vip(user_id, days):
|
||||||
|
"""延长用户VIP时间"""
|
||||||
|
user = get_user_by_id(user_id)
|
||||||
|
cst_tz = pytz.timezone("Asia/Shanghai")
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
current_expire = user.get("vip_expire_time")
|
||||||
|
|
||||||
|
if current_expire and current_expire != "2099-12-31 23:59:59":
|
||||||
|
try:
|
||||||
|
expire_time_naive = datetime.strptime(current_expire, "%Y-%m-%d %H:%M:%S")
|
||||||
|
expire_time = cst_tz.localize(expire_time_naive)
|
||||||
|
now = datetime.now(cst_tz)
|
||||||
|
if expire_time < now:
|
||||||
|
expire_time = now
|
||||||
|
new_expire = (expire_time + timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
except (ValueError, AttributeError) as e:
|
||||||
|
print(f"解析VIP过期时间失败: {e}, 使用当前时间")
|
||||||
|
new_expire = (datetime.now(cst_tz) + timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
else:
|
||||||
|
new_expire = (datetime.now(cst_tz) + timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
cursor.execute("UPDATE users SET vip_expire_time = ? WHERE id = ?", (new_expire, user_id))
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def remove_user_vip(user_id):
|
||||||
|
"""移除用户VIP"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("UPDATE users SET vip_expire_time = NULL WHERE id = ?", (user_id,))
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def is_user_vip(user_id):
|
||||||
|
"""检查用户是否是VIP
|
||||||
|
|
||||||
|
注意:数据库中存储的时间统一使用CST(Asia/Shanghai)时区
|
||||||
|
"""
|
||||||
|
cst_tz = pytz.timezone("Asia/Shanghai")
|
||||||
|
user = get_user_by_id(user_id)
|
||||||
|
|
||||||
|
if not user or not user.get("vip_expire_time"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
expire_time_naive = datetime.strptime(user["vip_expire_time"], "%Y-%m-%d %H:%M:%S")
|
||||||
|
expire_time = cst_tz.localize(expire_time_naive)
|
||||||
|
now = datetime.now(cst_tz)
|
||||||
|
return now < expire_time
|
||||||
|
except (ValueError, AttributeError) as e:
|
||||||
|
print(f"检查VIP状态失败 (user_id={user_id}): {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_vip_info(user_id):
|
||||||
|
"""获取用户VIP信息"""
|
||||||
|
cst_tz = pytz.timezone("Asia/Shanghai")
|
||||||
|
user = get_user_by_id(user_id)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return {"is_vip": False, "expire_time": None, "days_left": 0, "username": ""}
|
||||||
|
|
||||||
|
vip_expire_time = user.get("vip_expire_time")
|
||||||
|
if not vip_expire_time:
|
||||||
|
return {"is_vip": False, "expire_time": None, "days_left": 0, "username": user.get("username", "")}
|
||||||
|
|
||||||
|
try:
|
||||||
|
expire_time_naive = datetime.strptime(vip_expire_time, "%Y-%m-%d %H:%M:%S")
|
||||||
|
expire_time = cst_tz.localize(expire_time_naive)
|
||||||
|
now = datetime.now(cst_tz)
|
||||||
|
is_vip = now < expire_time
|
||||||
|
days_left = (expire_time - now).days if is_vip else 0
|
||||||
|
|
||||||
|
return {"username": user.get("username", ""), "is_vip": is_vip, "expire_time": vip_expire_time, "days_left": max(0, days_left)}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"VIP信息获取错误: {e}")
|
||||||
|
return {"is_vip": False, "expire_time": None, "days_left": 0, "username": user.get("username", "")}
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 用户相关 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def create_user(username, password, email=""):
|
||||||
|
"""创建新用户(待审核状态,赠送默认VIP)"""
|
||||||
|
cst_tz = pytz.timezone("Asia/Shanghai")
|
||||||
|
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
password_hash = hash_password_bcrypt(password)
|
||||||
|
cst_time = get_cst_now_str()
|
||||||
|
|
||||||
|
default_vip_days = get_vip_config()["default_vip_days"]
|
||||||
|
vip_expire_time = None
|
||||||
|
|
||||||
|
if default_vip_days > 0:
|
||||||
|
if default_vip_days == 999999:
|
||||||
|
vip_expire_time = "2099-12-31 23:59:59"
|
||||||
|
else:
|
||||||
|
vip_expire_time = (datetime.now(cst_tz) + timedelta(days=default_vip_days)).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO users (username, password_hash, email, status, vip_expire_time, created_at)
|
||||||
|
VALUES (?, ?, ?, 'pending', ?, ?)
|
||||||
|
""",
|
||||||
|
(username, password_hash, email, vip_expire_time, cst_time),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.lastrowid
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def verify_user(username, password):
|
||||||
|
"""验证用户登录 - 自动从SHA256升级到bcrypt"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT * FROM users WHERE username = ? AND status = 'approved'", (username,))
|
||||||
|
user = cursor.fetchone()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
|
||||||
|
user_dict = dict(user)
|
||||||
|
password_hash = user_dict["password_hash"]
|
||||||
|
|
||||||
|
if is_sha256_hash(password_hash):
|
||||||
|
if verify_password_sha256(password, password_hash):
|
||||||
|
new_hash = hash_password_bcrypt(password)
|
||||||
|
cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, user_dict["id"]))
|
||||||
|
conn.commit()
|
||||||
|
print(f"用户 {username} 密码已自动升级到bcrypt")
|
||||||
|
return user_dict
|
||||||
|
return None
|
||||||
|
|
||||||
|
if verify_password_bcrypt(password, password_hash):
|
||||||
|
return user_dict
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_id(user_id):
|
||||||
|
"""根据ID获取用户"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT * FROM users WHERE id = ?", (user_id,))
|
||||||
|
user = cursor.fetchone()
|
||||||
|
return dict(user) if user else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_username(username):
|
||||||
|
"""根据用户名获取用户"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT * FROM users WHERE username = ?", (username,))
|
||||||
|
user = cursor.fetchone()
|
||||||
|
return dict(user) if user else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_users():
|
||||||
|
"""获取所有用户"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT * FROM users ORDER BY created_at DESC")
|
||||||
|
return [dict(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def get_pending_users():
|
||||||
|
"""获取待审核用户"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT * FROM users WHERE status = 'pending' ORDER BY created_at DESC")
|
||||||
|
return [dict(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def approve_user(user_id):
|
||||||
|
"""审核通过用户"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cst_time = get_cst_now_str()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET status = 'approved', approved_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(cst_time, user_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def reject_user(user_id):
|
||||||
|
"""拒绝用户"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("UPDATE users SET status = 'rejected' WHERE id = ?", (user_id,))
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def delete_user(user_id):
|
||||||
|
"""删除用户(级联删除相关账号)"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_stats(user_id):
|
||||||
|
"""获取用户统计信息"""
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT COUNT(*) as count FROM accounts WHERE user_id = ?", (user_id,))
|
||||||
|
account_count = cursor.fetchone()["count"]
|
||||||
|
return {"account_count": account_count}
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
# ==================== 时区处理工具函数 ====================
|
||||||
|
|
||||||
|
CST_TZ = pytz.timezone("Asia/Shanghai")
|
||||||
|
|
||||||
|
|
||||||
|
def get_cst_now() -> datetime:
|
||||||
|
return datetime.now(CST_TZ)
|
||||||
|
|
||||||
|
|
||||||
|
def get_cst_now_str() -> str:
|
||||||
|
return get_cst_now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_cst_datetime(datetime_str: str) -> datetime:
|
||||||
|
naive = datetime.strptime(datetime_str, "%Y-%m-%d %H:%M:%S")
|
||||||
|
return CST_TZ.localize(naive)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 安全工具(与 app_security 保持兼容) ====================
|
||||||
|
|
||||||
|
|
||||||
|
def escape_html(text: Optional[object]) -> str:
|
||||||
|
try:
|
||||||
|
from app_security import escape_html as _escape_html
|
||||||
|
|
||||||
|
return _escape_html(text)
|
||||||
|
except Exception:
|
||||||
|
if text is None:
|
||||||
|
return ""
|
||||||
|
return html.escape(str(text))
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_sql_like_pattern(pattern: Optional[object]) -> str:
|
||||||
|
try:
|
||||||
|
from app_security import sanitize_sql_like_pattern as _sanitize_sql_like_pattern
|
||||||
|
|
||||||
|
return _sanitize_sql_like_pattern(pattern)
|
||||||
|
except Exception:
|
||||||
|
if pattern is None:
|
||||||
|
return ""
|
||||||
|
return str(pattern).replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
|
||||||
+22
-46
@@ -43,6 +43,9 @@ from io import BytesIO
|
|||||||
|
|
||||||
import db_pool
|
import db_pool
|
||||||
from crypto_utils import encrypt_password, decrypt_password, is_encrypted
|
from crypto_utils import encrypt_password, decrypt_password, is_encrypted
|
||||||
|
from app_logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("email_service")
|
||||||
|
|
||||||
|
|
||||||
def parse_datetime(dt_str: str) -> datetime:
|
def parse_datetime(dt_str: str) -> datetime:
|
||||||
@@ -230,7 +233,7 @@ def init_email_tables():
|
|||||||
)
|
)
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
print("[邮件服务] 数据库表初始化完成")
|
logger.info("[邮件服务] 数据库表初始化完成")
|
||||||
|
|
||||||
|
|
||||||
# ============ SMTP配置管理 ============
|
# ============ SMTP配置管理 ============
|
||||||
@@ -239,23 +242,6 @@ def get_email_settings() -> Dict[str, Any]:
|
|||||||
"""获取全局邮件设置"""
|
"""获取全局邮件设置"""
|
||||||
with db_pool.get_db() as conn:
|
with db_pool.get_db() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
# 先检查表结构,添加新字段(兼容旧版本数据库)
|
|
||||||
try:
|
|
||||||
cursor.execute("SELECT register_verify_enabled FROM email_settings LIMIT 1")
|
|
||||||
except:
|
|
||||||
cursor.execute("ALTER TABLE email_settings ADD COLUMN register_verify_enabled INTEGER DEFAULT 0")
|
|
||||||
conn.commit()
|
|
||||||
try:
|
|
||||||
cursor.execute("SELECT base_url FROM email_settings LIMIT 1")
|
|
||||||
except:
|
|
||||||
cursor.execute("ALTER TABLE email_settings ADD COLUMN base_url TEXT DEFAULT ''")
|
|
||||||
conn.commit()
|
|
||||||
try:
|
|
||||||
cursor.execute("SELECT task_notify_enabled FROM email_settings LIMIT 1")
|
|
||||||
except:
|
|
||||||
cursor.execute("ALTER TABLE email_settings ADD COLUMN task_notify_enabled INTEGER DEFAULT 0")
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT enabled, failover_enabled, register_verify_enabled, base_url,
|
SELECT enabled, failover_enabled, register_verify_enabled, base_url,
|
||||||
task_notify_enabled, updated_at
|
task_notify_enabled, updated_at
|
||||||
@@ -660,7 +646,7 @@ class EmailSender:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[邮件服务] SMTP连接失败 [{self.config['name']}]: {e}")
|
logger.warning(f"[邮件服务] SMTP连接失败 [{self.config['name']}]: {e}")
|
||||||
self.server = None
|
self.server = None
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@@ -735,7 +721,7 @@ class EmailSender:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[邮件服务] 发送失败: {e}")
|
logger.warning(f"[邮件服务] 发送失败: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
@@ -987,7 +973,7 @@ def reset_smtp_daily_quota():
|
|||||||
updated = cursor.rowcount
|
updated = cursor.rowcount
|
||||||
conn.commit()
|
conn.commit()
|
||||||
if updated > 0:
|
if updated > 0:
|
||||||
print(f"[邮件服务] 已重置 {updated} 个SMTP配置的每日配额")
|
logger.info(f"[邮件服务] 已重置 {updated} 个SMTP配置的每日配额")
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
|
|
||||||
@@ -1639,7 +1625,7 @@ class EmailQueue:
|
|||||||
worker.start()
|
worker.start()
|
||||||
self.workers.append(worker)
|
self.workers.append(worker)
|
||||||
|
|
||||||
print(f"[邮件服务] 异步队列已启动 ({self.worker_count}个工作线程)")
|
logger.info(f"[邮件服务] 异步队列已启动 ({self.worker_count}个工作线程)")
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
"""停止队列"""
|
"""停止队列"""
|
||||||
@@ -1657,7 +1643,7 @@ class EmailQueue:
|
|||||||
worker.join(timeout=5)
|
worker.join(timeout=5)
|
||||||
|
|
||||||
self.workers.clear()
|
self.workers.clear()
|
||||||
print("[邮件服务] 异步队列已停止")
|
logger.info("[邮件服务] 异步队列已停止")
|
||||||
|
|
||||||
def _worker(self):
|
def _worker(self):
|
||||||
"""工作线程"""
|
"""工作线程"""
|
||||||
@@ -1672,7 +1658,7 @@ class EmailQueue:
|
|||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
continue
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[邮件服务] 队列工作线程错误: {e}")
|
logger.exception(f"[邮件服务] 队列工作线程错误: {e}")
|
||||||
|
|
||||||
def _process_task(self, task: Dict):
|
def _process_task(self, task: Dict):
|
||||||
"""处理邮件任务"""
|
"""处理邮件任务"""
|
||||||
@@ -1702,7 +1688,7 @@ class EmailQueue:
|
|||||||
task['callback'](result)
|
task['callback'](result)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[邮件服务] 处理邮件任务失败: {e}")
|
logger.exception(f"[邮件服务] 处理邮件任务失败: {e}")
|
||||||
if task.get('callback'):
|
if task.get('callback'):
|
||||||
task['callback']({'success': False, 'error': str(e)})
|
task['callback']({'success': False, 'error': str(e)})
|
||||||
|
|
||||||
@@ -1730,7 +1716,7 @@ class EmailQueue:
|
|||||||
}, timeout=5)
|
}, timeout=5)
|
||||||
return True
|
return True
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
print("[邮件服务] 邮件队列已满")
|
logger.warning("[邮件服务] 邮件队列已满")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def enqueue_callable(self, func: Callable, args=None, kwargs=None, callback: Callable = None) -> bool:
|
def enqueue_callable(self, func: Callable, args=None, kwargs=None, callback: Callable = None) -> bool:
|
||||||
@@ -1744,7 +1730,7 @@ class EmailQueue:
|
|||||||
}, timeout=5)
|
}, timeout=5)
|
||||||
return True
|
return True
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
print("[邮件服务] 邮件队列已满")
|
logger.warning("[邮件服务] 邮件队列已满")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -2158,7 +2144,7 @@ def send_batch_task_complete_email(
|
|||||||
try:
|
try:
|
||||||
zf.write(file_path, arcname=arcname)
|
zf.write(file_path, arcname=arcname)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[邮件] 写入ZIP失败: {e}")
|
logger.warning(f"[邮件] 写入ZIP失败: {e}")
|
||||||
|
|
||||||
zip_size = os.path.getsize(zip_path) if zip_path and os.path.exists(zip_path) else 0
|
zip_size = os.path.getsize(zip_path) if zip_path and os.path.exists(zip_path) else 0
|
||||||
if zip_size <= 0:
|
if zip_size <= 0:
|
||||||
@@ -2171,7 +2157,7 @@ def send_batch_task_complete_email(
|
|||||||
zip_filename = f"screenshots_{datetime.now(BEIJING_TZ).strftime('%Y%m%d_%H%M%S')}.zip"
|
zip_filename = f"screenshots_{datetime.now(BEIJING_TZ).strftime('%Y%m%d_%H%M%S')}.zip"
|
||||||
attachment_note = "截图已打包为ZIP附件,请查收。"
|
attachment_note = "截图已打包为ZIP附件,请查收。"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[邮件] 打包截图失败: {e}")
|
logger.warning(f"[邮件] 打包截图失败: {e}")
|
||||||
attachment_note = "截图打包失败,本次不附加附件。"
|
attachment_note = "截图打包失败,本次不附加附件。"
|
||||||
finally:
|
finally:
|
||||||
if zip_path and os.path.exists(zip_path):
|
if zip_path and os.path.exists(zip_path):
|
||||||
@@ -2200,26 +2186,13 @@ def send_batch_task_complete_email(
|
|||||||
body='',
|
body='',
|
||||||
html_body=html_content,
|
html_body=html_content,
|
||||||
attachments=attachments,
|
attachments=attachments,
|
||||||
email_type='batch_task_complete'
|
email_type='batch_task_complete',
|
||||||
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if result['success']:
|
if result['success']:
|
||||||
# 记录发送日志
|
|
||||||
log_email_send(
|
|
||||||
email_type='batch_task_complete',
|
|
||||||
to_email=email,
|
|
||||||
subject=f'定时任务完成 - {schedule_name}',
|
|
||||||
success=True
|
|
||||||
)
|
|
||||||
return {'success': True}
|
return {'success': True}
|
||||||
else:
|
else:
|
||||||
log_email_send(
|
|
||||||
email_type='batch_task_complete',
|
|
||||||
to_email=email,
|
|
||||||
subject=f'定时任务完成 - {schedule_name}',
|
|
||||||
success=False,
|
|
||||||
error=result.get('error', '')
|
|
||||||
)
|
|
||||||
return {'success': False, 'error': result.get('error', '发送失败')}
|
return {'success': False, 'error': result.get('error', '发送失败')}
|
||||||
|
|
||||||
|
|
||||||
@@ -2238,7 +2211,7 @@ def send_batch_task_complete_email_async(
|
|||||||
args=(user_id, email, username, schedule_name, browse_type, screenshots),
|
args=(user_id, email, username, schedule_name, browse_type, screenshots),
|
||||||
)
|
)
|
||||||
if not ok:
|
if not ok:
|
||||||
print("[邮件] 邮件队列已满,批次任务邮件未发送")
|
logger.warning("[邮件] 邮件队列已满,批次任务邮件未发送")
|
||||||
|
|
||||||
|
|
||||||
# ============ 初始化 ============
|
# ============ 初始化 ============
|
||||||
@@ -2247,7 +2220,10 @@ def init_email_service():
|
|||||||
"""初始化邮件服务"""
|
"""初始化邮件服务"""
|
||||||
init_email_tables()
|
init_email_tables()
|
||||||
get_email_queue()
|
get_email_queue()
|
||||||
print("[邮件服务] 初始化完成")
|
try:
|
||||||
|
logger.info("[邮件服务] 初始化完成")
|
||||||
|
except Exception:
|
||||||
|
print("[邮件服务] 初始化完成")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import atexit
|
|||||||
import weakref
|
import weakref
|
||||||
from typing import Optional, Callable
|
from typing import Optional, Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from urllib.parse import urlsplit, urlunsplit
|
||||||
from app_config import get_config
|
from app_config import get_config
|
||||||
|
|
||||||
# 设置浏览器安装路径(优先使用环境变量,否则使用默认路径)
|
# 设置浏览器安装路径(优先使用环境变量,否则使用默认路径)
|
||||||
@@ -173,17 +174,15 @@ class PlaywrightAutomation:
|
|||||||
self.browser_manager.log(message, self.account_id)
|
self.browser_manager.log(message, self.account_id)
|
||||||
|
|
||||||
|
|
||||||
# Cookies存储目录
|
|
||||||
COOKIES_DIR = '/app/data/cookies'
|
|
||||||
|
|
||||||
def get_cookies_path(self, username: str) -> str:
|
def get_cookies_path(self, username: str) -> str:
|
||||||
"""获取cookies文件路径"""
|
"""获取cookies文件路径"""
|
||||||
import os
|
import os
|
||||||
os.makedirs(self.COOKIES_DIR, exist_ok=True)
|
cookies_dir = getattr(config, "COOKIES_DIR", "/app/data/cookies")
|
||||||
|
os.makedirs(cookies_dir, exist_ok=True)
|
||||||
# 安全修复:使用SHA256代替MD5作为文件名哈希
|
# 安全修复:使用SHA256代替MD5作为文件名哈希
|
||||||
import hashlib
|
import hashlib
|
||||||
filename = hashlib.sha256(username.encode()).hexdigest()[:32] + '.json'
|
filename = hashlib.sha256(username.encode()).hexdigest()[:32] + '.json'
|
||||||
return os.path.join(self.COOKIES_DIR, filename)
|
return os.path.join(cookies_dir, filename)
|
||||||
|
|
||||||
def save_cookies(self, username: str):
|
def save_cookies(self, username: str):
|
||||||
"""保存当前会话的cookies"""
|
"""保存当前会话的cookies"""
|
||||||
@@ -279,12 +278,27 @@ class PlaywrightAutomation:
|
|||||||
def check_login_state(self) -> bool:
|
def check_login_state(self) -> bool:
|
||||||
"""检查当前是否处于登录状态"""
|
"""检查当前是否处于登录状态"""
|
||||||
try:
|
try:
|
||||||
|
index_url = getattr(config, "ZSGL_INDEX_URL", None)
|
||||||
|
if not index_url:
|
||||||
|
login_url = getattr(config, "ZSGL_LOGIN_URL", "https://postoa.aidunsoft.com/admin/login.aspx")
|
||||||
|
index_filename = getattr(config, "ZSGL_INDEX_URL_PATTERN", "index.aspx")
|
||||||
|
parsed = urlsplit(login_url)
|
||||||
|
if parsed.scheme and parsed.netloc:
|
||||||
|
path = parsed.path or "/"
|
||||||
|
if path.endswith("/"):
|
||||||
|
path = path + index_filename
|
||||||
|
else:
|
||||||
|
path = path.rsplit("/", 1)[0] + "/" + index_filename
|
||||||
|
index_url = urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
|
||||||
|
else:
|
||||||
|
index_url = "https://postoa.aidunsoft.com/admin/index.aspx"
|
||||||
|
|
||||||
# 访问首页检查是否跳转到登录页
|
# 访问首页检查是否跳转到登录页
|
||||||
self.page.goto('https://postoa.aidunsoft.com/admin/index.aspx', timeout=15000)
|
self.page.goto(index_url, timeout=15000)
|
||||||
self.page.wait_for_load_state('networkidle', timeout=10000)
|
self.page.wait_for_load_state('networkidle', timeout=10000)
|
||||||
current_url = self.page.url
|
current_url = self.page.url
|
||||||
# 如果还在index页面,说明登录态有效
|
# 如果还在index页面,说明登录态有效
|
||||||
if 'index.aspx' in current_url:
|
if getattr(config, "ZSGL_INDEX_URL_PATTERN", "index.aspx") in current_url:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
except (TimeoutError, Exception) as e:
|
except (TimeoutError, Exception) as e:
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[tool.black]
|
||||||
|
line-length = 120
|
||||||
|
target-version = ["py310"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 120
|
||||||
|
target-version = "py310"
|
||||||
|
extend-exclude = [
|
||||||
|
"admin-frontend",
|
||||||
|
"app-frontend",
|
||||||
|
"static",
|
||||||
|
"templates",
|
||||||
|
"__pycache__",
|
||||||
|
"data",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E9", "F63", "F7", "F82"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
addopts = "-q --import-mode=prepend"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask_login import current_user
|
||||||
|
from flask_socketio import emit, join_room, leave_room
|
||||||
|
|
||||||
|
from services.accounts_service import load_user_accounts
|
||||||
|
from services.client_log import log_to_client
|
||||||
|
from services.state import safe_get_user_accounts_snapshot, safe_get_user_logs
|
||||||
|
|
||||||
|
|
||||||
|
def register_socketio_handlers(socketio) -> None:
|
||||||
|
@socketio.on("connect")
|
||||||
|
def handle_connect():
|
||||||
|
"""客户端连接"""
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
user_id = current_user.id
|
||||||
|
join_room(f"user_{user_id}")
|
||||||
|
log_to_client("客户端已连接", user_id)
|
||||||
|
|
||||||
|
accounts = safe_get_user_accounts_snapshot(user_id)
|
||||||
|
if not accounts:
|
||||||
|
load_user_accounts(user_id)
|
||||||
|
accounts = safe_get_user_accounts_snapshot(user_id)
|
||||||
|
|
||||||
|
emit("accounts_list", [acc.to_dict() for acc in accounts.values()])
|
||||||
|
|
||||||
|
for log_entry in safe_get_user_logs(user_id):
|
||||||
|
emit("log", log_entry)
|
||||||
|
|
||||||
|
@socketio.on("disconnect")
|
||||||
|
def handle_disconnect():
|
||||||
|
"""客户端断开"""
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
user_id = current_user.id
|
||||||
|
leave_room(f"user_{user_id}")
|
||||||
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
from services.runtime import get_logger, get_socketio
|
||||||
|
from services.state import safe_get_account, safe_iter_task_status_items
|
||||||
|
|
||||||
|
|
||||||
|
def status_push_worker() -> None:
|
||||||
|
"""后台线程:按间隔推送运行中任务的状态更新(可节流)。"""
|
||||||
|
logger = get_logger()
|
||||||
|
try:
|
||||||
|
push_interval = float(os.environ.get("STATUS_PUSH_INTERVAL_SECONDS", "2"))
|
||||||
|
except Exception:
|
||||||
|
push_interval = 2.0
|
||||||
|
push_interval = max(0.5, push_interval)
|
||||||
|
|
||||||
|
socketio = get_socketio()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
status_items = safe_iter_task_status_items()
|
||||||
|
for account_id, status_info in status_items:
|
||||||
|
if status_info.get("status") != "运行中":
|
||||||
|
continue
|
||||||
|
user_id = status_info.get("user_id")
|
||||||
|
if not user_id:
|
||||||
|
continue
|
||||||
|
account = safe_get_account(user_id, account_id)
|
||||||
|
if not account:
|
||||||
|
continue
|
||||||
|
account_data = account.to_dict()
|
||||||
|
socketio.emit("account_update", account_data, room=f"user_{user_id}")
|
||||||
|
|
||||||
|
progress = status_info.get("progress", {}) or {}
|
||||||
|
progress_data = {
|
||||||
|
"account_id": account_id,
|
||||||
|
"stage": status_info.get("detail_status", ""),
|
||||||
|
"total_items": account.total_items,
|
||||||
|
"browsed_items": progress.get("items", 0),
|
||||||
|
"total_attachments": account.total_attachments,
|
||||||
|
"viewed_attachments": progress.get("attachments", 0),
|
||||||
|
"start_time": status_info.get("start_time", 0),
|
||||||
|
"elapsed_seconds": account_data.get("elapsed_seconds", 0),
|
||||||
|
"elapsed_display": account_data.get("elapsed_display", ""),
|
||||||
|
}
|
||||||
|
socketio.emit("task_progress", progress_data, room=f"user_{user_id}")
|
||||||
|
|
||||||
|
time.sleep(push_interval)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"状态推送出错: {e}")
|
||||||
|
time.sleep(push_interval)
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ruff
|
||||||
|
black
|
||||||
|
pytest
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def register_blueprints(app) -> None:
|
||||||
|
from routes.admin_api import admin_api_bp
|
||||||
|
from routes.api_accounts import api_accounts_bp
|
||||||
|
from routes.api_auth import api_auth_bp
|
||||||
|
from routes.api_schedules import api_schedules_bp
|
||||||
|
from routes.api_screenshots import api_screenshots_bp
|
||||||
|
from routes.api_user import api_user_bp
|
||||||
|
from routes.pages import pages_bp
|
||||||
|
|
||||||
|
app.register_blueprint(pages_bp)
|
||||||
|
app.register_blueprint(api_auth_bp)
|
||||||
|
app.register_blueprint(api_user_bp)
|
||||||
|
app.register_blueprint(api_accounts_bp)
|
||||||
|
app.register_blueprint(api_screenshots_bp)
|
||||||
|
app.register_blueprint(api_schedules_bp)
|
||||||
|
app.register_blueprint(admin_api_bp)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
admin_api_bp = Blueprint("admin_api", __name__, url_prefix="/yuyx/api")
|
||||||
|
|
||||||
|
# Import side effects: register routes on blueprint
|
||||||
|
from routes.admin_api import core as _core # noqa: F401
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,410 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import database
|
||||||
|
import db_pool
|
||||||
|
from app_logger import get_logger
|
||||||
|
from crypto_utils import encrypt_password as encrypt_account_password
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
from flask_login import current_user, login_required
|
||||||
|
from services.accounts_service import load_user_accounts
|
||||||
|
from services.browser_manager import init_browser_manager
|
||||||
|
from services.browse_types import BROWSE_TYPE_SHOULD_READ, normalize_browse_type, validate_browse_type
|
||||||
|
from services.client_log import log_to_client
|
||||||
|
from services.models import Account
|
||||||
|
from services.runtime import get_socketio
|
||||||
|
from services.screenshots import take_screenshot_for_account
|
||||||
|
from services.state import (
|
||||||
|
safe_get_account,
|
||||||
|
safe_get_user_accounts_snapshot,
|
||||||
|
safe_remove_account,
|
||||||
|
safe_remove_task,
|
||||||
|
safe_remove_task_status,
|
||||||
|
safe_set_account,
|
||||||
|
safe_set_user_accounts,
|
||||||
|
)
|
||||||
|
from services.tasks import get_task_scheduler, submit_account_task
|
||||||
|
|
||||||
|
logger = get_logger("app")
|
||||||
|
|
||||||
|
api_accounts_bp = Blueprint("api_accounts", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _emit(event: str, data: object, *, room: str | None = None) -> None:
|
||||||
|
try:
|
||||||
|
socketio = get_socketio()
|
||||||
|
socketio.emit(event, data, room=room)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@api_accounts_bp.route("/api/accounts", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def get_accounts():
|
||||||
|
"""获取当前用户的所有账号"""
|
||||||
|
user_id = current_user.id
|
||||||
|
refresh = request.args.get("refresh", "false").lower() == "true"
|
||||||
|
|
||||||
|
accounts = safe_get_user_accounts_snapshot(user_id)
|
||||||
|
if refresh or not accounts:
|
||||||
|
load_user_accounts(user_id)
|
||||||
|
accounts = safe_get_user_accounts_snapshot(user_id)
|
||||||
|
|
||||||
|
return jsonify([acc.to_dict() for acc in accounts.values()])
|
||||||
|
|
||||||
|
|
||||||
|
@api_accounts_bp.route("/api/accounts", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def add_account():
|
||||||
|
"""添加账号"""
|
||||||
|
user_id = current_user.id
|
||||||
|
|
||||||
|
current_count = len(database.get_user_accounts(user_id))
|
||||||
|
is_vip = database.is_user_vip(user_id)
|
||||||
|
if not is_vip and current_count >= 3:
|
||||||
|
return jsonify({"error": "普通用户最多添加3个账号,升级VIP可无限添加"}), 403
|
||||||
|
data = request.json
|
||||||
|
username = data.get("username", "").strip()
|
||||||
|
password = data.get("password", "").strip()
|
||||||
|
remark = data.get("remark", "").strip()[:200]
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
return jsonify({"error": "用户名和密码不能为空"}), 400
|
||||||
|
|
||||||
|
accounts = safe_get_user_accounts_snapshot(user_id)
|
||||||
|
if not accounts:
|
||||||
|
load_user_accounts(user_id)
|
||||||
|
accounts = safe_get_user_accounts_snapshot(user_id)
|
||||||
|
for acc in accounts.values():
|
||||||
|
if acc.username == username:
|
||||||
|
return jsonify({"error": f"账号 '{username}' 已存在"}), 400
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
account_id = str(uuid.uuid4())[:8]
|
||||||
|
remember = data.get("remember", True)
|
||||||
|
|
||||||
|
database.create_account(user_id, account_id, username, password, remember, remark)
|
||||||
|
|
||||||
|
account = Account(account_id, user_id, username, password, remember, remark)
|
||||||
|
safe_set_account(user_id, account_id, account)
|
||||||
|
|
||||||
|
log_to_client(f"添加账号: {username}", user_id)
|
||||||
|
_emit("account_update", account.to_dict(), room=f"user_{user_id}")
|
||||||
|
|
||||||
|
return jsonify(account.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
@api_accounts_bp.route("/api/accounts/<account_id>", methods=["PUT"])
|
||||||
|
@login_required
|
||||||
|
def update_account(account_id):
|
||||||
|
"""更新账号信息(密码等)"""
|
||||||
|
user_id = current_user.id
|
||||||
|
|
||||||
|
account = safe_get_account(user_id, account_id)
|
||||||
|
if not account:
|
||||||
|
return jsonify({"error": "账号不存在"}), 404
|
||||||
|
|
||||||
|
if account.is_running:
|
||||||
|
return jsonify({"error": "账号正在运行中,请先停止"}), 400
|
||||||
|
|
||||||
|
data = request.json
|
||||||
|
new_password = data.get("password", "").strip()
|
||||||
|
new_remember = data.get("remember", account.remember)
|
||||||
|
|
||||||
|
if not new_password:
|
||||||
|
return jsonify({"error": "密码不能为空"}), 400
|
||||||
|
|
||||||
|
encrypted_password = encrypt_account_password(new_password)
|
||||||
|
|
||||||
|
with db_pool.get_db() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE accounts
|
||||||
|
SET password = ?, remember = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(encrypted_password, new_remember, account_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
database.reset_account_login_status(account_id)
|
||||||
|
logger.info(f"[账号更新] 用户 {user_id} 修改了账号 {account.username} 的密码,已重置登录状态")
|
||||||
|
|
||||||
|
account._password = new_password
|
||||||
|
account.remember = new_remember
|
||||||
|
|
||||||
|
log_to_client(f"账号 {account.username} 信息已更新,登录状态已重置", user_id)
|
||||||
|
return jsonify({"message": "账号更新成功", "account": account.to_dict()})
|
||||||
|
|
||||||
|
|
||||||
|
@api_accounts_bp.route("/api/accounts/<account_id>", methods=["DELETE"])
|
||||||
|
@login_required
|
||||||
|
def delete_account(account_id):
|
||||||
|
"""删除账号"""
|
||||||
|
user_id = current_user.id
|
||||||
|
|
||||||
|
account = safe_get_account(user_id, account_id)
|
||||||
|
if not account:
|
||||||
|
return jsonify({"error": "账号不存在"}), 404
|
||||||
|
|
||||||
|
if account.is_running:
|
||||||
|
account.should_stop = True
|
||||||
|
if account.automation:
|
||||||
|
account.automation.close()
|
||||||
|
|
||||||
|
username = account.username
|
||||||
|
|
||||||
|
database.delete_account(account_id)
|
||||||
|
|
||||||
|
safe_remove_account(user_id, account_id)
|
||||||
|
|
||||||
|
log_to_client(f"删除账号: {username}", user_id)
|
||||||
|
return jsonify({"success": True})
|
||||||
|
|
||||||
|
|
||||||
|
@api_accounts_bp.route("/api/accounts/clear", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def clear_accounts():
|
||||||
|
"""清空当前用户的所有账号"""
|
||||||
|
user_id = current_user.id
|
||||||
|
|
||||||
|
accounts = safe_get_user_accounts_snapshot(user_id)
|
||||||
|
if any(acc.is_running for acc in accounts.values()):
|
||||||
|
return jsonify({"error": "有任务正在运行,请先停止后再清空"}), 400
|
||||||
|
|
||||||
|
account_ids = list(accounts.keys())
|
||||||
|
|
||||||
|
deleted = database.delete_user_accounts(user_id)
|
||||||
|
|
||||||
|
safe_set_user_accounts(user_id, {})
|
||||||
|
|
||||||
|
for account_id in account_ids:
|
||||||
|
safe_remove_task_status(account_id)
|
||||||
|
safe_remove_task(account_id)
|
||||||
|
|
||||||
|
log_to_client(f"清空账号: {deleted} 个", user_id)
|
||||||
|
return jsonify({"success": True, "deleted": deleted})
|
||||||
|
|
||||||
|
|
||||||
|
@api_accounts_bp.route("/api/accounts/<account_id>/remark", methods=["PUT"])
|
||||||
|
@login_required
|
||||||
|
def update_remark(account_id):
|
||||||
|
"""更新备注"""
|
||||||
|
user_id = current_user.id
|
||||||
|
|
||||||
|
account = safe_get_account(user_id, account_id)
|
||||||
|
if not account:
|
||||||
|
return jsonify({"error": "账号不存在"}), 404
|
||||||
|
|
||||||
|
data = request.json
|
||||||
|
remark = data.get("remark", "").strip()[:200]
|
||||||
|
|
||||||
|
database.update_account_remark(account_id, remark)
|
||||||
|
|
||||||
|
account.remark = remark
|
||||||
|
log_to_client(f"更新备注: {account.username} -> {remark}", user_id)
|
||||||
|
|
||||||
|
return jsonify({"success": True})
|
||||||
|
|
||||||
|
|
||||||
|
@api_accounts_bp.route("/api/accounts/<account_id>/start", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def start_account(account_id):
|
||||||
|
"""启动账号任务"""
|
||||||
|
user_id = current_user.id
|
||||||
|
|
||||||
|
account = safe_get_account(user_id, account_id)
|
||||||
|
if not account:
|
||||||
|
return jsonify({"error": "账号不存在"}), 404
|
||||||
|
|
||||||
|
if account.is_running:
|
||||||
|
return jsonify({"error": "任务已在运行中"}), 400
|
||||||
|
|
||||||
|
data = request.json or {}
|
||||||
|
browse_type = validate_browse_type(data.get("browse_type"), default=BROWSE_TYPE_SHOULD_READ)
|
||||||
|
if not browse_type:
|
||||||
|
return jsonify({"error": "浏览类型无效"}), 400
|
||||||
|
enable_screenshot = data.get("enable_screenshot", True)
|
||||||
|
|
||||||
|
if not init_browser_manager():
|
||||||
|
return jsonify({"error": "浏览器初始化失败"}), 500
|
||||||
|
|
||||||
|
ok, message = submit_account_task(
|
||||||
|
user_id=user_id,
|
||||||
|
account_id=account_id,
|
||||||
|
browse_type=browse_type,
|
||||||
|
enable_screenshot=enable_screenshot,
|
||||||
|
source="manual",
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
return jsonify({"error": message}), 400
|
||||||
|
|
||||||
|
log_to_client(f"启动任务: {account.username} - {browse_type}", user_id)
|
||||||
|
return jsonify({"success": True})
|
||||||
|
|
||||||
|
|
||||||
|
@api_accounts_bp.route("/api/accounts/<account_id>/stop", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def stop_account(account_id):
|
||||||
|
"""停止账号任务"""
|
||||||
|
user_id = current_user.id
|
||||||
|
|
||||||
|
account = safe_get_account(user_id, account_id)
|
||||||
|
if not account:
|
||||||
|
return jsonify({"error": "账号不存在"}), 404
|
||||||
|
|
||||||
|
if not account.is_running:
|
||||||
|
return jsonify({"error": "任务未在运行"}), 400
|
||||||
|
|
||||||
|
account.should_stop = True
|
||||||
|
account.status = "正在停止"
|
||||||
|
|
||||||
|
try:
|
||||||
|
scheduler = get_task_scheduler()
|
||||||
|
if scheduler.cancel_pending_task(user_id=user_id, account_id=account_id):
|
||||||
|
account.status = "已停止"
|
||||||
|
account.is_running = False
|
||||||
|
safe_remove_task_status(account_id)
|
||||||
|
_emit("account_update", account.to_dict(), room=f"user_{user_id}")
|
||||||
|
log_to_client(f"任务已取消: {account.username}", user_id)
|
||||||
|
return jsonify({"success": True, "canceled": True})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
log_to_client(f"停止任务: {account.username}", user_id)
|
||||||
|
_emit("account_update", account.to_dict(), room=f"user_{user_id}")
|
||||||
|
|
||||||
|
return jsonify({"success": True})
|
||||||
|
|
||||||
|
|
||||||
|
@api_accounts_bp.route("/api/accounts/<account_id>/screenshot", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def manual_screenshot(account_id):
|
||||||
|
"""手动为指定账号截图"""
|
||||||
|
user_id = current_user.id
|
||||||
|
|
||||||
|
account = safe_get_account(user_id, account_id)
|
||||||
|
if not account:
|
||||||
|
load_user_accounts(user_id)
|
||||||
|
account = safe_get_account(user_id, account_id)
|
||||||
|
if not account:
|
||||||
|
return jsonify({"error": "账号不存在"}), 404
|
||||||
|
if account.is_running:
|
||||||
|
return jsonify({"error": "任务运行中,无法截图"}), 400
|
||||||
|
|
||||||
|
data = request.json or {}
|
||||||
|
requested_browse_type = data.get("browse_type", None)
|
||||||
|
if requested_browse_type is None:
|
||||||
|
browse_type = normalize_browse_type(account.last_browse_type)
|
||||||
|
else:
|
||||||
|
browse_type = validate_browse_type(requested_browse_type, default=BROWSE_TYPE_SHOULD_READ)
|
||||||
|
if not browse_type:
|
||||||
|
return jsonify({"error": "浏览类型无效"}), 400
|
||||||
|
|
||||||
|
account.last_browse_type = browse_type
|
||||||
|
|
||||||
|
threading.Thread(
|
||||||
|
target=take_screenshot_for_account,
|
||||||
|
args=(user_id, account_id, browse_type, "manual_screenshot"),
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
|
log_to_client(f"手动截图: {account.username} - {browse_type}", user_id)
|
||||||
|
return jsonify({"success": True})
|
||||||
|
|
||||||
|
|
||||||
|
@api_accounts_bp.route("/api/accounts/batch/start", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def batch_start_accounts():
|
||||||
|
"""批量启动账号"""
|
||||||
|
user_id = current_user.id
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
account_ids = data.get("account_ids", [])
|
||||||
|
browse_type = validate_browse_type(data.get("browse_type", BROWSE_TYPE_SHOULD_READ), default=BROWSE_TYPE_SHOULD_READ)
|
||||||
|
if not browse_type:
|
||||||
|
return jsonify({"error": "浏览类型无效"}), 400
|
||||||
|
enable_screenshot = data.get("enable_screenshot", True)
|
||||||
|
|
||||||
|
if not account_ids:
|
||||||
|
return jsonify({"error": "请选择要启动的账号"}), 400
|
||||||
|
|
||||||
|
started = []
|
||||||
|
failed = []
|
||||||
|
|
||||||
|
if not safe_get_user_accounts_snapshot(user_id):
|
||||||
|
load_user_accounts(user_id)
|
||||||
|
|
||||||
|
for account_id in account_ids:
|
||||||
|
account = safe_get_account(user_id, account_id)
|
||||||
|
if not account:
|
||||||
|
failed.append({"id": account_id, "reason": "账号不存在"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
if account.is_running:
|
||||||
|
failed.append({"id": account_id, "reason": "已在运行中"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
ok, msg = submit_account_task(
|
||||||
|
user_id=user_id,
|
||||||
|
account_id=account_id,
|
||||||
|
browse_type=browse_type,
|
||||||
|
enable_screenshot=enable_screenshot,
|
||||||
|
source="batch",
|
||||||
|
)
|
||||||
|
if ok:
|
||||||
|
started.append(account_id)
|
||||||
|
else:
|
||||||
|
failed.append({"id": account_id, "reason": msg})
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{"success": True, "started_count": len(started), "failed_count": len(failed), "started": started, "failed": failed}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_accounts_bp.route("/api/accounts/batch/stop", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def batch_stop_accounts():
|
||||||
|
"""批量停止账号"""
|
||||||
|
user_id = current_user.id
|
||||||
|
data = request.json
|
||||||
|
|
||||||
|
account_ids = data.get("account_ids", [])
|
||||||
|
|
||||||
|
if not account_ids:
|
||||||
|
return jsonify({"error": "请选择要停止的账号"}), 400
|
||||||
|
|
||||||
|
stopped = []
|
||||||
|
|
||||||
|
if not safe_get_user_accounts_snapshot(user_id):
|
||||||
|
load_user_accounts(user_id)
|
||||||
|
|
||||||
|
for account_id in account_ids:
|
||||||
|
account = safe_get_account(user_id, account_id)
|
||||||
|
if not account:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not account.is_running:
|
||||||
|
continue
|
||||||
|
|
||||||
|
account.should_stop = True
|
||||||
|
account.status = "正在停止"
|
||||||
|
stopped.append(account_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
scheduler = get_task_scheduler()
|
||||||
|
if scheduler.cancel_pending_task(user_id=user_id, account_id=account_id):
|
||||||
|
account.status = "已停止"
|
||||||
|
account.is_running = False
|
||||||
|
safe_remove_task_status(account_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
_emit("account_update", account.to_dict(), room=f"user_{user_id}")
|
||||||
|
|
||||||
|
return jsonify({"success": True, "stopped_count": len(stopped), "stopped": stopped})
|
||||||
|
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
|
||||||
|
import database
|
||||||
|
import email_service
|
||||||
|
from app_logger import get_logger
|
||||||
|
from app_security import get_client_ip, require_ip_not_locked, validate_password
|
||||||
|
from flask import Blueprint, jsonify, redirect, render_template, request, url_for
|
||||||
|
from flask_login import login_required, login_user, logout_user
|
||||||
|
from routes.pages import render_app_spa_or_legacy
|
||||||
|
from services.accounts_service import load_user_accounts
|
||||||
|
from services.models import User
|
||||||
|
from services.state import (
|
||||||
|
check_ip_rate_limit,
|
||||||
|
record_failed_captcha,
|
||||||
|
safe_cleanup_expired_captcha,
|
||||||
|
safe_delete_captcha,
|
||||||
|
safe_set_captcha,
|
||||||
|
safe_verify_and_consume_captcha,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = get_logger("app")
|
||||||
|
|
||||||
|
api_auth_bp = Blueprint("api_auth", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@api_auth_bp.route("/api/register", methods=["POST"])
|
||||||
|
@require_ip_not_locked
|
||||||
|
def register():
|
||||||
|
"""用户注册"""
|
||||||
|
data = request.json
|
||||||
|
username = data.get("username", "").strip()
|
||||||
|
password = data.get("password", "").strip()
|
||||||
|
email = data.get("email", "").strip()
|
||||||
|
captcha_session = data.get("captcha_session", "")
|
||||||
|
captcha_code = data.get("captcha", "").strip()
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
return jsonify({"error": "用户名和密码不能为空"}), 400
|
||||||
|
|
||||||
|
client_ip = get_client_ip()
|
||||||
|
|
||||||
|
allowed, error_msg = check_ip_rate_limit(client_ip)
|
||||||
|
if not allowed:
|
||||||
|
return jsonify({"error": error_msg}), 429
|
||||||
|
|
||||||
|
success, message = safe_verify_and_consume_captcha(captcha_session, captcha_code)
|
||||||
|
if not success:
|
||||||
|
is_locked = record_failed_captcha(client_ip)
|
||||||
|
if is_locked:
|
||||||
|
return jsonify({"error": "验证码错误次数过多,IP已被锁定1小时"}), 429
|
||||||
|
return jsonify({"error": message}), 400
|
||||||
|
|
||||||
|
email_settings = email_service.get_email_settings()
|
||||||
|
email_verify_enabled = email_settings.get("register_verify_enabled", False) and email_settings.get("enabled", False)
|
||||||
|
|
||||||
|
if email_verify_enabled and not email:
|
||||||
|
return jsonify({"error": "启用邮箱验证后,邮箱为必填项"}), 400
|
||||||
|
|
||||||
|
if email and "@" not in email:
|
||||||
|
return jsonify({"error": "邮箱格式不正确"}), 400
|
||||||
|
|
||||||
|
system_config = database.get_system_config()
|
||||||
|
auto_approve_enabled = system_config.get("auto_approve_enabled", 0) == 1
|
||||||
|
auto_approve_hourly_limit = system_config.get("auto_approve_hourly_limit", 10)
|
||||||
|
auto_approve_vip_days = system_config.get("auto_approve_vip_days", 7)
|
||||||
|
|
||||||
|
if auto_approve_enabled or email_verify_enabled:
|
||||||
|
hourly_count = database.get_hourly_registration_count()
|
||||||
|
if hourly_count >= auto_approve_hourly_limit:
|
||||||
|
return jsonify({"error": f"注册人数过多,请稍后再试(每小时限制{auto_approve_hourly_limit}人)"}), 429
|
||||||
|
|
||||||
|
user_id = database.create_user(username, password, email)
|
||||||
|
if user_id:
|
||||||
|
if email_verify_enabled and email:
|
||||||
|
result = email_service.send_register_verification_email(email=email, username=username, user_id=user_id)
|
||||||
|
if result["success"]:
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"message": "注册成功!验证邮件已发送,请查收邮箱并点击链接完成验证",
|
||||||
|
"need_verify": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
logger.error(f"注册验证邮件发送失败: {result['error']}")
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"message": f"注册成功,但验证邮件发送失败({result['error']})。请稍后在登录页面重新发送验证邮件",
|
||||||
|
"need_verify": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if auto_approve_enabled:
|
||||||
|
database.approve_user(user_id)
|
||||||
|
if auto_approve_vip_days > 0:
|
||||||
|
database.set_user_vip(user_id, auto_approve_vip_days)
|
||||||
|
return jsonify({"success": True, "message": f"注册成功!已自动审核通过,赠送{auto_approve_vip_days}天VIP"})
|
||||||
|
return jsonify({"success": True, "message": "注册成功!已自动审核通过"})
|
||||||
|
return jsonify({"success": True, "message": "注册成功,请等待管理员审核"})
|
||||||
|
return jsonify({"error": "用户名已存在"}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@api_auth_bp.route("/api/verify-email/<token>")
|
||||||
|
def verify_email(token):
|
||||||
|
"""验证邮箱 - 用户点击邮件中的链接"""
|
||||||
|
result = email_service.verify_email_token(token, email_service.EMAIL_TYPE_REGISTER)
|
||||||
|
|
||||||
|
if result:
|
||||||
|
user_id = result["user_id"]
|
||||||
|
email = result["email"]
|
||||||
|
|
||||||
|
database.approve_user(user_id)
|
||||||
|
|
||||||
|
system_config = database.get_system_config()
|
||||||
|
auto_approve_vip_days = system_config.get("auto_approve_vip_days", 7)
|
||||||
|
if auto_approve_vip_days > 0:
|
||||||
|
database.set_user_vip(user_id, auto_approve_vip_days)
|
||||||
|
|
||||||
|
logger.info(f"用户邮箱验证成功: user_id={user_id}, email={email}")
|
||||||
|
spa_initial_state = {
|
||||||
|
"page": "verify_result",
|
||||||
|
"success": True,
|
||||||
|
"title": "验证成功",
|
||||||
|
"message": "您的邮箱已验证成功!账号已激活,现在可以登录使用了。",
|
||||||
|
"primary_label": "立即登录",
|
||||||
|
"primary_url": "/login",
|
||||||
|
"redirect_url": "/login",
|
||||||
|
"redirect_seconds": 5,
|
||||||
|
}
|
||||||
|
return render_app_spa_or_legacy("verify_success.html", spa_initial_state=spa_initial_state)
|
||||||
|
|
||||||
|
logger.warning(f"邮箱验证失败: token={token[:20]}...")
|
||||||
|
error_message = "验证链接无效或已过期,请重新注册或申请重发验证邮件"
|
||||||
|
spa_initial_state = {
|
||||||
|
"page": "verify_result",
|
||||||
|
"success": False,
|
||||||
|
"title": "验证失败",
|
||||||
|
"error_message": error_message,
|
||||||
|
"primary_label": "重新注册",
|
||||||
|
"primary_url": "/register",
|
||||||
|
"secondary_label": "返回登录",
|
||||||
|
"secondary_url": "/login",
|
||||||
|
}
|
||||||
|
return render_app_spa_or_legacy(
|
||||||
|
"verify_failed.html",
|
||||||
|
legacy_context={"error_message": error_message},
|
||||||
|
spa_initial_state=spa_initial_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_auth_bp.route("/api/resend-verify-email", methods=["POST"])
|
||||||
|
@require_ip_not_locked
|
||||||
|
def resend_verify_email():
|
||||||
|
"""重发验证邮件"""
|
||||||
|
data = request.json
|
||||||
|
email = data.get("email", "").strip()
|
||||||
|
captcha_session = data.get("captcha_session", "")
|
||||||
|
captcha_code = data.get("captcha", "").strip()
|
||||||
|
|
||||||
|
if not email:
|
||||||
|
return jsonify({"error": "请输入邮箱"}), 400
|
||||||
|
|
||||||
|
client_ip = get_client_ip()
|
||||||
|
|
||||||
|
allowed, error_msg = check_ip_rate_limit(client_ip)
|
||||||
|
if not allowed:
|
||||||
|
return jsonify({"error": error_msg}), 429
|
||||||
|
|
||||||
|
success, message = safe_verify_and_consume_captcha(captcha_session, captcha_code)
|
||||||
|
if not success:
|
||||||
|
is_locked = record_failed_captcha(client_ip)
|
||||||
|
if is_locked:
|
||||||
|
return jsonify({"error": "验证码错误次数过多,IP已被锁定1小时"}), 429
|
||||||
|
return jsonify({"error": message}), 400
|
||||||
|
|
||||||
|
user = database.get_user_by_email(email)
|
||||||
|
if not user:
|
||||||
|
return jsonify({"error": "该邮箱未注册"}), 404
|
||||||
|
|
||||||
|
if user["status"] == "approved":
|
||||||
|
return jsonify({"error": "该账号已验证通过,请直接登录"}), 400
|
||||||
|
|
||||||
|
result = email_service.resend_register_verification_email(user_id=user["id"], email=email, username=user["username"])
|
||||||
|
|
||||||
|
if result["success"]:
|
||||||
|
return jsonify({"success": True, "message": "验证邮件已重新发送,请查收"})
|
||||||
|
return jsonify({"error": result["error"]}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_auth_bp.route("/api/email/verify-status")
|
||||||
|
def get_email_verify_status():
|
||||||
|
"""获取邮箱验证功能状态(公开API)"""
|
||||||
|
try:
|
||||||
|
settings = email_service.get_email_settings()
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"email_enabled": settings.get("enabled", False),
|
||||||
|
"register_verify_enabled": settings.get("register_verify_enabled", False) and settings.get("enabled", False),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return jsonify({"email_enabled": False, "register_verify_enabled": False})
|
||||||
|
|
||||||
|
|
||||||
|
@api_auth_bp.route("/api/forgot-password", methods=["POST"])
|
||||||
|
@require_ip_not_locked
|
||||||
|
def forgot_password():
|
||||||
|
"""发送密码重置邮件"""
|
||||||
|
data = request.json
|
||||||
|
email = data.get("email", "").strip()
|
||||||
|
captcha_session = data.get("captcha_session", "")
|
||||||
|
captcha_code = data.get("captcha", "").strip()
|
||||||
|
|
||||||
|
if not email:
|
||||||
|
return jsonify({"error": "请输入邮箱"}), 400
|
||||||
|
|
||||||
|
client_ip = get_client_ip()
|
||||||
|
|
||||||
|
allowed, error_msg = check_ip_rate_limit(client_ip)
|
||||||
|
if not allowed:
|
||||||
|
return jsonify({"error": error_msg}), 429
|
||||||
|
|
||||||
|
success, message = safe_verify_and_consume_captcha(captcha_session, captcha_code)
|
||||||
|
if not success:
|
||||||
|
is_locked = record_failed_captcha(client_ip)
|
||||||
|
if is_locked:
|
||||||
|
return jsonify({"error": "验证码错误次数过多,IP已被锁定1小时"}), 429
|
||||||
|
return jsonify({"error": message}), 400
|
||||||
|
|
||||||
|
email_settings = email_service.get_email_settings()
|
||||||
|
if not email_settings.get("enabled", False):
|
||||||
|
return jsonify({"error": "邮件功能未启用,请联系管理员"}), 400
|
||||||
|
|
||||||
|
user = database.get_user_by_email(email)
|
||||||
|
if user and user.get("status") == "approved":
|
||||||
|
result = email_service.send_password_reset_email(email=email, username=user["username"], user_id=user["id"])
|
||||||
|
if not result["success"]:
|
||||||
|
logger.error(f"密码重置邮件发送失败: {result['error']}")
|
||||||
|
|
||||||
|
return jsonify({"success": True, "message": "如果该邮箱已注册,您将收到密码重置邮件"})
|
||||||
|
|
||||||
|
|
||||||
|
@api_auth_bp.route("/reset-password/<token>")
|
||||||
|
def reset_password_page(token):
|
||||||
|
"""密码重置页面"""
|
||||||
|
result = email_service.verify_password_reset_token(token)
|
||||||
|
valid = bool(result)
|
||||||
|
error_message = "" if valid else "重置链接无效或已过期,请重新申请密码重置"
|
||||||
|
|
||||||
|
legacy_context = {"token": token, "valid": valid, "error_message": error_message}
|
||||||
|
spa_initial_state = {"page": "reset_password", "token": token, "valid": valid, "error_message": error_message}
|
||||||
|
|
||||||
|
return render_app_spa_or_legacy(
|
||||||
|
"reset_password.html",
|
||||||
|
legacy_context=legacy_context,
|
||||||
|
spa_initial_state=spa_initial_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_auth_bp.route("/api/reset-password-confirm", methods=["POST"])
|
||||||
|
def reset_password_confirm():
|
||||||
|
"""确认密码重置"""
|
||||||
|
data = request.json
|
||||||
|
token = data.get("token", "").strip()
|
||||||
|
new_password = data.get("new_password", "").strip()
|
||||||
|
|
||||||
|
if not token or not new_password:
|
||||||
|
return jsonify({"error": "参数不完整"}), 400
|
||||||
|
|
||||||
|
is_valid, error_msg = validate_password(new_password)
|
||||||
|
if not is_valid:
|
||||||
|
return jsonify({"error": error_msg}), 400
|
||||||
|
|
||||||
|
result = email_service.confirm_password_reset(token)
|
||||||
|
if not result:
|
||||||
|
return jsonify({"error": "重置链接无效或已过期"}), 400
|
||||||
|
|
||||||
|
user_id = result["user_id"]
|
||||||
|
if database.admin_reset_user_password(user_id, new_password):
|
||||||
|
logger.info(f"用户密码重置成功: user_id={user_id}")
|
||||||
|
return jsonify({"success": True, "message": "密码重置成功"})
|
||||||
|
return jsonify({"error": "密码重置失败"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_auth_bp.route("/api/reset_password_request", methods=["POST"])
|
||||||
|
def request_password_reset():
|
||||||
|
"""用户申请重置密码(需要审核)"""
|
||||||
|
data = request.json
|
||||||
|
username = data.get("username", "").strip()
|
||||||
|
email = data.get("email", "").strip()
|
||||||
|
new_password = data.get("new_password", "").strip()
|
||||||
|
|
||||||
|
if not username or not new_password:
|
||||||
|
return jsonify({"error": "用户名和新密码不能为空"}), 400
|
||||||
|
|
||||||
|
is_valid, error_msg = validate_password(new_password)
|
||||||
|
if not is_valid:
|
||||||
|
return jsonify({"error": error_msg}), 400
|
||||||
|
|
||||||
|
user = database.get_user_by_username(username)
|
||||||
|
|
||||||
|
if user:
|
||||||
|
if email and user.get("email") != email:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
database.create_password_reset_request(user["id"], new_password)
|
||||||
|
|
||||||
|
return jsonify({"message": "如果账号存在,密码重置申请已提交,请等待管理员审核"})
|
||||||
|
|
||||||
|
|
||||||
|
@api_auth_bp.route("/api/generate_captcha", methods=["POST"])
|
||||||
|
def generate_captcha():
|
||||||
|
"""生成4位数字验证码图片"""
|
||||||
|
import base64
|
||||||
|
import uuid
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
code = "".join([str(secrets.randbelow(10)) for _ in range(4)])
|
||||||
|
|
||||||
|
safe_set_captcha(session_id, {"code": code, "expire_time": time.time() + 300, "failed_attempts": 0})
|
||||||
|
safe_cleanup_expired_captcha()
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
import io
|
||||||
|
|
||||||
|
width, height = 160, 60
|
||||||
|
image = Image.new("RGB", (width, height), color=(255, 255, 255))
|
||||||
|
draw = ImageDraw.Draw(image)
|
||||||
|
|
||||||
|
for _ in range(6):
|
||||||
|
x1 = random.randint(0, width)
|
||||||
|
y1 = random.randint(0, height)
|
||||||
|
x2 = random.randint(0, width)
|
||||||
|
y2 = random.randint(0, height)
|
||||||
|
draw.line(
|
||||||
|
[(x1, y1), (x2, y2)],
|
||||||
|
fill=(random.randint(0, 200), random.randint(0, 200), random.randint(0, 200)),
|
||||||
|
width=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
for _ in range(80):
|
||||||
|
x = random.randint(0, width)
|
||||||
|
y = random.randint(0, height)
|
||||||
|
draw.point((x, y), fill=(random.randint(0, 200), random.randint(0, 200), random.randint(0, 200)))
|
||||||
|
|
||||||
|
font = None
|
||||||
|
font_paths = [
|
||||||
|
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
|
||||||
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||||
|
"/usr/share/fonts/truetype/freefont/FreeSansBold.ttf",
|
||||||
|
]
|
||||||
|
for font_path in font_paths:
|
||||||
|
try:
|
||||||
|
font = ImageFont.truetype(font_path, 42)
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if font is None:
|
||||||
|
font = ImageFont.load_default()
|
||||||
|
|
||||||
|
for i, char in enumerate(code):
|
||||||
|
x = 12 + i * 35 + random.randint(-3, 3)
|
||||||
|
y = random.randint(5, 12)
|
||||||
|
color = (random.randint(0, 150), random.randint(0, 150), random.randint(0, 150))
|
||||||
|
draw.text((x, y), char, font=font, fill=color)
|
||||||
|
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
image.save(buffer, format="PNG")
|
||||||
|
img_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8")
|
||||||
|
|
||||||
|
return jsonify({"session_id": session_id, "captcha_image": f"data:image/png;base64,{img_base64}"})
|
||||||
|
except ImportError as e:
|
||||||
|
logger.error(f"PIL库未安装,验证码功能不可用: {e}")
|
||||||
|
safe_delete_captcha(session_id)
|
||||||
|
return jsonify({"error": "验证码服务暂不可用,请联系管理员安装PIL库"}), 503
|
||||||
|
|
||||||
|
|
||||||
|
@api_auth_bp.route("/api/login", methods=["POST"])
|
||||||
|
@require_ip_not_locked
|
||||||
|
def login():
|
||||||
|
"""用户登录"""
|
||||||
|
data = request.json
|
||||||
|
username = data.get("username", "").strip()
|
||||||
|
password = data.get("password", "").strip()
|
||||||
|
captcha_session = data.get("captcha_session", "")
|
||||||
|
captcha_code = data.get("captcha", "").strip()
|
||||||
|
need_captcha = data.get("need_captcha", False)
|
||||||
|
|
||||||
|
if need_captcha:
|
||||||
|
success, message = safe_verify_and_consume_captcha(captcha_session, captcha_code)
|
||||||
|
if not success:
|
||||||
|
return jsonify({"error": message}), 400
|
||||||
|
|
||||||
|
user = database.verify_user(username, password)
|
||||||
|
if not user:
|
||||||
|
return jsonify({"error": "用户名或密码错误", "need_captcha": True}), 401
|
||||||
|
|
||||||
|
if user["status"] != "approved":
|
||||||
|
return jsonify({"error": "账号未审核,请等待管理员审核", "need_captcha": False}), 401
|
||||||
|
|
||||||
|
user_obj = User(user["id"])
|
||||||
|
login_user(user_obj)
|
||||||
|
load_user_accounts(user["id"])
|
||||||
|
return jsonify({"success": True})
|
||||||
|
|
||||||
|
|
||||||
|
@api_auth_bp.route("/api/logout", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def logout():
|
||||||
|
"""用户登出"""
|
||||||
|
logout_user()
|
||||||
|
return jsonify({"success": True})
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import database
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
from flask_login import current_user, login_required
|
||||||
|
from services.accounts_service import load_user_accounts
|
||||||
|
from services.browse_types import BROWSE_TYPE_SHOULD_READ, normalize_browse_type, validate_browse_type
|
||||||
|
from services.state import safe_get_account, safe_get_user_accounts_snapshot
|
||||||
|
from services.tasks import submit_account_task
|
||||||
|
|
||||||
|
api_schedules_bp = Blueprint("api_schedules", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@api_schedules_bp.route("/api/schedules", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def get_user_schedules_api():
|
||||||
|
"""获取当前用户的所有定时任务"""
|
||||||
|
schedules = database.get_user_schedules(current_user.id)
|
||||||
|
import json
|
||||||
|
|
||||||
|
for s in schedules:
|
||||||
|
try:
|
||||||
|
s["account_ids"] = json.loads(s.get("account_ids", "[]") or "[]")
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
s["account_ids"] = []
|
||||||
|
return jsonify(schedules)
|
||||||
|
|
||||||
|
|
||||||
|
@api_schedules_bp.route("/api/schedules", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def create_user_schedule_api():
|
||||||
|
"""创建用户定时任务"""
|
||||||
|
data = request.json
|
||||||
|
|
||||||
|
name = data.get("name", "我的定时任务")
|
||||||
|
schedule_time = data.get("schedule_time", "08:00")
|
||||||
|
weekdays = data.get("weekdays", "1,2,3,4,5")
|
||||||
|
browse_type = validate_browse_type(data.get("browse_type", BROWSE_TYPE_SHOULD_READ), default=BROWSE_TYPE_SHOULD_READ)
|
||||||
|
if not browse_type:
|
||||||
|
return jsonify({"error": "浏览类型无效"}), 400
|
||||||
|
enable_screenshot = data.get("enable_screenshot", 1)
|
||||||
|
random_delay = int(data.get("random_delay", 0) or 0)
|
||||||
|
account_ids = data.get("account_ids", [])
|
||||||
|
|
||||||
|
if not re.match(r"^\\d{2}:\\d{2}$", schedule_time):
|
||||||
|
return jsonify({"error": "时间格式不正确,应为 HH:MM"}), 400
|
||||||
|
if random_delay not in (0, 1):
|
||||||
|
return jsonify({"error": "random_delay必须是0或1"}), 400
|
||||||
|
|
||||||
|
schedule_id = database.create_user_schedule(
|
||||||
|
user_id=current_user.id,
|
||||||
|
name=name,
|
||||||
|
schedule_time=schedule_time,
|
||||||
|
weekdays=weekdays,
|
||||||
|
browse_type=browse_type,
|
||||||
|
enable_screenshot=enable_screenshot,
|
||||||
|
random_delay=random_delay,
|
||||||
|
account_ids=account_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
if schedule_id:
|
||||||
|
return jsonify({"success": True, "id": schedule_id})
|
||||||
|
return jsonify({"error": "创建失败"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_schedules_bp.route("/api/schedules/<int:schedule_id>", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def get_schedule_detail_api(schedule_id):
|
||||||
|
"""获取定时任务详情"""
|
||||||
|
schedule = database.get_schedule_by_id(schedule_id)
|
||||||
|
if not schedule:
|
||||||
|
return jsonify({"error": "定时任务不存在"}), 404
|
||||||
|
if schedule["user_id"] != current_user.id:
|
||||||
|
return jsonify({"error": "无权访问"}), 403
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
try:
|
||||||
|
schedule["account_ids"] = json.loads(schedule.get("account_ids", "[]") or "[]")
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
schedule["account_ids"] = []
|
||||||
|
return jsonify(schedule)
|
||||||
|
|
||||||
|
|
||||||
|
@api_schedules_bp.route("/api/schedules/<int:schedule_id>", methods=["PUT"])
|
||||||
|
@login_required
|
||||||
|
def update_schedule_api(schedule_id):
|
||||||
|
"""更新定时任务"""
|
||||||
|
schedule = database.get_schedule_by_id(schedule_id)
|
||||||
|
if not schedule:
|
||||||
|
return jsonify({"error": "定时任务不存在"}), 404
|
||||||
|
if schedule["user_id"] != current_user.id:
|
||||||
|
return jsonify({"error": "无权访问"}), 403
|
||||||
|
|
||||||
|
data = request.json
|
||||||
|
allowed_fields = [
|
||||||
|
"name",
|
||||||
|
"schedule_time",
|
||||||
|
"weekdays",
|
||||||
|
"browse_type",
|
||||||
|
"enable_screenshot",
|
||||||
|
"random_delay",
|
||||||
|
"account_ids",
|
||||||
|
"enabled",
|
||||||
|
]
|
||||||
|
|
||||||
|
update_data = {k: v for k, v in data.items() if k in allowed_fields}
|
||||||
|
|
||||||
|
if "schedule_time" in update_data:
|
||||||
|
if not re.match(r"^\\d{2}:\\d{2}$", update_data["schedule_time"]):
|
||||||
|
return jsonify({"error": "时间格式不正确"}), 400
|
||||||
|
if "random_delay" in update_data:
|
||||||
|
try:
|
||||||
|
update_data["random_delay"] = int(update_data.get("random_delay") or 0)
|
||||||
|
except Exception:
|
||||||
|
return jsonify({"error": "random_delay必须是0或1"}), 400
|
||||||
|
if update_data["random_delay"] not in (0, 1):
|
||||||
|
return jsonify({"error": "random_delay必须是0或1"}), 400
|
||||||
|
if "browse_type" in update_data:
|
||||||
|
normalized = validate_browse_type(update_data.get("browse_type"), default=BROWSE_TYPE_SHOULD_READ)
|
||||||
|
if not normalized:
|
||||||
|
return jsonify({"error": "浏览类型无效"}), 400
|
||||||
|
update_data["browse_type"] = normalized
|
||||||
|
|
||||||
|
success = database.update_user_schedule(schedule_id, **update_data)
|
||||||
|
if success:
|
||||||
|
return jsonify({"success": True})
|
||||||
|
return jsonify({"error": "更新失败"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_schedules_bp.route("/api/schedules/<int:schedule_id>", methods=["DELETE"])
|
||||||
|
@login_required
|
||||||
|
def delete_schedule_api(schedule_id):
|
||||||
|
"""删除定时任务"""
|
||||||
|
schedule = database.get_schedule_by_id(schedule_id)
|
||||||
|
if not schedule:
|
||||||
|
return jsonify({"error": "定时任务不存在"}), 404
|
||||||
|
if schedule["user_id"] != current_user.id:
|
||||||
|
return jsonify({"error": "无权访问"}), 403
|
||||||
|
|
||||||
|
success = database.delete_user_schedule(schedule_id)
|
||||||
|
if success:
|
||||||
|
return jsonify({"success": True})
|
||||||
|
return jsonify({"error": "删除失败"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_schedules_bp.route("/api/schedules/<int:schedule_id>/toggle", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def toggle_schedule_api(schedule_id):
|
||||||
|
"""启用/禁用定时任务"""
|
||||||
|
schedule = database.get_schedule_by_id(schedule_id)
|
||||||
|
if not schedule:
|
||||||
|
return jsonify({"error": "定时任务不存在"}), 404
|
||||||
|
if schedule["user_id"] != current_user.id:
|
||||||
|
return jsonify({"error": "无权访问"}), 403
|
||||||
|
|
||||||
|
data = request.json
|
||||||
|
enabled = data.get("enabled", not schedule["enabled"])
|
||||||
|
|
||||||
|
success = database.toggle_user_schedule(schedule_id, enabled)
|
||||||
|
if success:
|
||||||
|
return jsonify({"success": True, "enabled": enabled})
|
||||||
|
return jsonify({"error": "操作失败"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_schedules_bp.route("/api/schedules/<int:schedule_id>/run", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def run_schedule_now_api(schedule_id):
|
||||||
|
"""立即执行定时任务"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
schedule = database.get_schedule_by_id(schedule_id)
|
||||||
|
if not schedule:
|
||||||
|
return jsonify({"error": "定时任务不存在"}), 404
|
||||||
|
if schedule["user_id"] != current_user.id:
|
||||||
|
return jsonify({"error": "无权访问"}), 403
|
||||||
|
|
||||||
|
try:
|
||||||
|
account_ids = json.loads(schedule.get("account_ids", "[]") or "[]")
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
account_ids = []
|
||||||
|
|
||||||
|
if not account_ids:
|
||||||
|
return jsonify({"error": "没有配置账号"}), 400
|
||||||
|
|
||||||
|
user_id = current_user.id
|
||||||
|
browse_type = normalize_browse_type(schedule.get("browse_type", BROWSE_TYPE_SHOULD_READ))
|
||||||
|
enable_screenshot = schedule["enable_screenshot"]
|
||||||
|
|
||||||
|
if not safe_get_user_accounts_snapshot(user_id):
|
||||||
|
load_user_accounts(user_id)
|
||||||
|
|
||||||
|
started = []
|
||||||
|
for account_id in account_ids:
|
||||||
|
account = safe_get_account(user_id, account_id)
|
||||||
|
if not account:
|
||||||
|
continue
|
||||||
|
if account.is_running:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ok, msg = submit_account_task(
|
||||||
|
user_id=user_id,
|
||||||
|
account_id=account_id,
|
||||||
|
browse_type=browse_type,
|
||||||
|
enable_screenshot=enable_screenshot,
|
||||||
|
source="user_scheduled",
|
||||||
|
)
|
||||||
|
if ok:
|
||||||
|
started.append(account_id)
|
||||||
|
|
||||||
|
database.update_schedule_last_run(schedule_id)
|
||||||
|
|
||||||
|
return jsonify({"success": True, "started_count": len(started), "message": f"已启动 {len(started)} 个账号"})
|
||||||
|
|
||||||
|
|
||||||
|
@api_schedules_bp.route("/api/schedules/<int:schedule_id>/logs", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def get_schedule_logs_api(schedule_id):
|
||||||
|
"""获取定时任务执行日志"""
|
||||||
|
try:
|
||||||
|
schedule = database.get_schedule_by_id(schedule_id)
|
||||||
|
if not schedule or schedule["user_id"] != current_user.id:
|
||||||
|
return jsonify([])
|
||||||
|
|
||||||
|
limit = request.args.get("limit", 20, type=int)
|
||||||
|
logs = database.get_schedule_execution_logs(schedule_id, limit)
|
||||||
|
return jsonify(logs if logs else [])
|
||||||
|
except Exception:
|
||||||
|
return jsonify([])
|
||||||
|
|
||||||
|
|
||||||
|
@api_schedules_bp.route("/api/schedules/<int:schedule_id>/logs", methods=["DELETE"])
|
||||||
|
@login_required
|
||||||
|
def delete_schedule_logs_api(schedule_id):
|
||||||
|
"""清空定时任务执行日志"""
|
||||||
|
try:
|
||||||
|
schedule = database.get_schedule_by_id(schedule_id)
|
||||||
|
if not schedule or schedule["user_id"] != current_user.id:
|
||||||
|
return jsonify({"error": "无权限"}), 403
|
||||||
|
|
||||||
|
deleted = database.delete_schedule_logs(schedule_id, current_user.id)
|
||||||
|
return jsonify({"success": True, "deleted": deleted})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import database
|
||||||
|
from app_config import get_config
|
||||||
|
from app_security import is_safe_path
|
||||||
|
from flask import Blueprint, jsonify, send_from_directory
|
||||||
|
from flask_login import current_user, login_required
|
||||||
|
from services.client_log import log_to_client
|
||||||
|
from services.time_utils import BEIJING_TZ
|
||||||
|
|
||||||
|
config = get_config()
|
||||||
|
SCREENSHOTS_DIR = config.SCREENSHOTS_DIR
|
||||||
|
|
||||||
|
api_screenshots_bp = Blueprint("api_screenshots", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@api_screenshots_bp.route("/api/screenshots", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def get_screenshots():
|
||||||
|
"""获取当前用户的截图列表"""
|
||||||
|
user_id = current_user.id
|
||||||
|
user_info = database.get_user_by_id(user_id)
|
||||||
|
username_prefix = user_info["username"] if user_info else f"user{user_id}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
screenshots = []
|
||||||
|
if os.path.exists(SCREENSHOTS_DIR):
|
||||||
|
for filename in os.listdir(SCREENSHOTS_DIR):
|
||||||
|
if filename.lower().endswith((".png", ".jpg", ".jpeg")) and filename.startswith(username_prefix + "_"):
|
||||||
|
filepath = os.path.join(SCREENSHOTS_DIR, filename)
|
||||||
|
stat = os.stat(filepath)
|
||||||
|
created_time = datetime.fromtimestamp(stat.st_mtime, tz=BEIJING_TZ)
|
||||||
|
parts = filename.rsplit(".", 1)[0].split("_", 1)
|
||||||
|
if len(parts) > 1:
|
||||||
|
display_name = parts[1] + "." + filename.rsplit(".", 1)[1]
|
||||||
|
else:
|
||||||
|
display_name = filename
|
||||||
|
|
||||||
|
screenshots.append(
|
||||||
|
{
|
||||||
|
"filename": filename,
|
||||||
|
"display_name": display_name,
|
||||||
|
"size": stat.st_size,
|
||||||
|
"created": created_time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
screenshots.sort(key=lambda x: x["created"], reverse=True)
|
||||||
|
return jsonify(screenshots)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_screenshots_bp.route("/screenshots/<filename>")
|
||||||
|
@login_required
|
||||||
|
def serve_screenshot(filename):
|
||||||
|
"""提供截图文件访问"""
|
||||||
|
user_id = current_user.id
|
||||||
|
user_info = database.get_user_by_id(user_id)
|
||||||
|
username_prefix = user_info["username"] if user_info else f"user{user_id}"
|
||||||
|
|
||||||
|
if not filename.startswith(username_prefix + "_"):
|
||||||
|
return jsonify({"error": "无权访问"}), 403
|
||||||
|
|
||||||
|
if not is_safe_path(SCREENSHOTS_DIR, filename):
|
||||||
|
return jsonify({"error": "非法路径"}), 403
|
||||||
|
|
||||||
|
return send_from_directory(SCREENSHOTS_DIR, filename)
|
||||||
|
|
||||||
|
|
||||||
|
@api_screenshots_bp.route("/api/screenshots/<filename>", methods=["DELETE"])
|
||||||
|
@login_required
|
||||||
|
def delete_screenshot(filename):
|
||||||
|
"""删除指定截图"""
|
||||||
|
user_id = current_user.id
|
||||||
|
user_info = database.get_user_by_id(user_id)
|
||||||
|
username_prefix = user_info["username"] if user_info else f"user{user_id}"
|
||||||
|
|
||||||
|
if not filename.startswith(username_prefix + "_"):
|
||||||
|
return jsonify({"error": "无权删除"}), 403
|
||||||
|
|
||||||
|
try:
|
||||||
|
filepath = os.path.join(SCREENSHOTS_DIR, filename)
|
||||||
|
if os.path.exists(filepath):
|
||||||
|
os.remove(filepath)
|
||||||
|
log_to_client(f"删除截图: {filename}", user_id)
|
||||||
|
return jsonify({"success": True})
|
||||||
|
return jsonify({"error": "文件不存在"}), 404
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_screenshots_bp.route("/api/screenshots/clear", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def clear_all_screenshots():
|
||||||
|
"""清空当前用户的所有截图"""
|
||||||
|
user_id = current_user.id
|
||||||
|
user_info = database.get_user_by_id(user_id)
|
||||||
|
username_prefix = user_info["username"] if user_info else f"user{user_id}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
deleted_count = 0
|
||||||
|
if os.path.exists(SCREENSHOTS_DIR):
|
||||||
|
for filename in os.listdir(SCREENSHOTS_DIR):
|
||||||
|
if filename.lower().endswith((".png", ".jpg", ".jpeg")) and filename.startswith(username_prefix + "_"):
|
||||||
|
filepath = os.path.join(SCREENSHOTS_DIR, filename)
|
||||||
|
os.remove(filepath)
|
||||||
|
deleted_count += 1
|
||||||
|
log_to_client(f"清理了 {deleted_count} 个截图文件", user_id)
|
||||||
|
return jsonify({"success": True, "deleted": deleted_count})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import database
|
||||||
|
import email_service
|
||||||
|
from app_logger import get_logger
|
||||||
|
from app_security import require_ip_not_locked, validate_email
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
from flask_login import current_user, login_required
|
||||||
|
from routes.pages import render_app_spa_or_legacy
|
||||||
|
from services.state import safe_iter_task_status_items
|
||||||
|
|
||||||
|
logger = get_logger("app")
|
||||||
|
|
||||||
|
api_user_bp = Blueprint("api_user", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@api_user_bp.route("/api/announcements/active", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def get_active_announcement():
|
||||||
|
"""获取当前用户应展示的公告(若无则返回announcement=null)"""
|
||||||
|
try:
|
||||||
|
user_id = int(current_user.id)
|
||||||
|
except Exception:
|
||||||
|
return jsonify({"announcement": None})
|
||||||
|
|
||||||
|
announcement = database.get_active_announcement_for_user(user_id)
|
||||||
|
if not announcement:
|
||||||
|
return jsonify({"announcement": None})
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"announcement": {
|
||||||
|
"id": announcement.get("id"),
|
||||||
|
"title": announcement.get("title", ""),
|
||||||
|
"content": announcement.get("content", ""),
|
||||||
|
"created_at": announcement.get("created_at"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_user_bp.route("/api/announcements/<int:announcement_id>/dismiss", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def dismiss_announcement(announcement_id):
|
||||||
|
"""用户永久关闭某条公告(本次公告不再弹窗)"""
|
||||||
|
try:
|
||||||
|
user_id = int(current_user.id)
|
||||||
|
except Exception:
|
||||||
|
return jsonify({"error": "请先登录"}), 401
|
||||||
|
|
||||||
|
announcement = database.get_announcement_by_id(announcement_id)
|
||||||
|
if not announcement:
|
||||||
|
return jsonify({"error": "公告不存在"}), 404
|
||||||
|
|
||||||
|
database.dismiss_announcement_for_user(user_id, announcement_id)
|
||||||
|
return jsonify({"success": True})
|
||||||
|
|
||||||
|
|
||||||
|
@api_user_bp.route("/api/feedback", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def submit_feedback():
|
||||||
|
"""用户提交Bug反馈"""
|
||||||
|
data = request.get_json()
|
||||||
|
title = data.get("title", "").strip()
|
||||||
|
description = data.get("description", "").strip()
|
||||||
|
contact = data.get("contact", "").strip()
|
||||||
|
|
||||||
|
if not title or not description:
|
||||||
|
return jsonify({"error": "标题和描述不能为空"}), 400
|
||||||
|
|
||||||
|
if len(title) > 100:
|
||||||
|
return jsonify({"error": "标题不能超过100个字符"}), 400
|
||||||
|
|
||||||
|
if len(description) > 2000:
|
||||||
|
return jsonify({"error": "描述不能超过2000个字符"}), 400
|
||||||
|
|
||||||
|
user_info = database.get_user_by_id(current_user.id)
|
||||||
|
username = user_info["username"] if user_info else f"用户{current_user.id}"
|
||||||
|
|
||||||
|
feedback_id = database.create_bug_feedback(
|
||||||
|
user_id=current_user.id,
|
||||||
|
username=username,
|
||||||
|
title=title,
|
||||||
|
description=description,
|
||||||
|
contact=contact,
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({"message": "反馈提交成功", "id": feedback_id})
|
||||||
|
|
||||||
|
|
||||||
|
@api_user_bp.route("/api/feedback", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def get_my_feedbacks():
|
||||||
|
"""获取当前用户的反馈列表"""
|
||||||
|
feedbacks = database.get_user_feedbacks(current_user.id)
|
||||||
|
return jsonify(feedbacks)
|
||||||
|
|
||||||
|
|
||||||
|
@api_user_bp.route("/api/user/vip", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def get_current_user_vip():
|
||||||
|
"""获取当前用户VIP信息"""
|
||||||
|
vip_info = database.get_user_vip_info(current_user.id)
|
||||||
|
user_info = database.get_user_by_id(current_user.id)
|
||||||
|
vip_info["username"] = user_info["username"] if user_info else "Unknown"
|
||||||
|
return jsonify(vip_info)
|
||||||
|
|
||||||
|
|
||||||
|
@api_user_bp.route("/api/user/password", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def change_user_password():
|
||||||
|
"""用户修改自己的密码"""
|
||||||
|
data = request.get_json()
|
||||||
|
current_password = data.get("current_password")
|
||||||
|
new_password = data.get("new_password")
|
||||||
|
|
||||||
|
if not current_password or not new_password:
|
||||||
|
return jsonify({"error": "请填写完整信息"}), 400
|
||||||
|
|
||||||
|
if len(new_password) < 6:
|
||||||
|
return jsonify({"error": "新密码至少6位"}), 400
|
||||||
|
|
||||||
|
user = database.get_user_by_id(current_user.id)
|
||||||
|
if not user:
|
||||||
|
return jsonify({"error": "用户不存在"}), 404
|
||||||
|
|
||||||
|
username = user.get("username", "")
|
||||||
|
if not username or not database.verify_user(username, current_password):
|
||||||
|
return jsonify({"error": "当前密码错误"}), 400
|
||||||
|
|
||||||
|
if database.admin_reset_user_password(current_user.id, new_password):
|
||||||
|
return jsonify({"success": True})
|
||||||
|
return jsonify({"error": "密码更新失败"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_user_bp.route("/api/user/email", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def get_user_email():
|
||||||
|
"""获取当前用户的邮箱信息"""
|
||||||
|
user = database.get_user_by_id(current_user.id)
|
||||||
|
if not user:
|
||||||
|
return jsonify({"error": "用户不存在"}), 404
|
||||||
|
|
||||||
|
return jsonify({"email": user.get("email", ""), "email_verified": user.get("email_verified", False)})
|
||||||
|
|
||||||
|
|
||||||
|
@api_user_bp.route("/api/user/bind-email", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@require_ip_not_locked
|
||||||
|
def bind_user_email():
|
||||||
|
"""发送邮箱绑定验证邮件"""
|
||||||
|
data = request.get_json()
|
||||||
|
email = data.get("email", "").strip().lower()
|
||||||
|
|
||||||
|
if not email or not validate_email(email):
|
||||||
|
return jsonify({"error": "请输入有效的邮箱地址"}), 400
|
||||||
|
|
||||||
|
settings = email_service.get_email_settings()
|
||||||
|
if not settings.get("enabled", False):
|
||||||
|
return jsonify({"error": "邮件功能未启用,请联系管理员"}), 400
|
||||||
|
|
||||||
|
existing_user = database.get_user_by_email(email)
|
||||||
|
if existing_user and existing_user["id"] != current_user.id:
|
||||||
|
return jsonify({"error": "该邮箱已被其他用户绑定"}), 400
|
||||||
|
|
||||||
|
user = database.get_user_by_id(current_user.id)
|
||||||
|
if not user:
|
||||||
|
return jsonify({"error": "用户不存在"}), 404
|
||||||
|
|
||||||
|
if user.get("email") == email and user.get("email_verified"):
|
||||||
|
return jsonify({"error": "该邮箱已绑定并验证"}), 400
|
||||||
|
|
||||||
|
result = email_service.send_bind_email_verification(user_id=current_user.id, email=email, username=user["username"])
|
||||||
|
|
||||||
|
if result["success"]:
|
||||||
|
return jsonify({"success": True, "message": "验证邮件已发送,请查收"})
|
||||||
|
return jsonify({"error": result["error"]}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_user_bp.route("/api/verify-bind-email/<token>")
|
||||||
|
def verify_bind_email(token):
|
||||||
|
"""验证邮箱绑定Token"""
|
||||||
|
result = email_service.verify_bind_email_token(token)
|
||||||
|
|
||||||
|
if result:
|
||||||
|
user_id = result["user_id"]
|
||||||
|
email = result["email"]
|
||||||
|
|
||||||
|
if database.update_user_email(user_id, email, verified=True):
|
||||||
|
spa_initial_state = {
|
||||||
|
"page": "verify_result",
|
||||||
|
"success": True,
|
||||||
|
"title": "邮箱绑定成功",
|
||||||
|
"message": f"邮箱 {email} 已成功绑定到您的账号!",
|
||||||
|
"primary_label": "返回登录",
|
||||||
|
"primary_url": "/login",
|
||||||
|
"redirect_url": "/login",
|
||||||
|
"redirect_seconds": 5,
|
||||||
|
}
|
||||||
|
return render_app_spa_or_legacy("verify_success.html", spa_initial_state=spa_initial_state)
|
||||||
|
|
||||||
|
error_message = "邮箱绑定失败,请重试"
|
||||||
|
spa_initial_state = {
|
||||||
|
"page": "verify_result",
|
||||||
|
"success": False,
|
||||||
|
"title": "绑定失败",
|
||||||
|
"error_message": error_message,
|
||||||
|
"primary_label": "返回登录",
|
||||||
|
"primary_url": "/login",
|
||||||
|
}
|
||||||
|
return render_app_spa_or_legacy(
|
||||||
|
"verify_failed.html",
|
||||||
|
legacy_context={"error_message": error_message},
|
||||||
|
spa_initial_state=spa_initial_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
error_message = "验证链接已过期或无效,请重新发送验证邮件"
|
||||||
|
spa_initial_state = {
|
||||||
|
"page": "verify_result",
|
||||||
|
"success": False,
|
||||||
|
"title": "链接无效",
|
||||||
|
"error_message": error_message,
|
||||||
|
"primary_label": "返回登录",
|
||||||
|
"primary_url": "/login",
|
||||||
|
}
|
||||||
|
return render_app_spa_or_legacy(
|
||||||
|
"verify_failed.html",
|
||||||
|
legacy_context={"error_message": error_message},
|
||||||
|
spa_initial_state=spa_initial_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_user_bp.route("/api/user/unbind-email", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def unbind_user_email():
|
||||||
|
"""解绑用户邮箱"""
|
||||||
|
user = database.get_user_by_id(current_user.id)
|
||||||
|
if not user:
|
||||||
|
return jsonify({"error": "用户不存在"}), 404
|
||||||
|
|
||||||
|
if not user.get("email"):
|
||||||
|
return jsonify({"error": "当前未绑定邮箱"}), 400
|
||||||
|
|
||||||
|
if database.update_user_email(current_user.id, None, verified=False):
|
||||||
|
return jsonify({"success": True, "message": "邮箱已解绑"})
|
||||||
|
return jsonify({"error": "解绑失败"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_user_bp.route("/api/user/email-notify", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def get_user_email_notify():
|
||||||
|
"""获取用户邮件通知偏好"""
|
||||||
|
enabled = database.get_user_email_notify(current_user.id)
|
||||||
|
return jsonify({"enabled": enabled})
|
||||||
|
|
||||||
|
|
||||||
|
@api_user_bp.route("/api/user/email-notify", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def update_user_email_notify():
|
||||||
|
"""更新用户邮件通知偏好"""
|
||||||
|
data = request.get_json()
|
||||||
|
enabled = data.get("enabled", True)
|
||||||
|
|
||||||
|
if database.update_user_email_notify(current_user.id, enabled):
|
||||||
|
return jsonify({"success": True})
|
||||||
|
return jsonify({"error": "更新失败"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_user_bp.route("/api/run_stats", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def get_run_stats():
|
||||||
|
"""获取当前用户的运行统计"""
|
||||||
|
user_id = current_user.id
|
||||||
|
|
||||||
|
stats = database.get_user_run_stats(user_id)
|
||||||
|
|
||||||
|
current_running = 0
|
||||||
|
for _, info in safe_iter_task_status_items():
|
||||||
|
if info.get("user_id") == user_id and info.get("status") == "运行中":
|
||||||
|
current_running += 1
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"today_completed": stats.get("completed", 0),
|
||||||
|
"current_running": current_running,
|
||||||
|
"today_failed": stats.get("failed", 0),
|
||||||
|
"today_items": stats.get("total_items", 0),
|
||||||
|
"today_attachments": stats.get("total_attachments", 0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from flask import jsonify, request, session
|
||||||
|
|
||||||
|
from services.runtime import get_logger
|
||||||
|
|
||||||
|
|
||||||
|
def admin_required(f):
|
||||||
|
"""管理员权限装饰器(不改变原有接口/行为)。"""
|
||||||
|
|
||||||
|
@wraps(f)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
logger = get_logger()
|
||||||
|
logger.debug(f"[admin_required] 检查会话,admin_id存在: {'admin_id' in session}")
|
||||||
|
if "admin_id" not in session:
|
||||||
|
logger.warning(f"[admin_required] 拒绝访问 {request.path} - session中无admin_id")
|
||||||
|
return jsonify({"error": "需要管理员权限"}), 403
|
||||||
|
logger.info(f"[admin_required] 管理员 {session.get('admin_username')} 访问 {request.path}")
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from flask import Blueprint, current_app, redirect, render_template, session, url_for
|
||||||
|
from flask_login import current_user, login_required
|
||||||
|
|
||||||
|
from routes.decorators import admin_required
|
||||||
|
from services.runtime import get_logger
|
||||||
|
|
||||||
|
pages_bp = Blueprint("pages", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def render_app_spa_or_legacy(
|
||||||
|
legacy_template_name: str,
|
||||||
|
legacy_context: Optional[dict] = None,
|
||||||
|
spa_initial_state: Optional[dict] = None,
|
||||||
|
):
|
||||||
|
"""渲染前台 Vue SPA(构建产物位于 static/app),失败则回退旧模板。"""
|
||||||
|
logger = get_logger()
|
||||||
|
legacy_context = legacy_context or {}
|
||||||
|
manifest_path = os.path.join(current_app.root_path, "static", "app", ".vite", "manifest.json")
|
||||||
|
try:
|
||||||
|
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||||
|
manifest = json.load(f)
|
||||||
|
|
||||||
|
entry = manifest.get("index.html") or {}
|
||||||
|
js_file = entry.get("file")
|
||||||
|
css_files = entry.get("css") or []
|
||||||
|
|
||||||
|
if not js_file:
|
||||||
|
logger.warning(f"[app_spa] manifest缺少入口文件: {manifest_path}")
|
||||||
|
return render_template(legacy_template_name, **legacy_context)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"app.html",
|
||||||
|
app_spa_js_file=f"app/{js_file}",
|
||||||
|
app_spa_css_files=[f"app/{p}" for p in css_files],
|
||||||
|
app_spa_initial_state=spa_initial_state,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.info(f"[app_spa] 未找到manifest: {manifest_path},回退旧模板: {legacy_template_name}")
|
||||||
|
return render_template(legacy_template_name, **legacy_context)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[app_spa] 加载manifest失败: {e}")
|
||||||
|
return render_template(legacy_template_name, **legacy_context)
|
||||||
|
|
||||||
|
|
||||||
|
@pages_bp.route("/")
|
||||||
|
def index():
|
||||||
|
"""主页 - 重定向到登录或应用"""
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for("pages.app_page"))
|
||||||
|
return redirect(url_for("pages.login_page"))
|
||||||
|
|
||||||
|
|
||||||
|
@pages_bp.route("/login")
|
||||||
|
def login_page():
|
||||||
|
"""登录页面"""
|
||||||
|
return render_app_spa_or_legacy("login.html")
|
||||||
|
|
||||||
|
|
||||||
|
@pages_bp.route("/register")
|
||||||
|
def register_page():
|
||||||
|
"""注册页面"""
|
||||||
|
return render_app_spa_or_legacy("register.html")
|
||||||
|
|
||||||
|
|
||||||
|
@pages_bp.route("/app")
|
||||||
|
@login_required
|
||||||
|
def app_page():
|
||||||
|
"""主应用页面"""
|
||||||
|
return render_app_spa_or_legacy("index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@pages_bp.route("/app/<path:subpath>")
|
||||||
|
@login_required
|
||||||
|
def app_page_subpath(subpath):
|
||||||
|
"""SPA 子路由刷新支持(History 模式)"""
|
||||||
|
return render_app_spa_or_legacy("index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@pages_bp.route("/yuyx")
|
||||||
|
def admin_login_page():
|
||||||
|
"""后台登录页面"""
|
||||||
|
if "admin_id" in session:
|
||||||
|
return redirect(url_for("pages.admin_page"))
|
||||||
|
return render_template("admin_login.html")
|
||||||
|
|
||||||
|
|
||||||
|
@pages_bp.route("/yuyx/admin")
|
||||||
|
@admin_required
|
||||||
|
def admin_page():
|
||||||
|
"""后台管理页面"""
|
||||||
|
logger = get_logger()
|
||||||
|
manifest_path = os.path.join(current_app.root_path, "static", "admin", ".vite", "manifest.json")
|
||||||
|
try:
|
||||||
|
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||||
|
manifest = json.load(f)
|
||||||
|
|
||||||
|
entry = manifest.get("index.html") or {}
|
||||||
|
js_file = entry.get("file")
|
||||||
|
css_files = entry.get("css") or []
|
||||||
|
|
||||||
|
if not js_file:
|
||||||
|
logger.warning(f"[admin_spa] manifest缺少入口文件: {manifest_path}")
|
||||||
|
return render_template("admin_legacy.html")
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"admin.html",
|
||||||
|
admin_spa_js_file=f"admin/{js_file}",
|
||||||
|
admin_spa_css_files=[f"admin/{p}" for p in css_files],
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.warning(f"[admin_spa] 未找到manifest: {manifest_path},回退旧版后台模板")
|
||||||
|
return render_template("admin_legacy.html")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[admin_spa] 加载manifest失败: {e}")
|
||||||
|
return render_template("admin_legacy.html")
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""Service layer package."""
|
||||||
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import database
|
||||||
|
from services.models import Account
|
||||||
|
from services.state import safe_set_user_accounts
|
||||||
|
|
||||||
|
|
||||||
|
def load_user_accounts(user_id: int) -> None:
|
||||||
|
"""从数据库加载用户的账号到内存(保持原逻辑不变)。"""
|
||||||
|
accounts_by_id = {}
|
||||||
|
accounts_data = database.get_user_accounts(user_id)
|
||||||
|
for acc_data in accounts_data:
|
||||||
|
account = Account(
|
||||||
|
account_id=acc_data["id"],
|
||||||
|
user_id=user_id,
|
||||||
|
username=acc_data["username"],
|
||||||
|
password=acc_data["password"],
|
||||||
|
remember=bool(acc_data["remember"]),
|
||||||
|
remark=acc_data["remark"] or "",
|
||||||
|
)
|
||||||
|
accounts_by_id[account.id] = account
|
||||||
|
|
||||||
|
safe_set_user_accounts(user_id, accounts_by_id)
|
||||||
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
BROWSE_TYPE_SHOULD_READ = "应读"
|
||||||
|
BROWSE_TYPE_PRE_REG_UNREAD = "注册前未读"
|
||||||
|
|
||||||
|
_BROWSE_TYPES_ALLOWED_INPUT = {BROWSE_TYPE_SHOULD_READ, BROWSE_TYPE_PRE_REG_UNREAD}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_browse_type(value, default: str = BROWSE_TYPE_SHOULD_READ) -> str:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if text == BROWSE_TYPE_PRE_REG_UNREAD:
|
||||||
|
return BROWSE_TYPE_PRE_REG_UNREAD
|
||||||
|
if text == BROWSE_TYPE_SHOULD_READ:
|
||||||
|
return BROWSE_TYPE_SHOULD_READ
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def validate_browse_type(value, default: str = BROWSE_TYPE_SHOULD_READ):
|
||||||
|
text = str(value if value is not None else default).strip()
|
||||||
|
if text not in _BROWSE_TYPES_ALLOWED_INPUT:
|
||||||
|
return None
|
||||||
|
return normalize_browse_type(text, default=default)
|
||||||
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app_logger import get_logger
|
||||||
|
from browser_installer import check_and_install_browser
|
||||||
|
from playwright_automation import PlaywrightBrowserManager
|
||||||
|
|
||||||
|
logger = get_logger("browser_manager")
|
||||||
|
|
||||||
|
_browser_manager: Optional[PlaywrightBrowserManager] = None
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def get_browser_manager() -> Optional[PlaywrightBrowserManager]:
|
||||||
|
return _browser_manager
|
||||||
|
|
||||||
|
|
||||||
|
def init_browser_manager() -> bool:
|
||||||
|
global _browser_manager
|
||||||
|
|
||||||
|
with _lock:
|
||||||
|
if _browser_manager is not None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
logger.info("正在初始化Playwright浏览器管理器...")
|
||||||
|
if not check_and_install_browser(log_callback=lambda msg, account_id=None: logger.info(str(msg))):
|
||||||
|
logger.error("浏览器环境检查失败!")
|
||||||
|
return False
|
||||||
|
|
||||||
|
_browser_manager = PlaywrightBrowserManager(
|
||||||
|
headless=True,
|
||||||
|
log_callback=lambda msg, account_id=None: logger.info(str(msg)),
|
||||||
|
)
|
||||||
|
logger.info("Playwright浏览器管理器创建成功!")
|
||||||
|
return True
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from task_checkpoint import TaskStage, get_checkpoint_manager
|
||||||
|
|
||||||
|
_checkpoint_mgr = None # type: Optional[object]
|
||||||
|
|
||||||
|
|
||||||
|
def init_checkpoint_manager():
|
||||||
|
global _checkpoint_mgr
|
||||||
|
if _checkpoint_mgr is None:
|
||||||
|
_checkpoint_mgr = get_checkpoint_manager()
|
||||||
|
return _checkpoint_mgr
|
||||||
|
|
||||||
|
|
||||||
|
def get_checkpoint_mgr():
|
||||||
|
return init_checkpoint_manager()
|
||||||
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app_logger import LoggerAdapter
|
||||||
|
from app_security import escape_html
|
||||||
|
from services.runtime import get_socketio
|
||||||
|
from services.state import safe_add_log
|
||||||
|
from services.time_utils import get_beijing_now
|
||||||
|
|
||||||
|
|
||||||
|
def log_to_client(message, user_id=None, account_id=None):
|
||||||
|
"""发送日志到Web客户端(用户隔离) + 统一输出到 logger。"""
|
||||||
|
timestamp = get_beijing_now().strftime('%H:%M:%S')
|
||||||
|
log_data = {
|
||||||
|
'timestamp': timestamp,
|
||||||
|
'message': escape_html(str(message)) if message else '',
|
||||||
|
'account_id': account_id
|
||||||
|
}
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
safe_add_log(user_id, log_data)
|
||||||
|
get_socketio().emit('log', log_data, room=f'user_{user_id}')
|
||||||
|
|
||||||
|
ctx = {}
|
||||||
|
if user_id is not None:
|
||||||
|
ctx["user_id"] = user_id
|
||||||
|
if account_id:
|
||||||
|
ctx["account_id"] = account_id
|
||||||
|
LoggerAdapter("app", ctx).info(str(message) if message is not None else "")
|
||||||
|
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
from app_config import get_config
|
||||||
|
from app_logger import get_logger
|
||||||
|
from services.state import (
|
||||||
|
cleanup_expired_ip_rate_limits,
|
||||||
|
safe_cleanup_expired_batches,
|
||||||
|
safe_cleanup_expired_captcha,
|
||||||
|
safe_cleanup_expired_pending_random,
|
||||||
|
safe_get_user_accounts_last_access_items,
|
||||||
|
safe_has_user,
|
||||||
|
safe_iter_task_status_items,
|
||||||
|
safe_remove_task_status,
|
||||||
|
safe_remove_user_accounts,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = get_logger("app")
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
USER_ACCOUNTS_EXPIRE_SECONDS = int(getattr(config, "USER_ACCOUNTS_EXPIRE_SECONDS", 3600))
|
||||||
|
BATCH_TASK_EXPIRE_SECONDS = int(getattr(config, "BATCH_TASK_EXPIRE_SECONDS", 21600))
|
||||||
|
PENDING_RANDOM_EXPIRE_SECONDS = int(getattr(config, "PENDING_RANDOM_EXPIRE_SECONDS", 7200))
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_expired_data() -> None:
|
||||||
|
"""定期清理过期数据,防止内存泄漏(逻辑保持不变)。"""
|
||||||
|
current_time = time.time()
|
||||||
|
|
||||||
|
deleted_captchas = safe_cleanup_expired_captcha(current_time)
|
||||||
|
if deleted_captchas:
|
||||||
|
logger.debug(f"已清理 {deleted_captchas} 个过期验证码")
|
||||||
|
|
||||||
|
deleted_ips = cleanup_expired_ip_rate_limits(current_time)
|
||||||
|
if deleted_ips:
|
||||||
|
logger.debug(f"已清理 {deleted_ips} 个过期IP限流记录")
|
||||||
|
|
||||||
|
expired_users = []
|
||||||
|
last_access_items = safe_get_user_accounts_last_access_items()
|
||||||
|
if last_access_items:
|
||||||
|
task_items = safe_iter_task_status_items()
|
||||||
|
active_user_ids = {int(info.get("user_id")) for _, info in task_items if info.get("user_id")}
|
||||||
|
for user_id, last_access in last_access_items:
|
||||||
|
if (current_time - float(last_access)) <= USER_ACCOUNTS_EXPIRE_SECONDS:
|
||||||
|
continue
|
||||||
|
if int(user_id) in active_user_ids:
|
||||||
|
continue
|
||||||
|
if safe_has_user(user_id):
|
||||||
|
expired_users.append(int(user_id))
|
||||||
|
|
||||||
|
for user_id in expired_users:
|
||||||
|
safe_remove_user_accounts(user_id)
|
||||||
|
if expired_users:
|
||||||
|
logger.debug(f"已清理 {len(expired_users)} 个过期用户账号缓存")
|
||||||
|
|
||||||
|
completed_tasks = []
|
||||||
|
for account_id, status_data in safe_iter_task_status_items():
|
||||||
|
if status_data.get("status") in ["已完成", "失败", "已停止"]:
|
||||||
|
start_time = float(status_data.get("start_time", 0) or 0)
|
||||||
|
if (current_time - start_time) > 600: # 10分钟
|
||||||
|
completed_tasks.append(account_id)
|
||||||
|
for account_id in completed_tasks:
|
||||||
|
safe_remove_task_status(account_id)
|
||||||
|
if completed_tasks:
|
||||||
|
logger.debug(f"已清理 {len(completed_tasks)} 个已完成任务状态")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import os
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
pid, status = os.waitpid(-1, os.WNOHANG)
|
||||||
|
if pid == 0:
|
||||||
|
break
|
||||||
|
logger.debug(f"已回收僵尸进程: PID={pid}")
|
||||||
|
except ChildProcessError:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
deleted_batches = safe_cleanup_expired_batches(BATCH_TASK_EXPIRE_SECONDS, current_time)
|
||||||
|
if deleted_batches:
|
||||||
|
logger.debug(f"已清理 {deleted_batches} 个过期批次任务缓存")
|
||||||
|
|
||||||
|
deleted_random = safe_cleanup_expired_pending_random(PENDING_RANDOM_EXPIRE_SECONDS, current_time)
|
||||||
|
if deleted_random:
|
||||||
|
logger.debug(f"已清理 {deleted_random} 个过期随机延迟任务")
|
||||||
|
|
||||||
|
|
||||||
|
def start_cleanup_scheduler() -> None:
|
||||||
|
"""启动定期清理调度器"""
|
||||||
|
|
||||||
|
def cleanup_loop():
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
time.sleep(300) # 每5分钟执行一次清理
|
||||||
|
cleanup_expired_data()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"清理任务执行失败: {e}")
|
||||||
|
|
||||||
|
cleanup_thread = threading.Thread(target=cleanup_loop, daemon=True, name="cleanup-scheduler")
|
||||||
|
cleanup_thread.start()
|
||||||
|
logger.info("内存清理调度器已启动")
|
||||||
|
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask_login import UserMixin
|
||||||
|
|
||||||
|
from services.state import safe_get_task_status
|
||||||
|
|
||||||
|
|
||||||
|
class User(UserMixin):
|
||||||
|
"""Flask-Login 用户类"""
|
||||||
|
|
||||||
|
def __init__(self, user_id: int):
|
||||||
|
self.id = user_id
|
||||||
|
|
||||||
|
|
||||||
|
class Admin(UserMixin):
|
||||||
|
"""管理员类"""
|
||||||
|
|
||||||
|
def __init__(self, admin_id: int):
|
||||||
|
self.id = admin_id
|
||||||
|
self.is_admin = True
|
||||||
|
|
||||||
|
|
||||||
|
class Account:
|
||||||
|
"""账号类(用于内存缓存与任务执行)"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
account_id: str,
|
||||||
|
user_id: int,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
remember: bool = True,
|
||||||
|
remark: str = "",
|
||||||
|
):
|
||||||
|
self.id = account_id
|
||||||
|
self.user_id = user_id
|
||||||
|
self.username = username
|
||||||
|
self._password = password
|
||||||
|
self.remember = remember
|
||||||
|
self.remark = remark or ""
|
||||||
|
|
||||||
|
# 运行状态
|
||||||
|
self.is_running = False
|
||||||
|
self.should_stop = False
|
||||||
|
self.status = "未开始"
|
||||||
|
|
||||||
|
# 任务数据
|
||||||
|
self.total_items = 0
|
||||||
|
self.total_attachments = 0
|
||||||
|
self.last_browse_type = ""
|
||||||
|
|
||||||
|
# 浏览器自动化对象(主任务)
|
||||||
|
self.automation = None
|
||||||
|
|
||||||
|
# 代理配置(浏览与截图共用)
|
||||||
|
self.proxy_config = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def password(self) -> str:
|
||||||
|
return self._password
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"Account(id={self.id}, username={self.username}, status={self.status})"
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
result = {
|
||||||
|
"id": self.id,
|
||||||
|
"username": self.username,
|
||||||
|
"status": self.status,
|
||||||
|
"remark": self.remark,
|
||||||
|
"total_items": self.total_items,
|
||||||
|
"total_attachments": self.total_attachments,
|
||||||
|
"is_running": self.is_running,
|
||||||
|
}
|
||||||
|
|
||||||
|
ts = safe_get_task_status(self.id)
|
||||||
|
if ts:
|
||||||
|
progress = ts.get("progress", {}) or {}
|
||||||
|
result["detail_status"] = ts.get("detail_status", "")
|
||||||
|
result["progress_items"] = progress.get("items", 0)
|
||||||
|
result["progress_attachments"] = progress.get("attachments", 0)
|
||||||
|
result["start_time"] = ts.get("start_time", 0)
|
||||||
|
if ts.get("start_time"):
|
||||||
|
import time
|
||||||
|
|
||||||
|
elapsed = int(time.time() - ts["start_time"])
|
||||||
|
result["elapsed_seconds"] = elapsed
|
||||||
|
mins, secs = divmod(elapsed, 60)
|
||||||
|
result["elapsed_display"] = f"{mins}分{secs}秒"
|
||||||
|
else:
|
||||||
|
status_map = {
|
||||||
|
"已完成": "任务完成",
|
||||||
|
"截图中": "正在截图",
|
||||||
|
"浏览完成": "浏览完成",
|
||||||
|
"登录失败": "登录失败",
|
||||||
|
"已暂停": "任务已暂停",
|
||||||
|
}
|
||||||
|
for key, val in status_map.items():
|
||||||
|
if key in self.status:
|
||||||
|
result["detail_status"] = val
|
||||||
|
break
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from app_logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("proxy")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_ip_port(ip_port_str: str) -> bool:
|
||||||
|
"""验证IP:PORT格式是否有效(含范围校验)。"""
|
||||||
|
pattern = re.compile(r"^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3}):(\d{1,5})$")
|
||||||
|
match = pattern.match(ip_port_str or "")
|
||||||
|
if not match:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for i in range(1, 5):
|
||||||
|
octet = int(match.group(i))
|
||||||
|
if octet < 0 or octet > 255:
|
||||||
|
return False
|
||||||
|
|
||||||
|
port = int(match.group(5))
|
||||||
|
return 1 <= port <= 65535
|
||||||
|
|
||||||
|
|
||||||
|
def get_proxy_from_api(api_url: str, max_retries: int = 3) -> Optional[str]:
|
||||||
|
"""从API获取代理IP(支持重试)。"""
|
||||||
|
ip_port_pattern = re.compile(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d{1,5}$")
|
||||||
|
max_retries = max(1, int(max_retries or 1))
|
||||||
|
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
try:
|
||||||
|
response = requests.get(api_url, timeout=10)
|
||||||
|
if response.status_code == 200:
|
||||||
|
text = response.text.strip()
|
||||||
|
|
||||||
|
# 尝试解析JSON响应
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
|
||||||
|
data = json.loads(text)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
if data.get("status") not in (200, 0, None):
|
||||||
|
error_msg = data.get("msg", data.get("message", "未知错误"))
|
||||||
|
logger.warning(f"代理API返回错误: {error_msg} (尝试 {attempt + 1}/{max_retries})")
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
time.sleep(1)
|
||||||
|
continue
|
||||||
|
ip_port = data.get("ip") or data.get("proxy") or data.get("data")
|
||||||
|
if ip_port:
|
||||||
|
text = str(ip_port).strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if ip_port_pattern.match(text) and validate_ip_port(text):
|
||||||
|
proxy_server = f"http://{text}"
|
||||||
|
logger.info(f"获取代理成功: {proxy_server} (尝试 {attempt + 1}/{max_retries})")
|
||||||
|
return proxy_server
|
||||||
|
|
||||||
|
logger.warning(f"代理格式或范围无效: {text[:50]} (尝试 {attempt + 1}/{max_retries})")
|
||||||
|
else:
|
||||||
|
logger.warning(f"获取代理失败: HTTP {response.status_code} (尝试 {attempt + 1}/{max_retries})")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"获取代理异常: {str(e)} (尝试 {attempt + 1}/{max_retries})")
|
||||||
|
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
logger.warning(f"获取代理失败,已重试 {max_retries} 次,将不使用代理继续")
|
||||||
|
return None
|
||||||
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
运行时依赖注入:用于 services/routes 访问 socketio/logger 等全局实例。
|
||||||
|
|
||||||
|
说明:
|
||||||
|
- 仅在 app.py 启动装配时调用 init_runtime()
|
||||||
|
- 业务模块中避免直接 import app.py,统一通过本模块获取依赖
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
|
||||||
|
_socketio: Optional[Any] = None
|
||||||
|
_logger: Optional[Any] = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_runtime(*, socketio: Any, logger: Any) -> None:
|
||||||
|
global _socketio, _logger
|
||||||
|
_socketio = socketio
|
||||||
|
_logger = logger
|
||||||
|
|
||||||
|
|
||||||
|
def get_socketio() -> Any:
|
||||||
|
if _socketio is None:
|
||||||
|
raise RuntimeError("socketio 未初始化(请先在 app.py 调用 init_runtime)")
|
||||||
|
return _socketio
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger() -> Any:
|
||||||
|
if _logger is None:
|
||||||
|
raise RuntimeError("logger 未初始化(请先在 app.py 调用 init_runtime)")
|
||||||
|
return _logger
|
||||||
|
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Iterable, Optional, Set, Tuple
|
||||||
|
|
||||||
|
from services.time_utils import BEIJING_TZ
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_hhmm(value: str, default: Tuple[int, int] = (8, 0)) -> Tuple[int, int]:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if ":" not in text:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
h, m = text.split(":", 1)
|
||||||
|
hour = int(h)
|
||||||
|
minute = int(m)
|
||||||
|
if 0 <= hour <= 23 and 0 <= minute <= 59:
|
||||||
|
return hour, minute
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_weekdays(value: str, default: Iterable[int] = (1, 2, 3, 4, 5)) -> Set[int]:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
days = []
|
||||||
|
for part in text.split(","):
|
||||||
|
part = part.strip()
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
day = int(part)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if 1 <= day <= 7:
|
||||||
|
days.append(day)
|
||||||
|
return set(days) if days else set(default)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_cst_datetime(value: Optional[str]) -> Optional[datetime]:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
naive = datetime.strptime(text, "%Y-%m-%d %H:%M:%S")
|
||||||
|
return BEIJING_TZ.localize(naive)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def compute_next_run_at(
|
||||||
|
*,
|
||||||
|
now: datetime,
|
||||||
|
schedule_time: str,
|
||||||
|
weekdays: str,
|
||||||
|
random_delay: int = 0,
|
||||||
|
last_run_at: Optional[str] = None,
|
||||||
|
) -> datetime:
|
||||||
|
"""
|
||||||
|
计算下一次实际执行时间(北京时间,aware datetime)。
|
||||||
|
|
||||||
|
规则:
|
||||||
|
- weekday 过滤(1=周一..7=周日)
|
||||||
|
- random_delay=1 时:在 [schedule_time-15min, schedule_time+15min] 内随机
|
||||||
|
- 同一天只执行一次:若 last_run_at 是今天,则 next_run_at 至少是下一可用日
|
||||||
|
"""
|
||||||
|
if now.tzinfo is None:
|
||||||
|
now = BEIJING_TZ.localize(now)
|
||||||
|
|
||||||
|
hour, minute = _parse_hhmm(schedule_time, default=(8, 0))
|
||||||
|
allowed_weekdays = _parse_weekdays(weekdays, default=(1, 2, 3, 4, 5))
|
||||||
|
random_delay = 1 if int(random_delay or 0) == 1 else 0
|
||||||
|
|
||||||
|
last_run_dt = _parse_cst_datetime(last_run_at)
|
||||||
|
last_run_date = last_run_dt.date() if last_run_dt else None
|
||||||
|
|
||||||
|
base_today = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||||
|
for day_offset in range(0, 14):
|
||||||
|
day = (base_today + timedelta(days=day_offset)).date()
|
||||||
|
if last_run_date is not None and day == last_run_date:
|
||||||
|
continue
|
||||||
|
|
||||||
|
candidate_base = base_today.replace(year=day.year, month=day.month, day=day.day)
|
||||||
|
if candidate_base.isoweekday() not in allowed_weekdays:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if random_delay:
|
||||||
|
window_start = candidate_base - timedelta(minutes=15)
|
||||||
|
random_minutes = random.randint(0, 30)
|
||||||
|
candidate = window_start + timedelta(minutes=random_minutes)
|
||||||
|
else:
|
||||||
|
candidate = candidate_base
|
||||||
|
|
||||||
|
if candidate <= now:
|
||||||
|
continue
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
# 兜底:找不到则推迟一天
|
||||||
|
return now + timedelta(days=1)
|
||||||
|
|
||||||
|
|
||||||
|
def format_cst(dt: datetime) -> str:
|
||||||
|
"""格式化为 DB 存储用的 CST 字符串。"""
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = BEIJING_TZ.localize(dt)
|
||||||
|
dt = dt.astimezone(BEIJING_TZ)
|
||||||
|
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import database
|
||||||
|
import email_service
|
||||||
|
from app_config import get_config
|
||||||
|
from app_logger import get_logger
|
||||||
|
from services.accounts_service import load_user_accounts
|
||||||
|
from services.browse_types import BROWSE_TYPE_SHOULD_READ, normalize_browse_type
|
||||||
|
from services.state import (
|
||||||
|
safe_cleanup_expired_captcha,
|
||||||
|
safe_create_batch,
|
||||||
|
safe_finalize_batch_after_dispatch,
|
||||||
|
safe_get_account,
|
||||||
|
safe_get_user_accounts_snapshot,
|
||||||
|
)
|
||||||
|
from services.task_batches import _send_batch_task_email_if_configured
|
||||||
|
from services.tasks import submit_account_task
|
||||||
|
from services.time_utils import get_beijing_now
|
||||||
|
|
||||||
|
logger = get_logger("app")
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
SCREENSHOTS_DIR = config.SCREENSHOTS_DIR
|
||||||
|
os.makedirs(SCREENSHOTS_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def run_scheduled_task(skip_weekday_check: bool = False) -> None:
|
||||||
|
"""执行所有账号的浏览任务(可被手动调用,过滤重复账号)"""
|
||||||
|
try:
|
||||||
|
config_data = database.get_system_config()
|
||||||
|
browse_type = normalize_browse_type(config_data.get("schedule_browse_type", BROWSE_TYPE_SHOULD_READ))
|
||||||
|
|
||||||
|
if not skip_weekday_check:
|
||||||
|
now_beijing = get_beijing_now()
|
||||||
|
current_weekday = now_beijing.isoweekday()
|
||||||
|
schedule_weekdays = config_data.get("schedule_weekdays", "1,2,3,4,5,6,7")
|
||||||
|
allowed_weekdays = [int(d.strip()) for d in schedule_weekdays.split(",") if d.strip()]
|
||||||
|
|
||||||
|
if current_weekday not in allowed_weekdays:
|
||||||
|
weekday_names = ["", "周一", "周二", "周三", "周四", "周五", "周六", "周日"]
|
||||||
|
logger.info(f"[定时任务] 今天是{weekday_names[current_weekday]},不在执行日期内,跳过执行")
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
logger.info("[立即执行] 跳过星期检查,强制执行任务")
|
||||||
|
|
||||||
|
logger.info(f"[定时任务] 开始执行 - 浏览类型: {browse_type}")
|
||||||
|
|
||||||
|
all_users = database.get_all_users()
|
||||||
|
approved_users = [u for u in all_users if u["status"] == "approved"]
|
||||||
|
|
||||||
|
executed_usernames = set()
|
||||||
|
total_accounts = 0
|
||||||
|
skipped_duplicates = 0
|
||||||
|
executed_accounts = 0
|
||||||
|
|
||||||
|
cfg = database.get_system_config()
|
||||||
|
enable_screenshot_scheduled = cfg.get("enable_screenshot", 0) == 1
|
||||||
|
|
||||||
|
for user in approved_users:
|
||||||
|
user_id = user["id"]
|
||||||
|
accounts = safe_get_user_accounts_snapshot(user_id)
|
||||||
|
if not accounts:
|
||||||
|
load_user_accounts(user_id)
|
||||||
|
accounts = safe_get_user_accounts_snapshot(user_id)
|
||||||
|
for account_id, account in accounts.items():
|
||||||
|
total_accounts += 1
|
||||||
|
|
||||||
|
if account.is_running:
|
||||||
|
continue
|
||||||
|
|
||||||
|
account_status_info = database.get_account_status(account_id)
|
||||||
|
if account_status_info:
|
||||||
|
status = account_status_info["status"] if "status" in account_status_info.keys() else "active"
|
||||||
|
if status == "suspended":
|
||||||
|
fail_count = (
|
||||||
|
account_status_info["login_fail_count"]
|
||||||
|
if "login_fail_count" in account_status_info.keys()
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"[定时任务] 跳过暂停账号: {account.username} (用户:{user['username']}) - 连续{fail_count}次密码错误,需修改密码"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if account.username in executed_usernames:
|
||||||
|
skipped_duplicates += 1
|
||||||
|
logger.info(
|
||||||
|
f"[定时任务] 跳过重复账号: {account.username} (用户:{user['username']}) - 该账号已被其他用户执行"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
executed_usernames.add(account.username)
|
||||||
|
|
||||||
|
logger.info(f"[定时任务] 启动账号: {account.username} (用户:{user['username']})")
|
||||||
|
|
||||||
|
ok, msg = submit_account_task(
|
||||||
|
user_id=user_id,
|
||||||
|
account_id=account_id,
|
||||||
|
browse_type=browse_type,
|
||||||
|
enable_screenshot=enable_screenshot_scheduled,
|
||||||
|
source="scheduled",
|
||||||
|
)
|
||||||
|
if ok:
|
||||||
|
executed_accounts += 1
|
||||||
|
else:
|
||||||
|
logger.warning(f"[定时任务] 启动失败({account.username}): {msg}")
|
||||||
|
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[定时任务] 执行完成 - 总账号数:{total_accounts}, 已执行:{executed_accounts}, 跳过重复:{skipped_duplicates}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"[定时任务] 执行出错: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
def scheduled_task_worker() -> None:
|
||||||
|
"""定时任务工作线程"""
|
||||||
|
import schedule
|
||||||
|
|
||||||
|
def cleanup_expired_captcha():
|
||||||
|
try:
|
||||||
|
deleted_count = safe_cleanup_expired_captcha()
|
||||||
|
if deleted_count > 0:
|
||||||
|
logger.info(f"[定时清理] 已清理 {deleted_count} 个过期验证码")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[定时清理] 清理验证码出错: {str(e)}")
|
||||||
|
|
||||||
|
def cleanup_old_data():
|
||||||
|
"""清理旧数据:7天前截图和任务日志,30天前操作日志和定时任务执行日志"""
|
||||||
|
try:
|
||||||
|
logger.info("[定时清理] 开始清理旧数据...")
|
||||||
|
|
||||||
|
deleted_logs = database.delete_old_task_logs(7)
|
||||||
|
logger.info(f"[定时清理] 已删除 {deleted_logs} 条任务日志")
|
||||||
|
|
||||||
|
deleted_operation_logs = database.clean_old_operation_logs(30)
|
||||||
|
logger.info(f"[定时清理] 已删除 {deleted_operation_logs} 条操作日志")
|
||||||
|
|
||||||
|
deleted_schedule_logs = database.clean_old_schedule_logs(30)
|
||||||
|
logger.info(f"[定时清理] 已删除 {deleted_schedule_logs} 条定时任务执行日志")
|
||||||
|
|
||||||
|
deleted_screenshots = 0
|
||||||
|
if os.path.exists(SCREENSHOTS_DIR):
|
||||||
|
cutoff_time = time.time() - (7 * 24 * 60 * 60)
|
||||||
|
for filename in os.listdir(SCREENSHOTS_DIR):
|
||||||
|
if filename.lower().endswith((".png", ".jpg", ".jpeg")):
|
||||||
|
filepath = os.path.join(SCREENSHOTS_DIR, filename)
|
||||||
|
try:
|
||||||
|
if os.path.getmtime(filepath) < cutoff_time:
|
||||||
|
os.remove(filepath)
|
||||||
|
deleted_screenshots += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[定时清理] 删除截图失败 {filename}: {str(e)}")
|
||||||
|
|
||||||
|
logger.info(f"[定时清理] 已删除 {deleted_screenshots} 个截图文件")
|
||||||
|
logger.info("[定时清理] 清理完成!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"[定时清理] 清理任务出错: {str(e)}")
|
||||||
|
|
||||||
|
def check_user_schedules():
|
||||||
|
"""检查并执行用户定时任务(O-08:next_run_at 索引驱动)。"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
try:
|
||||||
|
now = get_beijing_now()
|
||||||
|
now_str = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
current_weekday = now.isoweekday()
|
||||||
|
|
||||||
|
due_schedules = database.get_due_user_schedules(now_str, limit=50) or []
|
||||||
|
|
||||||
|
for schedule_config in due_schedules:
|
||||||
|
schedule_name = schedule_config.get("name", "未命名任务")
|
||||||
|
schedule_id = schedule_config["id"]
|
||||||
|
|
||||||
|
weekdays_str = schedule_config.get("weekdays", "1,2,3,4,5")
|
||||||
|
try:
|
||||||
|
allowed_weekdays = [int(d) for d in weekdays_str.split(",") if d.strip()]
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[定时任务] 任务#{schedule_id} 解析weekdays失败: {e}")
|
||||||
|
try:
|
||||||
|
database.recompute_schedule_next_run(schedule_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
continue
|
||||||
|
|
||||||
|
if current_weekday not in allowed_weekdays:
|
||||||
|
try:
|
||||||
|
database.recompute_schedule_next_run(schedule_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(f"[用户定时任务] 任务#{schedule_id} '{schedule_name}' 到期,开始执行 (next_run_at={schedule_config.get('next_run_at')})")
|
||||||
|
|
||||||
|
user_id = schedule_config["user_id"]
|
||||||
|
schedule_id = schedule_config["id"]
|
||||||
|
browse_type = normalize_browse_type(schedule_config.get("browse_type", BROWSE_TYPE_SHOULD_READ))
|
||||||
|
enable_screenshot = schedule_config.get("enable_screenshot", 1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
account_ids_raw = schedule_config.get("account_ids", "[]") or "[]"
|
||||||
|
account_ids = json.loads(account_ids_raw)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[定时任务] 任务#{schedule_id} 解析account_ids失败: {e}")
|
||||||
|
account_ids = []
|
||||||
|
|
||||||
|
if not account_ids:
|
||||||
|
try:
|
||||||
|
database.recompute_schedule_next_run(schedule_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not safe_get_user_accounts_snapshot(user_id):
|
||||||
|
load_user_accounts(user_id)
|
||||||
|
|
||||||
|
import time as time_mod
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
execution_start_time = time_mod.time()
|
||||||
|
log_id = database.create_schedule_execution_log(
|
||||||
|
schedule_id=schedule_id, user_id=user_id, schedule_name=schedule_config.get("name", "未命名任务")
|
||||||
|
)
|
||||||
|
|
||||||
|
batch_id = f"batch_{uuid.uuid4().hex[:12]}"
|
||||||
|
now_ts = time_mod.time()
|
||||||
|
safe_create_batch(
|
||||||
|
batch_id,
|
||||||
|
{
|
||||||
|
"user_id": user_id,
|
||||||
|
"browse_type": browse_type,
|
||||||
|
"schedule_name": schedule_config.get("name", "未命名任务"),
|
||||||
|
"screenshots": [],
|
||||||
|
"total_accounts": 0,
|
||||||
|
"completed": 0,
|
||||||
|
"created_at": now_ts,
|
||||||
|
"updated_at": now_ts,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
started_count = 0
|
||||||
|
skipped_count = 0
|
||||||
|
completion_lock = threading.Lock()
|
||||||
|
remaining = {"count": 0, "done": False}
|
||||||
|
|
||||||
|
def on_browse_done():
|
||||||
|
with completion_lock:
|
||||||
|
remaining["count"] -= 1
|
||||||
|
if remaining["done"] or remaining["count"] > 0:
|
||||||
|
return
|
||||||
|
remaining["done"] = True
|
||||||
|
execution_duration = int(time_mod.time() - execution_start_time)
|
||||||
|
database.update_schedule_execution_log(
|
||||||
|
log_id,
|
||||||
|
total_accounts=len(account_ids),
|
||||||
|
success_accounts=started_count,
|
||||||
|
failed_accounts=len(account_ids) - started_count,
|
||||||
|
duration_seconds=execution_duration,
|
||||||
|
status="completed",
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"[用户定时任务] 任务#{schedule_id}浏览阶段完成,耗时{execution_duration}秒,等待截图完成后发送邮件"
|
||||||
|
)
|
||||||
|
|
||||||
|
for account_id in account_ids:
|
||||||
|
account = safe_get_account(user_id, account_id)
|
||||||
|
if not account:
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
if account.is_running:
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
task_source = f"user_scheduled:{batch_id}"
|
||||||
|
with completion_lock:
|
||||||
|
remaining["count"] += 1
|
||||||
|
ok, msg = submit_account_task(
|
||||||
|
user_id=user_id,
|
||||||
|
account_id=account_id,
|
||||||
|
browse_type=browse_type,
|
||||||
|
enable_screenshot=enable_screenshot,
|
||||||
|
source=task_source,
|
||||||
|
done_callback=on_browse_done,
|
||||||
|
)
|
||||||
|
if ok:
|
||||||
|
started_count += 1
|
||||||
|
else:
|
||||||
|
with completion_lock:
|
||||||
|
remaining["count"] -= 1
|
||||||
|
skipped_count += 1
|
||||||
|
logger.warning(f"[用户定时任务] 账号 {account.username} 启动失败: {msg}")
|
||||||
|
|
||||||
|
batch_info = safe_finalize_batch_after_dispatch(batch_id, started_count, now_ts=time_mod.time())
|
||||||
|
if batch_info:
|
||||||
|
_send_batch_task_email_if_configured(batch_info)
|
||||||
|
|
||||||
|
database.update_schedule_last_run(schedule_id)
|
||||||
|
|
||||||
|
logger.info(f"[用户定时任务] 已启动 {started_count} 个账号,跳过 {skipped_count} 个账号,批次ID: {batch_id}")
|
||||||
|
if started_count <= 0:
|
||||||
|
database.update_schedule_execution_log(
|
||||||
|
log_id,
|
||||||
|
total_accounts=len(account_ids),
|
||||||
|
success_accounts=0,
|
||||||
|
failed_accounts=len(account_ids),
|
||||||
|
duration_seconds=0,
|
||||||
|
status="completed",
|
||||||
|
)
|
||||||
|
if started_count == 0 and len(account_ids) > 0:
|
||||||
|
logger.warning("[用户定时任务] ⚠️ 警告:所有账号都被跳过了!请检查user_accounts状态")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"[用户定时任务] 检查出错: {str(e)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
config_check_interval = float(os.environ.get("SCHEDULER_CONFIG_CHECK_SECONDS", "30"))
|
||||||
|
except Exception:
|
||||||
|
config_check_interval = 30.0
|
||||||
|
config_check_interval = max(5.0, config_check_interval)
|
||||||
|
|
||||||
|
schedule_state = {"signature": None}
|
||||||
|
|
||||||
|
def check_and_schedule(force: bool = False):
|
||||||
|
config_data = database.get_system_config()
|
||||||
|
schedule_enabled = bool(config_data.get("schedule_enabled"))
|
||||||
|
schedule_time_cst = config_data.get("schedule_time", "02:00")
|
||||||
|
|
||||||
|
signature = (schedule_enabled, schedule_time_cst)
|
||||||
|
config_changed = schedule_state.get("signature") != signature
|
||||||
|
is_first_run = schedule_state.get("signature") is None
|
||||||
|
if (not force) and (not config_changed):
|
||||||
|
return
|
||||||
|
schedule_state["signature"] = signature
|
||||||
|
|
||||||
|
schedule.clear()
|
||||||
|
|
||||||
|
cleanup_time_cst = "03:00"
|
||||||
|
schedule.every().day.at(cleanup_time_cst).do(cleanup_old_data)
|
||||||
|
|
||||||
|
schedule.every().hour.do(cleanup_expired_captcha)
|
||||||
|
|
||||||
|
quota_reset_time_cst = "00:00"
|
||||||
|
schedule.every().day.at(quota_reset_time_cst).do(email_service.reset_smtp_daily_quota)
|
||||||
|
|
||||||
|
if is_first_run:
|
||||||
|
logger.info(f"[定时任务] 已设置数据清理任务: 每天 CST {cleanup_time_cst}")
|
||||||
|
logger.info(f"[定时任务] 已设置验证码清理任务: 每小时执行一次")
|
||||||
|
logger.info(f"[定时任务] 已设置SMTP配额重置: 每天 CST {quota_reset_time_cst}")
|
||||||
|
|
||||||
|
if schedule_enabled:
|
||||||
|
schedule.every().day.at(schedule_time_cst).do(run_scheduled_task)
|
||||||
|
if is_first_run or config_changed:
|
||||||
|
logger.info(f"[定时任务] 已设置浏览任务: 每天 CST {schedule_time_cst}")
|
||||||
|
elif config_changed and not is_first_run:
|
||||||
|
logger.info("[定时任务] 浏览任务已禁用")
|
||||||
|
|
||||||
|
check_and_schedule(force=True)
|
||||||
|
last_config_check = time.time()
|
||||||
|
last_user_schedule_minute = None
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
schedule.run_pending()
|
||||||
|
|
||||||
|
now_ts = time.time()
|
||||||
|
if now_ts - last_config_check >= config_check_interval:
|
||||||
|
check_and_schedule()
|
||||||
|
last_config_check = now_ts
|
||||||
|
|
||||||
|
now_beijing = get_beijing_now()
|
||||||
|
minute_key = now_beijing.strftime("%Y-%m-%d %H:%M")
|
||||||
|
if minute_key != last_user_schedule_minute:
|
||||||
|
check_user_schedules()
|
||||||
|
last_user_schedule_minute = minute_key
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"[定时任务] 调度器出错: {str(e)}")
|
||||||
|
time.sleep(5)
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import database
|
||||||
|
import email_service
|
||||||
|
from app_config import get_config
|
||||||
|
from app_logger import get_logger
|
||||||
|
from browser_pool_worker import get_browser_worker_pool
|
||||||
|
from playwright_automation import PlaywrightAutomation
|
||||||
|
from services.browser_manager import get_browser_manager
|
||||||
|
from services.client_log import log_to_client
|
||||||
|
from services.runtime import get_socketio
|
||||||
|
from services.state import safe_get_account, safe_remove_task_status, safe_update_task_status
|
||||||
|
from services.task_batches import _batch_task_record_result, _get_batch_id_from_source
|
||||||
|
from services.time_utils import get_beijing_now
|
||||||
|
|
||||||
|
logger = get_logger("app")
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
SCREENSHOTS_DIR = config.SCREENSHOTS_DIR
|
||||||
|
os.makedirs(SCREENSHOTS_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _emit(event: str, data: object, *, room: str | None = None) -> None:
|
||||||
|
try:
|
||||||
|
socketio = get_socketio()
|
||||||
|
socketio.emit(event, data, room=room)
|
||||||
|
except Exception:
|
||||||
|
# runtime 未初始化时(如测试/离线脚本),忽略推送
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def take_screenshot_for_account(
|
||||||
|
user_id,
|
||||||
|
account_id,
|
||||||
|
browse_type="应读",
|
||||||
|
source="manual",
|
||||||
|
task_start_time=None,
|
||||||
|
browse_result=None,
|
||||||
|
):
|
||||||
|
"""为账号任务完成后截图(使用工作线程池,真正的浏览器复用)"""
|
||||||
|
account = safe_get_account(user_id, account_id)
|
||||||
|
if not account:
|
||||||
|
return
|
||||||
|
# 以本次调用的 browse_type 为准(避免 last_browse_type 被刷新/重载导致截图页面不一致)
|
||||||
|
if browse_type:
|
||||||
|
account.last_browse_type = browse_type
|
||||||
|
|
||||||
|
# 标记账号正在截图(防止重复提交截图任务)
|
||||||
|
account.is_running = True
|
||||||
|
|
||||||
|
def screenshot_task(browser_instance, user_id, account_id, account, browse_type, source, task_start_time, browse_result):
|
||||||
|
"""在worker线程中执行的截图任务"""
|
||||||
|
# ✅ 获得worker后,立即更新状态为"截图中"
|
||||||
|
acc = safe_get_account(user_id, account_id)
|
||||||
|
if acc:
|
||||||
|
acc.status = "截图中"
|
||||||
|
safe_update_task_status(account_id, {"status": "运行中", "detail_status": "正在截图"})
|
||||||
|
_emit("account_update", acc.to_dict(), room=f"user_{user_id}")
|
||||||
|
|
||||||
|
max_retries = 3
|
||||||
|
|
||||||
|
for attempt in range(1, max_retries + 1):
|
||||||
|
automation = None
|
||||||
|
try:
|
||||||
|
safe_update_task_status(
|
||||||
|
account_id,
|
||||||
|
{"detail_status": f"正在截图{f' (第{attempt}次)' if attempt > 1 else ''}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if attempt > 1:
|
||||||
|
log_to_client(f"🔄 第 {attempt} 次截图尝试...", user_id, account_id)
|
||||||
|
|
||||||
|
log_to_client(
|
||||||
|
f"使用Worker-{browser_instance['worker_id']}的浏览器(已使用{browser_instance['use_count']}次)",
|
||||||
|
user_id,
|
||||||
|
account_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
proxy_config = account.proxy_config if hasattr(account, "proxy_config") else None
|
||||||
|
automation = PlaywrightAutomation(get_browser_manager(), account_id, proxy_config=proxy_config)
|
||||||
|
automation.playwright = browser_instance["playwright"]
|
||||||
|
automation.browser = browser_instance["browser"]
|
||||||
|
|
||||||
|
def custom_log(message: str):
|
||||||
|
log_to_client(message, user_id, account_id)
|
||||||
|
|
||||||
|
automation.log = custom_log
|
||||||
|
|
||||||
|
log_to_client("登录中...", user_id, account_id)
|
||||||
|
login_result = automation.quick_login(account.username, account.password, account.remember)
|
||||||
|
if not login_result["success"]:
|
||||||
|
error_message = login_result.get("message", "截图登录失败")
|
||||||
|
log_to_client(f"截图登录失败: {error_message}", user_id, account_id)
|
||||||
|
if attempt < max_retries:
|
||||||
|
log_to_client("将重试...", user_id, account_id)
|
||||||
|
time.sleep(2)
|
||||||
|
continue
|
||||||
|
log_to_client("❌ 截图失败: 登录失败", user_id, account_id)
|
||||||
|
return {"success": False, "error": "登录失败"}
|
||||||
|
|
||||||
|
log_to_client(f"导航到 '{browse_type}' 页面...", user_id, account_id)
|
||||||
|
|
||||||
|
# 截图场景:优先用 bz 参数直达页面(更稳定,避免页面按钮点击失败导致截图跑偏)
|
||||||
|
navigated = False
|
||||||
|
try:
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
parsed = urlsplit(config.ZSGL_LOGIN_URL)
|
||||||
|
base = f"{parsed.scheme}://{parsed.netloc}"
|
||||||
|
if "注册前" in str(browse_type):
|
||||||
|
bz = 0
|
||||||
|
else:
|
||||||
|
bz = 2 # 应读
|
||||||
|
target_url = f"{base}/admin/center.aspx?bz={bz}"
|
||||||
|
automation.main_page.goto(target_url, timeout=60000)
|
||||||
|
current_url = getattr(automation.main_page, "url", "") or ""
|
||||||
|
if "center.aspx" not in current_url:
|
||||||
|
raise RuntimeError(f"unexpected_url:{current_url}")
|
||||||
|
try:
|
||||||
|
automation.main_page.wait_for_load_state("networkidle", timeout=30000)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
automation.main_page.wait_for_selector("table.ltable", timeout=20000)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
navigated = True
|
||||||
|
except Exception as nav_error:
|
||||||
|
log_to_client(f"直达页面失败,将尝试按钮切换: {str(nav_error)[:120]}", user_id, account_id)
|
||||||
|
|
||||||
|
# 兼容兜底:若直达失败,则回退到原有按钮切换方式
|
||||||
|
if not navigated:
|
||||||
|
result = automation.browse_content(
|
||||||
|
navigate_only=True,
|
||||||
|
browse_type=browse_type,
|
||||||
|
auto_next_page=False,
|
||||||
|
auto_view_attachments=False,
|
||||||
|
interval=0,
|
||||||
|
should_stop_callback=None,
|
||||||
|
)
|
||||||
|
if not result.success and result.error_message:
|
||||||
|
log_to_client(f"导航警告: {result.error_message}", user_id, account_id)
|
||||||
|
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
timestamp = get_beijing_now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
|
||||||
|
user_info = database.get_user_by_id(user_id)
|
||||||
|
username_prefix = user_info["username"] if user_info else f"user{user_id}"
|
||||||
|
login_account = account.remark if account.remark else account.username
|
||||||
|
screenshot_filename = f"{username_prefix}_{login_account}_{browse_type}_{timestamp}.jpg"
|
||||||
|
screenshot_path = os.path.join(SCREENSHOTS_DIR, screenshot_filename)
|
||||||
|
|
||||||
|
if automation.take_screenshot(screenshot_path):
|
||||||
|
if os.path.exists(screenshot_path) and os.path.getsize(screenshot_path) > 1000:
|
||||||
|
log_to_client(f"✓ 截图成功: {screenshot_filename}", user_id, account_id)
|
||||||
|
return {"success": True, "filename": screenshot_filename}
|
||||||
|
log_to_client("截图文件异常,将重试", user_id, account_id)
|
||||||
|
if os.path.exists(screenshot_path):
|
||||||
|
os.remove(screenshot_path)
|
||||||
|
else:
|
||||||
|
log_to_client("截图保存失败", user_id, account_id)
|
||||||
|
|
||||||
|
if attempt < max_retries:
|
||||||
|
log_to_client("将重试...", user_id, account_id)
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log_to_client(f"截图出错: {str(e)}", user_id, account_id)
|
||||||
|
if attempt < max_retries:
|
||||||
|
log_to_client("将重试...", user_id, account_id)
|
||||||
|
time.sleep(2)
|
||||||
|
finally:
|
||||||
|
if automation:
|
||||||
|
try:
|
||||||
|
if automation.context:
|
||||||
|
automation.context.close()
|
||||||
|
automation.context = None
|
||||||
|
automation.page = None
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"关闭context时出错: {e}")
|
||||||
|
|
||||||
|
return {"success": False, "error": "截图失败,已重试3次"}
|
||||||
|
|
||||||
|
def screenshot_callback(result, error):
|
||||||
|
"""截图完成回调"""
|
||||||
|
try:
|
||||||
|
account.is_running = False
|
||||||
|
account.status = "未开始"
|
||||||
|
|
||||||
|
safe_remove_task_status(account_id)
|
||||||
|
|
||||||
|
_emit("account_update", account.to_dict(), room=f"user_{user_id}")
|
||||||
|
|
||||||
|
if error:
|
||||||
|
log_to_client(f"❌ 截图失败: {error}", user_id, account_id)
|
||||||
|
elif not result or not result.get("success"):
|
||||||
|
error_msg = result.get("error", "未知错误") if result else "未知错误"
|
||||||
|
log_to_client(f"❌ 截图失败: {error_msg}", user_id, account_id)
|
||||||
|
|
||||||
|
if task_start_time and browse_result:
|
||||||
|
import time as time_module
|
||||||
|
|
||||||
|
total_elapsed = int(time_module.time() - task_start_time)
|
||||||
|
database.create_task_log(
|
||||||
|
user_id=user_id,
|
||||||
|
account_id=account_id,
|
||||||
|
username=account.username,
|
||||||
|
browse_type=browse_type,
|
||||||
|
status="success",
|
||||||
|
total_items=browse_result.get("total_items", 0),
|
||||||
|
total_attachments=browse_result.get("total_attachments", 0),
|
||||||
|
duration=total_elapsed,
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
batch_id = _get_batch_id_from_source(source)
|
||||||
|
|
||||||
|
screenshot_path = None
|
||||||
|
if result and result.get("success") and result.get("filename"):
|
||||||
|
screenshot_path = os.path.join(SCREENSHOTS_DIR, result["filename"])
|
||||||
|
|
||||||
|
account_name = account.remark if account.remark else account.username
|
||||||
|
|
||||||
|
if batch_id:
|
||||||
|
_batch_task_record_result(
|
||||||
|
batch_id=batch_id,
|
||||||
|
account_name=account_name,
|
||||||
|
screenshot_path=screenshot_path,
|
||||||
|
total_items=browse_result.get("total_items", 0),
|
||||||
|
total_attachments=browse_result.get("total_attachments", 0),
|
||||||
|
)
|
||||||
|
elif source and source.startswith("user_scheduled"):
|
||||||
|
user_info = database.get_user_by_id(user_id)
|
||||||
|
if user_info and user_info.get("email") and database.get_user_email_notify(user_id):
|
||||||
|
email_service.send_task_complete_email_async(
|
||||||
|
user_id=user_id,
|
||||||
|
email=user_info["email"],
|
||||||
|
username=user_info["username"],
|
||||||
|
account_name=account_name,
|
||||||
|
browse_type=browse_type,
|
||||||
|
total_items=browse_result.get("total_items", 0),
|
||||||
|
total_attachments=browse_result.get("total_attachments", 0),
|
||||||
|
screenshot_path=screenshot_path,
|
||||||
|
log_callback=lambda msg: log_to_client(msg, user_id, account_id),
|
||||||
|
)
|
||||||
|
except Exception as email_error:
|
||||||
|
logger.warning(f"发送任务完成邮件失败: {email_error}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"截图回调出错: {e}")
|
||||||
|
|
||||||
|
pool = get_browser_worker_pool()
|
||||||
|
submitted = pool.submit_task(
|
||||||
|
screenshot_task,
|
||||||
|
screenshot_callback,
|
||||||
|
user_id,
|
||||||
|
account_id,
|
||||||
|
account,
|
||||||
|
browse_type,
|
||||||
|
source,
|
||||||
|
task_start_time,
|
||||||
|
browse_result,
|
||||||
|
)
|
||||||
|
if not submitted:
|
||||||
|
screenshot_callback(None, "截图队列已满,请稍后重试")
|
||||||
|
|
||||||
@@ -0,0 +1,481 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
线程安全的全局状态管理(P0 / O-01)
|
||||||
|
|
||||||
|
约束:
|
||||||
|
- 业务代码禁止直接读写底层 dict;必须通过本模块 safe_* API 访问
|
||||||
|
- 读:要么持锁并返回副本,要么以“快照”的方式返回可迭代列表
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from app_config import get_config
|
||||||
|
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Active tasks(运行中的任务句柄) ====================
|
||||||
|
|
||||||
|
_active_tasks: Dict[str, Any] = {}
|
||||||
|
_active_tasks_lock = threading.RLock()
|
||||||
|
|
||||||
|
|
||||||
|
def safe_set_task(account_id: str, handle: Any) -> None:
|
||||||
|
with _active_tasks_lock:
|
||||||
|
_active_tasks[account_id] = handle
|
||||||
|
|
||||||
|
|
||||||
|
def safe_get_task(account_id: str) -> Any:
|
||||||
|
with _active_tasks_lock:
|
||||||
|
return _active_tasks.get(account_id)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_remove_task(account_id: str) -> Any:
|
||||||
|
with _active_tasks_lock:
|
||||||
|
return _active_tasks.pop(account_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_get_active_task_ids() -> List[str]:
|
||||||
|
with _active_tasks_lock:
|
||||||
|
return list(_active_tasks.keys())
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Task status(前端展示状态) ====================
|
||||||
|
|
||||||
|
_task_status: Dict[str, Dict[str, Any]] = {}
|
||||||
|
_task_status_lock = threading.RLock()
|
||||||
|
|
||||||
|
|
||||||
|
def safe_set_task_status(account_id: str, status_dict: Dict[str, Any]) -> None:
|
||||||
|
with _task_status_lock:
|
||||||
|
_task_status[account_id] = dict(status_dict or {})
|
||||||
|
|
||||||
|
|
||||||
|
def safe_update_task_status(account_id: str, updates: Dict[str, Any]) -> bool:
|
||||||
|
with _task_status_lock:
|
||||||
|
if account_id not in _task_status:
|
||||||
|
return False
|
||||||
|
_task_status[account_id].update(updates or {})
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def safe_get_task_status(account_id: str) -> Dict[str, Any]:
|
||||||
|
with _task_status_lock:
|
||||||
|
value = _task_status.get(account_id)
|
||||||
|
return dict(value) if value else {}
|
||||||
|
|
||||||
|
|
||||||
|
def safe_remove_task_status(account_id: str) -> Dict[str, Any]:
|
||||||
|
with _task_status_lock:
|
||||||
|
return _task_status.pop(account_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_get_all_task_status() -> Dict[str, Dict[str, Any]]:
|
||||||
|
with _task_status_lock:
|
||||||
|
return {k: dict(v) for k, v in _task_status.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def safe_iter_task_status_items() -> List[Tuple[str, Dict[str, Any]]]:
|
||||||
|
with _task_status_lock:
|
||||||
|
return [(k, dict(v)) for k, v in _task_status.items()]
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== User accounts cache(账号对象缓存) ====================
|
||||||
|
|
||||||
|
_user_accounts: Dict[int, Dict[str, Any]] = {}
|
||||||
|
_user_accounts_last_access: Dict[int, float] = {}
|
||||||
|
_user_accounts_lock = threading.RLock()
|
||||||
|
|
||||||
|
|
||||||
|
def safe_touch_user_accounts(user_id: int) -> None:
|
||||||
|
now_ts = time.time()
|
||||||
|
with _user_accounts_lock:
|
||||||
|
_user_accounts_last_access[int(user_id)] = now_ts
|
||||||
|
|
||||||
|
|
||||||
|
def safe_get_user_accounts_last_access_items() -> List[Tuple[int, float]]:
|
||||||
|
with _user_accounts_lock:
|
||||||
|
return list(_user_accounts_last_access.items())
|
||||||
|
|
||||||
|
|
||||||
|
def safe_get_user_accounts_snapshot(user_id: int) -> Dict[str, Any]:
|
||||||
|
with _user_accounts_lock:
|
||||||
|
return dict(_user_accounts.get(int(user_id), {}))
|
||||||
|
|
||||||
|
|
||||||
|
def safe_set_user_accounts(user_id: int, accounts_by_id: Dict[str, Any]) -> None:
|
||||||
|
with _user_accounts_lock:
|
||||||
|
_user_accounts[int(user_id)] = dict(accounts_by_id or {})
|
||||||
|
_user_accounts_last_access[int(user_id)] = time.time()
|
||||||
|
|
||||||
|
|
||||||
|
def safe_get_account(user_id: int, account_id: str) -> Any:
|
||||||
|
with _user_accounts_lock:
|
||||||
|
return _user_accounts.get(int(user_id), {}).get(account_id)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_set_account(user_id: int, account_id: str, account_obj: Any) -> None:
|
||||||
|
with _user_accounts_lock:
|
||||||
|
uid = int(user_id)
|
||||||
|
if uid not in _user_accounts:
|
||||||
|
_user_accounts[uid] = {}
|
||||||
|
_user_accounts[uid][account_id] = account_obj
|
||||||
|
_user_accounts_last_access[uid] = time.time()
|
||||||
|
|
||||||
|
|
||||||
|
def safe_remove_account(user_id: int, account_id: str) -> Any:
|
||||||
|
with _user_accounts_lock:
|
||||||
|
uid = int(user_id)
|
||||||
|
if uid not in _user_accounts:
|
||||||
|
return None
|
||||||
|
return _user_accounts[uid].pop(account_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_remove_user_accounts(user_id: int) -> None:
|
||||||
|
with _user_accounts_lock:
|
||||||
|
uid = int(user_id)
|
||||||
|
_user_accounts.pop(uid, None)
|
||||||
|
_user_accounts_last_access.pop(uid, None)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_iter_user_accounts_items() -> List[Tuple[int, Dict[str, Any]]]:
|
||||||
|
with _user_accounts_lock:
|
||||||
|
return [(uid, dict(accounts)) for uid, accounts in _user_accounts.items()]
|
||||||
|
|
||||||
|
|
||||||
|
def safe_has_user(user_id: int) -> bool:
|
||||||
|
with _user_accounts_lock:
|
||||||
|
return int(user_id) in _user_accounts
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Log cache(用户维度日志缓存) ====================
|
||||||
|
|
||||||
|
_log_cache: Dict[int, List[Dict[str, Any]]] = {}
|
||||||
|
_log_cache_lock = threading.RLock()
|
||||||
|
_log_cache_total_count = 0
|
||||||
|
|
||||||
|
|
||||||
|
def safe_add_log(
|
||||||
|
user_id: int,
|
||||||
|
log_entry: Dict[str, Any],
|
||||||
|
*,
|
||||||
|
max_logs_per_user: Optional[int] = None,
|
||||||
|
max_total_logs: Optional[int] = None,
|
||||||
|
) -> None:
|
||||||
|
global _log_cache_total_count
|
||||||
|
|
||||||
|
uid = int(user_id)
|
||||||
|
max_logs_per_user = int(max_logs_per_user or config.MAX_LOGS_PER_USER)
|
||||||
|
max_total_logs = int(max_total_logs or config.MAX_TOTAL_LOGS)
|
||||||
|
|
||||||
|
with _log_cache_lock:
|
||||||
|
if uid not in _log_cache:
|
||||||
|
_log_cache[uid] = []
|
||||||
|
|
||||||
|
if len(_log_cache[uid]) >= max_logs_per_user:
|
||||||
|
_log_cache[uid].pop(0)
|
||||||
|
_log_cache_total_count = max(0, _log_cache_total_count - 1)
|
||||||
|
|
||||||
|
_log_cache[uid].append(dict(log_entry or {}))
|
||||||
|
_log_cache_total_count += 1
|
||||||
|
|
||||||
|
while _log_cache_total_count > max_total_logs:
|
||||||
|
if not _log_cache:
|
||||||
|
break
|
||||||
|
max_user = max(_log_cache.keys(), key=lambda u: len(_log_cache[u]))
|
||||||
|
if _log_cache.get(max_user):
|
||||||
|
_log_cache[max_user].pop(0)
|
||||||
|
_log_cache_total_count -= 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
def safe_get_user_logs(user_id: int) -> List[Dict[str, Any]]:
|
||||||
|
uid = int(user_id)
|
||||||
|
with _log_cache_lock:
|
||||||
|
return list(_log_cache.get(uid, []))
|
||||||
|
|
||||||
|
|
||||||
|
def safe_clear_user_logs(user_id: int) -> None:
|
||||||
|
global _log_cache_total_count
|
||||||
|
|
||||||
|
uid = int(user_id)
|
||||||
|
with _log_cache_lock:
|
||||||
|
removed = len(_log_cache.get(uid, []))
|
||||||
|
_log_cache.pop(uid, None)
|
||||||
|
_log_cache_total_count = max(0, _log_cache_total_count - removed)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_get_log_cache_total_count() -> int:
|
||||||
|
with _log_cache_lock:
|
||||||
|
return int(_log_cache_total_count)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Captcha storage(验证码存储) ====================
|
||||||
|
|
||||||
|
_captcha_storage: Dict[str, Dict[str, Any]] = {}
|
||||||
|
_captcha_storage_lock = threading.RLock()
|
||||||
|
|
||||||
|
|
||||||
|
def safe_set_captcha(session_id: str, captcha_data: Dict[str, Any]) -> None:
|
||||||
|
with _captcha_storage_lock:
|
||||||
|
_captcha_storage[str(session_id)] = dict(captcha_data or {})
|
||||||
|
|
||||||
|
|
||||||
|
def safe_cleanup_expired_captcha(now_ts: Optional[float] = None) -> int:
|
||||||
|
now_ts = float(now_ts if now_ts is not None else time.time())
|
||||||
|
with _captcha_storage_lock:
|
||||||
|
expired = [k for k, v in _captcha_storage.items() if float(v.get("expire_time", 0) or 0) < now_ts]
|
||||||
|
for k in expired:
|
||||||
|
_captcha_storage.pop(k, None)
|
||||||
|
return len(expired)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_delete_captcha(session_id: str) -> None:
|
||||||
|
with _captcha_storage_lock:
|
||||||
|
_captcha_storage.pop(str(session_id), None)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_verify_and_consume_captcha(session_id: str, code: str, *, max_attempts: Optional[int] = None) -> Tuple[bool, str]:
|
||||||
|
max_attempts = int(max_attempts or config.MAX_CAPTCHA_ATTEMPTS)
|
||||||
|
with _captcha_storage_lock:
|
||||||
|
captcha_data = _captcha_storage.pop(str(session_id), None)
|
||||||
|
|
||||||
|
if captcha_data is None:
|
||||||
|
return False, "验证码已过期或不存在,请重新获取"
|
||||||
|
|
||||||
|
try:
|
||||||
|
if float(captcha_data.get("expire_time", 0) or 0) < time.time():
|
||||||
|
return False, "验证码已过期,请重新获取"
|
||||||
|
|
||||||
|
failed_attempts = int(captcha_data.get("failed_attempts", 0) or 0)
|
||||||
|
if failed_attempts >= max_attempts:
|
||||||
|
return False, f"验证码错误次数过多({max_attempts}次),请重新获取"
|
||||||
|
|
||||||
|
expected = str(captcha_data.get("code", "") or "").lower()
|
||||||
|
actual = str(code or "").lower()
|
||||||
|
if expected != actual:
|
||||||
|
failed_attempts += 1
|
||||||
|
captcha_data["failed_attempts"] = failed_attempts
|
||||||
|
if failed_attempts < max_attempts:
|
||||||
|
_captcha_storage[str(session_id)] = captcha_data
|
||||||
|
return False, "验证码错误"
|
||||||
|
|
||||||
|
return True, "验证成功"
|
||||||
|
except Exception:
|
||||||
|
return False, "验证码验证失败,请重新获取"
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== IP rate limit(验证码失败限流) ====================
|
||||||
|
|
||||||
|
_ip_rate_limit: Dict[str, Dict[str, Any]] = {}
|
||||||
|
_ip_rate_limit_lock = threading.RLock()
|
||||||
|
|
||||||
|
|
||||||
|
def check_ip_rate_limit(
|
||||||
|
ip_address: str,
|
||||||
|
*,
|
||||||
|
max_attempts_per_hour: Optional[int] = None,
|
||||||
|
lock_duration_seconds: Optional[int] = None,
|
||||||
|
) -> Tuple[bool, Optional[str]]:
|
||||||
|
current_time = time.time()
|
||||||
|
max_attempts_per_hour = int(max_attempts_per_hour or config.MAX_IP_ATTEMPTS_PER_HOUR)
|
||||||
|
lock_duration_seconds = int(lock_duration_seconds or config.IP_LOCK_DURATION)
|
||||||
|
|
||||||
|
with _ip_rate_limit_lock:
|
||||||
|
expired_ips = []
|
||||||
|
for ip, data in _ip_rate_limit.items():
|
||||||
|
lock_expired = float(data.get("lock_until", 0) or 0) < current_time
|
||||||
|
first_attempt = data.get("first_attempt")
|
||||||
|
attempt_expired = first_attempt is None or (current_time - float(first_attempt)) > 3600
|
||||||
|
if lock_expired and attempt_expired:
|
||||||
|
expired_ips.append(ip)
|
||||||
|
|
||||||
|
for ip in expired_ips:
|
||||||
|
_ip_rate_limit.pop(ip, None)
|
||||||
|
|
||||||
|
ip_key = str(ip_address)
|
||||||
|
if ip_key in _ip_rate_limit:
|
||||||
|
ip_data = _ip_rate_limit[ip_key]
|
||||||
|
if float(ip_data.get("lock_until", 0) or 0) > current_time:
|
||||||
|
remaining_time = int(float(ip_data["lock_until"]) - current_time)
|
||||||
|
return False, f"IP已被锁定,请{remaining_time // 60 + 1}分钟后再试"
|
||||||
|
|
||||||
|
first_attempt = ip_data.get("first_attempt")
|
||||||
|
if first_attempt is None or current_time - float(first_attempt) > 3600:
|
||||||
|
_ip_rate_limit[ip_key] = {"attempts": 0, "first_attempt": current_time}
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
|
def record_failed_captcha(
|
||||||
|
ip_address: str,
|
||||||
|
*,
|
||||||
|
max_attempts_per_hour: Optional[int] = None,
|
||||||
|
lock_duration_seconds: Optional[int] = None,
|
||||||
|
) -> bool:
|
||||||
|
current_time = time.time()
|
||||||
|
max_attempts_per_hour = int(max_attempts_per_hour or config.MAX_IP_ATTEMPTS_PER_HOUR)
|
||||||
|
lock_duration_seconds = int(lock_duration_seconds or config.IP_LOCK_DURATION)
|
||||||
|
|
||||||
|
with _ip_rate_limit_lock:
|
||||||
|
ip_key = str(ip_address)
|
||||||
|
if ip_key not in _ip_rate_limit:
|
||||||
|
_ip_rate_limit[ip_key] = {"attempts": 1, "first_attempt": current_time}
|
||||||
|
else:
|
||||||
|
_ip_rate_limit[ip_key]["attempts"] = int(_ip_rate_limit[ip_key].get("attempts", 0) or 0) + 1
|
||||||
|
|
||||||
|
if int(_ip_rate_limit[ip_key].get("attempts", 0) or 0) >= max_attempts_per_hour:
|
||||||
|
_ip_rate_limit[ip_key]["lock_until"] = current_time + lock_duration_seconds
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_expired_ip_rate_limits(now_ts: Optional[float] = None) -> int:
|
||||||
|
now_ts = float(now_ts if now_ts is not None else time.time())
|
||||||
|
with _ip_rate_limit_lock:
|
||||||
|
expired_ips = []
|
||||||
|
for ip, data in _ip_rate_limit.items():
|
||||||
|
lock_until = float(data.get("lock_until", 0) or 0)
|
||||||
|
first_attempt = float(data.get("first_attempt", 0) or 0)
|
||||||
|
if lock_until < now_ts and (now_ts - first_attempt) > 3600:
|
||||||
|
expired_ips.append(ip)
|
||||||
|
for ip in expired_ips:
|
||||||
|
_ip_rate_limit.pop(ip, None)
|
||||||
|
return len(expired_ips)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_get_ip_lock_until(ip_address: str) -> float:
|
||||||
|
"""获取指定 IP 的锁定截至时间戳(未锁定返回 0)。"""
|
||||||
|
ip_key = str(ip_address)
|
||||||
|
with _ip_rate_limit_lock:
|
||||||
|
data = _ip_rate_limit.get(ip_key) or {}
|
||||||
|
try:
|
||||||
|
return float(data.get("lock_until", 0) or 0)
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Batch screenshots(批次任务截图收集) ====================
|
||||||
|
|
||||||
|
_batch_task_screenshots: Dict[str, Dict[str, Any]] = {}
|
||||||
|
_batch_task_lock = threading.RLock()
|
||||||
|
|
||||||
|
|
||||||
|
def safe_create_batch(batch_id: str, batch_info: Dict[str, Any]) -> None:
|
||||||
|
with _batch_task_lock:
|
||||||
|
_batch_task_screenshots[str(batch_id)] = dict(batch_info or {})
|
||||||
|
|
||||||
|
|
||||||
|
def safe_get_batch(batch_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
with _batch_task_lock:
|
||||||
|
info = _batch_task_screenshots.get(str(batch_id))
|
||||||
|
return dict(info) if info else None
|
||||||
|
|
||||||
|
|
||||||
|
def safe_update_batch(batch_id: str, updates: Dict[str, Any]) -> bool:
|
||||||
|
with _batch_task_lock:
|
||||||
|
if str(batch_id) not in _batch_task_screenshots:
|
||||||
|
return False
|
||||||
|
_batch_task_screenshots[str(batch_id)].update(dict(updates or {}))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def safe_pop_batch(batch_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
with _batch_task_lock:
|
||||||
|
return _batch_task_screenshots.pop(str(batch_id), None)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_batch_append_result(batch_id: str, result: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
|
now_ts = time.time()
|
||||||
|
with _batch_task_lock:
|
||||||
|
info = _batch_task_screenshots.get(str(batch_id))
|
||||||
|
if not info:
|
||||||
|
return None
|
||||||
|
info.setdefault("screenshots", []).append(dict(result or {}))
|
||||||
|
info["completed"] = int(info.get("completed", 0) or 0) + 1
|
||||||
|
info["updated_at"] = now_ts
|
||||||
|
total = int(info.get("total_accounts", 0) or 0)
|
||||||
|
if total > 0 and int(info.get("completed", 0) or 0) >= total:
|
||||||
|
return _batch_task_screenshots.pop(str(batch_id), None)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def safe_cleanup_expired_batches(expire_seconds: int, now_ts: Optional[float] = None) -> int:
|
||||||
|
now_ts = float(now_ts if now_ts is not None else time.time())
|
||||||
|
expire_seconds = max(1, int(expire_seconds))
|
||||||
|
with _batch_task_lock:
|
||||||
|
expired = []
|
||||||
|
for batch_id, info in list(_batch_task_screenshots.items()):
|
||||||
|
last_ts = info.get("updated_at") or info.get("created_at") or info.get("created_time") or now_ts
|
||||||
|
if (now_ts - float(last_ts)) > expire_seconds:
|
||||||
|
expired.append(batch_id)
|
||||||
|
for batch_id in expired:
|
||||||
|
_batch_task_screenshots.pop(batch_id, None)
|
||||||
|
return len(expired)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_finalize_batch_after_dispatch(batch_id: str, total_accounts: int, *, now_ts: Optional[float] = None) -> Optional[Dict[str, Any]]:
|
||||||
|
"""定时批次任务:更新总账号数,并在“已完成>=总数”时弹出批次数据用于发邮件。"""
|
||||||
|
now_ts = float(now_ts if now_ts is not None else time.time())
|
||||||
|
with _batch_task_lock:
|
||||||
|
info = _batch_task_screenshots.get(str(batch_id))
|
||||||
|
if not info:
|
||||||
|
return None
|
||||||
|
info["total_accounts"] = int(total_accounts or 0)
|
||||||
|
info["updated_at"] = now_ts
|
||||||
|
|
||||||
|
if int(total_accounts or 0) <= 0:
|
||||||
|
_batch_task_screenshots.pop(str(batch_id), None)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if int(info.get("completed", 0) or 0) >= int(total_accounts):
|
||||||
|
return _batch_task_screenshots.pop(str(batch_id), None)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Pending random schedules(兼容旧随机延迟逻辑) ====================
|
||||||
|
|
||||||
|
_pending_random_schedules: Dict[int, Dict[str, Any]] = {}
|
||||||
|
_pending_random_lock = threading.RLock()
|
||||||
|
|
||||||
|
|
||||||
|
def safe_set_pending_random_schedule(schedule_id: int, info: Dict[str, Any]) -> None:
|
||||||
|
with _pending_random_lock:
|
||||||
|
_pending_random_schedules[int(schedule_id)] = dict(info or {})
|
||||||
|
|
||||||
|
|
||||||
|
def safe_get_pending_random_schedule(schedule_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
with _pending_random_lock:
|
||||||
|
value = _pending_random_schedules.get(int(schedule_id))
|
||||||
|
return dict(value) if value else None
|
||||||
|
|
||||||
|
|
||||||
|
def safe_pop_pending_random_schedule(schedule_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
with _pending_random_lock:
|
||||||
|
return _pending_random_schedules.pop(int(schedule_id), None)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_iter_pending_random_schedules_items() -> List[Tuple[int, Dict[str, Any]]]:
|
||||||
|
with _pending_random_lock:
|
||||||
|
return [(sid, dict(info)) for sid, info in _pending_random_schedules.items()]
|
||||||
|
|
||||||
|
|
||||||
|
def safe_cleanup_expired_pending_random(expire_seconds: int, now_ts: Optional[float] = None) -> int:
|
||||||
|
now_ts = float(now_ts if now_ts is not None else time.time())
|
||||||
|
expire_seconds = max(1, int(expire_seconds))
|
||||||
|
with _pending_random_lock:
|
||||||
|
expired = []
|
||||||
|
for schedule_id, info in list(_pending_random_schedules.items()):
|
||||||
|
created_at = info.get("created_at") or info.get("created_time") or now_ts
|
||||||
|
if (now_ts - float(created_at)) > expire_seconds:
|
||||||
|
expired.append(schedule_id)
|
||||||
|
for schedule_id in expired:
|
||||||
|
_pending_random_schedules.pop(int(schedule_id), None)
|
||||||
|
return len(expired)
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import database
|
||||||
|
import email_service
|
||||||
|
from services.runtime import get_logger
|
||||||
|
from services.state import safe_batch_append_result
|
||||||
|
|
||||||
|
|
||||||
|
def _get_batch_id_from_source(source: str):
|
||||||
|
"""从source中提取批次ID(source格式: user_scheduled:batch_xxx)"""
|
||||||
|
if not source:
|
||||||
|
return None
|
||||||
|
if source.startswith("user_scheduled:batch_"):
|
||||||
|
return source.split(":", 1)[1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _send_batch_task_email_if_configured(batch_info: dict):
|
||||||
|
"""批次任务:当所有账号完成后发送打包邮件(在锁外调用)。"""
|
||||||
|
logger = get_logger()
|
||||||
|
try:
|
||||||
|
batch_user_id = batch_info.get("user_id")
|
||||||
|
if not batch_user_id:
|
||||||
|
return
|
||||||
|
user_info = database.get_user_by_id(batch_user_id)
|
||||||
|
if not user_info or not user_info.get("email"):
|
||||||
|
return
|
||||||
|
if not database.get_user_email_notify(batch_user_id):
|
||||||
|
return
|
||||||
|
if not batch_info.get("screenshots"):
|
||||||
|
return
|
||||||
|
email_service.send_batch_task_complete_email_async(
|
||||||
|
user_id=batch_user_id,
|
||||||
|
email=user_info["email"],
|
||||||
|
username=user_info["username"],
|
||||||
|
schedule_name=batch_info.get("schedule_name", "未命名任务"),
|
||||||
|
browse_type=batch_info.get("browse_type", "应读"),
|
||||||
|
screenshots=batch_info["screenshots"],
|
||||||
|
)
|
||||||
|
logger.info(f"[批次邮件] 已发送打包邮件,包含 {len(batch_info['screenshots'])} 条记录")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[批次邮件] 发送失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _batch_task_record_result(
|
||||||
|
batch_id: str,
|
||||||
|
account_name: str,
|
||||||
|
screenshot_path: str,
|
||||||
|
total_items: int,
|
||||||
|
total_attachments: int,
|
||||||
|
):
|
||||||
|
"""批次任务:记录单账号结果,达到完成条件时触发邮件并回收内存。"""
|
||||||
|
logger = get_logger()
|
||||||
|
batch_info = safe_batch_append_result(
|
||||||
|
batch_id,
|
||||||
|
{
|
||||||
|
"account_name": account_name,
|
||||||
|
"path": screenshot_path,
|
||||||
|
"items": total_items,
|
||||||
|
"attachments": total_attachments,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if batch_info:
|
||||||
|
logger.info(
|
||||||
|
f"[批次邮件] 批次 {batch_id} 已完成: {batch_info.get('completed')}/{batch_info.get('total_accounts')},准备发送邮件"
|
||||||
|
)
|
||||||
|
_send_batch_task_email_if_configured(batch_info)
|
||||||
|
|
||||||
@@ -0,0 +1,838 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import heapq
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import database
|
||||||
|
import email_service
|
||||||
|
from api_browser import APIBrowser
|
||||||
|
from app_config import get_config
|
||||||
|
from app_logger import LoggerAdapter, get_logger
|
||||||
|
from services.checkpoints import get_checkpoint_mgr
|
||||||
|
from services.client_log import log_to_client
|
||||||
|
from services.proxy import get_proxy_from_api
|
||||||
|
from services.runtime import get_socketio
|
||||||
|
from services.screenshots import take_screenshot_for_account
|
||||||
|
from services.state import (
|
||||||
|
safe_get_account,
|
||||||
|
safe_get_task,
|
||||||
|
safe_remove_task,
|
||||||
|
safe_remove_task_status,
|
||||||
|
safe_set_task,
|
||||||
|
safe_set_task_status,
|
||||||
|
safe_update_task_status,
|
||||||
|
)
|
||||||
|
from services.task_batches import _batch_task_record_result, _get_batch_id_from_source
|
||||||
|
from task_checkpoint import TaskStage
|
||||||
|
|
||||||
|
logger = get_logger("app")
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
# VIP优先级队列(仅用于可视化/调试)
|
||||||
|
vip_task_queue = [] # VIP用户任务队列
|
||||||
|
normal_task_queue = [] # 普通用户任务队列
|
||||||
|
task_queue_lock = threading.Lock()
|
||||||
|
|
||||||
|
# 并发默认值(启动后会由系统配置覆盖并调用 update_limits)
|
||||||
|
max_concurrent_per_account = config.MAX_CONCURRENT_PER_ACCOUNT
|
||||||
|
max_concurrent_global = config.MAX_CONCURRENT_GLOBAL
|
||||||
|
|
||||||
|
|
||||||
|
def _emit(event: str, data: object, *, room: str | None = None) -> None:
|
||||||
|
try:
|
||||||
|
socketio = get_socketio()
|
||||||
|
socketio.emit(event, data, room=room)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _TaskRequest:
|
||||||
|
user_id: int
|
||||||
|
account_id: str
|
||||||
|
browse_type: str
|
||||||
|
enable_screenshot: bool
|
||||||
|
source: str
|
||||||
|
retry_count: int
|
||||||
|
submitted_at: float
|
||||||
|
is_vip: bool
|
||||||
|
seq: int
|
||||||
|
canceled: bool = False
|
||||||
|
done_callback: object = None
|
||||||
|
|
||||||
|
|
||||||
|
class TaskScheduler:
|
||||||
|
"""全局任务调度器:队列排队,不为每个任务单独创建线程。"""
|
||||||
|
|
||||||
|
def __init__(self, max_global: int, max_per_user: int, max_queue_size: int = 1000):
|
||||||
|
self.max_global = max(1, int(max_global))
|
||||||
|
self.max_per_user = max(1, int(max_per_user))
|
||||||
|
self.max_queue_size = max(1, int(max_queue_size))
|
||||||
|
|
||||||
|
self._cond = threading.Condition()
|
||||||
|
self._pending = [] # heap: (priority, submitted_at, seq, task)
|
||||||
|
self._pending_by_account = {} # {account_id: task}
|
||||||
|
self._seq = 0
|
||||||
|
|
||||||
|
self._running_global = 0
|
||||||
|
self._running_by_user = {} # {user_id: running_count}
|
||||||
|
|
||||||
|
self._executor_max_workers = self.max_global
|
||||||
|
self._executor = ThreadPoolExecutor(max_workers=self._executor_max_workers, thread_name_prefix="TaskWorker")
|
||||||
|
self._old_executors = []
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
self._dispatcher_thread = threading.Thread(target=self._dispatch_loop, daemon=True, name="TaskDispatcher")
|
||||||
|
self._dispatcher_thread.start()
|
||||||
|
|
||||||
|
def shutdown(self, timeout: float = 5.0):
|
||||||
|
"""停止调度器(用于进程退出清理)"""
|
||||||
|
with self._cond:
|
||||||
|
self._running = False
|
||||||
|
self._cond.notify_all()
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._dispatcher_thread.join(timeout=timeout)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._executor.shutdown(wait=False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for ex in self._old_executors:
|
||||||
|
try:
|
||||||
|
ex.shutdown(wait=False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def update_limits(self, max_global: int = None, max_per_user: int = None, max_queue_size: int = None):
|
||||||
|
"""动态更新并发/队列上限(不影响已在运行的任务)"""
|
||||||
|
with self._cond:
|
||||||
|
if max_per_user is not None:
|
||||||
|
self.max_per_user = max(1, int(max_per_user))
|
||||||
|
if max_queue_size is not None:
|
||||||
|
self.max_queue_size = max(1, int(max_queue_size))
|
||||||
|
|
||||||
|
if max_global is not None:
|
||||||
|
new_max_global = max(1, int(max_global))
|
||||||
|
self.max_global = new_max_global
|
||||||
|
if new_max_global > self._executor_max_workers:
|
||||||
|
self._old_executors.append(self._executor)
|
||||||
|
self._executor_max_workers = new_max_global
|
||||||
|
self._executor = ThreadPoolExecutor(
|
||||||
|
max_workers=self._executor_max_workers, thread_name_prefix="TaskWorker"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
self._old_executors[-1].shutdown(wait=False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self._cond.notify_all()
|
||||||
|
|
||||||
|
def submit_task(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
account_id: str,
|
||||||
|
browse_type: str,
|
||||||
|
enable_screenshot: bool = True,
|
||||||
|
source: str = "manual",
|
||||||
|
retry_count: int = 0,
|
||||||
|
is_vip: bool = None,
|
||||||
|
done_callback=None,
|
||||||
|
):
|
||||||
|
"""提交任务进入队列(返回: (ok, message))"""
|
||||||
|
if not user_id or not account_id:
|
||||||
|
return False, "参数错误"
|
||||||
|
|
||||||
|
submitted_at = time.time()
|
||||||
|
if is_vip is None:
|
||||||
|
try:
|
||||||
|
is_vip = bool(database.is_user_vip(user_id))
|
||||||
|
except Exception:
|
||||||
|
is_vip = False
|
||||||
|
else:
|
||||||
|
is_vip = bool(is_vip)
|
||||||
|
|
||||||
|
with self._cond:
|
||||||
|
if not self._running:
|
||||||
|
return False, "调度器未运行"
|
||||||
|
if len(self._pending_by_account) >= self.max_queue_size:
|
||||||
|
return False, "任务队列已满,请稍后再试"
|
||||||
|
if account_id in self._pending_by_account:
|
||||||
|
return False, "任务已在队列中"
|
||||||
|
if safe_get_task(account_id) is not None:
|
||||||
|
return False, "任务已在运行中"
|
||||||
|
|
||||||
|
self._seq += 1
|
||||||
|
task = _TaskRequest(
|
||||||
|
user_id=user_id,
|
||||||
|
account_id=account_id,
|
||||||
|
browse_type=browse_type,
|
||||||
|
enable_screenshot=bool(enable_screenshot),
|
||||||
|
source=source,
|
||||||
|
retry_count=int(retry_count or 0),
|
||||||
|
submitted_at=submitted_at,
|
||||||
|
is_vip=is_vip,
|
||||||
|
seq=self._seq,
|
||||||
|
done_callback=done_callback,
|
||||||
|
)
|
||||||
|
self._pending_by_account[account_id] = task
|
||||||
|
priority = 0 if is_vip else 1
|
||||||
|
heapq.heappush(self._pending, (priority, task.submitted_at, task.seq, task))
|
||||||
|
self._cond.notify_all()
|
||||||
|
|
||||||
|
# 用于可视化/调试:记录队列
|
||||||
|
with task_queue_lock:
|
||||||
|
if is_vip:
|
||||||
|
vip_task_queue.append(account_id)
|
||||||
|
else:
|
||||||
|
normal_task_queue.append(account_id)
|
||||||
|
|
||||||
|
return True, "已加入队列"
|
||||||
|
|
||||||
|
def cancel_pending_task(self, user_id: int, account_id: str) -> bool:
|
||||||
|
"""取消尚未开始的排队任务(已运行的任务由 should_stop 控制)"""
|
||||||
|
canceled_task = None
|
||||||
|
with self._cond:
|
||||||
|
task = self._pending_by_account.pop(account_id, None)
|
||||||
|
if not task:
|
||||||
|
return False
|
||||||
|
task.canceled = True
|
||||||
|
canceled_task = task
|
||||||
|
self._cond.notify_all()
|
||||||
|
|
||||||
|
# 从可视化队列移除
|
||||||
|
with task_queue_lock:
|
||||||
|
if account_id in vip_task_queue:
|
||||||
|
vip_task_queue.remove(account_id)
|
||||||
|
if account_id in normal_task_queue:
|
||||||
|
normal_task_queue.remove(account_id)
|
||||||
|
|
||||||
|
# 批次任务:取消也要推进完成计数,避免批次缓存常驻
|
||||||
|
try:
|
||||||
|
batch_id = _get_batch_id_from_source(canceled_task.source)
|
||||||
|
if batch_id:
|
||||||
|
acc = safe_get_account(user_id, account_id)
|
||||||
|
if acc:
|
||||||
|
account_name = acc.remark if acc.remark else acc.username
|
||||||
|
else:
|
||||||
|
account_name = account_id
|
||||||
|
_batch_task_record_result(
|
||||||
|
batch_id=batch_id,
|
||||||
|
account_name=account_name,
|
||||||
|
screenshot_path=None,
|
||||||
|
total_items=0,
|
||||||
|
total_attachments=0,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _dispatch_loop(self):
|
||||||
|
while True:
|
||||||
|
task = None
|
||||||
|
with self._cond:
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self._pending or self._running_global >= self.max_global:
|
||||||
|
self._cond.wait(timeout=0.5)
|
||||||
|
continue
|
||||||
|
|
||||||
|
task = self._pop_next_runnable_locked()
|
||||||
|
if task is None:
|
||||||
|
self._cond.wait(timeout=0.5)
|
||||||
|
continue
|
||||||
|
|
||||||
|
self._running_global += 1
|
||||||
|
self._running_by_user[task.user_id] = self._running_by_user.get(task.user_id, 0) + 1
|
||||||
|
|
||||||
|
# 从队列移除(可视化)
|
||||||
|
with task_queue_lock:
|
||||||
|
if task.account_id in vip_task_queue:
|
||||||
|
vip_task_queue.remove(task.account_id)
|
||||||
|
if task.account_id in normal_task_queue:
|
||||||
|
normal_task_queue.remove(task.account_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
future = self._executor.submit(self._run_task_wrapper, task)
|
||||||
|
safe_set_task(task.account_id, future)
|
||||||
|
except Exception:
|
||||||
|
with self._cond:
|
||||||
|
self._running_global = max(0, self._running_global - 1)
|
||||||
|
self._running_by_user[task.user_id] = max(0, self._running_by_user.get(task.user_id, 1) - 1)
|
||||||
|
if self._running_by_user.get(task.user_id) == 0:
|
||||||
|
self._running_by_user.pop(task.user_id, None)
|
||||||
|
self._cond.notify_all()
|
||||||
|
|
||||||
|
def _pop_next_runnable_locked(self):
|
||||||
|
"""在锁内从优先队列取出“可运行”的任务,避免VIP任务占位阻塞普通任务。"""
|
||||||
|
if not self._pending:
|
||||||
|
return None
|
||||||
|
|
||||||
|
skipped = []
|
||||||
|
selected = None
|
||||||
|
|
||||||
|
while self._pending:
|
||||||
|
_, _, _, task = heapq.heappop(self._pending)
|
||||||
|
|
||||||
|
if task.canceled:
|
||||||
|
continue
|
||||||
|
if self._pending_by_account.get(task.account_id) is not task:
|
||||||
|
continue
|
||||||
|
|
||||||
|
running_for_user = self._running_by_user.get(task.user_id, 0)
|
||||||
|
if running_for_user >= self.max_per_user:
|
||||||
|
skipped.append(task)
|
||||||
|
continue
|
||||||
|
|
||||||
|
selected = task
|
||||||
|
break
|
||||||
|
|
||||||
|
for t in skipped:
|
||||||
|
priority = 0 if t.is_vip else 1
|
||||||
|
heapq.heappush(self._pending, (priority, t.submitted_at, t.seq, t))
|
||||||
|
|
||||||
|
if selected is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
self._pending_by_account.pop(selected.account_id, None)
|
||||||
|
return selected
|
||||||
|
|
||||||
|
def _run_task_wrapper(self, task: _TaskRequest):
|
||||||
|
try:
|
||||||
|
run_task(
|
||||||
|
user_id=task.user_id,
|
||||||
|
account_id=task.account_id,
|
||||||
|
browse_type=task.browse_type,
|
||||||
|
enable_screenshot=task.enable_screenshot,
|
||||||
|
source=task.source,
|
||||||
|
retry_count=task.retry_count,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if callable(task.done_callback):
|
||||||
|
task.done_callback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
safe_remove_task(task.account_id)
|
||||||
|
with self._cond:
|
||||||
|
self._running_global = max(0, self._running_global - 1)
|
||||||
|
self._running_by_user[task.user_id] = max(0, self._running_by_user.get(task.user_id, 1) - 1)
|
||||||
|
if self._running_by_user.get(task.user_id) == 0:
|
||||||
|
self._running_by_user.pop(task.user_id, None)
|
||||||
|
self._cond.notify_all()
|
||||||
|
|
||||||
|
|
||||||
|
_task_scheduler = None
|
||||||
|
_task_scheduler_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def get_task_scheduler() -> TaskScheduler:
|
||||||
|
"""获取全局任务调度器(单例)"""
|
||||||
|
global _task_scheduler
|
||||||
|
with _task_scheduler_lock:
|
||||||
|
if _task_scheduler is None:
|
||||||
|
try:
|
||||||
|
max_queue_size = int(os.environ.get("TASK_QUEUE_MAXSIZE", "1000"))
|
||||||
|
except Exception:
|
||||||
|
max_queue_size = 1000
|
||||||
|
_task_scheduler = TaskScheduler(
|
||||||
|
max_global=max_concurrent_global,
|
||||||
|
max_per_user=max_concurrent_per_account,
|
||||||
|
max_queue_size=max_queue_size,
|
||||||
|
)
|
||||||
|
return _task_scheduler
|
||||||
|
|
||||||
|
|
||||||
|
def submit_account_task(
|
||||||
|
user_id: int,
|
||||||
|
account_id: str,
|
||||||
|
browse_type: str,
|
||||||
|
enable_screenshot: bool = True,
|
||||||
|
source: str = "manual",
|
||||||
|
retry_count: int = 0,
|
||||||
|
done_callback=None,
|
||||||
|
):
|
||||||
|
"""统一入口:提交账号任务进入队列"""
|
||||||
|
account = safe_get_account(user_id, account_id)
|
||||||
|
if not account:
|
||||||
|
return False, "账号不存在"
|
||||||
|
if getattr(account, "is_running", False):
|
||||||
|
return False, "任务已在运行中"
|
||||||
|
|
||||||
|
try:
|
||||||
|
is_vip_user = bool(database.is_user_vip(user_id))
|
||||||
|
except Exception:
|
||||||
|
is_vip_user = False
|
||||||
|
|
||||||
|
account.is_running = True
|
||||||
|
account.should_stop = False
|
||||||
|
account.status = "排队中" + (" (VIP)" if is_vip_user else "")
|
||||||
|
|
||||||
|
safe_set_task_status(
|
||||||
|
account_id,
|
||||||
|
{
|
||||||
|
"user_id": user_id,
|
||||||
|
"username": account.username,
|
||||||
|
"status": "排队中",
|
||||||
|
"detail_status": "等待资源" + (" [VIP优先]" if is_vip_user else ""),
|
||||||
|
"browse_type": browse_type,
|
||||||
|
"start_time": time.time(),
|
||||||
|
"source": source,
|
||||||
|
"progress": {"items": 0, "attachments": 0},
|
||||||
|
"is_vip": is_vip_user,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
_emit("account_update", account.to_dict(), room=f"user_{user_id}")
|
||||||
|
|
||||||
|
scheduler = get_task_scheduler()
|
||||||
|
ok, message = scheduler.submit_task(
|
||||||
|
user_id=user_id,
|
||||||
|
account_id=account_id,
|
||||||
|
browse_type=browse_type,
|
||||||
|
enable_screenshot=enable_screenshot,
|
||||||
|
source=source,
|
||||||
|
retry_count=retry_count,
|
||||||
|
is_vip=is_vip_user,
|
||||||
|
done_callback=done_callback,
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
account.is_running = False
|
||||||
|
account.status = "未开始"
|
||||||
|
safe_remove_task_status(account_id)
|
||||||
|
_emit("account_update", account.to_dict(), room=f"user_{user_id}")
|
||||||
|
return False, message
|
||||||
|
|
||||||
|
log_to_client(message + (" [VIP优先]" if is_vip_user else ""), user_id, account_id)
|
||||||
|
return True, message
|
||||||
|
|
||||||
|
|
||||||
|
def run_task(user_id, account_id, browse_type, enable_screenshot=True, source="manual", retry_count=0):
|
||||||
|
"""运行自动化任务
|
||||||
|
|
||||||
|
Args:
|
||||||
|
retry_count: 当前重试次数,用于自动重试机制(最多重试2次)
|
||||||
|
"""
|
||||||
|
MAX_AUTO_RETRY = 2 # 最大自动重试次数
|
||||||
|
LoggerAdapter("app", {"user_id": user_id, "account_id": account_id, "source": source}).debug(
|
||||||
|
f"run_task enable_screenshot={enable_screenshot} ({type(enable_screenshot).__name__}), retry={retry_count}"
|
||||||
|
)
|
||||||
|
|
||||||
|
account = safe_get_account(user_id, account_id)
|
||||||
|
if not account:
|
||||||
|
return
|
||||||
|
batch_id = _get_batch_id_from_source(source)
|
||||||
|
batch_recorded = False
|
||||||
|
|
||||||
|
checkpoint_mgr = get_checkpoint_mgr()
|
||||||
|
|
||||||
|
import time as time_module
|
||||||
|
|
||||||
|
try:
|
||||||
|
if account.should_stop:
|
||||||
|
log_to_client("任务已取消", user_id, account_id)
|
||||||
|
account.status = "已停止"
|
||||||
|
account.is_running = False
|
||||||
|
safe_remove_task_status(account_id)
|
||||||
|
_emit("account_update", account.to_dict(), room=f"user_{user_id}")
|
||||||
|
if batch_id:
|
||||||
|
account_name = account.remark if account.remark else account.username
|
||||||
|
_batch_task_record_result(
|
||||||
|
batch_id=batch_id,
|
||||||
|
account_name=account_name,
|
||||||
|
screenshot_path=None,
|
||||||
|
total_items=0,
|
||||||
|
total_attachments=0,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
if account.should_stop:
|
||||||
|
log_to_client("任务已取消", user_id, account_id)
|
||||||
|
account.status = "已停止"
|
||||||
|
account.is_running = False
|
||||||
|
_emit("account_update", account.to_dict(), room=f"user_{user_id}")
|
||||||
|
return
|
||||||
|
|
||||||
|
task_id = checkpoint_mgr.create_checkpoint(
|
||||||
|
user_id=user_id, account_id=account_id, username=account.username, browse_type=browse_type
|
||||||
|
)
|
||||||
|
logger.info(f"[断点] 任务 {task_id} 已创建")
|
||||||
|
|
||||||
|
task_start_time = time_module.time()
|
||||||
|
|
||||||
|
account.status = "运行中"
|
||||||
|
_emit("account_update", account.to_dict(), room=f"user_{user_id}")
|
||||||
|
account.last_browse_type = browse_type
|
||||||
|
|
||||||
|
safe_update_task_status(account_id, {"status": "运行中", "detail_status": "初始化", "start_time": task_start_time})
|
||||||
|
|
||||||
|
max_attempts = 3
|
||||||
|
|
||||||
|
for attempt in range(1, max_attempts + 1):
|
||||||
|
try:
|
||||||
|
if attempt > 1:
|
||||||
|
log_to_client(f"🔄 第 {attempt} 次尝试(共{max_attempts}次)...", user_id, account_id)
|
||||||
|
|
||||||
|
proxy_config = None
|
||||||
|
config = database.get_system_config()
|
||||||
|
if config.get("proxy_enabled") == 1:
|
||||||
|
proxy_api_url = config.get("proxy_api_url", "").strip()
|
||||||
|
if proxy_api_url:
|
||||||
|
log_to_client("正在获取代理IP...", user_id, account_id)
|
||||||
|
proxy_server = get_proxy_from_api(proxy_api_url, max_retries=3)
|
||||||
|
if proxy_server:
|
||||||
|
proxy_config = {"server": proxy_server}
|
||||||
|
log_to_client(f"✓ 将使用代理: {proxy_server}", user_id, account_id)
|
||||||
|
account.proxy_config = proxy_config # 保存代理配置供截图使用
|
||||||
|
else:
|
||||||
|
log_to_client("✗ 代理获取失败,将不使用代理继续", user_id, account_id)
|
||||||
|
else:
|
||||||
|
log_to_client("⚠ 代理已启用但未配置API地址", user_id, account_id)
|
||||||
|
|
||||||
|
checkpoint_mgr.update_stage(task_id, TaskStage.STARTING, progress_percent=10)
|
||||||
|
|
||||||
|
def custom_log(message: str):
|
||||||
|
log_to_client(message, user_id, account_id)
|
||||||
|
|
||||||
|
log_to_client("开始登录...", user_id, account_id)
|
||||||
|
safe_update_task_status(account_id, {"detail_status": "正在登录"})
|
||||||
|
checkpoint_mgr.update_stage(task_id, TaskStage.LOGGING_IN, progress_percent=25)
|
||||||
|
|
||||||
|
with APIBrowser(log_callback=custom_log, proxy_config=proxy_config) as api_browser:
|
||||||
|
if api_browser.login(account.username, account.password):
|
||||||
|
log_to_client("✓ 登录成功!", user_id, account_id)
|
||||||
|
api_browser.save_cookies_for_playwright(account.username)
|
||||||
|
database.reset_account_login_status(account_id)
|
||||||
|
|
||||||
|
if not account.remark:
|
||||||
|
try:
|
||||||
|
real_name = api_browser.get_real_name()
|
||||||
|
if real_name:
|
||||||
|
account.remark = real_name
|
||||||
|
database.update_account_remark(account_id, real_name)
|
||||||
|
_emit("account_update", account.to_dict(), room=f"user_{user_id}")
|
||||||
|
logger.info(f"[自动备注] 账号 {account.username} 自动设置备注为: {real_name}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[自动备注] 获取姓名失败: {e}")
|
||||||
|
|
||||||
|
safe_update_task_status(account_id, {"detail_status": "正在浏览"})
|
||||||
|
log_to_client(f"开始浏览 '{browse_type}' 内容...", user_id, account_id)
|
||||||
|
|
||||||
|
def should_stop():
|
||||||
|
return account.should_stop
|
||||||
|
|
||||||
|
checkpoint_mgr.update_stage(task_id, TaskStage.BROWSING, progress_percent=50)
|
||||||
|
result = api_browser.browse_content(browse_type=browse_type, should_stop_callback=should_stop)
|
||||||
|
else:
|
||||||
|
error_message = "登录失败"
|
||||||
|
log_to_client(f"❌ {error_message}", user_id, account_id)
|
||||||
|
|
||||||
|
is_suspended = database.increment_account_login_fail(account_id, error_message)
|
||||||
|
if is_suspended:
|
||||||
|
log_to_client("⚠ 该账号连续3次密码错误,已自动暂停", user_id, account_id)
|
||||||
|
log_to_client("请在前台修改密码后才能继续使用", user_id, account_id)
|
||||||
|
|
||||||
|
retry_action = checkpoint_mgr.record_error(task_id, error_message)
|
||||||
|
if retry_action == "paused":
|
||||||
|
logger.warning(f"[断点] 任务 {task_id} 已暂停(登录失败)")
|
||||||
|
|
||||||
|
account.status = "登录失败"
|
||||||
|
account.is_running = False
|
||||||
|
database.create_task_log(
|
||||||
|
user_id=user_id,
|
||||||
|
account_id=account_id,
|
||||||
|
username=account.username,
|
||||||
|
browse_type=browse_type,
|
||||||
|
status="failed",
|
||||||
|
total_items=0,
|
||||||
|
total_attachments=0,
|
||||||
|
error_message=error_message,
|
||||||
|
duration=int(time_module.time() - task_start_time),
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
_emit("account_update", account.to_dict(), room=f"user_{user_id}")
|
||||||
|
return
|
||||||
|
|
||||||
|
account.total_items = result.total_items
|
||||||
|
account.total_attachments = result.total_attachments
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
log_to_client(
|
||||||
|
f"浏览完成! 共 {result.total_items} 条内容,{result.total_attachments} 个附件", user_id, account_id
|
||||||
|
)
|
||||||
|
safe_update_task_status(
|
||||||
|
account_id,
|
||||||
|
{
|
||||||
|
"detail_status": "浏览完成",
|
||||||
|
"progress": {"items": result.total_items, "attachments": result.total_attachments},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
account.status = "已完成"
|
||||||
|
checkpoint_mgr.update_stage(task_id, TaskStage.COMPLETING, progress_percent=95)
|
||||||
|
checkpoint_mgr.complete_task(task_id, success=True)
|
||||||
|
logger.info(f"[断点] 任务 {task_id} 已完成")
|
||||||
|
|
||||||
|
if not enable_screenshot:
|
||||||
|
database.create_task_log(
|
||||||
|
user_id=user_id,
|
||||||
|
account_id=account_id,
|
||||||
|
username=account.username,
|
||||||
|
browse_type=browse_type,
|
||||||
|
status="success",
|
||||||
|
total_items=result.total_items,
|
||||||
|
total_attachments=result.total_attachments,
|
||||||
|
error_message="",
|
||||||
|
duration=int(time_module.time() - task_start_time),
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
if batch_id:
|
||||||
|
account_name = account.remark if account.remark else account.username
|
||||||
|
_batch_task_record_result(
|
||||||
|
batch_id=batch_id,
|
||||||
|
account_name=account_name,
|
||||||
|
screenshot_path=None,
|
||||||
|
total_items=result.total_items,
|
||||||
|
total_attachments=result.total_attachments,
|
||||||
|
)
|
||||||
|
batch_recorded = True
|
||||||
|
elif source and source.startswith("user_scheduled"):
|
||||||
|
try:
|
||||||
|
user_info = database.get_user_by_id(user_id)
|
||||||
|
if user_info and user_info.get("email") and database.get_user_email_notify(user_id):
|
||||||
|
account_name = account.remark if account.remark else account.username
|
||||||
|
email_service.send_task_complete_email_async(
|
||||||
|
user_id=user_id,
|
||||||
|
email=user_info["email"],
|
||||||
|
username=user_info["username"],
|
||||||
|
account_name=account_name,
|
||||||
|
browse_type=browse_type,
|
||||||
|
total_items=result.total_items,
|
||||||
|
total_attachments=result.total_attachments,
|
||||||
|
screenshot_path=None,
|
||||||
|
log_callback=lambda msg: log_to_client(msg, user_id, account_id),
|
||||||
|
)
|
||||||
|
except Exception as email_error:
|
||||||
|
logger.warning(f"发送任务完成邮件失败: {email_error}")
|
||||||
|
break
|
||||||
|
|
||||||
|
error_msg = result.error_message
|
||||||
|
if "Timeout" in error_msg or "timeout" in error_msg:
|
||||||
|
log_to_client(f"⚠ 检测到超时错误: {error_msg}", user_id, account_id)
|
||||||
|
|
||||||
|
if account.automation:
|
||||||
|
try:
|
||||||
|
account.automation.close()
|
||||||
|
log_to_client("已关闭超时的浏览器实例", user_id, account_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"关闭超时浏览器实例失败: {e}")
|
||||||
|
account.automation = None
|
||||||
|
|
||||||
|
if attempt < max_attempts:
|
||||||
|
log_to_client(f"⚠ 代理可能速度过慢,将换新IP重试 ({attempt}/{max_attempts})", user_id, account_id)
|
||||||
|
time_module.sleep(2)
|
||||||
|
continue
|
||||||
|
log_to_client(f"❌ 已达到最大重试次数({max_attempts}),任务失败", user_id, account_id)
|
||||||
|
account.status = "出错"
|
||||||
|
database.create_task_log(
|
||||||
|
user_id=user_id,
|
||||||
|
account_id=account_id,
|
||||||
|
username=account.username,
|
||||||
|
browse_type=browse_type,
|
||||||
|
status="failed",
|
||||||
|
total_items=result.total_items,
|
||||||
|
total_attachments=result.total_attachments,
|
||||||
|
error_message=f"重试{max_attempts}次后仍失败: {error_msg}",
|
||||||
|
duration=int(time_module.time() - task_start_time),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
log_to_client(f"浏览出错: {error_msg}", user_id, account_id)
|
||||||
|
account.status = "出错"
|
||||||
|
database.create_task_log(
|
||||||
|
user_id=user_id,
|
||||||
|
account_id=account_id,
|
||||||
|
username=account.username,
|
||||||
|
browse_type=browse_type,
|
||||||
|
status="failed",
|
||||||
|
total_items=result.total_items,
|
||||||
|
total_attachments=result.total_attachments,
|
||||||
|
error_message=error_msg,
|
||||||
|
duration=int(time_module.time() - task_start_time),
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
except Exception as retry_error:
|
||||||
|
error_msg = str(retry_error)
|
||||||
|
if account.automation:
|
||||||
|
try:
|
||||||
|
account.automation.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"关闭浏览器实例失败: {e}")
|
||||||
|
account.automation = None
|
||||||
|
|
||||||
|
if "Timeout" in error_msg or "timeout" in error_msg:
|
||||||
|
log_to_client(f"⚠ 执行超时: {error_msg}", user_id, account_id)
|
||||||
|
if attempt < max_attempts:
|
||||||
|
log_to_client(f"⚠ 将换新IP重试 ({attempt}/{max_attempts})", user_id, account_id)
|
||||||
|
time_module.sleep(2)
|
||||||
|
continue
|
||||||
|
log_to_client(f"❌ 已达到最大重试次数({max_attempts}),任务失败", user_id, account_id)
|
||||||
|
account.status = "出错"
|
||||||
|
database.create_task_log(
|
||||||
|
user_id=user_id,
|
||||||
|
account_id=account_id,
|
||||||
|
username=account.username,
|
||||||
|
browse_type=browse_type,
|
||||||
|
status="failed",
|
||||||
|
total_items=account.total_items,
|
||||||
|
total_attachments=account.total_attachments,
|
||||||
|
error_message=f"重试{max_attempts}次后仍失败: {error_msg}",
|
||||||
|
duration=int(time_module.time() - task_start_time),
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
log_to_client(f"任务执行异常: {error_msg}", user_id, account_id)
|
||||||
|
account.status = "出错"
|
||||||
|
database.create_task_log(
|
||||||
|
user_id=user_id,
|
||||||
|
account_id=account_id,
|
||||||
|
username=account.username,
|
||||||
|
browse_type=browse_type,
|
||||||
|
status="failed",
|
||||||
|
total_items=account.total_items,
|
||||||
|
total_attachments=account.total_attachments,
|
||||||
|
error_message=error_msg,
|
||||||
|
duration=int(time_module.time() - task_start_time),
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = str(e)
|
||||||
|
log_to_client(f"任务执行出错: {error_msg}", user_id, account_id)
|
||||||
|
account.status = "出错"
|
||||||
|
database.create_task_log(
|
||||||
|
user_id=user_id,
|
||||||
|
account_id=account_id,
|
||||||
|
username=account.username,
|
||||||
|
browse_type=browse_type,
|
||||||
|
status="failed",
|
||||||
|
total_items=account.total_items,
|
||||||
|
total_attachments=account.total_attachments,
|
||||||
|
error_message=error_msg,
|
||||||
|
duration=int(time_module.time() - task_start_time),
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
account.is_running = False
|
||||||
|
screenshot_submitted = False
|
||||||
|
if account.status not in ["已完成"]:
|
||||||
|
account.status = "未开始"
|
||||||
|
|
||||||
|
if account.automation:
|
||||||
|
try:
|
||||||
|
account.automation.close()
|
||||||
|
except Exception as e:
|
||||||
|
log_to_client(f"关闭主任务浏览器时出错: {str(e)}", user_id, account_id)
|
||||||
|
finally:
|
||||||
|
account.automation = None
|
||||||
|
|
||||||
|
safe_remove_task(account_id)
|
||||||
|
safe_remove_task_status(account_id)
|
||||||
|
|
||||||
|
_emit("account_update", account.to_dict(), room=f"user_{user_id}")
|
||||||
|
|
||||||
|
if account.status == "已完成" and not account.should_stop:
|
||||||
|
if enable_screenshot:
|
||||||
|
log_to_client("等待2秒后开始截图...", user_id, account_id)
|
||||||
|
account.status = "等待截图"
|
||||||
|
_emit("account_update", account.to_dict(), room=f"user_{user_id}")
|
||||||
|
import time as time_mod
|
||||||
|
|
||||||
|
safe_set_task_status(
|
||||||
|
account_id,
|
||||||
|
{
|
||||||
|
"user_id": user_id,
|
||||||
|
"username": account.username,
|
||||||
|
"status": "排队中",
|
||||||
|
"detail_status": "等待截图资源",
|
||||||
|
"browse_type": browse_type,
|
||||||
|
"start_time": time_mod.time(),
|
||||||
|
"source": source,
|
||||||
|
"progress": {
|
||||||
|
"items": result.total_items if result else 0,
|
||||||
|
"attachments": result.total_attachments if result else 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
time.sleep(2)
|
||||||
|
browse_result_dict = {"total_items": result.total_items, "total_attachments": result.total_attachments}
|
||||||
|
screenshot_submitted = True
|
||||||
|
threading.Thread(
|
||||||
|
target=take_screenshot_for_account,
|
||||||
|
args=(user_id, account_id, browse_type, source, task_start_time, browse_result_dict),
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
|
else:
|
||||||
|
account.status = "未开始"
|
||||||
|
_emit("account_update", account.to_dict(), room=f"user_{user_id}")
|
||||||
|
log_to_client("截图功能已禁用,跳过截图", user_id, account_id)
|
||||||
|
else:
|
||||||
|
if account.status not in ["登录失败", "出错"]:
|
||||||
|
account.status = "未开始"
|
||||||
|
_emit("account_update", account.to_dict(), room=f"user_{user_id}")
|
||||||
|
elif account.status == "出错" and retry_count < MAX_AUTO_RETRY:
|
||||||
|
log_to_client(
|
||||||
|
f"⚠ 任务执行失败,5秒后自动重试 ({retry_count + 1}/{MAX_AUTO_RETRY})...", user_id, account_id
|
||||||
|
)
|
||||||
|
account.status = "等待重试"
|
||||||
|
_emit("account_update", account.to_dict(), room=f"user_{user_id}")
|
||||||
|
|
||||||
|
def delayed_retry_submit():
|
||||||
|
if account.should_stop:
|
||||||
|
return
|
||||||
|
log_to_client(f"🔄 开始第 {retry_count + 1} 次自动重试...", user_id, account_id)
|
||||||
|
ok, msg = submit_account_task(
|
||||||
|
user_id=user_id,
|
||||||
|
account_id=account_id,
|
||||||
|
browse_type=browse_type,
|
||||||
|
enable_screenshot=enable_screenshot,
|
||||||
|
source=source,
|
||||||
|
retry_count=retry_count + 1,
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
log_to_client(f"自动重试提交失败: {msg}", user_id, account_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
threading.Timer(5, delayed_retry_submit).start()
|
||||||
|
except Exception:
|
||||||
|
delayed_retry_submit()
|
||||||
|
|
||||||
|
if batch_id and (not screenshot_submitted) and (not batch_recorded) and account.status != "等待重试":
|
||||||
|
account_name = account.remark if account.remark else account.username
|
||||||
|
_batch_task_record_result(
|
||||||
|
batch_id=batch_id,
|
||||||
|
account_name=account_name,
|
||||||
|
screenshot_path=None,
|
||||||
|
total_items=getattr(account, "total_items", 0) or 0,
|
||||||
|
total_attachments=getattr(account, "total_attachments", 0) or 0,
|
||||||
|
)
|
||||||
|
batch_recorded = True
|
||||||
|
|
||||||
|
finally:
|
||||||
|
pass
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
BEIJING_TZ = pytz.timezone("Asia/Shanghai")
|
||||||
|
|
||||||
|
|
||||||
|
def get_beijing_now() -> datetime:
|
||||||
|
return datetime.now(BEIJING_TZ)
|
||||||
|
|
||||||
@@ -3,22 +3,22 @@
|
|||||||
"file": "assets/datetime-ZCuLLiQt.js",
|
"file": "assets/datetime-ZCuLLiQt.js",
|
||||||
"name": "datetime"
|
"name": "datetime"
|
||||||
},
|
},
|
||||||
"_tasks-BYcXDffp.js": {
|
"_tasks-D0zj3VJF.js": {
|
||||||
"file": "assets/tasks-BYcXDffp.js",
|
"file": "assets/tasks-D0zj3VJF.js",
|
||||||
"name": "tasks",
|
"name": "tasks",
|
||||||
"imports": [
|
"imports": [
|
||||||
"index.html"
|
"index.html"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"_users-Du3tLSHt.js": {
|
"_users-7Bk6NvSS.js": {
|
||||||
"file": "assets/users-Du3tLSHt.js",
|
"file": "assets/users-7Bk6NvSS.js",
|
||||||
"name": "users",
|
"name": "users",
|
||||||
"imports": [
|
"imports": [
|
||||||
"index.html"
|
"index.html"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"index.html": {
|
"index.html": {
|
||||||
"file": "assets/index-C5w7EVNo.js",
|
"file": "assets/index-8uFy3xP6.js",
|
||||||
"name": "index",
|
"name": "index",
|
||||||
"src": "index.html",
|
"src": "index.html",
|
||||||
"isEntry": true,
|
"isEntry": true,
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/pages/AnnouncementsPage.vue": {
|
"src/pages/AnnouncementsPage.vue": {
|
||||||
"file": "assets/AnnouncementsPage-C63j6LV5.js",
|
"file": "assets/AnnouncementsPage-BaAke3LD.js",
|
||||||
"name": "AnnouncementsPage",
|
"name": "AnnouncementsPage",
|
||||||
"src": "src/pages/AnnouncementsPage.vue",
|
"src": "src/pages/AnnouncementsPage.vue",
|
||||||
"isDynamicEntry": true,
|
"isDynamicEntry": true,
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/pages/EmailPage.vue": {
|
"src/pages/EmailPage.vue": {
|
||||||
"file": "assets/EmailPage-Bf6BbYPD.js",
|
"file": "assets/EmailPage-CQ54ILNk.js",
|
||||||
"name": "EmailPage",
|
"name": "EmailPage",
|
||||||
"src": "src/pages/EmailPage.vue",
|
"src": "src/pages/EmailPage.vue",
|
||||||
"isDynamicEntry": true,
|
"isDynamicEntry": true,
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/pages/FeedbacksPage.vue": {
|
"src/pages/FeedbacksPage.vue": {
|
||||||
"file": "assets/FeedbacksPage-mOUifait.js",
|
"file": "assets/FeedbacksPage-DE47SeFp.js",
|
||||||
"name": "FeedbacksPage",
|
"name": "FeedbacksPage",
|
||||||
"src": "src/pages/FeedbacksPage.vue",
|
"src": "src/pages/FeedbacksPage.vue",
|
||||||
"isDynamicEntry": true,
|
"isDynamicEntry": true,
|
||||||
@@ -74,13 +74,13 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/pages/LogsPage.vue": {
|
"src/pages/LogsPage.vue": {
|
||||||
"file": "assets/LogsPage-DVfeUO3d.js",
|
"file": "assets/LogsPage-UzX26IA-.js",
|
||||||
"name": "LogsPage",
|
"name": "LogsPage",
|
||||||
"src": "src/pages/LogsPage.vue",
|
"src": "src/pages/LogsPage.vue",
|
||||||
"isDynamicEntry": true,
|
"isDynamicEntry": true,
|
||||||
"imports": [
|
"imports": [
|
||||||
"_users-Du3tLSHt.js",
|
"_users-7Bk6NvSS.js",
|
||||||
"_tasks-BYcXDffp.js",
|
"_tasks-D0zj3VJF.js",
|
||||||
"index.html"
|
"index.html"
|
||||||
],
|
],
|
||||||
"css": [
|
"css": [
|
||||||
@@ -88,12 +88,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/pages/PendingPage.vue": {
|
"src/pages/PendingPage.vue": {
|
||||||
"file": "assets/PendingPage-BALptdIG.js",
|
"file": "assets/PendingPage-BxcKr1rh.js",
|
||||||
"name": "PendingPage",
|
"name": "PendingPage",
|
||||||
"src": "src/pages/PendingPage.vue",
|
"src": "src/pages/PendingPage.vue",
|
||||||
"isDynamicEntry": true,
|
"isDynamicEntry": true,
|
||||||
"imports": [
|
"imports": [
|
||||||
"_users-Du3tLSHt.js",
|
"_users-7Bk6NvSS.js",
|
||||||
"index.html",
|
"index.html",
|
||||||
"_datetime-ZCuLLiQt.js"
|
"_datetime-ZCuLLiQt.js"
|
||||||
],
|
],
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/pages/SettingsPage.vue": {
|
"src/pages/SettingsPage.vue": {
|
||||||
"file": "assets/SettingsPage-BKf3hQvU.js",
|
"file": "assets/SettingsPage-MZ5dlYEy.js",
|
||||||
"name": "SettingsPage",
|
"name": "SettingsPage",
|
||||||
"src": "src/pages/SettingsPage.vue",
|
"src": "src/pages/SettingsPage.vue",
|
||||||
"isDynamicEntry": true,
|
"isDynamicEntry": true,
|
||||||
@@ -114,12 +114,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/pages/StatsPage.vue": {
|
"src/pages/StatsPage.vue": {
|
||||||
"file": "assets/StatsPage-DjylIGTc.js",
|
"file": "assets/StatsPage-LZRKsKip.js",
|
||||||
"name": "StatsPage",
|
"name": "StatsPage",
|
||||||
"src": "src/pages/StatsPage.vue",
|
"src": "src/pages/StatsPage.vue",
|
||||||
"isDynamicEntry": true,
|
"isDynamicEntry": true,
|
||||||
"imports": [
|
"imports": [
|
||||||
"_tasks-BYcXDffp.js",
|
"_tasks-D0zj3VJF.js",
|
||||||
"index.html"
|
"index.html"
|
||||||
],
|
],
|
||||||
"css": [
|
"css": [
|
||||||
@@ -127,7 +127,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/pages/SystemPage.vue": {
|
"src/pages/SystemPage.vue": {
|
||||||
"file": "assets/SystemPage-k6FhqNid.js",
|
"file": "assets/SystemPage-DLPz_RK8.js",
|
||||||
"name": "SystemPage",
|
"name": "SystemPage",
|
||||||
"src": "src/pages/SystemPage.vue",
|
"src": "src/pages/SystemPage.vue",
|
||||||
"isDynamicEntry": true,
|
"isDynamicEntry": true,
|
||||||
@@ -139,12 +139,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/pages/UsersPage.vue": {
|
"src/pages/UsersPage.vue": {
|
||||||
"file": "assets/UsersPage-XRSMHsqH.js",
|
"file": "assets/UsersPage-whzb1Tl7.js",
|
||||||
"name": "UsersPage",
|
"name": "UsersPage",
|
||||||
"src": "src/pages/UsersPage.vue",
|
"src": "src/pages/UsersPage.vue",
|
||||||
"isDynamicEntry": true,
|
"isDynamicEntry": true,
|
||||||
"imports": [
|
"imports": [
|
||||||
"_users-Du3tLSHt.js",
|
"_users-7Bk6NvSS.js",
|
||||||
"_datetime-ZCuLLiQt.js",
|
"_datetime-ZCuLLiQt.js",
|
||||||
"index.html"
|
"index.html"
|
||||||
],
|
],
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
|||||||
import{f as E,a as I,r as A}from"./users-Du3tLSHt.js";import{_ as M,r as p,o as q,c as W,a as i,b as t,w as a,d,i as T,f as F,e as G,g as f,h as r,j as $,k as x,l as H,t as k,E as m,m as g,n as J,p as K}from"./index-C5w7EVNo.js";import{p as L}from"./datetime-ZCuLLiQt.js";const O={class:"page-stack"},Q={class:"app-page-title"},X={class:"table-wrap"},Y={class:"user-cell"},Z={class:"table-wrap"},ee={__name:"PendingPage",setup(te){const B=T("refreshStats",null),_=T("refreshNavBadges",null),v=p([]),c=p([]),w=p(!1),y=p(!1);function D(s){const e=s?.vip_expire_time;if(!e)return!1;if(String(e).startsWith("2099-12-31"))return!0;const o=L(e);return o?o.getTime()>Date.now():!1}async function j(){w.value=!0;try{v.value=await E()}catch{v.value=[]}finally{w.value=!1}}async function h(){y.value=!0;try{c.value=await F()}catch{c.value=[]}finally{y.value=!1}}async function u(){await Promise.all([j(),h()]),await _?.({pendingResets:c.value.length})}async function N(s){try{await m.confirm(`确定通过用户「${s.username}」的注册申请吗?`,"审核通过",{confirmButtonText:"通过",cancelButtonText:"取消",type:"success"})}catch{return}try{await I(s.id),g.success("用户审核通过"),await u(),await B?.()}catch{}}async function U(s){try{await m.confirm(`确定拒绝用户「${s.username}」的注册申请吗?`,"拒绝申请",{confirmButtonText:"拒绝",cancelButtonText:"取消",type:"warning"})}catch{return}try{await A(s.id),g.success("已拒绝用户"),await u(),await B?.()}catch{}}async function V(s){try{await m.confirm(`确定批准「${s.username}」的密码重置申请吗?`,"批准重置",{confirmButtonText:"批准",cancelButtonText:"取消",type:"success"})}catch{return}try{const e=await J(s.id);g.success(e?.message||"密码重置申请已批准"),await h(),await _?.({pendingResets:c.value.length})}catch{}}async function z(s){try{await m.confirm(`确定拒绝「${s.username}」的密码重置申请吗?`,"拒绝重置",{confirmButtonText:"拒绝",cancelButtonText:"取消",type:"warning"})}catch{return}try{const e=await K(s.id);g.success(e?.message||"密码重置申请已拒绝"),await h(),await _?.({pendingResets:c.value.length})}catch{}}return q(u),(s,e)=>{const o=d("el-button"),l=d("el-table-column"),S=d("el-tag"),R=d("el-table"),C=d("el-card"),P=G("loading");return f(),W("div",O,[i("div",Q,[e[1]||(e[1]=i("h2",null,"待审核",-1)),i("div",null,[t(o,{onClick:u},{default:a(()=>[...e[0]||(e[0]=[r("刷新",-1)])]),_:1})])]),t(C,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:a(()=>[e[5]||(e[5]=i("h3",{class:"section-title"},"用户注册审核",-1)),i("div",X,[$((f(),x(R,{data:v.value,style:{width:"100%"}},{default:a(()=>[t(l,{prop:"id",label:"ID",width:"80"}),t(l,{label:"用户名","min-width":"200"},{default:a(({row:n})=>[i("div",Y,[i("strong",null,k(n.username),1),D(n)?(f(),x(S,{key:0,type:"warning",effect:"light",size:"small"},{default:a(()=>[...e[2]||(e[2]=[r("VIP",-1)])]),_:1})):H("",!0)])]),_:1}),t(l,{prop:"email",label:"邮箱","min-width":"220"},{default:a(({row:n})=>[r(k(n.email||"-"),1)]),_:1}),t(l,{prop:"created_at",label:"注册时间","min-width":"180"}),t(l,{label:"操作",width:"180",fixed:"right"},{default:a(({row:n})=>[t(o,{type:"success",size:"small",onClick:b=>N(n)},{default:a(()=>[...e[3]||(e[3]=[r("通过",-1)])]),_:1},8,["onClick"]),t(o,{type:"danger",size:"small",onClick:b=>U(n)},{default:a(()=>[...e[4]||(e[4]=[r("拒绝",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[P,w.value]])])]),_:1}),t(C,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:a(()=>[e[8]||(e[8]=i("h3",{class:"section-title"},"密码重置审核",-1)),i("div",Z,[$((f(),x(R,{data:c.value,style:{width:"100%"}},{default:a(()=>[t(l,{prop:"id",label:"申请ID",width:"90"}),t(l,{prop:"username",label:"用户名","min-width":"200"}),t(l,{prop:"email",label:"邮箱","min-width":"220"},{default:a(({row:n})=>[r(k(n.email||"-"),1)]),_:1}),t(l,{prop:"created_at",label:"申请时间","min-width":"180"}),t(l,{label:"操作",width:"180",fixed:"right"},{default:a(({row:n})=>[t(o,{type:"success",size:"small",onClick:b=>V(n)},{default:a(()=>[...e[6]||(e[6]=[r("批准",-1)])]),_:1},8,["onClick"]),t(o,{type:"danger",size:"small",onClick:b=>z(n)},{default:a(()=>[...e[7]||(e[7]=[r("拒绝",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[P,y.value]])])]),_:1})])}}},le=M(ee,[["__scopeId","data-v-f2aa6820"]]);export{le as default};
|
import{f as E,a as I,r as A}from"./users-7Bk6NvSS.js";import{_ as M,r as p,o as q,c as W,a as i,b as t,w as a,d,i as T,f as F,e as G,g as f,h as r,j as $,k as x,l as H,t as k,E as m,m as g,n as J,p as K}from"./index-8uFy3xP6.js";import{p as L}from"./datetime-ZCuLLiQt.js";const O={class:"page-stack"},Q={class:"app-page-title"},X={class:"table-wrap"},Y={class:"user-cell"},Z={class:"table-wrap"},ee={__name:"PendingPage",setup(te){const B=T("refreshStats",null),_=T("refreshNavBadges",null),v=p([]),c=p([]),w=p(!1),y=p(!1);function D(s){const e=s?.vip_expire_time;if(!e)return!1;if(String(e).startsWith("2099-12-31"))return!0;const o=L(e);return o?o.getTime()>Date.now():!1}async function j(){w.value=!0;try{v.value=await E()}catch{v.value=[]}finally{w.value=!1}}async function h(){y.value=!0;try{c.value=await F()}catch{c.value=[]}finally{y.value=!1}}async function u(){await Promise.all([j(),h()]),await _?.({pendingResets:c.value.length})}async function N(s){try{await m.confirm(`确定通过用户「${s.username}」的注册申请吗?`,"审核通过",{confirmButtonText:"通过",cancelButtonText:"取消",type:"success"})}catch{return}try{await I(s.id),g.success("用户审核通过"),await u(),await B?.()}catch{}}async function U(s){try{await m.confirm(`确定拒绝用户「${s.username}」的注册申请吗?`,"拒绝申请",{confirmButtonText:"拒绝",cancelButtonText:"取消",type:"warning"})}catch{return}try{await A(s.id),g.success("已拒绝用户"),await u(),await B?.()}catch{}}async function V(s){try{await m.confirm(`确定批准「${s.username}」的密码重置申请吗?`,"批准重置",{confirmButtonText:"批准",cancelButtonText:"取消",type:"success"})}catch{return}try{const e=await J(s.id);g.success(e?.message||"密码重置申请已批准"),await h(),await _?.({pendingResets:c.value.length})}catch{}}async function z(s){try{await m.confirm(`确定拒绝「${s.username}」的密码重置申请吗?`,"拒绝重置",{confirmButtonText:"拒绝",cancelButtonText:"取消",type:"warning"})}catch{return}try{const e=await K(s.id);g.success(e?.message||"密码重置申请已拒绝"),await h(),await _?.({pendingResets:c.value.length})}catch{}}return q(u),(s,e)=>{const o=d("el-button"),l=d("el-table-column"),S=d("el-tag"),R=d("el-table"),C=d("el-card"),P=G("loading");return f(),W("div",O,[i("div",Q,[e[1]||(e[1]=i("h2",null,"待审核",-1)),i("div",null,[t(o,{onClick:u},{default:a(()=>[...e[0]||(e[0]=[r("刷新",-1)])]),_:1})])]),t(C,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:a(()=>[e[5]||(e[5]=i("h3",{class:"section-title"},"用户注册审核",-1)),i("div",X,[$((f(),x(R,{data:v.value,style:{width:"100%"}},{default:a(()=>[t(l,{prop:"id",label:"ID",width:"80"}),t(l,{label:"用户名","min-width":"200"},{default:a(({row:n})=>[i("div",Y,[i("strong",null,k(n.username),1),D(n)?(f(),x(S,{key:0,type:"warning",effect:"light",size:"small"},{default:a(()=>[...e[2]||(e[2]=[r("VIP",-1)])]),_:1})):H("",!0)])]),_:1}),t(l,{prop:"email",label:"邮箱","min-width":"220"},{default:a(({row:n})=>[r(k(n.email||"-"),1)]),_:1}),t(l,{prop:"created_at",label:"注册时间","min-width":"180"}),t(l,{label:"操作",width:"180",fixed:"right"},{default:a(({row:n})=>[t(o,{type:"success",size:"small",onClick:b=>N(n)},{default:a(()=>[...e[3]||(e[3]=[r("通过",-1)])]),_:1},8,["onClick"]),t(o,{type:"danger",size:"small",onClick:b=>U(n)},{default:a(()=>[...e[4]||(e[4]=[r("拒绝",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[P,w.value]])])]),_:1}),t(C,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:a(()=>[e[8]||(e[8]=i("h3",{class:"section-title"},"密码重置审核",-1)),i("div",Z,[$((f(),x(R,{data:c.value,style:{width:"100%"}},{default:a(()=>[t(l,{prop:"id",label:"申请ID",width:"90"}),t(l,{prop:"username",label:"用户名","min-width":"200"}),t(l,{prop:"email",label:"邮箱","min-width":"220"},{default:a(({row:n})=>[r(k(n.email||"-"),1)]),_:1}),t(l,{prop:"created_at",label:"申请时间","min-width":"180"}),t(l,{label:"操作",width:"180",fixed:"right"},{default:a(({row:n})=>[t(o,{type:"success",size:"small",onClick:b=>V(n)},{default:a(()=>[...e[6]||(e[6]=[r("批准",-1)])]),_:1},8,["onClick"]),t(o,{type:"danger",size:"small",onClick:b=>z(n)},{default:a(()=>[...e[7]||(e[7]=[r("拒绝",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[P,y.value]])])]),_:1})])}}},le=M(ee,[["__scopeId","data-v-f2aa6820"]]);export{le as default};
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
import{B as m,_ as T,r as p,c as h,a as r,b as a,w as s,d as u,g as k,h as b,m as d,E as x}from"./index-C5w7EVNo.js";async function C(o){const{data:t}=await m.put("/admin/username",{new_username:o});return t}async function E(o){const{data:t}=await m.put("/admin/password",{new_password:o});return t}async function P(){const{data:o}=await m.post("/logout");return o}const U={class:"page-stack"},N={__name:"SettingsPage",setup(o){const t=p(""),i=p(""),n=p(!1);async function f(){try{await P()}catch{}finally{window.location.href="/yuyx"}}async function B(){const l=t.value.trim();if(!l){d.error("请输入新用户名");return}try{await x.confirm(`确定将管理员用户名修改为「${l}」吗?修改后需要重新登录。`,"修改用户名",{confirmButtonText:"确认修改",cancelButtonText:"取消",type:"warning"})}catch{return}n.value=!0;try{await C(l),d.success("用户名修改成功,请重新登录"),t.value="",setTimeout(f,1200)}catch{}finally{n.value=!1}}async function V(){const l=i.value;if(!l){d.error("请输入新密码");return}if(l.length<6){d.error("密码至少6个字符");return}try{await x.confirm("确定修改管理员密码吗?修改后需要重新登录。","修改密码",{confirmButtonText:"确认修改",cancelButtonText:"取消",type:"warning"})}catch{return}n.value=!0;try{await E(l),d.success("密码修改成功,请重新登录"),i.value="",setTimeout(f,1200)}catch{}finally{n.value=!1}}return(l,e)=>{const w=u("el-input"),v=u("el-form-item"),y=u("el-form"),_=u("el-button"),g=u("el-card");return k(),h("div",U,[e[7]||(e[7]=r("div",{class:"app-page-title"},[r("h2",null,"设置"),r("span",{class:"app-muted"},"管理员账号设置")],-1)),a(g,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:s(()=>[e[3]||(e[3]=r("h3",{class:"section-title"},"修改管理员用户名",-1)),a(y,{"label-width":"120px"},{default:s(()=>[a(v,{label:"新用户名"},{default:s(()=>[a(w,{modelValue:t.value,"onUpdate:modelValue":e[0]||(e[0]=c=>t.value=c),placeholder:"输入新用户名",disabled:n.value},null,8,["modelValue","disabled"])]),_:1})]),_:1}),a(_,{type:"primary",loading:n.value,onClick:B},{default:s(()=>[...e[2]||(e[2]=[b("保存用户名",-1)])]),_:1},8,["loading"])]),_:1}),a(g,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:s(()=>[e[5]||(e[5]=r("h3",{class:"section-title"},"修改管理员密码",-1)),a(y,{"label-width":"120px"},{default:s(()=>[a(v,{label:"新密码"},{default:s(()=>[a(w,{modelValue:i.value,"onUpdate:modelValue":e[1]||(e[1]=c=>i.value=c),type:"password","show-password":"",placeholder:"输入新密码",disabled:n.value},null,8,["modelValue","disabled"])]),_:1})]),_:1}),a(_,{type:"primary",loading:n.value,onClick:V},{default:s(()=>[...e[4]||(e[4]=[b("保存密码",-1)])]),_:1},8,["loading"]),e[6]||(e[6]=r("div",{class:"help"},"建议使用更强密码(至少8位且包含字母与数字)。",-1))]),_:1})])}}},M=T(N,[["__scopeId","data-v-2f4b840f"]]);export{M as default};
|
import{B as m,_ as T,r as p,c as h,a as r,b as a,w as s,d as u,g as k,h as b,m as d,E as x}from"./index-8uFy3xP6.js";async function C(o){const{data:t}=await m.put("/admin/username",{new_username:o});return t}async function E(o){const{data:t}=await m.put("/admin/password",{new_password:o});return t}async function P(){const{data:o}=await m.post("/logout");return o}const U={class:"page-stack"},N={__name:"SettingsPage",setup(o){const t=p(""),i=p(""),n=p(!1);async function f(){try{await P()}catch{}finally{window.location.href="/yuyx"}}async function B(){const l=t.value.trim();if(!l){d.error("请输入新用户名");return}try{await x.confirm(`确定将管理员用户名修改为「${l}」吗?修改后需要重新登录。`,"修改用户名",{confirmButtonText:"确认修改",cancelButtonText:"取消",type:"warning"})}catch{return}n.value=!0;try{await C(l),d.success("用户名修改成功,请重新登录"),t.value="",setTimeout(f,1200)}catch{}finally{n.value=!1}}async function V(){const l=i.value;if(!l){d.error("请输入新密码");return}if(l.length<6){d.error("密码至少6个字符");return}try{await x.confirm("确定修改管理员密码吗?修改后需要重新登录。","修改密码",{confirmButtonText:"确认修改",cancelButtonText:"取消",type:"warning"})}catch{return}n.value=!0;try{await E(l),d.success("密码修改成功,请重新登录"),i.value="",setTimeout(f,1200)}catch{}finally{n.value=!1}}return(l,e)=>{const w=u("el-input"),v=u("el-form-item"),y=u("el-form"),_=u("el-button"),g=u("el-card");return k(),h("div",U,[e[7]||(e[7]=r("div",{class:"app-page-title"},[r("h2",null,"设置"),r("span",{class:"app-muted"},"管理员账号设置")],-1)),a(g,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:s(()=>[e[3]||(e[3]=r("h3",{class:"section-title"},"修改管理员用户名",-1)),a(y,{"label-width":"120px"},{default:s(()=>[a(v,{label:"新用户名"},{default:s(()=>[a(w,{modelValue:t.value,"onUpdate:modelValue":e[0]||(e[0]=c=>t.value=c),placeholder:"输入新用户名",disabled:n.value},null,8,["modelValue","disabled"])]),_:1})]),_:1}),a(_,{type:"primary",loading:n.value,onClick:B},{default:s(()=>[...e[2]||(e[2]=[b("保存用户名",-1)])]),_:1},8,["loading"])]),_:1}),a(g,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:s(()=>[e[5]||(e[5]=r("h3",{class:"section-title"},"修改管理员密码",-1)),a(y,{"label-width":"120px"},{default:s(()=>[a(v,{label:"新密码"},{default:s(()=>[a(w,{modelValue:i.value,"onUpdate:modelValue":e[1]||(e[1]=c=>i.value=c),type:"password","show-password":"",placeholder:"输入新密码",disabled:n.value},null,8,["modelValue","disabled"])]),_:1})]),_:1}),a(_,{type:"primary",loading:n.value,onClick:V},{default:s(()=>[...e[4]||(e[4]=[b("保存密码",-1)])]),_:1},8,["loading"]),e[6]||(e[6]=r("div",{class:"help"},"建议使用更强密码(至少8位且包含字母与数字)。",-1))]),_:1})])}}},M=T(N,[["__scopeId","data-v-2f4b840f"]]);export{M as default};
|
||||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import{B as x,_ as ae,r as s,y as te,o as oe,j as ne,e as ue,c as F,a as n,b as l,w as t,d as i,g as w,h as y,k as E,l as $,F as se,s as re,t as de,E as I,m as p}from"./index-C5w7EVNo.js";async function ie(){const{data:d}=await x.get("/system/config");return d}async function D(d){const{data:v}=await x.post("/system/config",d);return v}async function me(){const{data:d}=await x.post("/schedule/execute",{});return d}async function ce(){const{data:d}=await x.get("/proxy/config");return d}async function pe(d){const{data:v}=await x.post("/proxy/config",d);return v}async function ve(d){const{data:v}=await x.post("/proxy/test",d);return v}const ye={class:"page-stack"},_e={class:"app-page-title"},fe={class:"row-actions"},xe={class:"row-actions"},be={__name:"SystemPage",setup(d){const v=s(!1),V=s(2),k=s(1),P=s(3),m=s(!1),C=s("02:00"),b=s("应读"),_=s(["1","2","3","4","5","6","7"]),g=s(!1),f=s(""),B=s(3),N=s(!1),h=s(10),T=s(7),j=[{label:"周一",value:"1"},{label:"周二",value:"2"},{label:"周三",value:"3"},{label:"周四",value:"4"},{label:"周五",value:"5"},{label:"周六",value:"6"},{label:"周日",value:"7"}],L={1:"周一",2:"周二",3:"周三",4:"周四",5:"周五",6:"周六",7:"周日"},O=te(()=>(_.value||[]).map(a=>L[Number(a)]||a).join("、"));function W(a){return String(a)==="注册前未读"?"注册前未读":"应读"}async function H(){v.value=!0;try{const[a,e]=await Promise.all([ie(),ce()]);V.value=a.max_concurrent_global??2,k.value=a.max_concurrent_per_account??1,P.value=a.max_screenshot_concurrent??3,m.value=(a.schedule_enabled??0)===1,C.value=a.schedule_time||"02:00",b.value=W(a.schedule_browse_type);const u=String(a.schedule_weekdays||"1,2,3,4,5,6,7").split(",").map(c=>c.trim()).filter(Boolean);_.value=u.length?u:["1","2","3","4","5","6","7"],N.value=(a.auto_approve_enabled??0)===1,h.value=a.auto_approve_hourly_limit??10,T.value=a.auto_approve_vip_days??7,g.value=(e.proxy_enabled??0)===1,f.value=e.proxy_api_url||"",B.value=e.proxy_expire_minutes??3}catch{}finally{v.value=!1}}async function z(){const a={max_concurrent_global:Number(V.value),max_concurrent_per_account:Number(k.value),max_screenshot_concurrent:Number(P.value)};try{await I.confirm(`确定更新并发配置吗?
|
import{B as x,_ as ae,r as s,y as te,o as oe,j as ne,e as ue,c as F,a as n,b as l,w as t,d as i,g as w,h as y,k as E,l as $,F as se,s as re,t as de,E as I,m as p}from"./index-8uFy3xP6.js";async function ie(){const{data:d}=await x.get("/system/config");return d}async function D(d){const{data:v}=await x.post("/system/config",d);return v}async function me(){const{data:d}=await x.post("/schedule/execute",{});return d}async function ce(){const{data:d}=await x.get("/proxy/config");return d}async function pe(d){const{data:v}=await x.post("/proxy/config",d);return v}async function ve(d){const{data:v}=await x.post("/proxy/test",d);return v}const ye={class:"page-stack"},_e={class:"app-page-title"},fe={class:"row-actions"},xe={class:"row-actions"},be={__name:"SystemPage",setup(d){const v=s(!1),V=s(2),k=s(1),P=s(3),m=s(!1),C=s("02:00"),b=s("应读"),_=s(["1","2","3","4","5","6","7"]),g=s(!1),f=s(""),B=s(3),N=s(!1),h=s(10),T=s(7),j=[{label:"周一",value:"1"},{label:"周二",value:"2"},{label:"周三",value:"3"},{label:"周四",value:"4"},{label:"周五",value:"5"},{label:"周六",value:"6"},{label:"周日",value:"7"}],L={1:"周一",2:"周二",3:"周三",4:"周四",5:"周五",6:"周六",7:"周日"},O=te(()=>(_.value||[]).map(a=>L[Number(a)]||a).join("、"));function W(a){return String(a)==="注册前未读"?"注册前未读":"应读"}async function H(){v.value=!0;try{const[a,e]=await Promise.all([ie(),ce()]);V.value=a.max_concurrent_global??2,k.value=a.max_concurrent_per_account??1,P.value=a.max_screenshot_concurrent??3,m.value=(a.schedule_enabled??0)===1,C.value=a.schedule_time||"02:00",b.value=W(a.schedule_browse_type);const u=String(a.schedule_weekdays||"1,2,3,4,5,6,7").split(",").map(c=>c.trim()).filter(Boolean);_.value=u.length?u:["1","2","3","4","5","6","7"],N.value=(a.auto_approve_enabled??0)===1,h.value=a.auto_approve_hourly_limit??10,T.value=a.auto_approve_vip_days??7,g.value=(e.proxy_enabled??0)===1,f.value=e.proxy_api_url||"",B.value=e.proxy_expire_minutes??3}catch{}finally{v.value=!1}}async function z(){const a={max_concurrent_global:Number(V.value),max_concurrent_per_account:Number(k.value),max_screenshot_concurrent:Number(P.value)};try{await I.confirm(`确定更新并发配置吗?
|
||||||
|
|
||||||
全局并发数: ${a.max_concurrent_global}
|
全局并发数: ${a.max_concurrent_global}
|
||||||
单账号并发数: ${a.max_concurrent_per_account}
|
单账号并发数: ${a.max_concurrent_per_account}
|
||||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
import{B as a}from"./index-C5w7EVNo.js";async function c(){const{data:t}=await a.get("/server/info");return t}async function e(){const{data:t}=await a.get("/docker_stats");return t}async function o(){const{data:t}=await a.get("/task/stats");return t}async function r(){const{data:t}=await a.get("/task/running");return t}async function i(t){const{data:s}=await a.get("/task/logs",{params:t});return s}async function f(t){const{data:s}=await a.post("/task/logs/clear",{days:t});return s}export{e as a,o as b,r as c,i as d,f as e,c as f};
|
import{B as a}from"./index-8uFy3xP6.js";async function c(){const{data:t}=await a.get("/server/info");return t}async function e(){const{data:t}=await a.get("/docker_stats");return t}async function o(){const{data:t}=await a.get("/task/stats");return t}async function r(){const{data:t}=await a.get("/task/running");return t}async function i(t){const{data:s}=await a.get("/task/logs",{params:t});return s}async function f(t){const{data:s}=await a.post("/task/logs/clear",{days:t});return s}export{e as a,o as b,r as c,i as d,f as e,c as f};
|
||||||
@@ -1 +1 @@
|
|||||||
import{B as a}from"./index-C5w7EVNo.js";async function r(){const{data:s}=await a.get("/users");return s}async function c(){const{data:s}=await a.get("/users/pending");return s}async function o(s){const{data:t}=await a.post(`/users/${s}/approve`);return t}async function i(s){const{data:t}=await a.post(`/users/${s}/reject`);return t}async function u(s){const{data:t}=await a.delete(`/users/${s}`);return t}async function d(s,t){const{data:e}=await a.post(`/users/${s}/vip`,{days:t});return e}async function p(s){const{data:t}=await a.delete(`/users/${s}/vip`);return t}async function f(s,t){const{data:e}=await a.post(`/users/${s}/reset_password`,{new_password:t});return e}export{o as a,r as b,p as c,f as d,u as e,c as f,i as r,d as s};
|
import{B as a}from"./index-8uFy3xP6.js";async function r(){const{data:s}=await a.get("/users");return s}async function c(){const{data:s}=await a.get("/users/pending");return s}async function o(s){const{data:t}=await a.post(`/users/${s}/approve`);return t}async function i(s){const{data:t}=await a.post(`/users/${s}/reject`);return t}async function u(s){const{data:t}=await a.delete(`/users/${s}`);return t}async function d(s,t){const{data:e}=await a.post(`/users/${s}/vip`,{days:t});return e}async function p(s){const{data:t}=await a.delete(`/users/${s}/vip`);return t}async function f(s,t){const{data:e}=await a.post(`/users/${s}/reset_password`,{new_password:t});return e}export{o as a,r as b,p as c,f as d,u as e,c as f,i as r,d as s};
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="./vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="./vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>后台管理 - 知识管理平台</title>
|
<title>后台管理 - 知识管理平台</title>
|
||||||
<script type="module" crossorigin src="./assets/index-C5w7EVNo.js"></script>
|
<script type="module" crossorigin src="./assets/index-8uFy3xP6.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-BIDpnzAs.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-BIDpnzAs.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"_accounts-DuQjqW8V.js": {
|
"_accounts-Cs5suRwn.js": {
|
||||||
"file": "assets/accounts-DuQjqW8V.js",
|
"file": "assets/accounts-Cs5suRwn.js",
|
||||||
"name": "accounts",
|
"name": "accounts",
|
||||||
"imports": [
|
"imports": [
|
||||||
"index.html"
|
"index.html"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"_auth-C__02fQ5.js": {
|
"_auth-CGPXQwSI.js": {
|
||||||
"file": "assets/auth-C__02fQ5.js",
|
"file": "assets/auth-CGPXQwSI.js",
|
||||||
"name": "auth",
|
"name": "auth",
|
||||||
"imports": [
|
"imports": [
|
||||||
"index.html"
|
"index.html"
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
"name": "password"
|
"name": "password"
|
||||||
},
|
},
|
||||||
"index.html": {
|
"index.html": {
|
||||||
"file": "assets/index-2JnZbEa5.js",
|
"file": "assets/index-DYbFXn7x.js",
|
||||||
"name": "index",
|
"name": "index",
|
||||||
"src": "index.html",
|
"src": "index.html",
|
||||||
"isEntry": true,
|
"isEntry": true,
|
||||||
@@ -36,12 +36,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/pages/AccountsPage.vue": {
|
"src/pages/AccountsPage.vue": {
|
||||||
"file": "assets/AccountsPage-CugNoiiP.js",
|
"file": "assets/AccountsPage-Bx5BF0c_.js",
|
||||||
"name": "AccountsPage",
|
"name": "AccountsPage",
|
||||||
"src": "src/pages/AccountsPage.vue",
|
"src": "src/pages/AccountsPage.vue",
|
||||||
"isDynamicEntry": true,
|
"isDynamicEntry": true,
|
||||||
"imports": [
|
"imports": [
|
||||||
"_accounts-DuQjqW8V.js",
|
"_accounts-Cs5suRwn.js",
|
||||||
"index.html"
|
"index.html"
|
||||||
],
|
],
|
||||||
"css": [
|
"css": [
|
||||||
@@ -49,13 +49,13 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/pages/LoginPage.vue": {
|
"src/pages/LoginPage.vue": {
|
||||||
"file": "assets/LoginPage-8hxar7WW.js",
|
"file": "assets/LoginPage-C9yGySKX.js",
|
||||||
"name": "LoginPage",
|
"name": "LoginPage",
|
||||||
"src": "src/pages/LoginPage.vue",
|
"src": "src/pages/LoginPage.vue",
|
||||||
"isDynamicEntry": true,
|
"isDynamicEntry": true,
|
||||||
"imports": [
|
"imports": [
|
||||||
"index.html",
|
"index.html",
|
||||||
"_auth-C__02fQ5.js",
|
"_auth-CGPXQwSI.js",
|
||||||
"_password-7ryi82gE.js"
|
"_password-7ryi82gE.js"
|
||||||
],
|
],
|
||||||
"css": [
|
"css": [
|
||||||
@@ -63,26 +63,26 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/pages/RegisterPage.vue": {
|
"src/pages/RegisterPage.vue": {
|
||||||
"file": "assets/RegisterPage-B_EUWOuP.js",
|
"file": "assets/RegisterPage-Bovgf1zp.js",
|
||||||
"name": "RegisterPage",
|
"name": "RegisterPage",
|
||||||
"src": "src/pages/RegisterPage.vue",
|
"src": "src/pages/RegisterPage.vue",
|
||||||
"isDynamicEntry": true,
|
"isDynamicEntry": true,
|
||||||
"imports": [
|
"imports": [
|
||||||
"index.html",
|
"index.html",
|
||||||
"_auth-C__02fQ5.js"
|
"_auth-CGPXQwSI.js"
|
||||||
],
|
],
|
||||||
"css": [
|
"css": [
|
||||||
"assets/RegisterPage-CVjBOq6i.css"
|
"assets/RegisterPage-CVjBOq6i.css"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/pages/ResetPasswordPage.vue": {
|
"src/pages/ResetPasswordPage.vue": {
|
||||||
"file": "assets/ResetPasswordPage-BLEflhaq.js",
|
"file": "assets/ResetPasswordPage-DpyvwUux.js",
|
||||||
"name": "ResetPasswordPage",
|
"name": "ResetPasswordPage",
|
||||||
"src": "src/pages/ResetPasswordPage.vue",
|
"src": "src/pages/ResetPasswordPage.vue",
|
||||||
"isDynamicEntry": true,
|
"isDynamicEntry": true,
|
||||||
"imports": [
|
"imports": [
|
||||||
"index.html",
|
"index.html",
|
||||||
"_auth-C__02fQ5.js",
|
"_auth-CGPXQwSI.js",
|
||||||
"_password-7ryi82gE.js"
|
"_password-7ryi82gE.js"
|
||||||
],
|
],
|
||||||
"css": [
|
"css": [
|
||||||
@@ -90,12 +90,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/pages/SchedulesPage.vue": {
|
"src/pages/SchedulesPage.vue": {
|
||||||
"file": "assets/SchedulesPage-BbA-BJek.js",
|
"file": "assets/SchedulesPage-CBL4FA3b.js",
|
||||||
"name": "SchedulesPage",
|
"name": "SchedulesPage",
|
||||||
"src": "src/pages/SchedulesPage.vue",
|
"src": "src/pages/SchedulesPage.vue",
|
||||||
"isDynamicEntry": true,
|
"isDynamicEntry": true,
|
||||||
"imports": [
|
"imports": [
|
||||||
"_accounts-DuQjqW8V.js",
|
"_accounts-Cs5suRwn.js",
|
||||||
"index.html"
|
"index.html"
|
||||||
],
|
],
|
||||||
"css": [
|
"css": [
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/pages/ScreenshotsPage.vue": {
|
"src/pages/ScreenshotsPage.vue": {
|
||||||
"file": "assets/ScreenshotsPage-DCIxup8x.js",
|
"file": "assets/ScreenshotsPage-aDdDquT7.js",
|
||||||
"name": "ScreenshotsPage",
|
"name": "ScreenshotsPage",
|
||||||
"src": "src/pages/ScreenshotsPage.vue",
|
"src": "src/pages/ScreenshotsPage.vue",
|
||||||
"isDynamicEntry": true,
|
"isDynamicEntry": true,
|
||||||
@@ -115,7 +115,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/pages/VerifyResultPage.vue": {
|
"src/pages/VerifyResultPage.vue": {
|
||||||
"file": "assets/VerifyResultPage-QQKdIo1L.js",
|
"file": "assets/VerifyResultPage-D9WtDvDT.js",
|
||||||
"name": "VerifyResultPage",
|
"name": "VerifyResultPage",
|
||||||
"src": "src/pages/VerifyResultPage.vue",
|
"src": "src/pages/VerifyResultPage.vue",
|
||||||
"isDynamicEntry": true,
|
"isDynamicEntry": true,
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
|||||||
import{_ as M,r as j,a as p,c as B,o as A,b as S,d as t,w as o,e as m,u as H,f as g,g as n,h as U,i as x,j as N,t as q,k as E,E as d}from"./index-2JnZbEa5.js";import{g as z,f as F,c as G}from"./auth-C__02fQ5.js";const J={class:"auth-wrap"},O={class:"hint app-muted"},Q={class:"captcha-row"},W=["src"],X={class:"actions"},Y={__name:"RegisterPage",setup(Z){const T=H(),a=j({username:"",password:"",confirm_password:"",email:"",captcha:""}),v=p(!1),f=p(""),b=p(""),h=p(!1),l=p(""),_=p(""),V=p(""),K=B(()=>v.value?"邮箱 *":"邮箱(可选)"),P=B(()=>v.value?"必填,用于账号验证":"选填,用于接收审核通知");async function w(){try{const u=await z();b.value=u?.session_id||"",f.value=u?.captcha_image||"",a.captcha=""}catch{b.value="",f.value=""}}async function R(){try{const u=await F();v.value=!!u?.register_verify_enabled}catch{v.value=!1}}function D(){l.value="",_.value="",V.value=""}async function k(){D();const u=a.username.trim(),e=a.password,y=a.confirm_password,s=a.email.trim(),i=a.captcha.trim();if(u.length<3){l.value="用户名至少3个字符",d.error(l.value);return}if(e.length<6){l.value="密码至少6个字符",d.error(l.value);return}if(e!==y){l.value="两次输入的密码不一致",d.error(l.value);return}if(v.value&&!s){l.value="请填写邮箱地址用于账号验证",d.error(l.value);return}if(s&&!s.includes("@")){l.value="邮箱格式不正确",d.error(l.value);return}if(!i){l.value="请输入验证码",d.error(l.value);return}h.value=!0;try{const c=await G({username:u,password:e,email:s,captcha_session:b.value,captcha:i});_.value=c?.message||"注册成功",V.value=c?.need_verify?"请检查您的邮箱(包括垃圾邮件文件夹)":"",d.success("注册成功"),a.username="",a.password="",a.confirm_password="",a.email="",a.captcha="",setTimeout(()=>{window.location.href="/login"},3e3)}catch(c){const C=c?.response?.data;l.value=C?.error||"注册失败",d.error(l.value),await w()}finally{h.value=!1}}function I(){T.push("/login")}return A(async()=>{await w(),await R()}),(u,e)=>{const y=m("el-alert"),s=m("el-input"),i=m("el-form-item"),c=m("el-button"),C=m("el-form"),L=m("el-card");return g(),S("div",J,[t(L,{shadow:"never",class:"auth-card","body-style":{padding:"22px"}},{default:o(()=>[e[11]||(e[11]=n("div",{class:"brand"},[n("div",{class:"brand-title"},"知识管理平台"),n("div",{class:"brand-sub app-muted"},"用户注册")],-1)),l.value?(g(),U(y,{key:0,type:"error",closable:!1,title:l.value,"show-icon":"",class:"alert"},null,8,["title"])):x("",!0),_.value?(g(),U(y,{key:1,type:"success",closable:!1,title:_.value,description:V.value,"show-icon":"",class:"alert"},null,8,["title","description"])):x("",!0),t(C,{"label-position":"top"},{default:o(()=>[t(i,{label:"用户名 *"},{default:o(()=>[t(s,{modelValue:a.username,"onUpdate:modelValue":e[0]||(e[0]=r=>a.username=r),placeholder:"至少3个字符",autocomplete:"username"},null,8,["modelValue"]),e[5]||(e[5]=n("div",{class:"hint app-muted"},"至少3个字符",-1))]),_:1}),t(i,{label:"密码 *"},{default:o(()=>[t(s,{modelValue:a.password,"onUpdate:modelValue":e[1]||(e[1]=r=>a.password=r),type:"password","show-password":"",placeholder:"至少6个字符",autocomplete:"new-password"},null,8,["modelValue"]),e[6]||(e[6]=n("div",{class:"hint app-muted"},"至少6个字符",-1))]),_:1}),t(i,{label:"确认密码 *"},{default:o(()=>[t(s,{modelValue:a.confirm_password,"onUpdate:modelValue":e[2]||(e[2]=r=>a.confirm_password=r),type:"password","show-password":"",placeholder:"请再次输入密码",autocomplete:"new-password",onKeyup:N(k,["enter"])},null,8,["modelValue"])]),_:1}),t(i,{label:K.value},{default:o(()=>[t(s,{modelValue:a.email,"onUpdate:modelValue":e[3]||(e[3]=r=>a.email=r),placeholder:"name@example.com",autocomplete:"email"},null,8,["modelValue"]),n("div",O,q(P.value),1)]),_:1},8,["label"]),t(i,{label:"验证码 *"},{default:o(()=>[n("div",Q,[t(s,{modelValue:a.captcha,"onUpdate:modelValue":e[4]||(e[4]=r=>a.captcha=r),placeholder:"请输入验证码",onKeyup:N(k,["enter"])},null,8,["modelValue"]),f.value?(g(),S("img",{key:0,class:"captcha-img",src:f.value,alt:"验证码",title:"点击刷新",onClick:w},null,8,W)):x("",!0),t(c,{onClick:w},{default:o(()=>[...e[7]||(e[7]=[E("刷新",-1)])]),_:1})])]),_:1})]),_:1}),t(c,{type:"primary",class:"submit-btn",loading:h.value,onClick:k},{default:o(()=>[...e[8]||(e[8]=[E("注册",-1)])]),_:1},8,["loading"]),n("div",X,[e[10]||(e[10]=n("span",{class:"app-muted"},"已有账号?",-1)),t(c,{link:"",type:"primary",onClick:I},{default:o(()=>[...e[9]||(e[9]=[E("立即登录",-1)])]),_:1})])]),_:1})])}}},ae=M(Y,[["__scopeId","data-v-32684b4d"]]);export{ae as default};
|
import{_ as M,r as j,a as p,c as B,o as A,b as S,d as t,w as o,e as m,u as H,f as g,g as n,h as U,i as x,j as N,t as q,k as E,E as d}from"./index-DYbFXn7x.js";import{g as z,f as F,c as G}from"./auth-CGPXQwSI.js";const J={class:"auth-wrap"},O={class:"hint app-muted"},Q={class:"captcha-row"},W=["src"],X={class:"actions"},Y={__name:"RegisterPage",setup(Z){const T=H(),a=j({username:"",password:"",confirm_password:"",email:"",captcha:""}),v=p(!1),f=p(""),b=p(""),h=p(!1),l=p(""),_=p(""),V=p(""),K=B(()=>v.value?"邮箱 *":"邮箱(可选)"),P=B(()=>v.value?"必填,用于账号验证":"选填,用于接收审核通知");async function w(){try{const u=await z();b.value=u?.session_id||"",f.value=u?.captcha_image||"",a.captcha=""}catch{b.value="",f.value=""}}async function R(){try{const u=await F();v.value=!!u?.register_verify_enabled}catch{v.value=!1}}function D(){l.value="",_.value="",V.value=""}async function k(){D();const u=a.username.trim(),e=a.password,y=a.confirm_password,s=a.email.trim(),i=a.captcha.trim();if(u.length<3){l.value="用户名至少3个字符",d.error(l.value);return}if(e.length<6){l.value="密码至少6个字符",d.error(l.value);return}if(e!==y){l.value="两次输入的密码不一致",d.error(l.value);return}if(v.value&&!s){l.value="请填写邮箱地址用于账号验证",d.error(l.value);return}if(s&&!s.includes("@")){l.value="邮箱格式不正确",d.error(l.value);return}if(!i){l.value="请输入验证码",d.error(l.value);return}h.value=!0;try{const c=await G({username:u,password:e,email:s,captcha_session:b.value,captcha:i});_.value=c?.message||"注册成功",V.value=c?.need_verify?"请检查您的邮箱(包括垃圾邮件文件夹)":"",d.success("注册成功"),a.username="",a.password="",a.confirm_password="",a.email="",a.captcha="",setTimeout(()=>{window.location.href="/login"},3e3)}catch(c){const C=c?.response?.data;l.value=C?.error||"注册失败",d.error(l.value),await w()}finally{h.value=!1}}function I(){T.push("/login")}return A(async()=>{await w(),await R()}),(u,e)=>{const y=m("el-alert"),s=m("el-input"),i=m("el-form-item"),c=m("el-button"),C=m("el-form"),L=m("el-card");return g(),S("div",J,[t(L,{shadow:"never",class:"auth-card","body-style":{padding:"22px"}},{default:o(()=>[e[11]||(e[11]=n("div",{class:"brand"},[n("div",{class:"brand-title"},"知识管理平台"),n("div",{class:"brand-sub app-muted"},"用户注册")],-1)),l.value?(g(),U(y,{key:0,type:"error",closable:!1,title:l.value,"show-icon":"",class:"alert"},null,8,["title"])):x("",!0),_.value?(g(),U(y,{key:1,type:"success",closable:!1,title:_.value,description:V.value,"show-icon":"",class:"alert"},null,8,["title","description"])):x("",!0),t(C,{"label-position":"top"},{default:o(()=>[t(i,{label:"用户名 *"},{default:o(()=>[t(s,{modelValue:a.username,"onUpdate:modelValue":e[0]||(e[0]=r=>a.username=r),placeholder:"至少3个字符",autocomplete:"username"},null,8,["modelValue"]),e[5]||(e[5]=n("div",{class:"hint app-muted"},"至少3个字符",-1))]),_:1}),t(i,{label:"密码 *"},{default:o(()=>[t(s,{modelValue:a.password,"onUpdate:modelValue":e[1]||(e[1]=r=>a.password=r),type:"password","show-password":"",placeholder:"至少6个字符",autocomplete:"new-password"},null,8,["modelValue"]),e[6]||(e[6]=n("div",{class:"hint app-muted"},"至少6个字符",-1))]),_:1}),t(i,{label:"确认密码 *"},{default:o(()=>[t(s,{modelValue:a.confirm_password,"onUpdate:modelValue":e[2]||(e[2]=r=>a.confirm_password=r),type:"password","show-password":"",placeholder:"请再次输入密码",autocomplete:"new-password",onKeyup:N(k,["enter"])},null,8,["modelValue"])]),_:1}),t(i,{label:K.value},{default:o(()=>[t(s,{modelValue:a.email,"onUpdate:modelValue":e[3]||(e[3]=r=>a.email=r),placeholder:"name@example.com",autocomplete:"email"},null,8,["modelValue"]),n("div",O,q(P.value),1)]),_:1},8,["label"]),t(i,{label:"验证码 *"},{default:o(()=>[n("div",Q,[t(s,{modelValue:a.captcha,"onUpdate:modelValue":e[4]||(e[4]=r=>a.captcha=r),placeholder:"请输入验证码",onKeyup:N(k,["enter"])},null,8,["modelValue"]),f.value?(g(),S("img",{key:0,class:"captcha-img",src:f.value,alt:"验证码",title:"点击刷新",onClick:w},null,8,W)):x("",!0),t(c,{onClick:w},{default:o(()=>[...e[7]||(e[7]=[E("刷新",-1)])]),_:1})])]),_:1})]),_:1}),t(c,{type:"primary",class:"submit-btn",loading:h.value,onClick:k},{default:o(()=>[...e[8]||(e[8]=[E("注册",-1)])]),_:1},8,["loading"]),n("div",X,[e[10]||(e[10]=n("span",{class:"app-muted"},"已有账号?",-1)),t(c,{link:"",type:"primary",onClick:I},{default:o(()=>[...e[9]||(e[9]=[E("立即登录",-1)])]),_:1})])]),_:1})])}}},ae=M(Y,[["__scopeId","data-v-32684b4d"]]);export{ae as default};
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
import{_ as L,a as n,l as M,r as U,c as j,o as F,m as K,b as v,d as s,w as a,e as l,u as D,f as m,g as w,F as T,k,h as q,i as x,j as z,t as G,E as y}from"./index-2JnZbEa5.js";import{d as H}from"./auth-C__02fQ5.js";import{v as J}from"./password-7ryi82gE.js";const O={class:"auth-wrap"},Q={class:"actions"},W={class:"actions"},X={key:0,class:"app-muted"},Y={__name:"ResetPasswordPage",setup(Z){const B=M(),A=D(),r=n(String(B.params.token||"")),i=n(!0),b=n(""),t=U({newPassword:"",confirmPassword:""}),g=n(!1),f=n(""),d=n(0);let u=null;function C(){if(typeof window>"u")return null;const o=window.__APP_INITIAL_STATE__;return!o||typeof o!="object"?null:(window.__APP_INITIAL_STATE__=null,o)}const I=j(()=>!!(i.value&&r.value&&!f.value));function S(){A.push("/login")}function N(){d.value=3,u=window.setInterval(()=>{d.value-=1,d.value<=0&&(window.clearInterval(u),u=null,window.location.href="/login")},1e3)}async function V(){if(!I.value)return;const o=t.newPassword,e=t.confirmPassword,c=J(o);if(!c.ok){y.error(c.message);return}if(o!==e){y.error("两次输入的密码不一致");return}g.value=!0;try{await H({token:r.value,new_password:o}),f.value="密码重置成功!3秒后跳转到登录页面...",y.success("密码重置成功"),N()}catch(p){const _=p?.response?.data;y.error(_?.error||"重置失败")}finally{g.value=!1}}return F(()=>{const o=C();o?.page==="reset_password"?(r.value=String(o?.token||r.value||""),i.value=!!o?.valid,b.value=o?.error_message||(i.value?"":"重置链接无效或已过期,请重新申请密码重置")):r.value||(i.value=!1,b.value="重置链接无效或已过期,请重新申请密码重置")}),K(()=>{u&&window.clearInterval(u)}),(o,e)=>{const c=l("el-alert"),p=l("el-button"),_=l("el-input"),h=l("el-form-item"),R=l("el-form"),E=l("el-card");return m(),v("div",O,[s(E,{shadow:"never",class:"auth-card","body-style":{padding:"22px"}},{default:a(()=>[e[5]||(e[5]=w("div",{class:"brand"},[w("div",{class:"brand-title"},"知识管理平台"),w("div",{class:"brand-sub app-muted"},"重置密码")],-1)),i.value?(m(),v(T,{key:1},[f.value?(m(),q(c,{key:0,type:"success",closable:!1,title:"重置成功",description:f.value,"show-icon":"",class:"alert"},null,8,["description"])):x("",!0),s(R,{"label-position":"top"},{default:a(()=>[s(h,{label:"新密码(至少8位且包含字母和数字)"},{default:a(()=>[s(_,{modelValue:t.newPassword,"onUpdate:modelValue":e[0]||(e[0]=P=>t.newPassword=P),type:"password","show-password":"",placeholder:"请输入新密码",autocomplete:"new-password"},null,8,["modelValue"])]),_:1}),s(h,{label:"确认密码"},{default:a(()=>[s(_,{modelValue:t.confirmPassword,"onUpdate:modelValue":e[1]||(e[1]=P=>t.confirmPassword=P),type:"password","show-password":"",placeholder:"请再次输入新密码",autocomplete:"new-password",onKeyup:z(V,["enter"])},null,8,["modelValue"])]),_:1})]),_:1}),s(p,{type:"primary",class:"submit-btn",loading:g.value,disabled:!I.value,onClick:V},{default:a(()=>[...e[3]||(e[3]=[k(" 确认重置 ",-1)])]),_:1},8,["loading","disabled"]),w("div",W,[s(p,{link:"",type:"primary",onClick:S},{default:a(()=>[...e[4]||(e[4]=[k("返回登录",-1)])]),_:1}),d.value>0?(m(),v("span",X,G(d.value)+" 秒后自动跳转…",1)):x("",!0)])],64)):(m(),v(T,{key:0},[s(c,{type:"error",closable:!1,title:"链接已失效",description:b.value,"show-icon":""},null,8,["description"]),w("div",Q,[s(p,{type:"primary",onClick:S},{default:a(()=>[...e[2]||(e[2]=[k("返回登录",-1)])]),_:1})])],64))]),_:1})])}}},se=L(Y,[["__scopeId","data-v-0bbb511c"]]);export{se as default};
|
import{_ as L,a as n,l as M,r as U,c as j,o as F,m as K,b as v,d as s,w as a,e as l,u as D,f as m,g as w,F as T,k,h as q,i as x,j as z,t as G,E as y}from"./index-DYbFXn7x.js";import{d as H}from"./auth-CGPXQwSI.js";import{v as J}from"./password-7ryi82gE.js";const O={class:"auth-wrap"},Q={class:"actions"},W={class:"actions"},X={key:0,class:"app-muted"},Y={__name:"ResetPasswordPage",setup(Z){const B=M(),A=D(),r=n(String(B.params.token||"")),i=n(!0),b=n(""),t=U({newPassword:"",confirmPassword:""}),g=n(!1),f=n(""),d=n(0);let u=null;function C(){if(typeof window>"u")return null;const o=window.__APP_INITIAL_STATE__;return!o||typeof o!="object"?null:(window.__APP_INITIAL_STATE__=null,o)}const I=j(()=>!!(i.value&&r.value&&!f.value));function S(){A.push("/login")}function N(){d.value=3,u=window.setInterval(()=>{d.value-=1,d.value<=0&&(window.clearInterval(u),u=null,window.location.href="/login")},1e3)}async function V(){if(!I.value)return;const o=t.newPassword,e=t.confirmPassword,c=J(o);if(!c.ok){y.error(c.message);return}if(o!==e){y.error("两次输入的密码不一致");return}g.value=!0;try{await H({token:r.value,new_password:o}),f.value="密码重置成功!3秒后跳转到登录页面...",y.success("密码重置成功"),N()}catch(p){const _=p?.response?.data;y.error(_?.error||"重置失败")}finally{g.value=!1}}return F(()=>{const o=C();o?.page==="reset_password"?(r.value=String(o?.token||r.value||""),i.value=!!o?.valid,b.value=o?.error_message||(i.value?"":"重置链接无效或已过期,请重新申请密码重置")):r.value||(i.value=!1,b.value="重置链接无效或已过期,请重新申请密码重置")}),K(()=>{u&&window.clearInterval(u)}),(o,e)=>{const c=l("el-alert"),p=l("el-button"),_=l("el-input"),h=l("el-form-item"),R=l("el-form"),E=l("el-card");return m(),v("div",O,[s(E,{shadow:"never",class:"auth-card","body-style":{padding:"22px"}},{default:a(()=>[e[5]||(e[5]=w("div",{class:"brand"},[w("div",{class:"brand-title"},"知识管理平台"),w("div",{class:"brand-sub app-muted"},"重置密码")],-1)),i.value?(m(),v(T,{key:1},[f.value?(m(),q(c,{key:0,type:"success",closable:!1,title:"重置成功",description:f.value,"show-icon":"",class:"alert"},null,8,["description"])):x("",!0),s(R,{"label-position":"top"},{default:a(()=>[s(h,{label:"新密码(至少8位且包含字母和数字)"},{default:a(()=>[s(_,{modelValue:t.newPassword,"onUpdate:modelValue":e[0]||(e[0]=P=>t.newPassword=P),type:"password","show-password":"",placeholder:"请输入新密码",autocomplete:"new-password"},null,8,["modelValue"])]),_:1}),s(h,{label:"确认密码"},{default:a(()=>[s(_,{modelValue:t.confirmPassword,"onUpdate:modelValue":e[1]||(e[1]=P=>t.confirmPassword=P),type:"password","show-password":"",placeholder:"请再次输入新密码",autocomplete:"new-password",onKeyup:z(V,["enter"])},null,8,["modelValue"])]),_:1})]),_:1}),s(p,{type:"primary",class:"submit-btn",loading:g.value,disabled:!I.value,onClick:V},{default:a(()=>[...e[3]||(e[3]=[k(" 确认重置 ",-1)])]),_:1},8,["loading","disabled"]),w("div",W,[s(p,{link:"",type:"primary",onClick:S},{default:a(()=>[...e[4]||(e[4]=[k("返回登录",-1)])]),_:1}),d.value>0?(m(),v("span",X,G(d.value)+" 秒后自动跳转…",1)):x("",!0)])],64)):(m(),v(T,{key:0},[s(c,{type:"error",closable:!1,title:"链接已失效",description:b.value,"show-icon":""},null,8,["description"]),w("div",Q,[s(p,{type:"primary",onClick:S},{default:a(()=>[...e[2]||(e[2]=[k("返回登录",-1)])]),_:1})])],64))]),_:1})])}}},se=L(Y,[["__scopeId","data-v-0bbb511c"]]);export{se as default};
|
||||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
|||||||
import{_ as U,a as o,c as I,o as E,m as R,b as k,d as i,w as s,e as d,u as W,f as _,g as l,i as B,h as $,k as T,t as v}from"./index-2JnZbEa5.js";const j={class:"auth-wrap"},z={class:"actions"},D={key:0,class:"countdown app-muted"},M={__name:"VerifyResultPage",setup(q){const x=W(),p=o(!1),f=o(""),m=o(""),w=o(""),y=o(""),r=o(""),u=o(""),c=o(""),n=o(0);let a=null;function C(){if(typeof window>"u")return null;const e=window.__APP_INITIAL_STATE__;return!e||typeof e!="object"?null:(window.__APP_INITIAL_STATE__=null,e)}function N(e){const t=!!e?.success;p.value=t,f.value=e?.title||(t?"验证成功":"验证失败"),m.value=e?.message||e?.error_message||(t?"操作已完成,现在可以继续使用系统。":"操作失败,请稍后重试。"),w.value=e?.primary_label||(t?"立即登录":"重新注册"),y.value=e?.primary_url||(t?"/login":"/register"),r.value=e?.secondary_label||(t?"":"返回登录"),u.value=e?.secondary_url||(t?"":"/login"),c.value=e?.redirect_url||(t?"/login":""),n.value=Number(e?.redirect_seconds||(t?5:0))||0}const A=I(()=>!!(r.value&&u.value)),b=I(()=>!!(c.value&&n.value>0));async function g(e){if(e){if(e.startsWith("http://")||e.startsWith("https://")){window.location.href=e;return}await x.push(e)}}function P(){b.value&&(a=window.setInterval(()=>{n.value-=1,n.value<=0&&(window.clearInterval(a),a=null,window.location.href=c.value)},1e3))}return E(()=>{const e=C();N(e),P()}),R(()=>{a&&window.clearInterval(a)}),(e,t)=>{const h=d("el-button"),V=d("el-result"),L=d("el-card");return _(),k("div",j,[i(L,{shadow:"never",class:"auth-card","body-style":{padding:"22px"}},{default:s(()=>[t[2]||(t[2]=l("div",{class:"brand"},[l("div",{class:"brand-title"},"知识管理平台"),l("div",{class:"brand-sub app-muted"},"验证结果")],-1)),i(V,{icon:p.value?"success":"error",title:f.value,"sub-title":m.value,class:"result"},{extra:s(()=>[l("div",z,[i(h,{type:"primary",onClick:t[0]||(t[0]=S=>g(y.value))},{default:s(()=>[T(v(w.value),1)]),_:1}),A.value?(_(),$(h,{key:0,onClick:t[1]||(t[1]=S=>g(u.value))},{default:s(()=>[T(v(r.value),1)]),_:1})):B("",!0)]),b.value?(_(),k("div",D,v(n.value)+" 秒后自动跳转... ",1)):B("",!0)]),_:1},8,["icon","title","sub-title"])]),_:1})])}}},G=U(M,[["__scopeId","data-v-1fc6b081"]]);export{G as default};
|
import{_ as U,a as o,c as I,o as E,m as R,b as k,d as i,w as s,e as d,u as W,f as _,g as l,i as B,h as $,k as T,t as v}from"./index-DYbFXn7x.js";const j={class:"auth-wrap"},z={class:"actions"},D={key:0,class:"countdown app-muted"},M={__name:"VerifyResultPage",setup(q){const x=W(),p=o(!1),f=o(""),m=o(""),w=o(""),y=o(""),r=o(""),u=o(""),c=o(""),n=o(0);let a=null;function C(){if(typeof window>"u")return null;const e=window.__APP_INITIAL_STATE__;return!e||typeof e!="object"?null:(window.__APP_INITIAL_STATE__=null,e)}function N(e){const t=!!e?.success;p.value=t,f.value=e?.title||(t?"验证成功":"验证失败"),m.value=e?.message||e?.error_message||(t?"操作已完成,现在可以继续使用系统。":"操作失败,请稍后重试。"),w.value=e?.primary_label||(t?"立即登录":"重新注册"),y.value=e?.primary_url||(t?"/login":"/register"),r.value=e?.secondary_label||(t?"":"返回登录"),u.value=e?.secondary_url||(t?"":"/login"),c.value=e?.redirect_url||(t?"/login":""),n.value=Number(e?.redirect_seconds||(t?5:0))||0}const A=I(()=>!!(r.value&&u.value)),b=I(()=>!!(c.value&&n.value>0));async function g(e){if(e){if(e.startsWith("http://")||e.startsWith("https://")){window.location.href=e;return}await x.push(e)}}function P(){b.value&&(a=window.setInterval(()=>{n.value-=1,n.value<=0&&(window.clearInterval(a),a=null,window.location.href=c.value)},1e3))}return E(()=>{const e=C();N(e),P()}),R(()=>{a&&window.clearInterval(a)}),(e,t)=>{const h=d("el-button"),V=d("el-result"),L=d("el-card");return _(),k("div",j,[i(L,{shadow:"never",class:"auth-card","body-style":{padding:"22px"}},{default:s(()=>[t[2]||(t[2]=l("div",{class:"brand"},[l("div",{class:"brand-title"},"知识管理平台"),l("div",{class:"brand-sub app-muted"},"验证结果")],-1)),i(V,{icon:p.value?"success":"error",title:f.value,"sub-title":m.value,class:"result"},{extra:s(()=>[l("div",z,[i(h,{type:"primary",onClick:t[0]||(t[0]=S=>g(y.value))},{default:s(()=>[T(v(w.value),1)]),_:1}),A.value?(_(),$(h,{key:0,onClick:t[1]||(t[1]=S=>g(u.value))},{default:s(()=>[T(v(r.value),1)]),_:1})):B("",!0)]),b.value?(_(),k("div",D,v(n.value)+" 秒后自动跳转... ",1)):B("",!0)]),_:1},8,["icon","title","sub-title"])]),_:1})])}}},G=U(M,[["__scopeId","data-v-1fc6b081"]]);export{G as default};
|
||||||
@@ -1 +1 @@
|
|||||||
import{p as c}from"./index-2JnZbEa5.js";async function o(t={}){const{data:a}=await c.get("/accounts",{params:t});return a}async function u(t){const{data:a}=await c.post("/accounts",t);return a}async function r(t,a){const{data:n}=await c.put(`/accounts/${t}`,a);return n}async function e(t){const{data:a}=await c.delete(`/accounts/${t}`);return a}async function i(t,a){const{data:n}=await c.put(`/accounts/${t}/remark`,a);return n}async function p(t,a){const{data:n}=await c.post(`/accounts/${t}/start`,a);return n}async function d(t){const{data:a}=await c.post(`/accounts/${t}/stop`,{});return a}async function f(t){const{data:a}=await c.post("/accounts/batch/start",t);return a}async function w(t){const{data:a}=await c.post("/accounts/batch/stop",t);return a}async function y(){const{data:t}=await c.post("/accounts/clear",{});return t}async function A(t,a={}){const{data:n}=await c.post(`/accounts/${t}/screenshot`,a);return n}export{w as a,f as b,y as c,d,e,o as f,u as g,i as h,p as s,A as t,r as u};
|
import{p as c}from"./index-DYbFXn7x.js";async function o(t={}){const{data:a}=await c.get("/accounts",{params:t});return a}async function u(t){const{data:a}=await c.post("/accounts",t);return a}async function r(t,a){const{data:n}=await c.put(`/accounts/${t}`,a);return n}async function e(t){const{data:a}=await c.delete(`/accounts/${t}`);return a}async function i(t,a){const{data:n}=await c.put(`/accounts/${t}/remark`,a);return n}async function p(t,a){const{data:n}=await c.post(`/accounts/${t}/start`,a);return n}async function d(t){const{data:a}=await c.post(`/accounts/${t}/stop`,{});return a}async function f(t){const{data:a}=await c.post("/accounts/batch/start",t);return a}async function w(t){const{data:a}=await c.post("/accounts/batch/stop",t);return a}async function y(){const{data:t}=await c.post("/accounts/clear",{});return t}async function A(t,a={}){const{data:n}=await c.post(`/accounts/${t}/screenshot`,a);return n}export{w as a,f as b,y as c,d,e,o as f,u as g,i as h,p as s,A as t,r as u};
|
||||||
@@ -1 +1 @@
|
|||||||
import{p as s}from"./index-2JnZbEa5.js";async function r(){const{data:t}=await s.get("/email/verify-status");return t}async function e(){const{data:t}=await s.post("/generate_captcha",{});return t}async function o(t){const{data:a}=await s.post("/login",t);return a}async function i(t){const{data:a}=await s.post("/register",t);return a}async function c(t){const{data:a}=await s.post("/resend-verify-email",t);return a}async function u(t){const{data:a}=await s.post("/forgot-password",t);return a}async function f(t){const{data:a}=await s.post("/reset_password_request",t);return a}async function d(t){const{data:a}=await s.post("/reset-password-confirm",t);return a}export{u as a,c as b,i as c,d,r as f,e as g,o as l,f as r};
|
import{p as s}from"./index-DYbFXn7x.js";async function r(){const{data:t}=await s.get("/email/verify-status");return t}async function e(){const{data:t}=await s.post("/generate_captcha",{});return t}async function o(t){const{data:a}=await s.post("/login",t);return a}async function i(t){const{data:a}=await s.post("/register",t);return a}async function c(t){const{data:a}=await s.post("/resend-verify-email",t);return a}async function u(t){const{data:a}=await s.post("/forgot-password",t);return a}async function f(t){const{data:a}=await s.post("/reset_password_request",t);return a}async function d(t){const{data:a}=await s.post("/reset-password-confirm",t);return a}export{u as a,c as b,i as c,d,r as f,e as g,o as l,f as r};
|
||||||
File diff suppressed because one or more lines are too long
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
|
||||||
<title>知识管理平台</title>
|
<title>知识管理平台</title>
|
||||||
<script type="module" crossorigin src="./assets/index-2JnZbEa5.js"></script>
|
<script type="module" crossorigin src="./assets/index-DYbFXn7x.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-Cvi4RJz4.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-Cvi4RJz4.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from services.schedule_utils import compute_next_run_at, format_cst
|
||||||
|
from services.time_utils import BEIJING_TZ
|
||||||
|
|
||||||
|
|
||||||
|
def _dt(text: str) -> datetime:
|
||||||
|
naive = datetime.strptime(text, "%Y-%m-%d %H:%M:%S")
|
||||||
|
return BEIJING_TZ.localize(naive)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_next_run_at_weekday_filter():
|
||||||
|
now = _dt("2025-01-06 07:00:00") # 周一
|
||||||
|
next_dt = compute_next_run_at(
|
||||||
|
now=now,
|
||||||
|
schedule_time="08:00",
|
||||||
|
weekdays="2", # 仅周二
|
||||||
|
random_delay=0,
|
||||||
|
last_run_at=None,
|
||||||
|
)
|
||||||
|
assert format_cst(next_dt) == "2025-01-07 08:00:00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_next_run_at_random_delay_within_window(monkeypatch):
|
||||||
|
now = _dt("2025-01-06 06:00:00")
|
||||||
|
|
||||||
|
# 固定随机值:0 => window_start(schedule_time-15min)
|
||||||
|
monkeypatch.setattr("services.schedule_utils.random.randint", lambda a, b: 0)
|
||||||
|
|
||||||
|
next_dt = compute_next_run_at(
|
||||||
|
now=now,
|
||||||
|
schedule_time="08:00",
|
||||||
|
weekdays="1,2,3,4,5,6,7",
|
||||||
|
random_delay=1,
|
||||||
|
last_run_at=None,
|
||||||
|
)
|
||||||
|
assert format_cst(next_dt) == "2025-01-06 07:45:00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_next_run_at_skips_same_day_if_last_run_today(monkeypatch):
|
||||||
|
now = _dt("2025-01-06 06:00:00")
|
||||||
|
|
||||||
|
# 让次日的随机值固定,便于断言
|
||||||
|
monkeypatch.setattr("services.schedule_utils.random.randint", lambda a, b: 30)
|
||||||
|
|
||||||
|
next_dt = compute_next_run_at(
|
||||||
|
now=now,
|
||||||
|
schedule_time="08:00",
|
||||||
|
weekdays="1,2,3,4,5,6,7",
|
||||||
|
random_delay=1,
|
||||||
|
last_run_at="2025-01-06 01:00:00",
|
||||||
|
)
|
||||||
|
# 次日 window_start=07:45 + 30min => 08:15
|
||||||
|
assert format_cst(next_dt) == "2025-01-07 08:15:00"
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
from services import state
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_status_returns_copy():
|
||||||
|
account_id = "acc_test_copy"
|
||||||
|
state.safe_set_task_status(account_id, {"status": "运行中", "progress": {"items": 1}})
|
||||||
|
|
||||||
|
snapshot = state.safe_get_task_status(account_id)
|
||||||
|
snapshot["status"] = "已修改"
|
||||||
|
|
||||||
|
snapshot2 = state.safe_get_task_status(account_id)
|
||||||
|
assert snapshot2["status"] == "运行中"
|
||||||
|
|
||||||
|
|
||||||
|
def test_captcha_roundtrip():
|
||||||
|
session_id = "captcha_test"
|
||||||
|
state.safe_set_captcha(session_id, {"code": "1234", "expire_time": time.time() + 60, "failed_attempts": 0})
|
||||||
|
|
||||||
|
ok, msg = state.safe_verify_and_consume_captcha(session_id, "1234", max_attempts=5)
|
||||||
|
assert ok, msg
|
||||||
|
|
||||||
|
ok2, _ = state.safe_verify_and_consume_captcha(session_id, "1234", max_attempts=5)
|
||||||
|
assert not ok2
|
||||||
|
|
||||||
|
|
||||||
|
def test_ip_rate_limit_locking():
|
||||||
|
ip = "203.0.113.9"
|
||||||
|
ok, msg = state.check_ip_rate_limit(ip, max_attempts_per_hour=2, lock_duration_seconds=10)
|
||||||
|
assert ok and msg is None
|
||||||
|
|
||||||
|
locked = state.record_failed_captcha(ip, max_attempts_per_hour=2, lock_duration_seconds=10)
|
||||||
|
assert locked is False
|
||||||
|
locked2 = state.record_failed_captcha(ip, max_attempts_per_hour=2, lock_duration_seconds=10)
|
||||||
|
assert locked2 is True
|
||||||
|
|
||||||
|
ok3, msg3 = state.check_ip_rate_limit(ip, max_attempts_per_hour=2, lock_duration_seconds=10)
|
||||||
|
assert ok3 is False
|
||||||
|
assert "锁定" in (msg3 or "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_finalize_after_dispatch():
|
||||||
|
batch_id = "batch_test"
|
||||||
|
now_ts = time.time()
|
||||||
|
state.safe_create_batch(
|
||||||
|
batch_id,
|
||||||
|
{"screenshots": [], "total_accounts": 0, "completed": 0, "created_at": now_ts, "updated_at": now_ts},
|
||||||
|
)
|
||||||
|
state.safe_batch_append_result(batch_id, {"path": "a.png"})
|
||||||
|
state.safe_batch_append_result(batch_id, {"path": "b.png"})
|
||||||
|
|
||||||
|
batch_info = state.safe_finalize_batch_after_dispatch(batch_id, total_accounts=2, now_ts=time.time())
|
||||||
|
assert batch_info is not None
|
||||||
|
assert batch_info["completed"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_thread_safety_smoke():
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
def worker(i: int):
|
||||||
|
try:
|
||||||
|
aid = f"acc_{i % 10}"
|
||||||
|
state.safe_set_task_status(aid, {"status": "运行中", "i": i})
|
||||||
|
_ = state.safe_get_task_status(aid)
|
||||||
|
except Exception as exc: # pragma: no cover
|
||||||
|
errors.append(exc)
|
||||||
|
|
||||||
|
threads = [threading.Thread(target=worker, args=(i,)) for i in range(200)]
|
||||||
|
for t in threads:
|
||||||
|
t.start()
|
||||||
|
for t in threads:
|
||||||
|
t.join()
|
||||||
|
|
||||||
|
assert not errors
|
||||||
|
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
from services.tasks import TaskScheduler
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_scheduler_vip_priority(monkeypatch):
|
||||||
|
calls: list[str] = []
|
||||||
|
blocker_started = threading.Event()
|
||||||
|
blocker_release = threading.Event()
|
||||||
|
|
||||||
|
def fake_run_task(*, user_id, account_id, **kwargs):
|
||||||
|
calls.append(account_id)
|
||||||
|
if account_id == "block":
|
||||||
|
blocker_started.set()
|
||||||
|
blocker_release.wait(timeout=5)
|
||||||
|
|
||||||
|
import services.tasks as tasks_mod
|
||||||
|
|
||||||
|
monkeypatch.setattr(tasks_mod, "run_task", fake_run_task)
|
||||||
|
|
||||||
|
scheduler = TaskScheduler(max_global=1, max_per_user=1, max_queue_size=10)
|
||||||
|
try:
|
||||||
|
ok, _ = scheduler.submit_task(user_id=1, account_id="block", browse_type="应读", is_vip=False)
|
||||||
|
assert ok
|
||||||
|
assert blocker_started.wait(timeout=2)
|
||||||
|
|
||||||
|
ok2, _ = scheduler.submit_task(user_id=1, account_id="normal", browse_type="应读", is_vip=False)
|
||||||
|
ok3, _ = scheduler.submit_task(user_id=2, account_id="vip", browse_type="应读", is_vip=True)
|
||||||
|
assert ok2 and ok3
|
||||||
|
|
||||||
|
blocker_release.set()
|
||||||
|
|
||||||
|
deadline = time.time() + 3
|
||||||
|
while time.time() < deadline:
|
||||||
|
if calls[:3] == ["block", "vip", "normal"]:
|
||||||
|
break
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
assert calls[:3] == ["block", "vip", "normal"]
|
||||||
|
finally:
|
||||||
|
scheduler.shutdown(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_scheduler_per_user_concurrency(monkeypatch):
|
||||||
|
started: list[str] = []
|
||||||
|
a1_started = threading.Event()
|
||||||
|
a1_release = threading.Event()
|
||||||
|
a2_started = threading.Event()
|
||||||
|
|
||||||
|
def fake_run_task(*, user_id, account_id, **kwargs):
|
||||||
|
started.append(account_id)
|
||||||
|
if account_id == "a1":
|
||||||
|
a1_started.set()
|
||||||
|
a1_release.wait(timeout=5)
|
||||||
|
if account_id == "a2":
|
||||||
|
a2_started.set()
|
||||||
|
|
||||||
|
import services.tasks as tasks_mod
|
||||||
|
|
||||||
|
monkeypatch.setattr(tasks_mod, "run_task", fake_run_task)
|
||||||
|
|
||||||
|
scheduler = TaskScheduler(max_global=2, max_per_user=1, max_queue_size=10)
|
||||||
|
try:
|
||||||
|
ok, _ = scheduler.submit_task(user_id=1, account_id="a1", browse_type="应读", is_vip=False)
|
||||||
|
assert ok
|
||||||
|
assert a1_started.wait(timeout=2)
|
||||||
|
|
||||||
|
ok2, _ = scheduler.submit_task(user_id=1, account_id="a2", browse_type="应读", is_vip=False)
|
||||||
|
assert ok2
|
||||||
|
|
||||||
|
# 同一用户并发=1:a2 不应在 a1 未结束时启动
|
||||||
|
assert not a2_started.wait(timeout=0.3)
|
||||||
|
|
||||||
|
a1_release.set()
|
||||||
|
assert a2_started.wait(timeout=2)
|
||||||
|
assert started[0] == "a1"
|
||||||
|
assert "a2" in started
|
||||||
|
finally:
|
||||||
|
scheduler.shutdown(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_scheduler_cancel_pending(monkeypatch):
|
||||||
|
calls: list[str] = []
|
||||||
|
blocker_started = threading.Event()
|
||||||
|
blocker_release = threading.Event()
|
||||||
|
|
||||||
|
def fake_run_task(*, user_id, account_id, **kwargs):
|
||||||
|
calls.append(account_id)
|
||||||
|
if account_id == "block":
|
||||||
|
blocker_started.set()
|
||||||
|
blocker_release.wait(timeout=5)
|
||||||
|
|
||||||
|
import services.tasks as tasks_mod
|
||||||
|
|
||||||
|
monkeypatch.setattr(tasks_mod, "run_task", fake_run_task)
|
||||||
|
|
||||||
|
scheduler = TaskScheduler(max_global=1, max_per_user=1, max_queue_size=10)
|
||||||
|
try:
|
||||||
|
ok, _ = scheduler.submit_task(user_id=1, account_id="block", browse_type="应读", is_vip=False)
|
||||||
|
assert ok
|
||||||
|
assert blocker_started.wait(timeout=2)
|
||||||
|
|
||||||
|
ok2, _ = scheduler.submit_task(user_id=1, account_id="to_cancel", browse_type="应读", is_vip=False)
|
||||||
|
assert ok2
|
||||||
|
|
||||||
|
assert scheduler.cancel_pending_task(user_id=1, account_id="to_cancel") is True
|
||||||
|
|
||||||
|
blocker_release.set()
|
||||||
|
time.sleep(0.3)
|
||||||
|
assert "to_cancel" not in calls
|
||||||
|
finally:
|
||||||
|
scheduler.shutdown(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_scheduler_queue_full(monkeypatch):
|
||||||
|
blocker_started = threading.Event()
|
||||||
|
blocker_release = threading.Event()
|
||||||
|
|
||||||
|
def fake_run_task(*, user_id, account_id, **kwargs):
|
||||||
|
if account_id == "block":
|
||||||
|
blocker_started.set()
|
||||||
|
blocker_release.wait(timeout=5)
|
||||||
|
|
||||||
|
import services.tasks as tasks_mod
|
||||||
|
|
||||||
|
monkeypatch.setattr(tasks_mod, "run_task", fake_run_task)
|
||||||
|
|
||||||
|
scheduler = TaskScheduler(max_global=1, max_per_user=1, max_queue_size=1)
|
||||||
|
try:
|
||||||
|
ok, _ = scheduler.submit_task(user_id=1, account_id="block", browse_type="应读", is_vip=False)
|
||||||
|
assert ok
|
||||||
|
assert blocker_started.wait(timeout=2)
|
||||||
|
|
||||||
|
ok2, _ = scheduler.submit_task(user_id=1, account_id="p1", browse_type="应读", is_vip=False)
|
||||||
|
assert ok2
|
||||||
|
|
||||||
|
ok3, msg3 = scheduler.submit_task(user_id=1, account_id="p2", browse_type="应读", is_vip=False)
|
||||||
|
assert ok3 is False
|
||||||
|
assert "队列已满" in (msg3 or "")
|
||||||
|
finally:
|
||||||
|
blocker_release.set()
|
||||||
|
scheduler.shutdown(timeout=2)
|
||||||
|
|
||||||
Reference in New Issue
Block a user