同步更新:重构路由、服务模块,更新前端构建

This commit is contained in:
2025-12-14 21:47:08 +08:00
parent e01a7b5235
commit a346509a5f
87 changed files with 9186 additions and 7826 deletions

View File

@@ -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/

View File

@@ -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)

View File

@@ -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

View File

@@ -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)
},
)

5644
app.py Executable file → Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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_urlscheme://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'))
# ==================== 服务器配置 ==================== # ==================== 服务器配置 ====================

View File

@@ -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__':

View File

@@ -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)

View File

@@ -3,6 +3,10 @@
""" """
应用状态管理模块 应用状态管理模块
提供线程安全的全局状态管理 提供线程安全的全局状态管理
说明P0P3 优化后):
- 该模块为历史遗留实现,保留用于兼容与参考
- 当前实际生效的全局状态入口为 `services/state.py`safe_* API
""" """
import threading import threading

View File

@@ -1,6 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""浏览器池管理 - 线程本地存储,每个线程复用自己的浏览器""" """浏览器池管理 - 线程本地存储,每个线程复用自己的浏览器
说明P0P3 优化后):
- 该实现为遗留版本,当前截图并发与浏览器复用已迁移到 `browser_pool_worker.py` 的 WorkerPool 方案
- 本文件保留用于兼容/回滚参考(当前主流程不再依赖)
"""
import threading import threading
import time import time

2371
database.py Executable file → Normal file

File diff suppressed because it is too large Load Diff

10
db/__init__.py Normal file
View File

@@ -0,0 +1,10 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
DB 包:按领域拆分的 DAO + schema/migrations。
约束:
- 外部仍通过 `import database` 访问稳定 API
- 本包仅提供内部实现与组织结构P2 / O-07
"""

150
db/accounts.py Normal file
View File

@@ -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
db/admin.py Normal file
View File

@@ -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

132
db/announcements.py Normal file
View File

@@ -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
db/email.py Normal file
View File

@@ -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
db/feedbacks.py Normal file
View File

@@ -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}

452
db/migrations.py Normal file
View File

@@ -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
db/schedules.py Normal file
View File

@@ -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_atO-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
db/schema.py Normal file
View File

@@ -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
db/tasks.py Normal file
View File

@@ -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
db/users.py Normal file
View File

@@ -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
注意数据库中存储的时间统一使用CSTAsia/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
db/utils.py Normal file
View File

@@ -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("_", "\\_")

View File

@@ -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__':

View File

@@ -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:

22
pyproject.toml Normal file
View File

@@ -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"

3
realtime/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

View File

@@ -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}")

56
realtime/status_push.py Normal file
View File

@@ -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)

3
requirements-dev.txt Normal file
View File

@@ -0,0 +1,3 @@
ruff
black
pytest

21
routes/__init__.py Normal file
View File

@@ -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)

View File

@@ -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

1142
routes/admin_api/core.py Normal file

File diff suppressed because it is too large Load Diff

410
routes/api_accounts.py Normal file
View File

@@ -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})

420
routes/api_auth.py Normal file
View File

@@ -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})

248
routes/api_schedules.py Normal file
View File

@@ -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

117
routes/api_screenshots.py Normal file
View File

@@ -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

293
routes/api_user.py Normal file
View File

@@ -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),
}
)

26
routes/decorators.py Normal file
View File

@@ -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
routes/pages.py Normal file
View File

@@ -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")

2
services/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
"""Service layer package."""

View File

@@ -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)

26
services/browse_types.py Normal file
View File

@@ -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)

View File

@@ -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

21
services/checkpoints.py Normal file
View File

@@ -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()

31
services/client_log.py Normal file
View File

@@ -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 "")

108
services/maintenance.py Normal file
View File

@@ -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("内存清理调度器已启动")

106
services/models.py Normal file
View File

@@ -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

77
services/proxy.py Normal file
View File

@@ -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

36
services/runtime.py Normal file
View File

@@ -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

113
services/schedule_utils.py Normal file
View File

@@ -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")

389
services/scheduler.py Normal file
View File

@@ -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-08next_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)

272
services/screenshots.py Normal file
View File

@@ -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, "截图队列已满,请稍后重试")

481
services/state.py Normal file
View File

@@ -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)

70
services/task_batches.py Normal file
View File

@@ -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中提取批次IDsource格式: 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)

838
services/tasks.py Normal file
View File

@@ -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

14
services/time_utils.py Normal file
View File

@@ -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)

View File

@@ -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"
], ],

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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};

View File

@@ -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};

File diff suppressed because one or more lines are too long

View File

@@ -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}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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};

View File

@@ -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};

View File

@@ -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>

View File

@@ -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,

File diff suppressed because one or more lines are too long

View File

@@ -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};

View File

@@ -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};

View File

@@ -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};

View File

@@ -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};

View File

@@ -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

View File

@@ -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>

7
tests/conftest.py Normal file
View File

@@ -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))

View File

@@ -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_startschedule_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"

77
tests/test_state.py Normal file
View File

@@ -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

View File

@@ -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
# 同一用户并发=1a2 不应在 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)