同步更新:重构路由、服务模块,更新前端构建
This commit is contained in:
@@ -39,6 +39,10 @@ COPY app_logger.py .
|
||||
COPY app_security.py .
|
||||
COPY app_state.py .
|
||||
COPY app_utils.py .
|
||||
COPY routes/ ./routes/
|
||||
COPY services/ ./services/
|
||||
COPY realtime/ ./realtime/
|
||||
COPY db/ ./db/
|
||||
|
||||
COPY templates/ ./templates/
|
||||
COPY static/ ./static/
|
||||
|
||||
@@ -25,7 +25,11 @@ api.interceptors.response.use(
|
||||
const payload = error?.response?.data
|
||||
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)
|
||||
} else if (status) {
|
||||
toastErrorOnce(`http:${status}:${message}`, message)
|
||||
|
||||
@@ -13,9 +13,18 @@ import atexit
|
||||
import weakref
|
||||
from typing import Optional, Callable
|
||||
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()
|
||||
|
||||
@@ -79,12 +88,11 @@ class APIBrowser:
|
||||
import json
|
||||
import hashlib
|
||||
|
||||
cookies_dir = '/app/data/cookies'
|
||||
os.makedirs(cookies_dir, exist_ok=True)
|
||||
os.makedirs(COOKIES_DIR, exist_ok=True)
|
||||
|
||||
# 安全修复:使用SHA256代替MD5作为文件名哈希
|
||||
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:
|
||||
# 获取requests session的cookies
|
||||
@@ -93,7 +101,7 @@ class APIBrowser:
|
||||
cookies_list.append({
|
||||
'name': cookie.name,
|
||||
'value': cookie.value,
|
||||
'domain': cookie.domain or 'postoa.aidunsoft.com',
|
||||
'domain': cookie.domain or _cookie_domain_fallback,
|
||||
'path': cookie.path or '/',
|
||||
})
|
||||
|
||||
@@ -182,8 +190,7 @@ class APIBrowser:
|
||||
self.log(f"[API] 登录: {username}")
|
||||
|
||||
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')
|
||||
fields = self._get_aspnet_fields(soup)
|
||||
@@ -195,17 +202,17 @@ class APIBrowser:
|
||||
|
||||
resp = self._request_with_retry(
|
||||
'post',
|
||||
login_url,
|
||||
LOGIN_URL,
|
||||
data=data,
|
||||
headers={
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Origin': BASE_URL,
|
||||
'Referer': login_url,
|
||||
'Referer': LOGIN_URL,
|
||||
},
|
||||
allow_redirects=True
|
||||
)
|
||||
|
||||
if 'index.aspx' in resp.url:
|
||||
if INDEX_URL_PATTERN in resp.url:
|
||||
self.logged_in = True
|
||||
self.log(f"[API] 登录成功")
|
||||
return True
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
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({
|
||||
baseURL: '/api',
|
||||
@@ -6,3 +18,25 @@ export const publicApi = axios.create({
|
||||
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)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import os
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
# 尝试加载.env文件(如果存在)
|
||||
# Bug fix: 添加警告日志,避免静默失败
|
||||
@@ -48,6 +49,33 @@ def get_secret_key():
|
||||
return new_key
|
||||
|
||||
|
||||
def _derive_base_url_from_full_url(url: str, fallback: str) -> str:
|
||||
"""从完整 URL 推导出 base_url(scheme://netloc)。"""
|
||||
try:
|
||||
parsed = urlsplit(str(url or "").strip())
|
||||
if parsed.scheme and parsed.netloc:
|
||||
return f"{parsed.scheme}://{parsed.netloc}"
|
||||
except Exception:
|
||||
pass
|
||||
return fallback
|
||||
|
||||
|
||||
def _derive_sibling_url(full_url: str, filename: str, fallback: str) -> str:
|
||||
"""把 full_url 的最后路径段替换为 filename(忽略 query/fragment)。"""
|
||||
try:
|
||||
parsed = urlsplit(str(full_url or "").strip())
|
||||
if not parsed.scheme or not parsed.netloc:
|
||||
return fallback
|
||||
path = parsed.path or "/"
|
||||
if path.endswith("/"):
|
||||
new_path = path + filename
|
||||
else:
|
||||
new_path = path.rsplit("/", 1)[0] + "/" + filename
|
||||
return urlunsplit((parsed.scheme, parsed.netloc, new_path, "", ""))
|
||||
except Exception:
|
||||
return fallback
|
||||
|
||||
|
||||
class Config:
|
||||
"""应用配置基类"""
|
||||
|
||||
@@ -93,6 +121,7 @@ class Config:
|
||||
|
||||
# ==================== 浏览器配置 ====================
|
||||
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'))
|
||||
@@ -102,6 +131,11 @@ class Config:
|
||||
MAX_LOGS_PER_USER = int(os.environ.get('MAX_LOGS_PER_USER', '100'))
|
||||
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'))
|
||||
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_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'))
|
||||
|
||||
# ==================== 服务器配置 ====================
|
||||
|
||||
@@ -280,6 +280,9 @@ def init_logging(log_level='INFO', log_file='logs/app.log'):
|
||||
|
||||
# 创建审计日志器(已在AuditLogger中创建)
|
||||
|
||||
try:
|
||||
get_logger('app').info("✓ 日志系统初始化完成")
|
||||
except Exception:
|
||||
print("✓ 日志系统初始化完成")
|
||||
|
||||
|
||||
|
||||
@@ -201,11 +201,33 @@ def require_ip_not_locked(f):
|
||||
def decorated_function(*args, **kwargs):
|
||||
ip_address = request.remote_addr
|
||||
|
||||
# P0 / O-01:统一使用 services.state 的线程安全限流状态
|
||||
try:
|
||||
from services.state import check_ip_rate_limit, safe_get_ip_lock_until
|
||||
|
||||
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({
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": "由于多次失败尝试,您的IP已被临时锁定",
|
||||
"locked_until": ip_rate_limiter._locked.get(ip_address, 0)
|
||||
}), 429
|
||||
"locked_until": ip_rate_limiter._locked.get(ip_address, 0),
|
||||
}
|
||||
),
|
||||
429,
|
||||
)
|
||||
|
||||
return f(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"""
|
||||
应用状态管理模块
|
||||
提供线程安全的全局状态管理
|
||||
|
||||
说明(P0–P3 优化后):
|
||||
- 该模块为历史遗留实现,保留用于兼容与参考
|
||||
- 当前实际生效的全局状态入口为 `services/state.py`(safe_* API)
|
||||
"""
|
||||
|
||||
import threading
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""浏览器池管理 - 线程本地存储,每个线程复用自己的浏览器"""
|
||||
"""浏览器池管理 - 线程本地存储,每个线程复用自己的浏览器
|
||||
|
||||
说明(P0–P3 优化后):
|
||||
- 该实现为遗留版本,当前截图并发与浏览器复用已迁移到 `browser_pool_worker.py` 的 WorkerPool 方案
|
||||
- 本文件保留用于兼容/回滚参考(当前主流程不再依赖)
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
2361
database.py
Executable file → Normal file
2361
database.py
Executable file → Normal file
File diff suppressed because it is too large
Load Diff
10
db/__init__.py
Normal file
10
db/__init__.py
Normal 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
150
db/accounts.py
Normal 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
419
db/admin.py
Normal 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
132
db/announcements.py
Normal 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
62
db/email.py
Normal 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
144
db/feedbacks.py
Normal 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
452
db/migrations.py
Normal 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
485
db/schedules.py
Normal 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_at(O-08)"""
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
now_dt = get_beijing_now()
|
||||
now_str = format_cst(now_dt)
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT schedule_time, weekdays, random_delay
|
||||
FROM user_schedules
|
||||
WHERE id = ?
|
||||
""",
|
||||
(schedule_id,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
return False
|
||||
schedule_time, weekdays, random_delay = row[0], row[1], row[2]
|
||||
|
||||
next_dt = compute_next_run_at(
|
||||
now=now_dt,
|
||||
schedule_time=str(schedule_time or "08:00"),
|
||||
weekdays=str(weekdays or "1,2,3,4,5"),
|
||||
random_delay=int(random_delay or 0),
|
||||
last_run_at=now_str,
|
||||
)
|
||||
next_run_at = format_cst(next_dt)
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE user_schedules
|
||||
SET last_run_at = ?, next_run_at = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(now_str, next_run_at, now_str, schedule_id),
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
def update_schedule_next_run(schedule_id: int, next_run_at: str) -> bool:
|
||||
"""仅更新 next_run_at(不改变 last_run_at),用于跳过执行时推进。"""
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE user_schedules
|
||||
SET next_run_at = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(str(next_run_at or "").strip() or None, format_cst(get_beijing_now()), int(schedule_id)),
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
def recompute_schedule_next_run(schedule_id: int, *, now_dt=None) -> bool:
|
||||
"""按当前配置重算 next_run_at(不改变 last_run_at)。"""
|
||||
now_dt = now_dt or get_beijing_now()
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT schedule_time, weekdays, random_delay, last_run_at
|
||||
FROM user_schedules
|
||||
WHERE id = ?
|
||||
""",
|
||||
(int(schedule_id),),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
return False
|
||||
|
||||
schedule_time, weekdays, random_delay, last_run_at = row[0], row[1], row[2], row[3]
|
||||
next_dt = compute_next_run_at(
|
||||
now=now_dt,
|
||||
schedule_time=str(schedule_time or "08:00"),
|
||||
weekdays=str(weekdays or "1,2,3,4,5"),
|
||||
random_delay=int(random_delay or 0),
|
||||
last_run_at=str(last_run_at or "") if last_run_at else None,
|
||||
)
|
||||
return update_schedule_next_run(int(schedule_id), format_cst(next_dt))
|
||||
|
||||
|
||||
def get_due_user_schedules(now_cst: str, limit: int = 50):
|
||||
"""获取到期需要执行的用户定时任务(索引驱动)。"""
|
||||
now_cst = str(now_cst or "").strip()
|
||||
if not now_cst:
|
||||
now_cst = format_cst(get_beijing_now())
|
||||
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT us.*, u.username as user_username
|
||||
FROM user_schedules us
|
||||
JOIN users u ON us.user_id = u.id
|
||||
WHERE us.enabled = 1
|
||||
AND us.next_run_at IS NOT NULL
|
||||
AND us.next_run_at <= ?
|
||||
ORDER BY us.next_run_at ASC
|
||||
LIMIT ?
|
||||
""",
|
||||
(now_cst, int(limit)),
|
||||
)
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
|
||||
# ==================== 定时任务执行日志 ====================
|
||||
|
||||
|
||||
def create_schedule_execution_log(schedule_id, user_id, schedule_name):
|
||||
"""创建定时任务执行日志"""
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
execute_time = format_cst(get_beijing_now())
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO schedule_execution_logs (
|
||||
schedule_id, user_id, schedule_name, execute_time, status
|
||||
) VALUES (?, ?, ?, ?, 'running')
|
||||
""",
|
||||
(schedule_id, user_id, schedule_name, execute_time),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
def update_schedule_execution_log(log_id, **kwargs):
|
||||
"""更新定时任务执行日志"""
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
updates = []
|
||||
params = []
|
||||
|
||||
allowed_fields = [
|
||||
"total_accounts",
|
||||
"success_accounts",
|
||||
"failed_accounts",
|
||||
"total_items",
|
||||
"total_attachments",
|
||||
"total_screenshots",
|
||||
"duration_seconds",
|
||||
"status",
|
||||
"error_message",
|
||||
]
|
||||
|
||||
for field in allowed_fields:
|
||||
if field in kwargs:
|
||||
updates.append(f"{field} = ?")
|
||||
params.append(kwargs[field])
|
||||
|
||||
if not updates:
|
||||
return False
|
||||
|
||||
params.append(log_id)
|
||||
sql = f"UPDATE schedule_execution_logs SET {', '.join(updates)} WHERE id = ?"
|
||||
|
||||
cursor.execute(sql, params)
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
def get_schedule_execution_logs(schedule_id, limit=10):
|
||||
"""获取定时任务执行日志"""
|
||||
try:
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT * FROM schedule_execution_logs
|
||||
WHERE schedule_id = ?
|
||||
ORDER BY execute_time DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(schedule_id, limit),
|
||||
)
|
||||
|
||||
logs = []
|
||||
rows = cursor.fetchall()
|
||||
|
||||
for row in rows:
|
||||
try:
|
||||
log = dict(row)
|
||||
log["created_at"] = log.get("execute_time")
|
||||
log["success_count"] = log.get("success_accounts", 0)
|
||||
log["failed_count"] = log.get("failed_accounts", 0)
|
||||
log["duration"] = log.get("duration_seconds", 0)
|
||||
logs.append(log)
|
||||
except Exception as e:
|
||||
print(f"[数据库] 处理日志行时出错: {e}")
|
||||
continue
|
||||
|
||||
return logs
|
||||
except Exception as e:
|
||||
print(f"[数据库] 查询定时任务日志时出错: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
|
||||
def get_user_all_schedule_logs(user_id, limit=50):
|
||||
"""获取用户所有定时任务的执行日志"""
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT * FROM schedule_execution_logs
|
||||
WHERE user_id = ?
|
||||
ORDER BY execute_time DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(user_id, limit),
|
||||
)
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
|
||||
def delete_schedule_logs(schedule_id, user_id):
|
||||
"""删除指定定时任务的所有执行日志(需验证用户权限)"""
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
DELETE FROM schedule_execution_logs
|
||||
WHERE schedule_id = ? AND user_id = ?
|
||||
""",
|
||||
(schedule_id, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
|
||||
def clean_old_schedule_logs(days=30):
|
||||
"""清理指定天数前的定时任务执行日志"""
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
DELETE FROM schedule_execution_logs
|
||||
WHERE execute_time < datetime('now', 'localtime', '-' || ? || ' days')
|
||||
""",
|
||||
(days,),
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount
|
||||
288
db/schema.py
Normal file
288
db/schema.py
Normal 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
235
db/tasks.py
Normal 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
284
db/users.py
Normal 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
|
||||
|
||||
注意:数据库中存储的时间统一使用CST(Asia/Shanghai)时区
|
||||
"""
|
||||
cst_tz = pytz.timezone("Asia/Shanghai")
|
||||
user = get_user_by_id(user_id)
|
||||
|
||||
if not user or not user.get("vip_expire_time"):
|
||||
return False
|
||||
|
||||
try:
|
||||
expire_time_naive = datetime.strptime(user["vip_expire_time"], "%Y-%m-%d %H:%M:%S")
|
||||
expire_time = cst_tz.localize(expire_time_naive)
|
||||
now = datetime.now(cst_tz)
|
||||
return now < expire_time
|
||||
except (ValueError, AttributeError) as e:
|
||||
print(f"检查VIP状态失败 (user_id={user_id}): {e}")
|
||||
return False
|
||||
|
||||
|
||||
def get_user_vip_info(user_id):
|
||||
"""获取用户VIP信息"""
|
||||
cst_tz = pytz.timezone("Asia/Shanghai")
|
||||
user = get_user_by_id(user_id)
|
||||
|
||||
if not user:
|
||||
return {"is_vip": False, "expire_time": None, "days_left": 0, "username": ""}
|
||||
|
||||
vip_expire_time = user.get("vip_expire_time")
|
||||
if not vip_expire_time:
|
||||
return {"is_vip": False, "expire_time": None, "days_left": 0, "username": user.get("username", "")}
|
||||
|
||||
try:
|
||||
expire_time_naive = datetime.strptime(vip_expire_time, "%Y-%m-%d %H:%M:%S")
|
||||
expire_time = cst_tz.localize(expire_time_naive)
|
||||
now = datetime.now(cst_tz)
|
||||
is_vip = now < expire_time
|
||||
days_left = (expire_time - now).days if is_vip else 0
|
||||
|
||||
return {"username": user.get("username", ""), "is_vip": is_vip, "expire_time": vip_expire_time, "days_left": max(0, days_left)}
|
||||
except Exception as e:
|
||||
print(f"VIP信息获取错误: {e}")
|
||||
return {"is_vip": False, "expire_time": None, "days_left": 0, "username": user.get("username", "")}
|
||||
|
||||
|
||||
# ==================== 用户相关 ====================
|
||||
|
||||
|
||||
def create_user(username, password, email=""):
|
||||
"""创建新用户(待审核状态,赠送默认VIP)"""
|
||||
cst_tz = pytz.timezone("Asia/Shanghai")
|
||||
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
password_hash = hash_password_bcrypt(password)
|
||||
cst_time = get_cst_now_str()
|
||||
|
||||
default_vip_days = get_vip_config()["default_vip_days"]
|
||||
vip_expire_time = None
|
||||
|
||||
if default_vip_days > 0:
|
||||
if default_vip_days == 999999:
|
||||
vip_expire_time = "2099-12-31 23:59:59"
|
||||
else:
|
||||
vip_expire_time = (datetime.now(cst_tz) + timedelta(days=default_vip_days)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
try:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO users (username, password_hash, email, status, vip_expire_time, created_at)
|
||||
VALUES (?, ?, ?, 'pending', ?, ?)
|
||||
""",
|
||||
(username, password_hash, email, vip_expire_time, cst_time),
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.lastrowid
|
||||
except sqlite3.IntegrityError:
|
||||
return None
|
||||
|
||||
|
||||
def verify_user(username, password):
|
||||
"""验证用户登录 - 自动从SHA256升级到bcrypt"""
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM users WHERE username = ? AND status = 'approved'", (username,))
|
||||
user = cursor.fetchone()
|
||||
|
||||
if not user:
|
||||
return None
|
||||
|
||||
user_dict = dict(user)
|
||||
password_hash = user_dict["password_hash"]
|
||||
|
||||
if is_sha256_hash(password_hash):
|
||||
if verify_password_sha256(password, password_hash):
|
||||
new_hash = hash_password_bcrypt(password)
|
||||
cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, user_dict["id"]))
|
||||
conn.commit()
|
||||
print(f"用户 {username} 密码已自动升级到bcrypt")
|
||||
return user_dict
|
||||
return None
|
||||
|
||||
if verify_password_bcrypt(password, password_hash):
|
||||
return user_dict
|
||||
return None
|
||||
|
||||
|
||||
def get_user_by_id(user_id):
|
||||
"""根据ID获取用户"""
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM users WHERE id = ?", (user_id,))
|
||||
user = cursor.fetchone()
|
||||
return dict(user) if user else None
|
||||
|
||||
|
||||
def get_user_by_username(username):
|
||||
"""根据用户名获取用户"""
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM users WHERE username = ?", (username,))
|
||||
user = cursor.fetchone()
|
||||
return dict(user) if user else None
|
||||
|
||||
|
||||
def get_all_users():
|
||||
"""获取所有用户"""
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM users ORDER BY created_at DESC")
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
|
||||
def get_pending_users():
|
||||
"""获取待审核用户"""
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM users WHERE status = 'pending' ORDER BY created_at DESC")
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
|
||||
def approve_user(user_id):
|
||||
"""审核通过用户"""
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cst_time = get_cst_now_str()
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET status = 'approved', approved_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(cst_time, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
def reject_user(user_id):
|
||||
"""拒绝用户"""
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("UPDATE users SET status = 'rejected' WHERE id = ?", (user_id,))
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
def delete_user(user_id):
|
||||
"""删除用户(级联删除相关账号)"""
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
def get_user_stats(user_id):
|
||||
"""获取用户统计信息"""
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT COUNT(*) as count FROM accounts WHERE user_id = ?", (user_id,))
|
||||
account_count = cursor.fetchone()["count"]
|
||||
return {"account_count": account_count}
|
||||
52
db/utils.py
Normal file
52
db/utils.py
Normal 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("_", "\\_")
|
||||
|
||||
@@ -43,6 +43,9 @@ from io import BytesIO
|
||||
|
||||
import db_pool
|
||||
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:
|
||||
@@ -230,7 +233,7 @@ def init_email_tables():
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
print("[邮件服务] 数据库表初始化完成")
|
||||
logger.info("[邮件服务] 数据库表初始化完成")
|
||||
|
||||
|
||||
# ============ SMTP配置管理 ============
|
||||
@@ -239,23 +242,6 @@ def get_email_settings() -> Dict[str, Any]:
|
||||
"""获取全局邮件设置"""
|
||||
with db_pool.get_db() as conn:
|
||||
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("""
|
||||
SELECT enabled, failover_enabled, register_verify_enabled, base_url,
|
||||
task_notify_enabled, updated_at
|
||||
@@ -660,7 +646,7 @@ class EmailSender:
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"[邮件服务] SMTP连接失败 [{self.config['name']}]: {e}")
|
||||
logger.warning(f"[邮件服务] SMTP连接失败 [{self.config['name']}]: {e}")
|
||||
self.server = None
|
||||
raise
|
||||
|
||||
@@ -735,7 +721,7 @@ class EmailSender:
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"[邮件服务] 发送失败: {e}")
|
||||
logger.warning(f"[邮件服务] 发送失败: {e}")
|
||||
raise
|
||||
|
||||
|
||||
@@ -987,7 +973,7 @@ def reset_smtp_daily_quota():
|
||||
updated = cursor.rowcount
|
||||
conn.commit()
|
||||
if updated > 0:
|
||||
print(f"[邮件服务] 已重置 {updated} 个SMTP配置的每日配额")
|
||||
logger.info(f"[邮件服务] 已重置 {updated} 个SMTP配置的每日配额")
|
||||
return updated
|
||||
|
||||
|
||||
@@ -1639,7 +1625,7 @@ class EmailQueue:
|
||||
worker.start()
|
||||
self.workers.append(worker)
|
||||
|
||||
print(f"[邮件服务] 异步队列已启动 ({self.worker_count}个工作线程)")
|
||||
logger.info(f"[邮件服务] 异步队列已启动 ({self.worker_count}个工作线程)")
|
||||
|
||||
def stop(self):
|
||||
"""停止队列"""
|
||||
@@ -1657,7 +1643,7 @@ class EmailQueue:
|
||||
worker.join(timeout=5)
|
||||
|
||||
self.workers.clear()
|
||||
print("[邮件服务] 异步队列已停止")
|
||||
logger.info("[邮件服务] 异步队列已停止")
|
||||
|
||||
def _worker(self):
|
||||
"""工作线程"""
|
||||
@@ -1672,7 +1658,7 @@ class EmailQueue:
|
||||
except queue.Empty:
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"[邮件服务] 队列工作线程错误: {e}")
|
||||
logger.exception(f"[邮件服务] 队列工作线程错误: {e}")
|
||||
|
||||
def _process_task(self, task: Dict):
|
||||
"""处理邮件任务"""
|
||||
@@ -1702,7 +1688,7 @@ class EmailQueue:
|
||||
task['callback'](result)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[邮件服务] 处理邮件任务失败: {e}")
|
||||
logger.exception(f"[邮件服务] 处理邮件任务失败: {e}")
|
||||
if task.get('callback'):
|
||||
task['callback']({'success': False, 'error': str(e)})
|
||||
|
||||
@@ -1730,7 +1716,7 @@ class EmailQueue:
|
||||
}, timeout=5)
|
||||
return True
|
||||
except queue.Full:
|
||||
print("[邮件服务] 邮件队列已满")
|
||||
logger.warning("[邮件服务] 邮件队列已满")
|
||||
return False
|
||||
|
||||
def enqueue_callable(self, func: Callable, args=None, kwargs=None, callback: Callable = None) -> bool:
|
||||
@@ -1744,7 +1730,7 @@ class EmailQueue:
|
||||
}, timeout=5)
|
||||
return True
|
||||
except queue.Full:
|
||||
print("[邮件服务] 邮件队列已满")
|
||||
logger.warning("[邮件服务] 邮件队列已满")
|
||||
return False
|
||||
|
||||
@property
|
||||
@@ -2158,7 +2144,7 @@ def send_batch_task_complete_email(
|
||||
try:
|
||||
zf.write(file_path, arcname=arcname)
|
||||
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
|
||||
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"
|
||||
attachment_note = "截图已打包为ZIP附件,请查收。"
|
||||
except Exception as e:
|
||||
print(f"[邮件] 打包截图失败: {e}")
|
||||
logger.warning(f"[邮件] 打包截图失败: {e}")
|
||||
attachment_note = "截图打包失败,本次不附加附件。"
|
||||
finally:
|
||||
if zip_path and os.path.exists(zip_path):
|
||||
@@ -2200,26 +2186,13 @@ def send_batch_task_complete_email(
|
||||
body='',
|
||||
html_body=html_content,
|
||||
attachments=attachments,
|
||||
email_type='batch_task_complete'
|
||||
email_type='batch_task_complete',
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
if result['success']:
|
||||
# 记录发送日志
|
||||
log_email_send(
|
||||
email_type='batch_task_complete',
|
||||
to_email=email,
|
||||
subject=f'定时任务完成 - {schedule_name}',
|
||||
success=True
|
||||
)
|
||||
return {'success': True}
|
||||
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', '发送失败')}
|
||||
|
||||
|
||||
@@ -2238,7 +2211,7 @@ def send_batch_task_complete_email_async(
|
||||
args=(user_id, email, username, schedule_name, browse_type, screenshots),
|
||||
)
|
||||
if not ok:
|
||||
print("[邮件] 邮件队列已满,批次任务邮件未发送")
|
||||
logger.warning("[邮件] 邮件队列已满,批次任务邮件未发送")
|
||||
|
||||
|
||||
# ============ 初始化 ============
|
||||
@@ -2247,6 +2220,9 @@ def init_email_service():
|
||||
"""初始化邮件服务"""
|
||||
init_email_tables()
|
||||
get_email_queue()
|
||||
try:
|
||||
logger.info("[邮件服务] 初始化完成")
|
||||
except Exception:
|
||||
print("[邮件服务] 初始化完成")
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import atexit
|
||||
import weakref
|
||||
from typing import Optional, Callable
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
from app_config import get_config
|
||||
|
||||
# 设置浏览器安装路径(优先使用环境变量,否则使用默认路径)
|
||||
@@ -173,17 +174,15 @@ class PlaywrightAutomation:
|
||||
self.browser_manager.log(message, self.account_id)
|
||||
|
||||
|
||||
# Cookies存储目录
|
||||
COOKIES_DIR = '/app/data/cookies'
|
||||
|
||||
def get_cookies_path(self, username: str) -> str:
|
||||
"""获取cookies文件路径"""
|
||||
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作为文件名哈希
|
||||
import hashlib
|
||||
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):
|
||||
"""保存当前会话的cookies"""
|
||||
@@ -279,12 +278,27 @@ class PlaywrightAutomation:
|
||||
def check_login_state(self) -> bool:
|
||||
"""检查当前是否处于登录状态"""
|
||||
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)
|
||||
current_url = self.page.url
|
||||
# 如果还在index页面,说明登录态有效
|
||||
if 'index.aspx' in current_url:
|
||||
if getattr(config, "ZSGL_INDEX_URL_PATTERN", "index.aspx") in current_url:
|
||||
return True
|
||||
return False
|
||||
except (TimeoutError, Exception) as e:
|
||||
|
||||
22
pyproject.toml
Normal file
22
pyproject.toml
Normal 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
3
realtime/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
38
realtime/socketio_handlers.py
Normal file
38
realtime/socketio_handlers.py
Normal 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
56
realtime/status_push.py
Normal 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
3
requirements-dev.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
ruff
|
||||
black
|
||||
pytest
|
||||
21
routes/__init__.py
Normal file
21
routes/__init__.py
Normal 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)
|
||||
11
routes/admin_api/__init__.py
Normal file
11
routes/admin_api/__init__.py
Normal 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
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
410
routes/api_accounts.py
Normal 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
420
routes/api_auth.py
Normal 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
248
routes/api_schedules.py
Normal 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
117
routes/api_screenshots.py
Normal 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
293
routes/api_user.py
Normal 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
26
routes/decorators.py
Normal 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
123
routes/pages.py
Normal 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
2
services/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Service layer package."""
|
||||
|
||||
26
services/accounts_service.py
Normal file
26
services/accounts_service.py
Normal 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
26
services/browse_types.py
Normal 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)
|
||||
|
||||
40
services/browser_manager.py
Normal file
40
services/browser_manager.py
Normal 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
21
services/checkpoints.py
Normal 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
31
services/client_log.py
Normal 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
108
services/maintenance.py
Normal 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
106
services/models.py
Normal 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
77
services/proxy.py
Normal 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
36
services/runtime.py
Normal 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
113
services/schedule_utils.py
Normal 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
389
services/scheduler.py
Normal 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-08:next_run_at 索引驱动)。"""
|
||||
import json
|
||||
|
||||
try:
|
||||
now = get_beijing_now()
|
||||
now_str = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
current_weekday = now.isoweekday()
|
||||
|
||||
due_schedules = database.get_due_user_schedules(now_str, limit=50) or []
|
||||
|
||||
for schedule_config in due_schedules:
|
||||
schedule_name = schedule_config.get("name", "未命名任务")
|
||||
schedule_id = schedule_config["id"]
|
||||
|
||||
weekdays_str = schedule_config.get("weekdays", "1,2,3,4,5")
|
||||
try:
|
||||
allowed_weekdays = [int(d) for d in weekdays_str.split(",") if d.strip()]
|
||||
except Exception as e:
|
||||
logger.warning(f"[定时任务] 任务#{schedule_id} 解析weekdays失败: {e}")
|
||||
try:
|
||||
database.recompute_schedule_next_run(schedule_id)
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
|
||||
if current_weekday not in allowed_weekdays:
|
||||
try:
|
||||
database.recompute_schedule_next_run(schedule_id)
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
|
||||
logger.info(f"[用户定时任务] 任务#{schedule_id} '{schedule_name}' 到期,开始执行 (next_run_at={schedule_config.get('next_run_at')})")
|
||||
|
||||
user_id = schedule_config["user_id"]
|
||||
schedule_id = schedule_config["id"]
|
||||
browse_type = normalize_browse_type(schedule_config.get("browse_type", BROWSE_TYPE_SHOULD_READ))
|
||||
enable_screenshot = schedule_config.get("enable_screenshot", 1)
|
||||
|
||||
try:
|
||||
account_ids_raw = schedule_config.get("account_ids", "[]") or "[]"
|
||||
account_ids = json.loads(account_ids_raw)
|
||||
except Exception as e:
|
||||
logger.warning(f"[定时任务] 任务#{schedule_id} 解析account_ids失败: {e}")
|
||||
account_ids = []
|
||||
|
||||
if not account_ids:
|
||||
try:
|
||||
database.recompute_schedule_next_run(schedule_id)
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
|
||||
if not safe_get_user_accounts_snapshot(user_id):
|
||||
load_user_accounts(user_id)
|
||||
|
||||
import time as time_mod
|
||||
import uuid
|
||||
|
||||
execution_start_time = time_mod.time()
|
||||
log_id = database.create_schedule_execution_log(
|
||||
schedule_id=schedule_id, user_id=user_id, schedule_name=schedule_config.get("name", "未命名任务")
|
||||
)
|
||||
|
||||
batch_id = f"batch_{uuid.uuid4().hex[:12]}"
|
||||
now_ts = time_mod.time()
|
||||
safe_create_batch(
|
||||
batch_id,
|
||||
{
|
||||
"user_id": user_id,
|
||||
"browse_type": browse_type,
|
||||
"schedule_name": schedule_config.get("name", "未命名任务"),
|
||||
"screenshots": [],
|
||||
"total_accounts": 0,
|
||||
"completed": 0,
|
||||
"created_at": now_ts,
|
||||
"updated_at": now_ts,
|
||||
},
|
||||
)
|
||||
|
||||
started_count = 0
|
||||
skipped_count = 0
|
||||
completion_lock = threading.Lock()
|
||||
remaining = {"count": 0, "done": False}
|
||||
|
||||
def on_browse_done():
|
||||
with completion_lock:
|
||||
remaining["count"] -= 1
|
||||
if remaining["done"] or remaining["count"] > 0:
|
||||
return
|
||||
remaining["done"] = True
|
||||
execution_duration = int(time_mod.time() - execution_start_time)
|
||||
database.update_schedule_execution_log(
|
||||
log_id,
|
||||
total_accounts=len(account_ids),
|
||||
success_accounts=started_count,
|
||||
failed_accounts=len(account_ids) - started_count,
|
||||
duration_seconds=execution_duration,
|
||||
status="completed",
|
||||
)
|
||||
logger.info(
|
||||
f"[用户定时任务] 任务#{schedule_id}浏览阶段完成,耗时{execution_duration}秒,等待截图完成后发送邮件"
|
||||
)
|
||||
|
||||
for account_id in account_ids:
|
||||
account = safe_get_account(user_id, account_id)
|
||||
if not account:
|
||||
skipped_count += 1
|
||||
continue
|
||||
if account.is_running:
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
task_source = f"user_scheduled:{batch_id}"
|
||||
with completion_lock:
|
||||
remaining["count"] += 1
|
||||
ok, msg = submit_account_task(
|
||||
user_id=user_id,
|
||||
account_id=account_id,
|
||||
browse_type=browse_type,
|
||||
enable_screenshot=enable_screenshot,
|
||||
source=task_source,
|
||||
done_callback=on_browse_done,
|
||||
)
|
||||
if ok:
|
||||
started_count += 1
|
||||
else:
|
||||
with completion_lock:
|
||||
remaining["count"] -= 1
|
||||
skipped_count += 1
|
||||
logger.warning(f"[用户定时任务] 账号 {account.username} 启动失败: {msg}")
|
||||
|
||||
batch_info = safe_finalize_batch_after_dispatch(batch_id, started_count, now_ts=time_mod.time())
|
||||
if batch_info:
|
||||
_send_batch_task_email_if_configured(batch_info)
|
||||
|
||||
database.update_schedule_last_run(schedule_id)
|
||||
|
||||
logger.info(f"[用户定时任务] 已启动 {started_count} 个账号,跳过 {skipped_count} 个账号,批次ID: {batch_id}")
|
||||
if started_count <= 0:
|
||||
database.update_schedule_execution_log(
|
||||
log_id,
|
||||
total_accounts=len(account_ids),
|
||||
success_accounts=0,
|
||||
failed_accounts=len(account_ids),
|
||||
duration_seconds=0,
|
||||
status="completed",
|
||||
)
|
||||
if started_count == 0 and len(account_ids) > 0:
|
||||
logger.warning("[用户定时任务] ⚠️ 警告:所有账号都被跳过了!请检查user_accounts状态")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"[用户定时任务] 检查出错: {str(e)}")
|
||||
|
||||
try:
|
||||
config_check_interval = float(os.environ.get("SCHEDULER_CONFIG_CHECK_SECONDS", "30"))
|
||||
except Exception:
|
||||
config_check_interval = 30.0
|
||||
config_check_interval = max(5.0, config_check_interval)
|
||||
|
||||
schedule_state = {"signature": None}
|
||||
|
||||
def check_and_schedule(force: bool = False):
|
||||
config_data = database.get_system_config()
|
||||
schedule_enabled = bool(config_data.get("schedule_enabled"))
|
||||
schedule_time_cst = config_data.get("schedule_time", "02:00")
|
||||
|
||||
signature = (schedule_enabled, schedule_time_cst)
|
||||
config_changed = schedule_state.get("signature") != signature
|
||||
is_first_run = schedule_state.get("signature") is None
|
||||
if (not force) and (not config_changed):
|
||||
return
|
||||
schedule_state["signature"] = signature
|
||||
|
||||
schedule.clear()
|
||||
|
||||
cleanup_time_cst = "03:00"
|
||||
schedule.every().day.at(cleanup_time_cst).do(cleanup_old_data)
|
||||
|
||||
schedule.every().hour.do(cleanup_expired_captcha)
|
||||
|
||||
quota_reset_time_cst = "00:00"
|
||||
schedule.every().day.at(quota_reset_time_cst).do(email_service.reset_smtp_daily_quota)
|
||||
|
||||
if is_first_run:
|
||||
logger.info(f"[定时任务] 已设置数据清理任务: 每天 CST {cleanup_time_cst}")
|
||||
logger.info(f"[定时任务] 已设置验证码清理任务: 每小时执行一次")
|
||||
logger.info(f"[定时任务] 已设置SMTP配额重置: 每天 CST {quota_reset_time_cst}")
|
||||
|
||||
if schedule_enabled:
|
||||
schedule.every().day.at(schedule_time_cst).do(run_scheduled_task)
|
||||
if is_first_run or config_changed:
|
||||
logger.info(f"[定时任务] 已设置浏览任务: 每天 CST {schedule_time_cst}")
|
||||
elif config_changed and not is_first_run:
|
||||
logger.info("[定时任务] 浏览任务已禁用")
|
||||
|
||||
check_and_schedule(force=True)
|
||||
last_config_check = time.time()
|
||||
last_user_schedule_minute = None
|
||||
|
||||
while True:
|
||||
try:
|
||||
schedule.run_pending()
|
||||
|
||||
now_ts = time.time()
|
||||
if now_ts - last_config_check >= config_check_interval:
|
||||
check_and_schedule()
|
||||
last_config_check = now_ts
|
||||
|
||||
now_beijing = get_beijing_now()
|
||||
minute_key = now_beijing.strftime("%Y-%m-%d %H:%M")
|
||||
if minute_key != last_user_schedule_minute:
|
||||
check_user_schedules()
|
||||
last_user_schedule_minute = minute_key
|
||||
|
||||
time.sleep(1)
|
||||
except Exception as e:
|
||||
logger.exception(f"[定时任务] 调度器出错: {str(e)}")
|
||||
time.sleep(5)
|
||||
272
services/screenshots.py
Normal file
272
services/screenshots.py
Normal 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
481
services/state.py
Normal 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
70
services/task_batches.py
Normal 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中提取批次ID(source格式: user_scheduled:batch_xxx)"""
|
||||
if not source:
|
||||
return None
|
||||
if source.startswith("user_scheduled:batch_"):
|
||||
return source.split(":", 1)[1]
|
||||
return None
|
||||
|
||||
|
||||
def _send_batch_task_email_if_configured(batch_info: dict):
|
||||
"""批次任务:当所有账号完成后发送打包邮件(在锁外调用)。"""
|
||||
logger = get_logger()
|
||||
try:
|
||||
batch_user_id = batch_info.get("user_id")
|
||||
if not batch_user_id:
|
||||
return
|
||||
user_info = database.get_user_by_id(batch_user_id)
|
||||
if not user_info or not user_info.get("email"):
|
||||
return
|
||||
if not database.get_user_email_notify(batch_user_id):
|
||||
return
|
||||
if not batch_info.get("screenshots"):
|
||||
return
|
||||
email_service.send_batch_task_complete_email_async(
|
||||
user_id=batch_user_id,
|
||||
email=user_info["email"],
|
||||
username=user_info["username"],
|
||||
schedule_name=batch_info.get("schedule_name", "未命名任务"),
|
||||
browse_type=batch_info.get("browse_type", "应读"),
|
||||
screenshots=batch_info["screenshots"],
|
||||
)
|
||||
logger.info(f"[批次邮件] 已发送打包邮件,包含 {len(batch_info['screenshots'])} 条记录")
|
||||
except Exception as e:
|
||||
logger.warning(f"[批次邮件] 发送失败: {e}")
|
||||
|
||||
|
||||
def _batch_task_record_result(
|
||||
batch_id: str,
|
||||
account_name: str,
|
||||
screenshot_path: str,
|
||||
total_items: int,
|
||||
total_attachments: int,
|
||||
):
|
||||
"""批次任务:记录单账号结果,达到完成条件时触发邮件并回收内存。"""
|
||||
logger = get_logger()
|
||||
batch_info = safe_batch_append_result(
|
||||
batch_id,
|
||||
{
|
||||
"account_name": account_name,
|
||||
"path": screenshot_path,
|
||||
"items": total_items,
|
||||
"attachments": total_attachments,
|
||||
},
|
||||
)
|
||||
if batch_info:
|
||||
logger.info(
|
||||
f"[批次邮件] 批次 {batch_id} 已完成: {batch_info.get('completed')}/{batch_info.get('total_accounts')},准备发送邮件"
|
||||
)
|
||||
_send_batch_task_email_if_configured(batch_info)
|
||||
|
||||
838
services/tasks.py
Normal file
838
services/tasks.py
Normal 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
14
services/time_utils.py
Normal 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)
|
||||
|
||||
@@ -3,22 +3,22 @@
|
||||
"file": "assets/datetime-ZCuLLiQt.js",
|
||||
"name": "datetime"
|
||||
},
|
||||
"_tasks-BYcXDffp.js": {
|
||||
"file": "assets/tasks-BYcXDffp.js",
|
||||
"_tasks-D0zj3VJF.js": {
|
||||
"file": "assets/tasks-D0zj3VJF.js",
|
||||
"name": "tasks",
|
||||
"imports": [
|
||||
"index.html"
|
||||
]
|
||||
},
|
||||
"_users-Du3tLSHt.js": {
|
||||
"file": "assets/users-Du3tLSHt.js",
|
||||
"_users-7Bk6NvSS.js": {
|
||||
"file": "assets/users-7Bk6NvSS.js",
|
||||
"name": "users",
|
||||
"imports": [
|
||||
"index.html"
|
||||
]
|
||||
},
|
||||
"index.html": {
|
||||
"file": "assets/index-C5w7EVNo.js",
|
||||
"file": "assets/index-8uFy3xP6.js",
|
||||
"name": "index",
|
||||
"src": "index.html",
|
||||
"isEntry": true,
|
||||
@@ -38,7 +38,7 @@
|
||||
]
|
||||
},
|
||||
"src/pages/AnnouncementsPage.vue": {
|
||||
"file": "assets/AnnouncementsPage-C63j6LV5.js",
|
||||
"file": "assets/AnnouncementsPage-BaAke3LD.js",
|
||||
"name": "AnnouncementsPage",
|
||||
"src": "src/pages/AnnouncementsPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
@@ -50,7 +50,7 @@
|
||||
]
|
||||
},
|
||||
"src/pages/EmailPage.vue": {
|
||||
"file": "assets/EmailPage-Bf6BbYPD.js",
|
||||
"file": "assets/EmailPage-CQ54ILNk.js",
|
||||
"name": "EmailPage",
|
||||
"src": "src/pages/EmailPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
@@ -62,7 +62,7 @@
|
||||
]
|
||||
},
|
||||
"src/pages/FeedbacksPage.vue": {
|
||||
"file": "assets/FeedbacksPage-mOUifait.js",
|
||||
"file": "assets/FeedbacksPage-DE47SeFp.js",
|
||||
"name": "FeedbacksPage",
|
||||
"src": "src/pages/FeedbacksPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
@@ -74,13 +74,13 @@
|
||||
]
|
||||
},
|
||||
"src/pages/LogsPage.vue": {
|
||||
"file": "assets/LogsPage-DVfeUO3d.js",
|
||||
"file": "assets/LogsPage-UzX26IA-.js",
|
||||
"name": "LogsPage",
|
||||
"src": "src/pages/LogsPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_users-Du3tLSHt.js",
|
||||
"_tasks-BYcXDffp.js",
|
||||
"_users-7Bk6NvSS.js",
|
||||
"_tasks-D0zj3VJF.js",
|
||||
"index.html"
|
||||
],
|
||||
"css": [
|
||||
@@ -88,12 +88,12 @@
|
||||
]
|
||||
},
|
||||
"src/pages/PendingPage.vue": {
|
||||
"file": "assets/PendingPage-BALptdIG.js",
|
||||
"file": "assets/PendingPage-BxcKr1rh.js",
|
||||
"name": "PendingPage",
|
||||
"src": "src/pages/PendingPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_users-Du3tLSHt.js",
|
||||
"_users-7Bk6NvSS.js",
|
||||
"index.html",
|
||||
"_datetime-ZCuLLiQt.js"
|
||||
],
|
||||
@@ -102,7 +102,7 @@
|
||||
]
|
||||
},
|
||||
"src/pages/SettingsPage.vue": {
|
||||
"file": "assets/SettingsPage-BKf3hQvU.js",
|
||||
"file": "assets/SettingsPage-MZ5dlYEy.js",
|
||||
"name": "SettingsPage",
|
||||
"src": "src/pages/SettingsPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
@@ -114,12 +114,12 @@
|
||||
]
|
||||
},
|
||||
"src/pages/StatsPage.vue": {
|
||||
"file": "assets/StatsPage-DjylIGTc.js",
|
||||
"file": "assets/StatsPage-LZRKsKip.js",
|
||||
"name": "StatsPage",
|
||||
"src": "src/pages/StatsPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_tasks-BYcXDffp.js",
|
||||
"_tasks-D0zj3VJF.js",
|
||||
"index.html"
|
||||
],
|
||||
"css": [
|
||||
@@ -127,7 +127,7 @@
|
||||
]
|
||||
},
|
||||
"src/pages/SystemPage.vue": {
|
||||
"file": "assets/SystemPage-k6FhqNid.js",
|
||||
"file": "assets/SystemPage-DLPz_RK8.js",
|
||||
"name": "SystemPage",
|
||||
"src": "src/pages/SystemPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
@@ -139,12 +139,12 @@
|
||||
]
|
||||
},
|
||||
"src/pages/UsersPage.vue": {
|
||||
"file": "assets/UsersPage-XRSMHsqH.js",
|
||||
"file": "assets/UsersPage-whzb1Tl7.js",
|
||||
"name": "UsersPage",
|
||||
"src": "src/pages/UsersPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_users-Du3tLSHt.js",
|
||||
"_users-7Bk6NvSS.js",
|
||||
"_datetime-ZCuLLiQt.js",
|
||||
"index.html"
|
||||
],
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
import{f as E,a as I,r as A}from"./users-Du3tLSHt.js";import{_ as M,r as p,o as q,c as W,a as i,b as t,w as a,d,i as T,f as F,e as G,g as f,h as r,j as $,k as x,l as H,t as k,E as m,m as g,n as J,p as K}from"./index-C5w7EVNo.js";import{p as L}from"./datetime-ZCuLLiQt.js";const O={class:"page-stack"},Q={class:"app-page-title"},X={class:"table-wrap"},Y={class:"user-cell"},Z={class:"table-wrap"},ee={__name:"PendingPage",setup(te){const B=T("refreshStats",null),_=T("refreshNavBadges",null),v=p([]),c=p([]),w=p(!1),y=p(!1);function D(s){const e=s?.vip_expire_time;if(!e)return!1;if(String(e).startsWith("2099-12-31"))return!0;const o=L(e);return o?o.getTime()>Date.now():!1}async function j(){w.value=!0;try{v.value=await E()}catch{v.value=[]}finally{w.value=!1}}async function h(){y.value=!0;try{c.value=await F()}catch{c.value=[]}finally{y.value=!1}}async function u(){await Promise.all([j(),h()]),await _?.({pendingResets:c.value.length})}async function N(s){try{await m.confirm(`确定通过用户「${s.username}」的注册申请吗?`,"审核通过",{confirmButtonText:"通过",cancelButtonText:"取消",type:"success"})}catch{return}try{await I(s.id),g.success("用户审核通过"),await u(),await B?.()}catch{}}async function U(s){try{await m.confirm(`确定拒绝用户「${s.username}」的注册申请吗?`,"拒绝申请",{confirmButtonText:"拒绝",cancelButtonText:"取消",type:"warning"})}catch{return}try{await A(s.id),g.success("已拒绝用户"),await u(),await B?.()}catch{}}async function V(s){try{await m.confirm(`确定批准「${s.username}」的密码重置申请吗?`,"批准重置",{confirmButtonText:"批准",cancelButtonText:"取消",type:"success"})}catch{return}try{const e=await J(s.id);g.success(e?.message||"密码重置申请已批准"),await h(),await _?.({pendingResets:c.value.length})}catch{}}async function z(s){try{await m.confirm(`确定拒绝「${s.username}」的密码重置申请吗?`,"拒绝重置",{confirmButtonText:"拒绝",cancelButtonText:"取消",type:"warning"})}catch{return}try{const e=await K(s.id);g.success(e?.message||"密码重置申请已拒绝"),await h(),await _?.({pendingResets:c.value.length})}catch{}}return q(u),(s,e)=>{const o=d("el-button"),l=d("el-table-column"),S=d("el-tag"),R=d("el-table"),C=d("el-card"),P=G("loading");return f(),W("div",O,[i("div",Q,[e[1]||(e[1]=i("h2",null,"待审核",-1)),i("div",null,[t(o,{onClick:u},{default:a(()=>[...e[0]||(e[0]=[r("刷新",-1)])]),_:1})])]),t(C,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:a(()=>[e[5]||(e[5]=i("h3",{class:"section-title"},"用户注册审核",-1)),i("div",X,[$((f(),x(R,{data:v.value,style:{width:"100%"}},{default:a(()=>[t(l,{prop:"id",label:"ID",width:"80"}),t(l,{label:"用户名","min-width":"200"},{default:a(({row:n})=>[i("div",Y,[i("strong",null,k(n.username),1),D(n)?(f(),x(S,{key:0,type:"warning",effect:"light",size:"small"},{default:a(()=>[...e[2]||(e[2]=[r("VIP",-1)])]),_:1})):H("",!0)])]),_:1}),t(l,{prop:"email",label:"邮箱","min-width":"220"},{default:a(({row:n})=>[r(k(n.email||"-"),1)]),_:1}),t(l,{prop:"created_at",label:"注册时间","min-width":"180"}),t(l,{label:"操作",width:"180",fixed:"right"},{default:a(({row:n})=>[t(o,{type:"success",size:"small",onClick:b=>N(n)},{default:a(()=>[...e[3]||(e[3]=[r("通过",-1)])]),_:1},8,["onClick"]),t(o,{type:"danger",size:"small",onClick:b=>U(n)},{default:a(()=>[...e[4]||(e[4]=[r("拒绝",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[P,w.value]])])]),_:1}),t(C,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:a(()=>[e[8]||(e[8]=i("h3",{class:"section-title"},"密码重置审核",-1)),i("div",Z,[$((f(),x(R,{data:c.value,style:{width:"100%"}},{default:a(()=>[t(l,{prop:"id",label:"申请ID",width:"90"}),t(l,{prop:"username",label:"用户名","min-width":"200"}),t(l,{prop:"email",label:"邮箱","min-width":"220"},{default:a(({row:n})=>[r(k(n.email||"-"),1)]),_:1}),t(l,{prop:"created_at",label:"申请时间","min-width":"180"}),t(l,{label:"操作",width:"180",fixed:"right"},{default:a(({row:n})=>[t(o,{type:"success",size:"small",onClick:b=>V(n)},{default:a(()=>[...e[6]||(e[6]=[r("批准",-1)])]),_:1},8,["onClick"]),t(o,{type:"danger",size:"small",onClick:b=>z(n)},{default:a(()=>[...e[7]||(e[7]=[r("拒绝",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[P,y.value]])])]),_:1})])}}},le=M(ee,[["__scopeId","data-v-f2aa6820"]]);export{le as default};
|
||||
import{f as E,a as I,r as A}from"./users-7Bk6NvSS.js";import{_ as M,r as p,o as q,c as W,a as i,b as t,w as a,d,i as T,f as F,e as G,g as f,h as r,j as $,k as x,l as H,t as k,E as m,m as g,n as J,p as K}from"./index-8uFy3xP6.js";import{p as L}from"./datetime-ZCuLLiQt.js";const O={class:"page-stack"},Q={class:"app-page-title"},X={class:"table-wrap"},Y={class:"user-cell"},Z={class:"table-wrap"},ee={__name:"PendingPage",setup(te){const B=T("refreshStats",null),_=T("refreshNavBadges",null),v=p([]),c=p([]),w=p(!1),y=p(!1);function D(s){const e=s?.vip_expire_time;if(!e)return!1;if(String(e).startsWith("2099-12-31"))return!0;const o=L(e);return o?o.getTime()>Date.now():!1}async function j(){w.value=!0;try{v.value=await E()}catch{v.value=[]}finally{w.value=!1}}async function h(){y.value=!0;try{c.value=await F()}catch{c.value=[]}finally{y.value=!1}}async function u(){await Promise.all([j(),h()]),await _?.({pendingResets:c.value.length})}async function N(s){try{await m.confirm(`确定通过用户「${s.username}」的注册申请吗?`,"审核通过",{confirmButtonText:"通过",cancelButtonText:"取消",type:"success"})}catch{return}try{await I(s.id),g.success("用户审核通过"),await u(),await B?.()}catch{}}async function U(s){try{await m.confirm(`确定拒绝用户「${s.username}」的注册申请吗?`,"拒绝申请",{confirmButtonText:"拒绝",cancelButtonText:"取消",type:"warning"})}catch{return}try{await A(s.id),g.success("已拒绝用户"),await u(),await B?.()}catch{}}async function V(s){try{await m.confirm(`确定批准「${s.username}」的密码重置申请吗?`,"批准重置",{confirmButtonText:"批准",cancelButtonText:"取消",type:"success"})}catch{return}try{const e=await J(s.id);g.success(e?.message||"密码重置申请已批准"),await h(),await _?.({pendingResets:c.value.length})}catch{}}async function z(s){try{await m.confirm(`确定拒绝「${s.username}」的密码重置申请吗?`,"拒绝重置",{confirmButtonText:"拒绝",cancelButtonText:"取消",type:"warning"})}catch{return}try{const e=await K(s.id);g.success(e?.message||"密码重置申请已拒绝"),await h(),await _?.({pendingResets:c.value.length})}catch{}}return q(u),(s,e)=>{const o=d("el-button"),l=d("el-table-column"),S=d("el-tag"),R=d("el-table"),C=d("el-card"),P=G("loading");return f(),W("div",O,[i("div",Q,[e[1]||(e[1]=i("h2",null,"待审核",-1)),i("div",null,[t(o,{onClick:u},{default:a(()=>[...e[0]||(e[0]=[r("刷新",-1)])]),_:1})])]),t(C,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:a(()=>[e[5]||(e[5]=i("h3",{class:"section-title"},"用户注册审核",-1)),i("div",X,[$((f(),x(R,{data:v.value,style:{width:"100%"}},{default:a(()=>[t(l,{prop:"id",label:"ID",width:"80"}),t(l,{label:"用户名","min-width":"200"},{default:a(({row:n})=>[i("div",Y,[i("strong",null,k(n.username),1),D(n)?(f(),x(S,{key:0,type:"warning",effect:"light",size:"small"},{default:a(()=>[...e[2]||(e[2]=[r("VIP",-1)])]),_:1})):H("",!0)])]),_:1}),t(l,{prop:"email",label:"邮箱","min-width":"220"},{default:a(({row:n})=>[r(k(n.email||"-"),1)]),_:1}),t(l,{prop:"created_at",label:"注册时间","min-width":"180"}),t(l,{label:"操作",width:"180",fixed:"right"},{default:a(({row:n})=>[t(o,{type:"success",size:"small",onClick:b=>N(n)},{default:a(()=>[...e[3]||(e[3]=[r("通过",-1)])]),_:1},8,["onClick"]),t(o,{type:"danger",size:"small",onClick:b=>U(n)},{default:a(()=>[...e[4]||(e[4]=[r("拒绝",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[P,w.value]])])]),_:1}),t(C,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:a(()=>[e[8]||(e[8]=i("h3",{class:"section-title"},"密码重置审核",-1)),i("div",Z,[$((f(),x(R,{data:c.value,style:{width:"100%"}},{default:a(()=>[t(l,{prop:"id",label:"申请ID",width:"90"}),t(l,{prop:"username",label:"用户名","min-width":"200"}),t(l,{prop:"email",label:"邮箱","min-width":"220"},{default:a(({row:n})=>[r(k(n.email||"-"),1)]),_:1}),t(l,{prop:"created_at",label:"申请时间","min-width":"180"}),t(l,{label:"操作",width:"180",fixed:"right"},{default:a(({row:n})=>[t(o,{type:"success",size:"small",onClick:b=>V(n)},{default:a(()=>[...e[6]||(e[6]=[r("批准",-1)])]),_:1},8,["onClick"]),t(o,{type:"danger",size:"small",onClick:b=>z(n)},{default:a(()=>[...e[7]||(e[7]=[r("拒绝",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[P,y.value]])])]),_:1})])}}},le=M(ee,[["__scopeId","data-v-f2aa6820"]]);export{le as default};
|
||||
@@ -1 +1 @@
|
||||
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
@@ -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_per_account}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
import{B as a}from"./index-C5w7EVNo.js";async function c(){const{data:t}=await a.get("/server/info");return t}async function e(){const{data:t}=await a.get("/docker_stats");return t}async function o(){const{data:t}=await a.get("/task/stats");return t}async function r(){const{data:t}=await a.get("/task/running");return t}async function i(t){const{data:s}=await a.get("/task/logs",{params:t});return s}async function f(t){const{data:s}=await a.post("/task/logs/clear",{days:t});return s}export{e as a,o as b,r as c,i as d,f as e,c as f};
|
||||
import{B as a}from"./index-8uFy3xP6.js";async function c(){const{data:t}=await a.get("/server/info");return t}async function e(){const{data:t}=await a.get("/docker_stats");return t}async function o(){const{data:t}=await a.get("/task/stats");return t}async function r(){const{data:t}=await a.get("/task/running");return t}async function i(t){const{data:s}=await a.get("/task/logs",{params:t});return s}async function f(t){const{data:s}=await a.post("/task/logs/clear",{days:t});return s}export{e as a,o as b,r as c,i as d,f as e,c as f};
|
||||
@@ -1 +1 @@
|
||||
import{B as a}from"./index-C5w7EVNo.js";async function r(){const{data:s}=await a.get("/users");return s}async function c(){const{data:s}=await a.get("/users/pending");return s}async function o(s){const{data:t}=await a.post(`/users/${s}/approve`);return t}async function i(s){const{data:t}=await a.post(`/users/${s}/reject`);return t}async function u(s){const{data:t}=await a.delete(`/users/${s}`);return t}async function d(s,t){const{data:e}=await a.post(`/users/${s}/vip`,{days:t});return e}async function p(s){const{data:t}=await a.delete(`/users/${s}/vip`);return t}async function f(s,t){const{data:e}=await a.post(`/users/${s}/reset_password`,{new_password:t});return e}export{o as a,r as b,p as c,f as d,u as e,c as f,i as r,d as s};
|
||||
import{B as a}from"./index-8uFy3xP6.js";async function r(){const{data:s}=await a.get("/users");return s}async function c(){const{data:s}=await a.get("/users/pending");return s}async function o(s){const{data:t}=await a.post(`/users/${s}/approve`);return t}async function i(s){const{data:t}=await a.post(`/users/${s}/reject`);return t}async function u(s){const{data:t}=await a.delete(`/users/${s}`);return t}async function d(s,t){const{data:e}=await a.post(`/users/${s}/vip`,{days:t});return e}async function p(s){const{data:t}=await a.delete(`/users/${s}/vip`);return t}async function f(s,t){const{data:e}=await a.post(`/users/${s}/reset_password`,{new_password:t});return e}export{o as a,r as b,p as c,f as d,u as e,c as f,i as r,d as s};
|
||||
@@ -5,7 +5,7 @@
|
||||
<link rel="icon" type="image/svg+xml" href="./vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"_accounts-DuQjqW8V.js": {
|
||||
"file": "assets/accounts-DuQjqW8V.js",
|
||||
"_accounts-Cs5suRwn.js": {
|
||||
"file": "assets/accounts-Cs5suRwn.js",
|
||||
"name": "accounts",
|
||||
"imports": [
|
||||
"index.html"
|
||||
]
|
||||
},
|
||||
"_auth-C__02fQ5.js": {
|
||||
"file": "assets/auth-C__02fQ5.js",
|
||||
"_auth-CGPXQwSI.js": {
|
||||
"file": "assets/auth-CGPXQwSI.js",
|
||||
"name": "auth",
|
||||
"imports": [
|
||||
"index.html"
|
||||
@@ -18,7 +18,7 @@
|
||||
"name": "password"
|
||||
},
|
||||
"index.html": {
|
||||
"file": "assets/index-2JnZbEa5.js",
|
||||
"file": "assets/index-DYbFXn7x.js",
|
||||
"name": "index",
|
||||
"src": "index.html",
|
||||
"isEntry": true,
|
||||
@@ -36,12 +36,12 @@
|
||||
]
|
||||
},
|
||||
"src/pages/AccountsPage.vue": {
|
||||
"file": "assets/AccountsPage-CugNoiiP.js",
|
||||
"file": "assets/AccountsPage-Bx5BF0c_.js",
|
||||
"name": "AccountsPage",
|
||||
"src": "src/pages/AccountsPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_accounts-DuQjqW8V.js",
|
||||
"_accounts-Cs5suRwn.js",
|
||||
"index.html"
|
||||
],
|
||||
"css": [
|
||||
@@ -49,13 +49,13 @@
|
||||
]
|
||||
},
|
||||
"src/pages/LoginPage.vue": {
|
||||
"file": "assets/LoginPage-8hxar7WW.js",
|
||||
"file": "assets/LoginPage-C9yGySKX.js",
|
||||
"name": "LoginPage",
|
||||
"src": "src/pages/LoginPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"index.html",
|
||||
"_auth-C__02fQ5.js",
|
||||
"_auth-CGPXQwSI.js",
|
||||
"_password-7ryi82gE.js"
|
||||
],
|
||||
"css": [
|
||||
@@ -63,26 +63,26 @@
|
||||
]
|
||||
},
|
||||
"src/pages/RegisterPage.vue": {
|
||||
"file": "assets/RegisterPage-B_EUWOuP.js",
|
||||
"file": "assets/RegisterPage-Bovgf1zp.js",
|
||||
"name": "RegisterPage",
|
||||
"src": "src/pages/RegisterPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"index.html",
|
||||
"_auth-C__02fQ5.js"
|
||||
"_auth-CGPXQwSI.js"
|
||||
],
|
||||
"css": [
|
||||
"assets/RegisterPage-CVjBOq6i.css"
|
||||
]
|
||||
},
|
||||
"src/pages/ResetPasswordPage.vue": {
|
||||
"file": "assets/ResetPasswordPage-BLEflhaq.js",
|
||||
"file": "assets/ResetPasswordPage-DpyvwUux.js",
|
||||
"name": "ResetPasswordPage",
|
||||
"src": "src/pages/ResetPasswordPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"index.html",
|
||||
"_auth-C__02fQ5.js",
|
||||
"_auth-CGPXQwSI.js",
|
||||
"_password-7ryi82gE.js"
|
||||
],
|
||||
"css": [
|
||||
@@ -90,12 +90,12 @@
|
||||
]
|
||||
},
|
||||
"src/pages/SchedulesPage.vue": {
|
||||
"file": "assets/SchedulesPage-BbA-BJek.js",
|
||||
"file": "assets/SchedulesPage-CBL4FA3b.js",
|
||||
"name": "SchedulesPage",
|
||||
"src": "src/pages/SchedulesPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"_accounts-DuQjqW8V.js",
|
||||
"_accounts-Cs5suRwn.js",
|
||||
"index.html"
|
||||
],
|
||||
"css": [
|
||||
@@ -103,7 +103,7 @@
|
||||
]
|
||||
},
|
||||
"src/pages/ScreenshotsPage.vue": {
|
||||
"file": "assets/ScreenshotsPage-DCIxup8x.js",
|
||||
"file": "assets/ScreenshotsPage-aDdDquT7.js",
|
||||
"name": "ScreenshotsPage",
|
||||
"src": "src/pages/ScreenshotsPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
@@ -115,7 +115,7 @@
|
||||
]
|
||||
},
|
||||
"src/pages/VerifyResultPage.vue": {
|
||||
"file": "assets/VerifyResultPage-QQKdIo1L.js",
|
||||
"file": "assets/VerifyResultPage-D9WtDvDT.js",
|
||||
"name": "VerifyResultPage",
|
||||
"src": "src/pages/VerifyResultPage.vue",
|
||||
"isDynamicEntry": true,
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
import{_ as M,r as j,a as p,c as B,o as A,b as S,d as t,w as o,e as m,u as H,f as g,g as n,h as U,i as x,j as N,t as q,k as E,E as d}from"./index-2JnZbEa5.js";import{g as z,f as F,c as G}from"./auth-C__02fQ5.js";const J={class:"auth-wrap"},O={class:"hint app-muted"},Q={class:"captcha-row"},W=["src"],X={class:"actions"},Y={__name:"RegisterPage",setup(Z){const T=H(),a=j({username:"",password:"",confirm_password:"",email:"",captcha:""}),v=p(!1),f=p(""),b=p(""),h=p(!1),l=p(""),_=p(""),V=p(""),K=B(()=>v.value?"邮箱 *":"邮箱(可选)"),P=B(()=>v.value?"必填,用于账号验证":"选填,用于接收审核通知");async function w(){try{const u=await z();b.value=u?.session_id||"",f.value=u?.captcha_image||"",a.captcha=""}catch{b.value="",f.value=""}}async function R(){try{const u=await F();v.value=!!u?.register_verify_enabled}catch{v.value=!1}}function D(){l.value="",_.value="",V.value=""}async function k(){D();const u=a.username.trim(),e=a.password,y=a.confirm_password,s=a.email.trim(),i=a.captcha.trim();if(u.length<3){l.value="用户名至少3个字符",d.error(l.value);return}if(e.length<6){l.value="密码至少6个字符",d.error(l.value);return}if(e!==y){l.value="两次输入的密码不一致",d.error(l.value);return}if(v.value&&!s){l.value="请填写邮箱地址用于账号验证",d.error(l.value);return}if(s&&!s.includes("@")){l.value="邮箱格式不正确",d.error(l.value);return}if(!i){l.value="请输入验证码",d.error(l.value);return}h.value=!0;try{const c=await G({username:u,password:e,email:s,captcha_session:b.value,captcha:i});_.value=c?.message||"注册成功",V.value=c?.need_verify?"请检查您的邮箱(包括垃圾邮件文件夹)":"",d.success("注册成功"),a.username="",a.password="",a.confirm_password="",a.email="",a.captcha="",setTimeout(()=>{window.location.href="/login"},3e3)}catch(c){const C=c?.response?.data;l.value=C?.error||"注册失败",d.error(l.value),await w()}finally{h.value=!1}}function I(){T.push("/login")}return A(async()=>{await w(),await R()}),(u,e)=>{const y=m("el-alert"),s=m("el-input"),i=m("el-form-item"),c=m("el-button"),C=m("el-form"),L=m("el-card");return g(),S("div",J,[t(L,{shadow:"never",class:"auth-card","body-style":{padding:"22px"}},{default:o(()=>[e[11]||(e[11]=n("div",{class:"brand"},[n("div",{class:"brand-title"},"知识管理平台"),n("div",{class:"brand-sub app-muted"},"用户注册")],-1)),l.value?(g(),U(y,{key:0,type:"error",closable:!1,title:l.value,"show-icon":"",class:"alert"},null,8,["title"])):x("",!0),_.value?(g(),U(y,{key:1,type:"success",closable:!1,title:_.value,description:V.value,"show-icon":"",class:"alert"},null,8,["title","description"])):x("",!0),t(C,{"label-position":"top"},{default:o(()=>[t(i,{label:"用户名 *"},{default:o(()=>[t(s,{modelValue:a.username,"onUpdate:modelValue":e[0]||(e[0]=r=>a.username=r),placeholder:"至少3个字符",autocomplete:"username"},null,8,["modelValue"]),e[5]||(e[5]=n("div",{class:"hint app-muted"},"至少3个字符",-1))]),_:1}),t(i,{label:"密码 *"},{default:o(()=>[t(s,{modelValue:a.password,"onUpdate:modelValue":e[1]||(e[1]=r=>a.password=r),type:"password","show-password":"",placeholder:"至少6个字符",autocomplete:"new-password"},null,8,["modelValue"]),e[6]||(e[6]=n("div",{class:"hint app-muted"},"至少6个字符",-1))]),_:1}),t(i,{label:"确认密码 *"},{default:o(()=>[t(s,{modelValue:a.confirm_password,"onUpdate:modelValue":e[2]||(e[2]=r=>a.confirm_password=r),type:"password","show-password":"",placeholder:"请再次输入密码",autocomplete:"new-password",onKeyup:N(k,["enter"])},null,8,["modelValue"])]),_:1}),t(i,{label:K.value},{default:o(()=>[t(s,{modelValue:a.email,"onUpdate:modelValue":e[3]||(e[3]=r=>a.email=r),placeholder:"name@example.com",autocomplete:"email"},null,8,["modelValue"]),n("div",O,q(P.value),1)]),_:1},8,["label"]),t(i,{label:"验证码 *"},{default:o(()=>[n("div",Q,[t(s,{modelValue:a.captcha,"onUpdate:modelValue":e[4]||(e[4]=r=>a.captcha=r),placeholder:"请输入验证码",onKeyup:N(k,["enter"])},null,8,["modelValue"]),f.value?(g(),S("img",{key:0,class:"captcha-img",src:f.value,alt:"验证码",title:"点击刷新",onClick:w},null,8,W)):x("",!0),t(c,{onClick:w},{default:o(()=>[...e[7]||(e[7]=[E("刷新",-1)])]),_:1})])]),_:1})]),_:1}),t(c,{type:"primary",class:"submit-btn",loading:h.value,onClick:k},{default:o(()=>[...e[8]||(e[8]=[E("注册",-1)])]),_:1},8,["loading"]),n("div",X,[e[10]||(e[10]=n("span",{class:"app-muted"},"已有账号?",-1)),t(c,{link:"",type:"primary",onClick:I},{default:o(()=>[...e[9]||(e[9]=[E("立即登录",-1)])]),_:1})])]),_:1})])}}},ae=M(Y,[["__scopeId","data-v-32684b4d"]]);export{ae as default};
|
||||
import{_ as M,r as j,a as p,c as B,o as A,b as S,d as t,w as o,e as m,u as H,f as g,g as n,h as U,i as x,j as N,t as q,k as E,E as d}from"./index-DYbFXn7x.js";import{g as z,f as F,c as G}from"./auth-CGPXQwSI.js";const J={class:"auth-wrap"},O={class:"hint app-muted"},Q={class:"captcha-row"},W=["src"],X={class:"actions"},Y={__name:"RegisterPage",setup(Z){const T=H(),a=j({username:"",password:"",confirm_password:"",email:"",captcha:""}),v=p(!1),f=p(""),b=p(""),h=p(!1),l=p(""),_=p(""),V=p(""),K=B(()=>v.value?"邮箱 *":"邮箱(可选)"),P=B(()=>v.value?"必填,用于账号验证":"选填,用于接收审核通知");async function w(){try{const u=await z();b.value=u?.session_id||"",f.value=u?.captcha_image||"",a.captcha=""}catch{b.value="",f.value=""}}async function R(){try{const u=await F();v.value=!!u?.register_verify_enabled}catch{v.value=!1}}function D(){l.value="",_.value="",V.value=""}async function k(){D();const u=a.username.trim(),e=a.password,y=a.confirm_password,s=a.email.trim(),i=a.captcha.trim();if(u.length<3){l.value="用户名至少3个字符",d.error(l.value);return}if(e.length<6){l.value="密码至少6个字符",d.error(l.value);return}if(e!==y){l.value="两次输入的密码不一致",d.error(l.value);return}if(v.value&&!s){l.value="请填写邮箱地址用于账号验证",d.error(l.value);return}if(s&&!s.includes("@")){l.value="邮箱格式不正确",d.error(l.value);return}if(!i){l.value="请输入验证码",d.error(l.value);return}h.value=!0;try{const c=await G({username:u,password:e,email:s,captcha_session:b.value,captcha:i});_.value=c?.message||"注册成功",V.value=c?.need_verify?"请检查您的邮箱(包括垃圾邮件文件夹)":"",d.success("注册成功"),a.username="",a.password="",a.confirm_password="",a.email="",a.captcha="",setTimeout(()=>{window.location.href="/login"},3e3)}catch(c){const C=c?.response?.data;l.value=C?.error||"注册失败",d.error(l.value),await w()}finally{h.value=!1}}function I(){T.push("/login")}return A(async()=>{await w(),await R()}),(u,e)=>{const y=m("el-alert"),s=m("el-input"),i=m("el-form-item"),c=m("el-button"),C=m("el-form"),L=m("el-card");return g(),S("div",J,[t(L,{shadow:"never",class:"auth-card","body-style":{padding:"22px"}},{default:o(()=>[e[11]||(e[11]=n("div",{class:"brand"},[n("div",{class:"brand-title"},"知识管理平台"),n("div",{class:"brand-sub app-muted"},"用户注册")],-1)),l.value?(g(),U(y,{key:0,type:"error",closable:!1,title:l.value,"show-icon":"",class:"alert"},null,8,["title"])):x("",!0),_.value?(g(),U(y,{key:1,type:"success",closable:!1,title:_.value,description:V.value,"show-icon":"",class:"alert"},null,8,["title","description"])):x("",!0),t(C,{"label-position":"top"},{default:o(()=>[t(i,{label:"用户名 *"},{default:o(()=>[t(s,{modelValue:a.username,"onUpdate:modelValue":e[0]||(e[0]=r=>a.username=r),placeholder:"至少3个字符",autocomplete:"username"},null,8,["modelValue"]),e[5]||(e[5]=n("div",{class:"hint app-muted"},"至少3个字符",-1))]),_:1}),t(i,{label:"密码 *"},{default:o(()=>[t(s,{modelValue:a.password,"onUpdate:modelValue":e[1]||(e[1]=r=>a.password=r),type:"password","show-password":"",placeholder:"至少6个字符",autocomplete:"new-password"},null,8,["modelValue"]),e[6]||(e[6]=n("div",{class:"hint app-muted"},"至少6个字符",-1))]),_:1}),t(i,{label:"确认密码 *"},{default:o(()=>[t(s,{modelValue:a.confirm_password,"onUpdate:modelValue":e[2]||(e[2]=r=>a.confirm_password=r),type:"password","show-password":"",placeholder:"请再次输入密码",autocomplete:"new-password",onKeyup:N(k,["enter"])},null,8,["modelValue"])]),_:1}),t(i,{label:K.value},{default:o(()=>[t(s,{modelValue:a.email,"onUpdate:modelValue":e[3]||(e[3]=r=>a.email=r),placeholder:"name@example.com",autocomplete:"email"},null,8,["modelValue"]),n("div",O,q(P.value),1)]),_:1},8,["label"]),t(i,{label:"验证码 *"},{default:o(()=>[n("div",Q,[t(s,{modelValue:a.captcha,"onUpdate:modelValue":e[4]||(e[4]=r=>a.captcha=r),placeholder:"请输入验证码",onKeyup:N(k,["enter"])},null,8,["modelValue"]),f.value?(g(),S("img",{key:0,class:"captcha-img",src:f.value,alt:"验证码",title:"点击刷新",onClick:w},null,8,W)):x("",!0),t(c,{onClick:w},{default:o(()=>[...e[7]||(e[7]=[E("刷新",-1)])]),_:1})])]),_:1})]),_:1}),t(c,{type:"primary",class:"submit-btn",loading:h.value,onClick:k},{default:o(()=>[...e[8]||(e[8]=[E("注册",-1)])]),_:1},8,["loading"]),n("div",X,[e[10]||(e[10]=n("span",{class:"app-muted"},"已有账号?",-1)),t(c,{link:"",type:"primary",onClick:I},{default:o(()=>[...e[9]||(e[9]=[E("立即登录",-1)])]),_:1})])]),_:1})])}}},ae=M(Y,[["__scopeId","data-v-32684b4d"]]);export{ae as default};
|
||||
@@ -1 +1 @@
|
||||
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};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
import{_ as U,a as o,c as I,o as E,m as R,b as k,d as i,w as s,e as d,u as W,f as _,g as l,i as B,h as $,k as T,t as v}from"./index-2JnZbEa5.js";const j={class:"auth-wrap"},z={class:"actions"},D={key:0,class:"countdown app-muted"},M={__name:"VerifyResultPage",setup(q){const x=W(),p=o(!1),f=o(""),m=o(""),w=o(""),y=o(""),r=o(""),u=o(""),c=o(""),n=o(0);let a=null;function C(){if(typeof window>"u")return null;const e=window.__APP_INITIAL_STATE__;return!e||typeof e!="object"?null:(window.__APP_INITIAL_STATE__=null,e)}function N(e){const t=!!e?.success;p.value=t,f.value=e?.title||(t?"验证成功":"验证失败"),m.value=e?.message||e?.error_message||(t?"操作已完成,现在可以继续使用系统。":"操作失败,请稍后重试。"),w.value=e?.primary_label||(t?"立即登录":"重新注册"),y.value=e?.primary_url||(t?"/login":"/register"),r.value=e?.secondary_label||(t?"":"返回登录"),u.value=e?.secondary_url||(t?"":"/login"),c.value=e?.redirect_url||(t?"/login":""),n.value=Number(e?.redirect_seconds||(t?5:0))||0}const A=I(()=>!!(r.value&&u.value)),b=I(()=>!!(c.value&&n.value>0));async function g(e){if(e){if(e.startsWith("http://")||e.startsWith("https://")){window.location.href=e;return}await x.push(e)}}function P(){b.value&&(a=window.setInterval(()=>{n.value-=1,n.value<=0&&(window.clearInterval(a),a=null,window.location.href=c.value)},1e3))}return E(()=>{const e=C();N(e),P()}),R(()=>{a&&window.clearInterval(a)}),(e,t)=>{const h=d("el-button"),V=d("el-result"),L=d("el-card");return _(),k("div",j,[i(L,{shadow:"never",class:"auth-card","body-style":{padding:"22px"}},{default:s(()=>[t[2]||(t[2]=l("div",{class:"brand"},[l("div",{class:"brand-title"},"知识管理平台"),l("div",{class:"brand-sub app-muted"},"验证结果")],-1)),i(V,{icon:p.value?"success":"error",title:f.value,"sub-title":m.value,class:"result"},{extra:s(()=>[l("div",z,[i(h,{type:"primary",onClick:t[0]||(t[0]=S=>g(y.value))},{default:s(()=>[T(v(w.value),1)]),_:1}),A.value?(_(),$(h,{key:0,onClick:t[1]||(t[1]=S=>g(u.value))},{default:s(()=>[T(v(r.value),1)]),_:1})):B("",!0)]),b.value?(_(),k("div",D,v(n.value)+" 秒后自动跳转... ",1)):B("",!0)]),_:1},8,["icon","title","sub-title"])]),_:1})])}}},G=U(M,[["__scopeId","data-v-1fc6b081"]]);export{G as default};
|
||||
import{_ as U,a as o,c as I,o as E,m as R,b as k,d as i,w as s,e as d,u as W,f as _,g as l,i as B,h as $,k as T,t as v}from"./index-DYbFXn7x.js";const j={class:"auth-wrap"},z={class:"actions"},D={key:0,class:"countdown app-muted"},M={__name:"VerifyResultPage",setup(q){const x=W(),p=o(!1),f=o(""),m=o(""),w=o(""),y=o(""),r=o(""),u=o(""),c=o(""),n=o(0);let a=null;function C(){if(typeof window>"u")return null;const e=window.__APP_INITIAL_STATE__;return!e||typeof e!="object"?null:(window.__APP_INITIAL_STATE__=null,e)}function N(e){const t=!!e?.success;p.value=t,f.value=e?.title||(t?"验证成功":"验证失败"),m.value=e?.message||e?.error_message||(t?"操作已完成,现在可以继续使用系统。":"操作失败,请稍后重试。"),w.value=e?.primary_label||(t?"立即登录":"重新注册"),y.value=e?.primary_url||(t?"/login":"/register"),r.value=e?.secondary_label||(t?"":"返回登录"),u.value=e?.secondary_url||(t?"":"/login"),c.value=e?.redirect_url||(t?"/login":""),n.value=Number(e?.redirect_seconds||(t?5:0))||0}const A=I(()=>!!(r.value&&u.value)),b=I(()=>!!(c.value&&n.value>0));async function g(e){if(e){if(e.startsWith("http://")||e.startsWith("https://")){window.location.href=e;return}await x.push(e)}}function P(){b.value&&(a=window.setInterval(()=>{n.value-=1,n.value<=0&&(window.clearInterval(a),a=null,window.location.href=c.value)},1e3))}return E(()=>{const e=C();N(e),P()}),R(()=>{a&&window.clearInterval(a)}),(e,t)=>{const h=d("el-button"),V=d("el-result"),L=d("el-card");return _(),k("div",j,[i(L,{shadow:"never",class:"auth-card","body-style":{padding:"22px"}},{default:s(()=>[t[2]||(t[2]=l("div",{class:"brand"},[l("div",{class:"brand-title"},"知识管理平台"),l("div",{class:"brand-sub app-muted"},"验证结果")],-1)),i(V,{icon:p.value?"success":"error",title:f.value,"sub-title":m.value,class:"result"},{extra:s(()=>[l("div",z,[i(h,{type:"primary",onClick:t[0]||(t[0]=S=>g(y.value))},{default:s(()=>[T(v(w.value),1)]),_:1}),A.value?(_(),$(h,{key:0,onClick:t[1]||(t[1]=S=>g(u.value))},{default:s(()=>[T(v(r.value),1)]),_:1})):B("",!0)]),b.value?(_(),k("div",D,v(n.value)+" 秒后自动跳转... ",1)):B("",!0)]),_:1},8,["icon","title","sub-title"])]),_:1})])}}},G=U(M,[["__scopeId","data-v-1fc6b081"]]);export{G as default};
|
||||
@@ -1 +1 @@
|
||||
import{p as c}from"./index-2JnZbEa5.js";async function o(t={}){const{data:a}=await c.get("/accounts",{params:t});return a}async function u(t){const{data:a}=await c.post("/accounts",t);return a}async function r(t,a){const{data:n}=await c.put(`/accounts/${t}`,a);return n}async function e(t){const{data:a}=await c.delete(`/accounts/${t}`);return a}async function i(t,a){const{data:n}=await c.put(`/accounts/${t}/remark`,a);return n}async function p(t,a){const{data:n}=await c.post(`/accounts/${t}/start`,a);return n}async function d(t){const{data:a}=await c.post(`/accounts/${t}/stop`,{});return a}async function f(t){const{data:a}=await c.post("/accounts/batch/start",t);return a}async function w(t){const{data:a}=await c.post("/accounts/batch/stop",t);return a}async function y(){const{data:t}=await c.post("/accounts/clear",{});return t}async function A(t,a={}){const{data:n}=await c.post(`/accounts/${t}/screenshot`,a);return n}export{w as a,f as b,y as c,d,e,o as f,u as g,i as h,p as s,A as t,r as u};
|
||||
import{p as c}from"./index-DYbFXn7x.js";async function o(t={}){const{data:a}=await c.get("/accounts",{params:t});return a}async function u(t){const{data:a}=await c.post("/accounts",t);return a}async function r(t,a){const{data:n}=await c.put(`/accounts/${t}`,a);return n}async function e(t){const{data:a}=await c.delete(`/accounts/${t}`);return a}async function i(t,a){const{data:n}=await c.put(`/accounts/${t}/remark`,a);return n}async function p(t,a){const{data:n}=await c.post(`/accounts/${t}/start`,a);return n}async function d(t){const{data:a}=await c.post(`/accounts/${t}/stop`,{});return a}async function f(t){const{data:a}=await c.post("/accounts/batch/start",t);return a}async function w(t){const{data:a}=await c.post("/accounts/batch/stop",t);return a}async function y(){const{data:t}=await c.post("/accounts/clear",{});return t}async function A(t,a={}){const{data:n}=await c.post(`/accounts/${t}/screenshot`,a);return n}export{w as a,f as b,y as c,d,e,o as f,u as g,i as h,p as s,A as t,r as u};
|
||||
@@ -1 +1 @@
|
||||
import{p as s}from"./index-2JnZbEa5.js";async function r(){const{data:t}=await s.get("/email/verify-status");return t}async function e(){const{data:t}=await s.post("/generate_captcha",{});return t}async function o(t){const{data:a}=await s.post("/login",t);return a}async function i(t){const{data:a}=await s.post("/register",t);return a}async function c(t){const{data:a}=await s.post("/resend-verify-email",t);return a}async function u(t){const{data:a}=await s.post("/forgot-password",t);return a}async function f(t){const{data:a}=await s.post("/reset_password_request",t);return a}async function d(t){const{data:a}=await s.post("/reset-password-confirm",t);return a}export{u as a,c as b,i as c,d,r as f,e as g,o as l,f as r};
|
||||
import{p as s}from"./index-DYbFXn7x.js";async function r(){const{data:t}=await s.get("/email/verify-status");return t}async function e(){const{data:t}=await s.post("/generate_captcha",{});return t}async function o(t){const{data:a}=await s.post("/login",t);return a}async function i(t){const{data:a}=await s.post("/register",t);return a}async function c(t){const{data:a}=await s.post("/resend-verify-email",t);return a}async function u(t){const{data:a}=await s.post("/forgot-password",t);return a}async function f(t){const{data:a}=await s.post("/reset_password_request",t);return a}async function d(t){const{data:a}=await s.post("/reset-password-confirm",t);return a}export{u as a,c as b,i as c,d,r as f,e as g,o as l,f as r};
|
||||
File diff suppressed because one or more lines are too long
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
7
tests/conftest.py
Normal file
7
tests/conftest.py
Normal 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))
|
||||
|
||||
56
tests/test_schedule_utils.py
Normal file
56
tests/test_schedule_utils.py
Normal 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_start(schedule_time-15min)
|
||||
monkeypatch.setattr("services.schedule_utils.random.randint", lambda a, b: 0)
|
||||
|
||||
next_dt = compute_next_run_at(
|
||||
now=now,
|
||||
schedule_time="08:00",
|
||||
weekdays="1,2,3,4,5,6,7",
|
||||
random_delay=1,
|
||||
last_run_at=None,
|
||||
)
|
||||
assert format_cst(next_dt) == "2025-01-06 07:45:00"
|
||||
|
||||
|
||||
def test_compute_next_run_at_skips_same_day_if_last_run_today(monkeypatch):
|
||||
now = _dt("2025-01-06 06:00:00")
|
||||
|
||||
# 让次日的随机值固定,便于断言
|
||||
monkeypatch.setattr("services.schedule_utils.random.randint", lambda a, b: 30)
|
||||
|
||||
next_dt = compute_next_run_at(
|
||||
now=now,
|
||||
schedule_time="08:00",
|
||||
weekdays="1,2,3,4,5,6,7",
|
||||
random_delay=1,
|
||||
last_run_at="2025-01-06 01:00:00",
|
||||
)
|
||||
# 次日 window_start=07:45 + 30min => 08:15
|
||||
assert format_cst(next_dt) == "2025-01-07 08:15:00"
|
||||
77
tests/test_state.py
Normal file
77
tests/test_state.py
Normal 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
|
||||
|
||||
146
tests/test_task_scheduler.py
Normal file
146
tests/test_task_scheduler.py
Normal 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
|
||||
|
||||
# 同一用户并发=1:a2 不应在 a1 未结束时启动
|
||||
assert not a2_started.wait(timeout=0.3)
|
||||
|
||||
a1_release.set()
|
||||
assert a2_started.wait(timeout=2)
|
||||
assert started[0] == "a1"
|
||||
assert "a2" in started
|
||||
finally:
|
||||
scheduler.shutdown(timeout=2)
|
||||
|
||||
|
||||
def test_task_scheduler_cancel_pending(monkeypatch):
|
||||
calls: list[str] = []
|
||||
blocker_started = threading.Event()
|
||||
blocker_release = threading.Event()
|
||||
|
||||
def fake_run_task(*, user_id, account_id, **kwargs):
|
||||
calls.append(account_id)
|
||||
if account_id == "block":
|
||||
blocker_started.set()
|
||||
blocker_release.wait(timeout=5)
|
||||
|
||||
import services.tasks as tasks_mod
|
||||
|
||||
monkeypatch.setattr(tasks_mod, "run_task", fake_run_task)
|
||||
|
||||
scheduler = TaskScheduler(max_global=1, max_per_user=1, max_queue_size=10)
|
||||
try:
|
||||
ok, _ = scheduler.submit_task(user_id=1, account_id="block", browse_type="应读", is_vip=False)
|
||||
assert ok
|
||||
assert blocker_started.wait(timeout=2)
|
||||
|
||||
ok2, _ = scheduler.submit_task(user_id=1, account_id="to_cancel", browse_type="应读", is_vip=False)
|
||||
assert ok2
|
||||
|
||||
assert scheduler.cancel_pending_task(user_id=1, account_id="to_cancel") is True
|
||||
|
||||
blocker_release.set()
|
||||
time.sleep(0.3)
|
||||
assert "to_cancel" not in calls
|
||||
finally:
|
||||
scheduler.shutdown(timeout=2)
|
||||
|
||||
|
||||
def test_task_scheduler_queue_full(monkeypatch):
|
||||
blocker_started = threading.Event()
|
||||
blocker_release = threading.Event()
|
||||
|
||||
def fake_run_task(*, user_id, account_id, **kwargs):
|
||||
if account_id == "block":
|
||||
blocker_started.set()
|
||||
blocker_release.wait(timeout=5)
|
||||
|
||||
import services.tasks as tasks_mod
|
||||
|
||||
monkeypatch.setattr(tasks_mod, "run_task", fake_run_task)
|
||||
|
||||
scheduler = TaskScheduler(max_global=1, max_per_user=1, max_queue_size=1)
|
||||
try:
|
||||
ok, _ = scheduler.submit_task(user_id=1, account_id="block", browse_type="应读", is_vip=False)
|
||||
assert ok
|
||||
assert blocker_started.wait(timeout=2)
|
||||
|
||||
ok2, _ = scheduler.submit_task(user_id=1, account_id="p1", browse_type="应读", is_vip=False)
|
||||
assert ok2
|
||||
|
||||
ok3, msg3 = scheduler.submit_task(user_id=1, account_id="p2", browse_type="应读", is_vip=False)
|
||||
assert ok3 is False
|
||||
assert "队列已满" in (msg3 or "")
|
||||
finally:
|
||||
blocker_release.set()
|
||||
scheduler.shutdown(timeout=2)
|
||||
|
||||
Reference in New Issue
Block a user