feat: 实现 SQLite 初始化与 v1 迁移 (#7)
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
"""Client SQLite 数据库的路径、连接和初始化函数。"""
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
|
||||
from .db_schema import MIGRATIONS, SCHEMA_VERSION
|
||||
|
||||
|
||||
PathValue = Union[str, Path]
|
||||
|
||||
|
||||
class DatabaseVersionError(RuntimeError):
|
||||
"""数据库版本比当前程序支持的版本新。"""
|
||||
|
||||
|
||||
def data_dir() -> Path:
|
||||
"""返回可写数据目录;目录不存在时自动创建。"""
|
||||
|
||||
if getattr(sys, "frozen", False):
|
||||
directory = Path(sys.executable).resolve().parent / "data"
|
||||
else:
|
||||
directory = Path(__file__).resolve().parents[1] / "data"
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
return directory
|
||||
|
||||
|
||||
def default_database_path() -> Path:
|
||||
"""返回默认数据库文件路径。"""
|
||||
|
||||
return data_dir() / "client.db"
|
||||
|
||||
|
||||
def open_database(db_path: Optional[PathValue] = None) -> sqlite3.Connection:
|
||||
"""打开一个独立连接,并启用项目要求的 SQLite 参数。
|
||||
|
||||
调用者用完后必须关闭连接。不同线程不能共享同一个连接。
|
||||
"""
|
||||
|
||||
path = Path(db_path) if db_path is not None else default_database_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
connection = sqlite3.connect(str(path), timeout=5.0)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys = ON")
|
||||
connection.execute("PRAGMA journal_mode = WAL")
|
||||
connection.execute("PRAGMA busy_timeout = 5000")
|
||||
return connection
|
||||
|
||||
|
||||
def migrate_database(connection: sqlite3.Connection) -> None:
|
||||
"""把数据库从当前版本依次迁移到最新版本。"""
|
||||
|
||||
current_version = int(connection.execute("PRAGMA user_version").fetchone()[0])
|
||||
if current_version > SCHEMA_VERSION:
|
||||
raise DatabaseVersionError(
|
||||
f"数据库版本 {current_version} 高于程序支持版本 {SCHEMA_VERSION}"
|
||||
)
|
||||
|
||||
for version in range(current_version + 1, SCHEMA_VERSION + 1):
|
||||
statements = MIGRATIONS.get(version)
|
||||
if statements is None:
|
||||
raise RuntimeError(f"缺少数据库迁移版本 {version}")
|
||||
|
||||
try:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
for statement in statements:
|
||||
connection.execute(statement)
|
||||
connection.execute(f"PRAGMA user_version = {version}")
|
||||
connection.commit()
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
|
||||
|
||||
def initialize_database(db_path: Optional[PathValue] = None) -> Path:
|
||||
"""创建或升级数据库,完成后返回数据库文件路径。"""
|
||||
|
||||
path = Path(db_path) if db_path is not None else default_database_path()
|
||||
connection = open_database(path)
|
||||
try:
|
||||
migrate_database(connection)
|
||||
finally:
|
||||
connection.close()
|
||||
return path
|
||||
@@ -0,0 +1,132 @@
|
||||
"""SQLite 数据库结构和迁移定义。
|
||||
|
||||
每个版本对应一组按顺序执行的 SQL。新增数据库版本时,只能在
|
||||
``MIGRATIONS`` 末尾增加版本,不能修改已经发布的迁移。
|
||||
"""
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
MIGRATION_1 = (
|
||||
"""
|
||||
CREATE TABLE pdd_tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
remote_task_id TEXT NOT NULL UNIQUE,
|
||||
task_type TEXT NOT NULL
|
||||
CHECK (task_type IN ('collect', 'purchase')),
|
||||
goods_id TEXT,
|
||||
goods_url TEXT NOT NULL,
|
||||
title TEXT,
|
||||
target_color TEXT,
|
||||
target_size TEXT,
|
||||
price_cent INTEGER
|
||||
CHECK (price_cent IS NULL OR price_cent >= 0),
|
||||
quantity INTEGER
|
||||
CHECK (quantity IS NULL OR quantity > 0),
|
||||
status TEXT NOT NULL DEFAULT 'claimed'
|
||||
CHECK (status IN (
|
||||
'claimed', 'running',
|
||||
'result_pending', 'retry_wait',
|
||||
'manual_review', 'succeeded',
|
||||
'failed', 'cancelled'
|
||||
)),
|
||||
current_step TEXT,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0),
|
||||
admin_payload TEXT NOT NULL DEFAULT '{}',
|
||||
pdd_data TEXT,
|
||||
retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count >= 0),
|
||||
last_error_code TEXT,
|
||||
last_error_message TEXT,
|
||||
received_at TEXT NOT NULL,
|
||||
started_at TEXT,
|
||||
finished_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX idx_pdd_tasks_list
|
||||
ON pdd_tasks(updated_at DESC, id DESC)
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX idx_pdd_tasks_status_type
|
||||
ON pdd_tasks(status, task_type)
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX idx_pdd_tasks_goods_id
|
||||
ON pdd_tasks(goods_id)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE task_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id INTEGER NOT NULL,
|
||||
attempt_id TEXT NOT NULL UNIQUE,
|
||||
attempt_no INTEGER NOT NULL CHECK (attempt_no > 0),
|
||||
device_address TEXT NOT NULL,
|
||||
run_status TEXT NOT NULL
|
||||
CHECK (run_status IN (
|
||||
'running', 'succeeded', 'failed',
|
||||
'cancelled', 'manual_review'
|
||||
)),
|
||||
current_step TEXT,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
irreversible_action_at TEXT,
|
||||
order_submitted_at TEXT,
|
||||
error_code TEXT,
|
||||
error_message TEXT,
|
||||
diagnostics_json TEXT,
|
||||
artifact_directory TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (task_id) REFERENCES pdd_tasks(id) ON DELETE CASCADE,
|
||||
UNIQUE (task_id, attempt_no)
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX idx_task_runs_task
|
||||
ON task_runs(task_id, attempt_no DESC)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE outbox_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id INTEGER NOT NULL,
|
||||
event_type TEXT NOT NULL
|
||||
CHECK (event_type IN (
|
||||
'collect_result', 'purchase_result',
|
||||
'task_failure'
|
||||
)),
|
||||
idempotency_key TEXT NOT NULL UNIQUE,
|
||||
payload_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN (
|
||||
'pending', 'sending', 'sent', 'failed'
|
||||
)),
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (attempt_count >= 0),
|
||||
next_retry_at TEXT,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
sent_at TEXT,
|
||||
FOREIGN KEY (task_id) REFERENCES pdd_tasks(id) ON DELETE CASCADE
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX idx_outbox_pending
|
||||
ON outbox_events(status, next_retry_at, id)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE app_settings (
|
||||
setting_key TEXT PRIMARY KEY,
|
||||
value_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
MIGRATIONS = {
|
||||
1: MIGRATION_1,
|
||||
}
|
||||
Reference in New Issue
Block a user