feat: 定义任务模型与 SQLite Repository (#8)
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
"""PDD 任务的 SQLite Repository。
|
||||
|
||||
Repository 是数据库访问入口。界面和自动化代码不应自行拼接任务 SQL。
|
||||
"""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
|
||||
from .db import initialize_database, open_database
|
||||
from .task_models import (
|
||||
NewClaimedTask,
|
||||
TaskDetail,
|
||||
TaskFilters,
|
||||
TaskStatus,
|
||||
TaskSummary,
|
||||
TaskType,
|
||||
)
|
||||
|
||||
|
||||
PathValue = Union[str, Path]
|
||||
MAX_PAGE_SIZE = 500
|
||||
|
||||
|
||||
class DuplicateTaskError(ValueError):
|
||||
"""相同远程任务编号已经存在,不能覆盖。"""
|
||||
|
||||
|
||||
def utc_now_iso() -> str:
|
||||
"""返回精确到秒的 UTC ISO 8601 时间。"""
|
||||
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
|
||||
|
||||
class TaskRepository:
|
||||
"""保存和查询本机已经领取的 PDD 任务。"""
|
||||
|
||||
def __init__(self, db_path: Optional[PathValue] = None):
|
||||
self._db_path = initialize_database(db_path)
|
||||
|
||||
def add_claimed_task(
|
||||
self, task: NewClaimedTask, received_at: Optional[str] = None
|
||||
) -> int:
|
||||
"""写入一条新领取任务,返回本地自增编号。
|
||||
|
||||
相同 ``remote_task_id`` 已存在时抛出 ``DuplicateTaskError``,
|
||||
不覆盖已经保存的本地状态。
|
||||
"""
|
||||
|
||||
now = received_at or utc_now_iso()
|
||||
payload = json.dumps(task.admin_payload, ensure_ascii=False)
|
||||
connection = open_database(self._db_path)
|
||||
try:
|
||||
try:
|
||||
with connection:
|
||||
cursor = connection.execute(
|
||||
"INSERT INTO pdd_tasks ("
|
||||
" remote_task_id, task_type, goods_id, goods_url, title,"
|
||||
" target_color, target_size, price_cent, quantity, status,"
|
||||
" priority, version, admin_payload, received_at, created_at,"
|
||||
" updated_at"
|
||||
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
task.remote_task_id.strip(),
|
||||
task.task_type.value,
|
||||
task.goods_id,
|
||||
task.goods_url.strip(),
|
||||
task.title,
|
||||
task.target_color,
|
||||
task.target_size,
|
||||
task.price_cent,
|
||||
task.quantity,
|
||||
TaskStatus.CLAIMED.value,
|
||||
task.priority,
|
||||
task.version,
|
||||
payload,
|
||||
now,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
return int(cursor.lastrowid)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
if "pdd_tasks.remote_task_id" in str(exc):
|
||||
raise DuplicateTaskError(
|
||||
f"任务 {task.remote_task_id} 已经存在"
|
||||
) from exc
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def list_tasks(
|
||||
self,
|
||||
filters: Optional[TaskFilters] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> List[TaskSummary]:
|
||||
"""分页查询任务摘要,默认按更新时间和本地编号倒序。"""
|
||||
|
||||
self._validate_page(limit, offset)
|
||||
where_sql, parameters = self._build_where(filters or TaskFilters())
|
||||
parameters.extend((limit, offset))
|
||||
connection = open_database(self._db_path)
|
||||
try:
|
||||
rows = connection.execute(
|
||||
"SELECT id, remote_task_id, task_type, goods_id, title,"
|
||||
" target_color, target_size, price_cent, quantity, status, updated_at"
|
||||
" FROM pdd_tasks"
|
||||
f"{where_sql}"
|
||||
" ORDER BY updated_at DESC, id DESC"
|
||||
" LIMIT ? OFFSET ?",
|
||||
parameters,
|
||||
).fetchall()
|
||||
finally:
|
||||
connection.close()
|
||||
return [self._to_summary(row) for row in rows]
|
||||
|
||||
def count_tasks(self, filters: Optional[TaskFilters] = None) -> int:
|
||||
"""返回符合筛选条件的任务总数。"""
|
||||
|
||||
where_sql, parameters = self._build_where(filters or TaskFilters())
|
||||
connection = open_database(self._db_path)
|
||||
try:
|
||||
row = connection.execute(
|
||||
f"SELECT COUNT(*) FROM pdd_tasks{where_sql}", parameters
|
||||
).fetchone()
|
||||
finally:
|
||||
connection.close()
|
||||
return int(row[0])
|
||||
|
||||
def get_task(self, remote_task_id: str) -> Optional[TaskDetail]:
|
||||
"""按稳定远程编号读取完整任务;不存在时返回 None。"""
|
||||
|
||||
connection = open_database(self._db_path)
|
||||
try:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM pdd_tasks WHERE remote_task_id = ?",
|
||||
(remote_task_id,),
|
||||
).fetchone()
|
||||
finally:
|
||||
connection.close()
|
||||
return self._to_detail(row) if row is not None else None
|
||||
|
||||
@staticmethod
|
||||
def _validate_page(limit: int, offset: int) -> None:
|
||||
if not 1 <= limit <= MAX_PAGE_SIZE:
|
||||
raise ValueError(f"limit 必须在 1 到 {MAX_PAGE_SIZE} 之间")
|
||||
if offset < 0:
|
||||
raise ValueError("offset 不能小于 0")
|
||||
|
||||
@staticmethod
|
||||
def _build_where(filters: TaskFilters) -> Tuple[str, List[object]]:
|
||||
clauses = []
|
||||
parameters: List[object] = []
|
||||
if filters.task_type is not None:
|
||||
clauses.append("task_type = ?")
|
||||
parameters.append(filters.task_type.value)
|
||||
if filters.status is not None:
|
||||
clauses.append("status = ?")
|
||||
parameters.append(filters.status.value)
|
||||
if filters.keyword.strip():
|
||||
keyword = TaskRepository._escape_like(filters.keyword.strip())
|
||||
pattern = f"%{keyword}%"
|
||||
clauses.append(
|
||||
"(remote_task_id LIKE ? ESCAPE '\\'"
|
||||
" OR COALESCE(goods_id, '') LIKE ? ESCAPE '\\'"
|
||||
" OR COALESCE(title, '') LIKE ? ESCAPE '\\')"
|
||||
)
|
||||
parameters.extend((pattern, pattern, pattern))
|
||||
return (" WHERE " + " AND ".join(clauses) if clauses else "", parameters)
|
||||
|
||||
@staticmethod
|
||||
def _escape_like(value: str) -> str:
|
||||
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
@staticmethod
|
||||
def _to_summary(row: sqlite3.Row) -> TaskSummary:
|
||||
return TaskSummary(
|
||||
id=row["id"],
|
||||
remote_task_id=row["remote_task_id"],
|
||||
task_type=TaskType(row["task_type"]),
|
||||
goods_id=row["goods_id"],
|
||||
title=row["title"],
|
||||
target_color=row["target_color"],
|
||||
target_size=row["target_size"],
|
||||
price_cent=row["price_cent"],
|
||||
quantity=row["quantity"],
|
||||
status=TaskStatus(row["status"]),
|
||||
updated_at=row["updated_at"],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _to_detail(row: sqlite3.Row) -> TaskDetail:
|
||||
admin_payload = TaskRepository._load_json_object(row["admin_payload"])
|
||||
pdd_data = (
|
||||
TaskRepository._load_json_object(row["pdd_data"])
|
||||
if row["pdd_data"] is not None
|
||||
else None
|
||||
)
|
||||
return TaskDetail(
|
||||
id=row["id"],
|
||||
remote_task_id=row["remote_task_id"],
|
||||
task_type=TaskType(row["task_type"]),
|
||||
goods_id=row["goods_id"],
|
||||
goods_url=row["goods_url"],
|
||||
title=row["title"],
|
||||
target_color=row["target_color"],
|
||||
target_size=row["target_size"],
|
||||
price_cent=row["price_cent"],
|
||||
quantity=row["quantity"],
|
||||
status=TaskStatus(row["status"]),
|
||||
current_step=row["current_step"],
|
||||
priority=row["priority"],
|
||||
version=row["version"],
|
||||
admin_payload=admin_payload,
|
||||
pdd_data=pdd_data,
|
||||
retry_count=row["retry_count"],
|
||||
last_error_code=row["last_error_code"],
|
||||
last_error_message=row["last_error_message"],
|
||||
received_at=row["received_at"],
|
||||
started_at=row["started_at"],
|
||||
finished_at=row["finished_at"],
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _load_json_object(text: str) -> Dict[str, object]:
|
||||
value = json.loads(text)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("数据库 JSON 字段必须是对象")
|
||||
return value
|
||||
Reference in New Issue
Block a user