feat: 定义任务模型与 SQLite Repository (#8)
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
"""非敏感应用设置的 SQLite Repository。"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from .db import initialize_database, open_database
|
||||
from .task_models import AppSettingRecord
|
||||
|
||||
|
||||
PathValue = Union[str, Path]
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
|
||||
|
||||
class SettingsRepository:
|
||||
"""用 JSON 保存非敏感设置;凭据不得传入本类。"""
|
||||
|
||||
def __init__(self, db_path: Optional[PathValue] = None):
|
||||
self._db_path = initialize_database(db_path)
|
||||
|
||||
def get(self, setting_key: str, default: Any = None) -> Any:
|
||||
"""读取设置值;键不存在时返回 default。"""
|
||||
|
||||
record = self.get_record(setting_key)
|
||||
return default if record is None else record.value
|
||||
|
||||
def get_record(self, setting_key: str) -> Optional[AppSettingRecord]:
|
||||
"""读取包含更新时间的完整设置记录。"""
|
||||
|
||||
key = self._validate_key(setting_key)
|
||||
connection = open_database(self._db_path)
|
||||
try:
|
||||
row = connection.execute(
|
||||
"SELECT setting_key, value_json, updated_at"
|
||||
" FROM app_settings WHERE setting_key = ?",
|
||||
(key,),
|
||||
).fetchone()
|
||||
finally:
|
||||
connection.close()
|
||||
if row is None:
|
||||
return None
|
||||
return AppSettingRecord(
|
||||
setting_key=row["setting_key"],
|
||||
value=json.loads(row["value_json"]),
|
||||
updated_at=row["updated_at"],
|
||||
)
|
||||
|
||||
def set(
|
||||
self, setting_key: str, value: Any, updated_at: Optional[str] = None
|
||||
) -> AppSettingRecord:
|
||||
"""新增或更新一个非敏感设置并返回保存后的记录。"""
|
||||
|
||||
key = self._validate_key(setting_key)
|
||||
now = updated_at or _utc_now_iso()
|
||||
value_json = json.dumps(value, ensure_ascii=False)
|
||||
connection = open_database(self._db_path)
|
||||
try:
|
||||
with connection:
|
||||
connection.execute(
|
||||
"INSERT INTO app_settings (setting_key, value_json, updated_at)"
|
||||
" VALUES (?, ?, ?)"
|
||||
" ON CONFLICT(setting_key) DO UPDATE SET"
|
||||
" value_json = excluded.value_json,"
|
||||
" updated_at = excluded.updated_at",
|
||||
(key, value_json, now),
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
return AppSettingRecord(key, value, now)
|
||||
|
||||
def delete(self, setting_key: str) -> bool:
|
||||
"""删除设置;确实删除了一条记录时返回 True。"""
|
||||
|
||||
key = self._validate_key(setting_key)
|
||||
connection = open_database(self._db_path)
|
||||
try:
|
||||
with connection:
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM app_settings WHERE setting_key = ?", (key,)
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
@staticmethod
|
||||
def _validate_key(setting_key: str) -> str:
|
||||
key = setting_key.strip()
|
||||
if not key:
|
||||
raise ValueError("setting_key 不能为空")
|
||||
return key
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Client 任务数据库使用的枚举和简单数据对象。
|
||||
|
||||
本文件不依赖 Qt、Admin 客户端或 uiautomator2,只表达数据含义。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, Mapping, Optional
|
||||
|
||||
|
||||
class TaskType(str, Enum):
|
||||
"""PDD 任务类型。"""
|
||||
|
||||
COLLECT = "collect"
|
||||
PURCHASE = "purchase"
|
||||
|
||||
|
||||
class TaskStatus(str, Enum):
|
||||
"""PDD 任务本地状态。"""
|
||||
|
||||
CLAIMED = "claimed"
|
||||
RUNNING = "running"
|
||||
RESULT_PENDING = "result_pending"
|
||||
RETRY_WAIT = "retry_wait"
|
||||
MANUAL_REVIEW = "manual_review"
|
||||
SUCCEEDED = "succeeded"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class RunStatus(str, Enum):
|
||||
"""单次任务执行状态。"""
|
||||
|
||||
RUNNING = "running"
|
||||
SUCCEEDED = "succeeded"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
MANUAL_REVIEW = "manual_review"
|
||||
|
||||
|
||||
class OutboxEventType(str, Enum):
|
||||
"""等待提交 Admin 的事件类型。"""
|
||||
|
||||
COLLECT_RESULT = "collect_result"
|
||||
PURCHASE_RESULT = "purchase_result"
|
||||
TASK_FAILURE = "task_failure"
|
||||
|
||||
|
||||
class OutboxStatus(str, Enum):
|
||||
"""Outbox 事件发送状态。"""
|
||||
|
||||
PENDING = "pending"
|
||||
SENDING = "sending"
|
||||
SENT = "sent"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NewClaimedTask:
|
||||
"""刚从 Admin 领取、准备写入本地数据库的任务。"""
|
||||
|
||||
remote_task_id: str
|
||||
task_type: TaskType
|
||||
goods_url: str
|
||||
goods_id: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
target_color: Optional[str] = None
|
||||
target_size: Optional[str] = None
|
||||
price_cent: Optional[int] = None
|
||||
quantity: Optional[int] = None
|
||||
priority: int = 0
|
||||
version: int = 1
|
||||
admin_payload: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.task_type, TaskType):
|
||||
raise ValueError("task_type 必须是 TaskType")
|
||||
if not self.remote_task_id.strip():
|
||||
raise ValueError("remote_task_id 不能为空")
|
||||
if not self.goods_url.strip():
|
||||
raise ValueError("goods_url 不能为空")
|
||||
if self.price_cent is not None and self.price_cent < 0:
|
||||
raise ValueError("price_cent 不能小于 0")
|
||||
if self.quantity is not None and self.quantity <= 0:
|
||||
raise ValueError("quantity 必须大于 0")
|
||||
if self.version <= 0:
|
||||
raise ValueError("version 必须大于 0")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskFilters:
|
||||
"""本地任务列表的可选筛选条件。"""
|
||||
|
||||
task_type: Optional[TaskType] = None
|
||||
status: Optional[TaskStatus] = None
|
||||
keyword: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskSummary:
|
||||
"""任务表格使用的轻量数据,不包含完整 JSON。"""
|
||||
|
||||
id: int
|
||||
remote_task_id: str
|
||||
task_type: TaskType
|
||||
goods_id: Optional[str]
|
||||
title: Optional[str]
|
||||
target_color: Optional[str]
|
||||
target_size: Optional[str]
|
||||
price_cent: Optional[int]
|
||||
quantity: Optional[int]
|
||||
status: TaskStatus
|
||||
updated_at: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskDetail:
|
||||
"""一条 PDD 任务的完整本地记录。"""
|
||||
|
||||
id: int
|
||||
remote_task_id: str
|
||||
task_type: TaskType
|
||||
goods_id: Optional[str]
|
||||
goods_url: str
|
||||
title: Optional[str]
|
||||
target_color: Optional[str]
|
||||
target_size: Optional[str]
|
||||
price_cent: Optional[int]
|
||||
quantity: Optional[int]
|
||||
status: TaskStatus
|
||||
current_step: Optional[str]
|
||||
priority: int
|
||||
version: int
|
||||
admin_payload: Dict[str, Any]
|
||||
pdd_data: Optional[Dict[str, Any]]
|
||||
retry_count: int
|
||||
last_error_code: Optional[str]
|
||||
last_error_message: Optional[str]
|
||||
received_at: str
|
||||
started_at: Optional[str]
|
||||
finished_at: Optional[str]
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskRunRecord:
|
||||
"""一次任务执行的数据库记录。"""
|
||||
|
||||
id: int
|
||||
task_id: int
|
||||
attempt_id: str
|
||||
attempt_no: int
|
||||
device_address: str
|
||||
run_status: RunStatus
|
||||
current_step: Optional[str]
|
||||
started_at: str
|
||||
finished_at: Optional[str]
|
||||
irreversible_action_at: Optional[str]
|
||||
order_submitted_at: Optional[str]
|
||||
error_code: Optional[str]
|
||||
error_message: Optional[str]
|
||||
diagnostics_json: Optional[Dict[str, Any]]
|
||||
artifact_directory: Optional[str]
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OutboxEventRecord:
|
||||
"""一条等待提交 Admin 的可靠事件记录。"""
|
||||
|
||||
id: int
|
||||
task_id: int
|
||||
event_type: OutboxEventType
|
||||
idempotency_key: str
|
||||
payload_json: Dict[str, Any]
|
||||
status: OutboxStatus
|
||||
attempt_count: int
|
||||
next_retry_at: Optional[str]
|
||||
last_error: Optional[str]
|
||||
created_at: str
|
||||
updated_at: str
|
||||
sent_at: Optional[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AppSettingRecord:
|
||||
"""一条非敏感应用设置。"""
|
||||
|
||||
setting_key: str
|
||||
value: Any
|
||||
updated_at: str
|
||||
@@ -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