Files
outlookEmailPlus/outlook_web/db.py
T
胡绍康andClaude Opus 4.6 0eb36f5175 style: 运行 Black 格式化所有 Python 代码
- 格式化 66 个 Python 文件以符合 Black 代码风格
- 修复 GitHub Actions Code Quality 工作流失败问题

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 18:23:57 +08:00

680 lines
22 KiB
Python

from __future__ import annotations
import os
import sqlite3
import time
from typing import Optional
from flask import g
from outlook_web import config
from outlook_web.errors import generate_trace_id, sanitize_error_details
from outlook_web.security.crypto import (
encrypt_data,
hash_password,
is_encrypted,
is_password_hashed,
)
# 数据库 Schema 版本(用于升级可验证/可诊断)
DB_SCHEMA_VERSION = 2
DB_SCHEMA_VERSION_KEY = "db_schema_version"
DB_SCHEMA_LAST_UPGRADE_TRACE_ID_KEY = "db_schema_last_upgrade_trace_id"
DB_SCHEMA_LAST_UPGRADE_ERROR_KEY = "db_schema_last_upgrade_error"
def create_sqlite_connection(database_path: Optional[str] = None) -> sqlite3.Connection:
"""创建 SQLite 连接(带基础一致性/并发配置)"""
path = database_path or config.get_database_path()
conn = sqlite3.connect(path, timeout=30)
conn.row_factory = sqlite3.Row
try:
conn.execute("PRAGMA foreign_keys = ON")
except Exception:
pass
try:
conn.execute("PRAGMA busy_timeout = 5000")
except Exception:
pass
return conn
def get_db() -> sqlite3.Connection:
"""获取数据库连接(绑定到 flask.g 生命周期)"""
db = getattr(g, "_database", None)
if db is None:
db = g._database = create_sqlite_connection()
return db
def close_db(_exception=None):
"""关闭数据库连接"""
db = getattr(g, "_database", None)
if db is not None:
db.close()
def register_db(app):
"""向 Flask app 注册 teardown,保证请求结束释放连接"""
app.teardown_appcontext(close_db)
def init_db(database_path: Optional[str] = None):
"""初始化数据库(含升级记录与可验证状态)"""
path = database_path or config.get_database_path()
login_password_default = config.get_login_password_default()
gptmail_api_key_default = config.get_gptmail_api_key_default()
db_existed = False
try:
db_existed = os.path.exists(path) and os.path.getsize(path) > 0
except Exception:
db_existed = False
conn = create_sqlite_connection(path)
cursor = conn.cursor()
# 基础并发配置(对既存数据库同样生效)
try:
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA synchronous=NORMAL")
except Exception:
pass
migration_id = None
migration_trace_id = None
upgrading = False
try:
# 获取写锁:避免多进程启动时并发迁移导致的偶发失败
cursor.execute("BEGIN IMMEDIATE")
# 创建设置表
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
# 数据库迁移记录(用于升级可验证/可诊断)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS schema_migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
from_version INTEGER NOT NULL,
to_version INTEGER NOT NULL,
status TEXT NOT NULL,
started_at REAL NOT NULL,
finished_at REAL,
error TEXT,
trace_id TEXT
)
"""
)
cursor.execute(
"""
CREATE INDEX IF NOT EXISTS idx_schema_migrations_started_at
ON schema_migrations(started_at)
"""
)
# 在锁内读取当前 schema 版本(保证一致性)
row = cursor.execute(
"SELECT value FROM settings WHERE key = ?",
(DB_SCHEMA_VERSION_KEY,),
).fetchone()
try:
current_version = (
int(row["value"]) if row and row["value"] is not None else 0
)
except Exception:
current_version = 0
upgrading = current_version < DB_SCHEMA_VERSION
if upgrading:
migration_trace_id = generate_trace_id()
if db_existed:
try:
print("=" * 60)
print(
f"[升级提示] 检测到数据库需要升级:v{current_version} -> v{DB_SCHEMA_VERSION}"
)
print(f"[升级提示] 强烈建议先备份数据库文件:{path}")
print(f'[升级提示] 示例:cp "{path}" "{path}.backup"')
print(f"[升级提示] trace_id={migration_trace_id}")
print("=" * 60)
except Exception:
pass
cursor.execute(
"""
INSERT INTO schema_migrations (from_version, to_version, status, started_at, trace_id)
VALUES (?, ?, 'running', ?, ?)
""",
(current_version, DB_SCHEMA_VERSION, time.time(), migration_trace_id),
)
migration_id = cursor.lastrowid
cursor.execute("SAVEPOINT migration_work")
# -------------------- Schema 创建/迁移(幂等) --------------------
# 分组表
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
description TEXT,
color TEXT DEFAULT '#1a1a1a',
proxy_url TEXT,
is_system INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
# 邮箱账号表
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password TEXT,
client_id TEXT NOT NULL,
refresh_token TEXT NOT NULL,
group_id INTEGER,
remark TEXT,
status TEXT DEFAULT 'active',
last_refresh_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (group_id) REFERENCES groups (id)
)
"""
)
# 临时邮箱表
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS temp_emails (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
status TEXT DEFAULT 'active',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
# 临时邮件表(存储从 GPTMail 获取的邮件)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS temp_email_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id TEXT UNIQUE NOT NULL,
email_address TEXT NOT NULL,
from_address TEXT,
subject TEXT,
content TEXT,
html_content TEXT,
has_html INTEGER DEFAULT 0,
timestamp INTEGER,
raw_content TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (email_address) REFERENCES temp_emails (email)
)
"""
)
# 刷新记录表(账号级)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS account_refresh_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL,
account_email TEXT NOT NULL,
refresh_type TEXT DEFAULT 'manual',
status TEXT NOT NULL,
error_message TEXT,
run_id TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE CASCADE
)
"""
)
cursor.execute(
"""
CREATE INDEX IF NOT EXISTS idx_account_refresh_logs_run_id
ON account_refresh_logs(run_id)
"""
)
# 审计日志表
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL,
resource_type TEXT NOT NULL,
resource_id TEXT,
user_ip TEXT,
details TEXT,
trace_id TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
cursor.execute(
"""
CREATE INDEX IF NOT EXISTS idx_audit_logs_trace_id
ON audit_logs(trace_id)
"""
)
cursor.execute(
"""
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at
ON audit_logs(created_at)
"""
)
# 标签表
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
color TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
# 账号标签关联表
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS account_tags (
account_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (account_id, tag_id),
FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags (id) ON DELETE CASCADE
)
"""
)
# 分布式锁(用于刷新冲突控制/多进程一致性)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS distributed_locks (
name TEXT PRIMARY KEY,
owner_id TEXT NOT NULL,
acquired_at REAL NOT NULL,
expires_at REAL NOT NULL
)
"""
)
# 导出二次验证 Token(持久化,支持重启/多进程)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS export_verify_tokens (
token TEXT PRIMARY KEY,
ip TEXT,
user_agent TEXT,
expires_at REAL NOT NULL,
created_at REAL NOT NULL
)
"""
)
# 登录速率限制(持久化,支持重启/多进程)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS login_attempts (
ip TEXT PRIMARY KEY,
count INTEGER NOT NULL,
last_attempt_at REAL NOT NULL,
locked_until_at REAL
)
"""
)
# 刷新运行记录(用于“最近触发/来源/统计/运行中状态”的可验证性)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS refresh_runs (
id TEXT PRIMARY KEY,
trigger_source TEXT NOT NULL,
status TEXT NOT NULL,
requested_by_ip TEXT,
requested_by_user_agent TEXT,
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
finished_at TIMESTAMP,
total INTEGER DEFAULT 0,
success_count INTEGER DEFAULT 0,
failed_count INTEGER DEFAULT 0,
message TEXT,
trace_id TEXT
)
"""
)
cursor.execute(
"""
CREATE INDEX IF NOT EXISTS idx_refresh_runs_started_at
ON refresh_runs(started_at)
"""
)
cursor.execute(
"""
CREATE INDEX IF NOT EXISTS idx_refresh_runs_trigger_source
ON refresh_runs(trigger_source)
"""
)
# 兼容旧 schema:补齐缺失列
cursor.execute("PRAGMA table_info(accounts)")
columns = [col[1] for col in cursor.fetchall()]
if "group_id" not in columns:
cursor.execute("ALTER TABLE accounts ADD COLUMN group_id INTEGER DEFAULT 1")
if "remark" not in columns:
cursor.execute("ALTER TABLE accounts ADD COLUMN remark TEXT")
if "status" not in columns:
cursor.execute(
"ALTER TABLE accounts ADD COLUMN status TEXT DEFAULT 'active'"
)
if "updated_at" not in columns:
cursor.execute(
"ALTER TABLE accounts ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP"
)
if "last_refresh_at" not in columns:
cursor.execute("ALTER TABLE accounts ADD COLUMN last_refresh_at TIMESTAMP")
cursor.execute("PRAGMA table_info(groups)")
group_columns = [col[1] for col in cursor.fetchall()]
if "is_system" not in group_columns:
cursor.execute("ALTER TABLE groups ADD COLUMN is_system INTEGER DEFAULT 0")
if "proxy_url" not in group_columns:
cursor.execute("ALTER TABLE groups ADD COLUMN proxy_url TEXT")
cursor.execute("PRAGMA table_info(account_refresh_logs)")
refresh_log_columns = [col[1] for col in cursor.fetchall()]
if "run_id" not in refresh_log_columns:
cursor.execute("ALTER TABLE account_refresh_logs ADD COLUMN run_id TEXT")
cursor.execute("PRAGMA table_info(audit_logs)")
audit_columns = [col[1] for col in cursor.fetchall()]
if "trace_id" not in audit_columns:
cursor.execute("ALTER TABLE audit_logs ADD COLUMN trace_id TEXT")
# 默认分组
cursor.execute(
"""
INSERT OR IGNORE INTO groups (name, description, color)
VALUES ('默认分组', '未分组的邮箱', '#666666')
"""
)
# 临时邮箱分组(系统分组)
cursor.execute(
"""
INSERT OR IGNORE INTO groups (name, description, color, is_system)
VALUES ('临时邮箱', 'GPTMail 临时邮箱服务', '#00bcf2', 1)
"""
)
# 初始化默认设置:登录密码(自动迁移明文 -> 哈希)
cursor.execute("SELECT value FROM settings WHERE key = 'login_password'")
existing_password = cursor.fetchone()
if existing_password:
password_value = existing_password[0]
if password_value and not is_password_hashed(password_value):
hashed_password = hash_password(password_value)
cursor.execute(
"""
UPDATE settings SET value = ? WHERE key = 'login_password'
""",
(hashed_password,),
)
else:
hashed_password = hash_password(login_password_default)
cursor.execute(
"""
INSERT INTO settings (key, value)
VALUES ('login_password', ?)
""",
(hashed_password,),
)
cursor.execute(
"""
INSERT OR IGNORE INTO settings (key, value)
VALUES ('gptmail_api_key', ?)
""",
(gptmail_api_key_default,),
)
# 初始化刷新配置
cursor.execute(
"""
INSERT OR IGNORE INTO settings (key, value)
VALUES ('refresh_interval_days', '30')
"""
)
cursor.execute(
"""
INSERT OR IGNORE INTO settings (key, value)
VALUES ('refresh_delay_seconds', '5')
"""
)
cursor.execute(
"""
INSERT OR IGNORE INTO settings (key, value)
VALUES ('refresh_cron', '0 2 * * *')
"""
)
cursor.execute(
"""
INSERT OR IGNORE INTO settings (key, value)
VALUES ('use_cron_schedule', 'false')
"""
)
cursor.execute(
"""
INSERT OR IGNORE INTO settings (key, value)
VALUES ('enable_scheduled_refresh', 'true')
"""
)
# 初始化轮询配置
cursor.execute(
"""
INSERT OR IGNORE INTO settings (key, value)
VALUES ('enable_auto_polling', 'false')
"""
)
cursor.execute(
"""
INSERT OR IGNORE INTO settings (key, value)
VALUES ('polling_interval', '10')
"""
)
cursor.execute(
"""
INSERT OR IGNORE INTO settings (key, value)
VALUES ('polling_count', '5')
"""
)
# 索引(性能基线)
cursor.execute(
"""
CREATE INDEX IF NOT EXISTS idx_accounts_last_refresh_at
ON accounts(last_refresh_at)
"""
)
cursor.execute(
"""
CREATE INDEX IF NOT EXISTS idx_accounts_status
ON accounts(status)
"""
)
cursor.execute(
"""
CREATE INDEX IF NOT EXISTS idx_accounts_group_id
ON accounts(group_id)
"""
)
cursor.execute(
"""
CREATE INDEX IF NOT EXISTS idx_account_refresh_logs_account_id
ON account_refresh_logs(account_id)
"""
)
cursor.execute(
"""
CREATE INDEX IF NOT EXISTS idx_account_refresh_logs_account_id_id
ON account_refresh_logs(account_id, id)
"""
)
cursor.execute(
"""
CREATE INDEX IF NOT EXISTS idx_account_tags_tag_id
ON account_tags(tag_id)
"""
)
# 迁移现有明文数据为加密数据
migrate_sensitive_data(conn)
# 升级完成标记:写入 schema 版本,便于“升级可验证”
cursor.execute(
"""
INSERT OR REPLACE INTO settings (key, value, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
""",
(DB_SCHEMA_VERSION_KEY, str(DB_SCHEMA_VERSION)),
)
if upgrading and migration_id is not None:
try:
cursor.execute("RELEASE SAVEPOINT migration_work")
except Exception:
pass
cursor.execute(
"""
UPDATE schema_migrations
SET status = 'success', finished_at = ?, error = NULL
WHERE id = ?
""",
(time.time(), migration_id),
)
cursor.execute(
"""
INSERT OR REPLACE INTO settings (key, value, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
""",
(DB_SCHEMA_LAST_UPGRADE_TRACE_ID_KEY, migration_trace_id),
)
cursor.execute(
"""
INSERT OR REPLACE INTO settings (key, value, updated_at)
VALUES (?, '', CURRENT_TIMESTAMP)
""",
(DB_SCHEMA_LAST_UPGRADE_ERROR_KEY,),
)
conn.commit()
except Exception as e:
error_text = sanitize_error_details(str(e))
try:
if upgrading and migration_id is not None:
try:
cursor.execute("ROLLBACK TO SAVEPOINT migration_work")
cursor.execute("RELEASE SAVEPOINT migration_work")
except Exception:
pass
cursor.execute(
"""
UPDATE schema_migrations
SET status = 'failed', finished_at = ?, error = ?
WHERE id = ?
""",
(time.time(), error_text, migration_id),
)
cursor.execute(
"""
INSERT OR REPLACE INTO settings (key, value, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
""",
(DB_SCHEMA_LAST_UPGRADE_TRACE_ID_KEY, migration_trace_id),
)
cursor.execute(
"""
INSERT OR REPLACE INTO settings (key, value, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
""",
(DB_SCHEMA_LAST_UPGRADE_ERROR_KEY, error_text),
)
conn.commit()
else:
conn.rollback()
except Exception:
try:
conn.rollback()
except Exception:
pass
raise
finally:
try:
conn.close()
except Exception:
pass
def migrate_sensitive_data(conn: sqlite3.Connection):
"""迁移现有明文敏感数据为加密数据"""
cursor = conn.cursor()
# 获取所有账号
cursor.execute("SELECT id, password, refresh_token FROM accounts")
accounts = cursor.fetchall()
migrated_count = 0
for account_id, password, refresh_token in accounts:
needs_update = False
new_password = password
new_refresh_token = refresh_token
# 检查并加密 password
if password and not is_encrypted(password):
new_password = encrypt_data(password)
needs_update = True
# 检查并加密 refresh_token
if refresh_token and not is_encrypted(refresh_token):
new_refresh_token = encrypt_data(refresh_token)
needs_update = True
# 更新数据库
if needs_update:
cursor.execute(
"""
UPDATE accounts
SET password = ?, refresh_token = ?
WHERE id = ?
""",
(new_password, new_refresh_token, account_id),
)
migrated_count += 1
if migrated_count > 0:
print(f"已迁移 {migrated_count} 个账号的敏感数据为加密存储")