237 lines
8.4 KiB
Python
237 lines
8.4 KiB
Python
"""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
|