You've already forked pruchase_jhw
first commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# 任务模块包初始化
|
||||
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user