first commit

This commit is contained in:
zmq
2026-05-26 14:16:16 +08:00
committed by Jerry Yan
commit 535d7f4ac5
40 changed files with 4571 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# 任务模块包初始化
+325
View File
@@ -0,0 +1,325 @@
import asyncio
from abc import ABC, abstractmethod
from datetime import datetime
import json
from pathlib import Path
from typing import Dict, Any
import redis.asyncio as aioredis
from playwright.async_api import Playwright, BrowserContext, Page
from loguru import logger
from src.config.settings import get_settings
from src.config.accounts import get_account_config, AccountConfig
from src.browser.manager import BrowserManager
from src.notifications.service import NotificationService
from src.utils.video_recorder import TaskVideoRecorder
class BasePurchaseTask(ABC):
"""
采购任务基类:定义工作流骨架与生命周期。
- 从配置文件解析账号信息。
- 启动并管理隔离的 Playwright 上下文。
- 支持自定义截图,失败时自动紧急截图。
- 任何情况下都强制回收浏览器资源,防止泄漏。
"""
def __init__(self, message_data: Dict[str, Any]) -> None:
self.message = message_data
self.settings = get_settings()
self.browser_manager = BrowserManager()
self.notification_service = NotificationService(self.settings)
# 从消息中提取的任务参数
self.task_id: str = message_data.get("task_id", "N/A")
self.topic: str = message_data.get("topic", "unknown")
self.account_id: str = message_data.get("purchase_account", "")
self.site_id: str = message_data.get("site_id", "unknown")
# 需在结束时清理的 Playwright 对象
self.playwright_driver: Playwright | None = None
self.context: BrowserContext | None = None
self.page: Page | None = None
self.video_recorder: TaskVideoRecorder | None = None
self.video_output_path: Path | None = None
self.last_screenshot_path: Path | None = None
self.record_video_enabled = self._resolve_record_video_enabled()
# 记录任务执行状态与错误信息
self.is_success: bool = False
self.error_message: str = ""
self.task_result_data: Any = None
# 上报的事件,有值才上报
self.report_event: str = ""
# 是否上报任务结果
self.is_report: bool = False
async def run(self) -> None:
"""
执行采购任务完整生命周期:初始化、业务逻辑、异常处理、资源清理。
"""
logger.info(f"[任务 {self.task_id}] 正在解析账号配置,账号 ID: '{self.account_id}'...")
# 1. 查找账号配置(先骏河屋,后续可扩展其他站点)
account_config = get_account_config("surugaya", self.account_id)
if not account_config:
err_msg = f"无法解析账号 '{self.account_id}' 的配置。"
logger.error(f"[任务 {self.task_id}] {err_msg}")
raise ValueError(err_msg)
logger.debug(
f"[任务 {self.task_id}] 使用账号: {account_config.username} | 数据目录: {account_config.user_data_dir}")
try:
# 开启任务录屏(如果配置)
if self.record_video_enabled:
self.video_recorder = TaskVideoRecorder(
archives_root=self.settings.screenshot_dir,
topic=self.topic,
task_id=self.task_id,
)
self.video_output_path = await self.video_recorder.start()
logger.warning(
f"[任务 {self.task_id}] 已开启任务录屏,输出文件: "
f"{self.video_output_path.name} | 路径: {self.video_output_path.absolute()}"
)
# 通过 BrowserManager 启动浏览器
self.playwright_driver, self.context = await self.browser_manager.get_context(account_config)
# 打开新标签页
self.page = await self.context.new_page()
# 交由子类实现具体站点逻辑
self.task_result_data = await self.execute()
self.is_success = True
except Exception as e:
self.error_message = str(e)
# 未预期失败时拍摄紧急截图留存现场
logger.error(f"[任务 {self.task_id}] 执行出错: {e},延迟关闭并截图")
if self.page:
try:
await asyncio.sleep(0.5)
await self.take_screenshot(scene="任务执行出错")
except Exception as ss_err:
logger.error(f"[任务 {self.task_id}] 截图失败: {ss_err}")
# 在挂起排障前,必须先结束录屏,否则录像进程会无休止录制占用硬盘
await self._stop_video_record_safely()
# 通知平台人员任务失败
await self._notify_task_failed()
# raise
finally:
# 正常结束或未被 except 拦截的其他情况(如主动 Cancel),确保停止录屏
await self._stop_video_record_safely()
# 无论成败都回收 Playwright 后台进程
logger.debug(f"[任务 {self.task_id}] 正在清理浏览器上下文并停止驱动...")
try:
if self.context:
await self.context.close()
if self.playwright_driver:
await self.playwright_driver.stop()
logger.debug(f"[任务 {self.task_id}] 浏览器资源已安全释放。")
except Exception as close_err:
logger.error(f"[任务 {self.task_id}] 释放浏览器资源时出错: {close_err}")
# 上报任务结果
if self.is_report:
await self.report_task_result()
@abstractmethod
async def execute(self) -> Any:
"""
抽象方法:各站点具体的采购业务流程。
由子类实现(例如 SurugayaPurchaseTask),可返回执行结果数据以供上报。
"""
pass
async def report_task_result(self) -> None:
"""
上报任务执行结果。
无论成功还是失败都会调用,其失败不影响消息的 ACK。
"""
url = self.settings.callback_url
if not url:
return
payload = {
"event": self.report_event,
"task_id": self.task_id,
"is_success": 1 if self.is_success else 0,
"error_message": self.error_message,
"result_data": self.task_result_data,
"original_message": self.message,
}
logger.info(f"[任务 {self.task_id}] 正在上报任务结果 (is_success={payload['is_success']}) 至 {url} ...")
logger.debug(f"[任务 {self.task_id}] 上报内容: {json.dumps(payload, ensure_ascii=False)}")
try:
await self._send_http_report_async(url, payload)
logger.success(f"[任务 {self.task_id}] 任务结果上报成功。")
except Exception as e:
logger.error(f"[任务 {self.task_id}] 任务结果上报失败: {e}")
async def _send_http_report_async(self, url: str, data: dict) -> None:
"""
使用 httpx 发送异步 HTTP 请求,并附带签名认证(AppId + 签名摘要)。
"""
import json
import httpx
from src.utils.http_utils import generate_signed_headers
# 1. 准备基础数据
app_id = self.settings.appid or ""
app_secret = self.settings.app_secret or ""
# 2. 组装 Headers
headers = generate_signed_headers(app_id, app_secret, data)
headers["Content-Type"] = "application/json; charset=utf-8"
# 3. 序列化 Body,需与生成签名时的序列化方式一致
payload_bytes = json.dumps(data, ensure_ascii=False).encode("utf-8")
# 4. 异步发送请求
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, content=payload_bytes, headers=headers)
response.raise_for_status()
# 5. 解析响应内容(根据实际业务接口文档可作调整)
response_data = response.json()
logger.info(f"[任务 {self.task_id}] 任务结果上报成功,响应内容: {response_data}")
async def _stop_video_record_safely(self) -> None:
"""
安全停止录屏的幂等方法,确保任务出错挂起或自然结束时都能正确停止 FFmpeg。
"""
if self.video_recorder and self.video_recorder.is_recording:
try:
saved_video_path = await self.video_recorder.stop()
if saved_video_path:
self.video_output_path = saved_video_path
logger.warning(
f"[任务 {self.task_id}] 任务录屏已保存: "
f"{saved_video_path.name} | 路径: {saved_video_path.absolute()}"
)
else:
logger.warning(f"[任务 {self.task_id}] 录屏已停止,但未找到输出文件。")
except Exception as video_err:
logger.error(f"[任务 {self.task_id}] 停止录屏时出错: {video_err}")
async def take_screenshot(self, scene: str, **screenshot_options: Any) -> Path | None:
"""
截取当前页面并保存至:
archives/{YYYYMMDD}/{topic}/{task_id}_{scene}_{timestamp}.png
额外的 screenshot 参数会透传给 Playwright 的 page.screenshot(),
例如:full_page=True
"""
if not self.page:
logger.warning(f"[任务 {self.task_id}] 无法截图 '{scene}':当前没有可用的 Page 对象。")
return None
# 1. 按日期组织目录
now = datetime.now()
date_str = now.strftime("%Y%m%d")
timestamp_ms = int(now.timestamp() * 1000)
# 2. 清理路径中的非法字符(仅保留字母数字、连字符、下划线)
topic_clean = "".join(c for c in self.topic if c.isalnum() or c in ("_", "-"))
scene_clean = "".join(c for c in scene if c.isalnum() or c in ("_", "-"))
task_id_clean = "".join(c for c in self.task_id if c.isalnum() or c in ("_", "-"))
# 3. 创建目标目录
screenshot_dir = Path(self.settings.screenshot_dir) / date_str / topic_clean
try:
screenshot_dir.mkdir(parents=True, exist_ok=True)
filename = f"{task_id_clean}_{scene_clean}_{timestamp_ms}.png"
filepath = screenshot_dir / filename
logger.debug(f"[任务 {self.task_id}] 保存截图至: {filepath.absolute()}")
# 加入默认超时时间,避免默认的 30s 导致任务长时间挂起
if "timeout" not in screenshot_options:
screenshot_options["timeout"] = 10000
try:
await self.page.screenshot(
path=str(filepath.absolute()),
**screenshot_options,
)
except Exception as e:
# Playwright 截图时常会因为等待 Web 字体加载 (waiting for fonts to load) 而超时挂起
if "Timeout" in str(e) or "fonts" in str(e):
logger.warning(f"[任务 {self.task_id}] 截图遇到超时或字体加载阻塞,尝试强制停止页面网络后重试...")
# 强制停止页面中所有还在 pending 的网络请求(如加载极慢的外部字体)
await self.page.evaluate("window.stop()")
await self.page.screenshot(
path=str(filepath.absolute()),
timeout=5000, # 重试给 5 秒
)
else:
raise e
logger.success(f"[任务 {self.task_id}] 截图已保存: {filename}")
self.last_screenshot_path = filepath
return filepath
except Exception as e:
logger.error(f"[任务 {self.task_id}] 保存场景 '{scene}' 的截图失败: {e}")
return None
def _resolve_record_video_enabled(self) -> bool:
top_level_value = self.message.get("record_video")
if top_level_value is not None:
return str(top_level_value).strip() == "1"
payload = self.message.get("payload")
if isinstance(payload, dict) and "record_video" in payload:
return str(payload.get("record_video")).strip() == "1"
return False
def _build_failure_notification_context(self) -> Dict[str, Any]:
return {
"title": "任务执行失败",
"task_id": self.task_id,
"topic": self.topic,
"account_id": self.account_id,
"site_id": self.site_id,
"error_message": self.error_message,
"screenshot_path": str(self.last_screenshot_path.absolute()) if self.last_screenshot_path else "",
"video_path": str(self.video_output_path.absolute()) if self.video_output_path else "",
"occurred_at": datetime.now().isoformat(timespec="seconds"),
"message": self.message,
}
async def _notify_task_failed(self) -> None:
try:
await self.notification_service.notify_task_failed(
self._build_failure_notification_context()
)
except Exception as notify_err:
logger.error(f"[任务 {self.task_id}] 发送失败通知时出错: {notify_err}")
def get_delay_zset_key(self) -> str:
return f"{self.settings.redis_delay_zset_key}:{self.settings.node_id}"
def get_hash_key(self) -> str:
"""
生成Redis Hash表的key,包含节点ID和交易单号。
"""
return "jp_surugaya_trade"
def get_delay_interval_seconds(self) -> int:
"""
获取延时队列间隔,单位秒。
"""
intervals = self.settings.redis_delay_poll_interval or 3600
return intervals
File diff suppressed because it is too large Load Diff
+106
View File
@@ -0,0 +1,106 @@
import json
import time
from typing import Any
from loguru import logger
from parsers.surugaya import SurugayaOrderDetailParser
from src.tasks.base import BasePurchaseTask
from task_queue.redis_manager import get_redis_client
class SurugayaTradeMonitorTask(BasePurchaseTask):
"""
骏河屋交易订单获取任务。
入参约定:
- topic: surugaya_trade_monitor
- payload.trade_list: 交易单号列表
"""
async def execute(self) -> dict[str, Any]:
self.report_event = "surugaya_trade_monitor"
self.is_report = False
redis_client = None
trade_code = ""
try:
logger.debug(f"[任务 {self.task_id}] 骏河屋交易订单获取工作流已启动。")
if not self.page:
raise ValueError("页面对象未初始化,无法执行交易订单获取任务。")
payload = self.message.get("payload", {})
trade_code = payload.get("trade_code", "")
if not trade_code:
err_msg = f"无法解析交易单号 '{trade_code}'"
logger.error(err_msg)
raise ValueError(err_msg)
page = self.page
await page.goto(f"https://www.suruga-ya.jp/pcmypage/action_sell_search/detail?trade_code={trade_code}",
wait_until="domcontentloaded", timeout=30000)
order_detail_model = await SurugayaOrderDetailParser.parse_page(page)
order_detail = order_detail_model.model_dump(mode="json") if order_detail_model else None
logger.info(f"[任务 {self.task_id}] 骏河屋交易订单获取工作流执行成功,订单详情:{order_detail}")
# 写入hash表
if not order_detail:
raise Exception(f"交易单号 '{trade_code}' 未解析到订单详情。")
new_trade_status = order_detail_model.status.trade_status or ""
trade_number = order_detail_model.summary.trade_number or None
redis_client = get_redis_client()
# 更新hash表
hash_key = self.get_hash_key()
delay_zset_key = self.get_delay_zset_key()
requeue_member = json.dumps({"trade_code": trade_code}, ensure_ascii=False)
# 获取上次保存的订单状态
old_trade_status = None
existing_order_detail = await redis_client.hget(hash_key, trade_code)
if existing_order_detail:
old_trade_status = json.loads(existing_order_detail)["status"]["trade_status"]
if old_trade_status and old_trade_status != new_trade_status:
logger.info(f"[任务 {self.task_id}] 订单状态发生改变,需要上报。")
self.is_report = True
await redis_client.hset(
hash_key,
trade_code,
json.dumps(order_detail, ensure_ascii=False),
)
logger.info(
f"[任务 {self.task_id}] 已将订单详情写入 Redis Hash '{hash_key}',field='{trade_code}'"
)
enqueue = True # 是否需要再次入队
if trade_number is not None:
logger.warning(
f"[任务 {self.task_id}] 订单物流单号已获取到 '{trade_number}',放弃再次入队。"
)
enqueue = False
elif new_trade_status == "発送済み" or new_trade_status == "取引終了":
logger.warning(f"[任务 {self.task_id}] 订单状态为 '{new_trade_status}',放弃再次入队。")
enqueue = False
# 任务再次入队
if enqueue:
next_score = int(time.time()) + self.get_delay_interval_seconds()
await redis_client.zadd(delay_zset_key, {requeue_member: next_score})
logger.info(
f"[任务 {self.task_id}] 延时任务已重新入队,key='{delay_zset_key}',score={next_score}"
)
return {
"trade_code": trade_code,
"order_detail": order_detail,
}
except Exception as e:
self.is_report = False
logger.error(f"[任务 {self.task_id}] 骏河屋交易订单获取工作流执行失败,错误信息:{e}")
raise e