feat: 实现 SQLite 初始化与 v1 迁移 (#7)

This commit is contained in:
chengma
2026-08-06 16:10:57 +08:00
parent 2fafa05d91
commit 69f0812b9c
3 changed files with 412 additions and 0 deletions
+86
View File
@@ -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
+132
View File
@@ -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,
}