实现下单任务网关与本地 worker
按 docs/order-gateway.md 落地:第三个部署单元 app.gateway(:31109)承担任务队列 + 状态镜像;本地 worker 在 app.trading.worker 内,按 RAKUTEN_ORDER_GATEWAY_URL 决定是否启动。规格 §5 最关键约束已守:租约过期绝不自动重投,恢复只能 reclaim, worker 收到 lease_count>1 时先核对站点订单。 站点交互(加购/下单/付款/订单列表反查)按规格 §10 留接口缝,site_interact.py 全部 NotImplementedError,verify.py 恒返回 unknown——等真实账号实测后再填, 不写猜测的提交逻辑。 310 个测试全绿,覆盖规格 §9 验收清单 12 条;架构测试守住三方互不 import。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,15 @@
|
||||
"""交易服务容器:集中管理交易侧服务实例,用于依赖注入
|
||||
|
||||
目前只有登录态会话。加购、下单、付款、订单监控与页面证据留痕的服务实例
|
||||
后续挂在这里,它们共享同一份 auth_session——同一个账号的写操作必须走同一条
|
||||
cookie 通道,且必须串行,不能各建各的客户端。
|
||||
持有两类东西:
|
||||
- 登录态会话(auth_session)——加购与下单必须用同一份 cookie,全程共享
|
||||
- 本地下单 worker(可选)——RAKUTEN_ORDER_GATEWAY_URL 留空时整套不构造
|
||||
|
||||
worker 的子组件(gateway_client / local_db / evidence_store / runner)都在
|
||||
app.trading.worker 包内,与抓取/网关互不 import;worker 需要商品数据时走抓取
|
||||
服务的 HTTP 接口,需要任务调度时走网关的 HTTP 接口。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.shared.config import Settings
|
||||
@@ -17,7 +23,15 @@ class TradingContainer:
|
||||
与抓取容器最本质的差别不是字段多少,而是**这个进程有状态**:登录态、
|
||||
订单、页面证据都属于某个具体账号,因此交易服务只能单实例运行
|
||||
(或按账号分片),不能像抓取服务那样随意横向扩容。
|
||||
|
||||
worker_* 字段在 RAKUTEN_ORDER_GATEWAY_URL 留空时为 None——此时交易服务
|
||||
只跑登录态接口,不起后台 worker。
|
||||
"""
|
||||
|
||||
settings: Settings
|
||||
auth_session: AuthSession
|
||||
# 以下四项仅在 order_gateway_url 配置时构造;否则为 None,worker 不启动
|
||||
worker_client: object | None = None # app.trading.worker.client.GatewayClient
|
||||
worker_local_db: object | None = None # app.trading.worker.local_db.LocalDB
|
||||
worker_evidence: object | None = None # app.trading.worker.evidence.EvidenceStore
|
||||
worker_runner: object | None = None # app.trading.worker.runner.WorkerRunner
|
||||
|
||||
+65
-4
@@ -10,10 +10,12 @@
|
||||
|
||||
因此本服务只能单实例运行(或按账号分片),扩容靠抓取服务那一侧。
|
||||
|
||||
当前只提供登录态的查询与重载;加购、下单、付款与订单监控在此基础上叠加。
|
||||
worker 启动条件:RAKUTEN_ORDER_GATEWAY_URL 非空。留空时本服务只跑登录态接口,
|
||||
不构造任何 worker 子组件(本地 DB / 证据目录 / 出站客户端都不创建)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@@ -32,14 +34,41 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def build_container() -> TradingContainer:
|
||||
"""构建交易服务容器,组装所有依赖"""
|
||||
"""构建交易服务容器:登录态会话始终构造,worker 仅在配置了网关 URL 时构造"""
|
||||
settings = get_settings()
|
||||
return TradingContainer(settings=settings, auth_session=AuthSession(settings))
|
||||
container = TradingContainer(settings=settings, auth_session=AuthSession(settings))
|
||||
|
||||
if settings.order_gateway_url:
|
||||
# 延迟 import:未配置网关 URL 时不加载 worker 模块(也就不会拉起 aiosqlite 等)
|
||||
from app.trading.worker.client import GatewayClient
|
||||
from app.trading.worker.evidence import EvidenceStore
|
||||
from app.trading.worker.local_db import LocalDB
|
||||
from app.trading.worker.runner import WorkerRunner
|
||||
|
||||
client = GatewayClient(
|
||||
settings.order_gateway_url,
|
||||
settings.bearer_token,
|
||||
timeout=max(60.0, settings.lease_max_wait_seconds + 10),
|
||||
)
|
||||
local_db = LocalDB(settings.trading_db_path_resolved)
|
||||
evidence = EvidenceStore(settings.evidence_path)
|
||||
runner = WorkerRunner(
|
||||
settings=settings,
|
||||
gateway_client=client,
|
||||
local_db=local_db,
|
||||
evidence=evidence,
|
||||
)
|
||||
container.worker_client = client
|
||||
container.worker_local_db = local_db
|
||||
container.worker_evidence = evidence
|
||||
container.worker_runner = runner
|
||||
|
||||
return container
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""应用生命周期管理:启动时加载登录态,关闭时释放 HTTP 客户端"""
|
||||
"""应用生命周期管理:登录态会话 + 可选 worker"""
|
||||
container = build_container()
|
||||
app.state.container = container
|
||||
|
||||
@@ -52,10 +81,42 @@ async def lifespan(app: FastAPI):
|
||||
container.settings.trading_port,
|
||||
)
|
||||
logger.info("当前环境:%s", container.settings.app_env)
|
||||
|
||||
await container.auth_session.start()
|
||||
|
||||
worker_task: asyncio.Task | None = None
|
||||
if container.worker_runner is not None:
|
||||
# 顺序:先开本地 DB(worker 写证据前要先能写库),再起后台任务
|
||||
assert container.worker_local_db is not None
|
||||
assert container.worker_client is not None
|
||||
await container.worker_local_db.start()
|
||||
worker_task = asyncio.create_task(
|
||||
container.worker_runner.run(), name="trading-worker"
|
||||
)
|
||||
logger.info(
|
||||
"下单 worker 已启动:worker_id=%s gateway=%s",
|
||||
container.settings.worker_id_effective,
|
||||
container.settings.order_gateway_url,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"未配置 RAKUTEN_ORDER_GATEWAY_URL,下单 worker 不启动(仅登录态接口)"
|
||||
)
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if worker_task is not None:
|
||||
container.worker_runner.stop() # type: ignore[union-attr]
|
||||
worker_task.cancel()
|
||||
try:
|
||||
await worker_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if container.worker_local_db is not None:
|
||||
await container.worker_local_db.close()
|
||||
if container.worker_client is not None:
|
||||
await container.worker_client.aclose()
|
||||
await container.auth_session.close()
|
||||
shutdown_telemetry()
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
"""本地下单 worker:从网关长轮询领任务,本地执行下单与付款
|
||||
|
||||
部署在本地交易服务进程内(NAT 后无公网入口),通过出站长轮询从服务器侧的
|
||||
order-gateway 取任务。**零入站端口**:所有跨机通信都由本地发起。
|
||||
|
||||
主循环见 runner.WorkerRunner.run:lease → 本地幂等闸门 → 恢复核对 → 执行 → 监控。
|
||||
站点交互(加购 / 下单 / 付款)当前未实测(docs/order-gateway.md §10),留作
|
||||
NotImplementedError 缺口,runner 捕获后上报 needs_human。
|
||||
"""
|
||||
@@ -0,0 +1,145 @@
|
||||
"""网关 HTTP 客户端:出站长轮询领任务、回报状态、续租、恢复领取
|
||||
|
||||
worker 与网关之间是出站单向通信。所有方法都包装成「成功 → data,失败 → 抛
|
||||
AppError」的形式,runner 拿到 AppError 直接打日志或转报 needs_human。
|
||||
|
||||
请求与响应严格使用 shared.api 的 ApiResponse 信封;网关侧错误码(6xxx)原样上抛,
|
||||
不在这里翻译。鉴权头从 settings.bearer_token 取,与抓取/交易服务共用同一份。
|
||||
|
||||
**不 import app.gateway**:worker 只看 HTTP 响应 JSON,本地用 LeaseTask 表达领到的
|
||||
任务,与网关侧的 Pydantic 模型解耦。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.shared.errors import AppError
|
||||
from app.shared.task_state import OrderState, TaskStatus
|
||||
from app.trading.worker.models import LeaseTask
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GatewayClient:
|
||||
"""网关 HTTP 客户端
|
||||
|
||||
一份 AsyncClient 实例贯穿 worker 整个生命周期,连接池由 httpx 管理。
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str, bearer_token: str, *, timeout: float = 60.0):
|
||||
# 末尾去斜杠,避免 base + "/api/..." 拼出双斜杠
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self._base_url,
|
||||
headers={"Authorization": f"Bearer {bearer_token}"},
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._client.aclose()
|
||||
|
||||
# ---- 基础封装 ----
|
||||
|
||||
async def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
|
||||
"""发起请求并解信封。失败(success=False)抛 AppError"""
|
||||
response = await self._client.request(method, path, **kwargs)
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError as exc:
|
||||
raise AppError(
|
||||
message=f"网关响应不是合法 JSON:HTTP {response.status_code}",
|
||||
code="GATEWAY_BAD_BODY",
|
||||
err_code=3001,
|
||||
retryable=True,
|
||||
) from exc
|
||||
|
||||
if not body.get("success"):
|
||||
raise AppError(
|
||||
message=body.get("msg", "网关返回失败"),
|
||||
code="GATEWAY_ERROR",
|
||||
err_code=int(body.get("code", 1500)),
|
||||
retryable=False,
|
||||
status_code=response.status_code,
|
||||
)
|
||||
return body
|
||||
|
||||
# ---- 接口 ----
|
||||
|
||||
async def lease(
|
||||
self, worker_id: str, *, wait: int = 30, site: str | None = None
|
||||
) -> LeaseTask | None:
|
||||
"""长轮询领取。无可领任务时返回 None"""
|
||||
params: dict[str, Any] = {"worker_id": worker_id, "wait": wait}
|
||||
if site:
|
||||
params["site"] = site
|
||||
body = await self._request("GET", "/api/orders/lease", params=params)
|
||||
data = body.get("data")
|
||||
if not data:
|
||||
return None
|
||||
return LeaseTask(
|
||||
task_id=data["task_id"],
|
||||
site=data["site"],
|
||||
intent=data.get("intent") or {},
|
||||
lease_expires_at=data.get("lease_expires_at", ""),
|
||||
lease_count=data.get("lease_count", 1),
|
||||
known_state=data.get("known_state"),
|
||||
)
|
||||
|
||||
async def renew(self, task_id: str, worker_id: str) -> dict[str, Any]:
|
||||
"""续租,返回网关响应里的 data 字段"""
|
||||
body = await self._request(
|
||||
"POST",
|
||||
f"/api/orders/{task_id}/renew",
|
||||
json={"worker_id": worker_id},
|
||||
)
|
||||
return body["data"]
|
||||
|
||||
async def report(
|
||||
self,
|
||||
task_id: str,
|
||||
worker_id: str,
|
||||
*,
|
||||
state: OrderState,
|
||||
payable_yen: int | None = None,
|
||||
pay_deadline: str | None = None,
|
||||
site_order_id: str | None = None,
|
||||
evidence_ref: str | None = None,
|
||||
detail: str = "",
|
||||
terminal: bool = False,
|
||||
terminal_status: TaskStatus | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""回报状态,返回网关响应里的 data 字段"""
|
||||
payload: dict[str, Any] = {
|
||||
"worker_id": worker_id,
|
||||
"state": state.value,
|
||||
"payable_yen": payable_yen,
|
||||
"pay_deadline": pay_deadline,
|
||||
"site_order_id": site_order_id,
|
||||
"evidence_ref": evidence_ref,
|
||||
"detail": detail,
|
||||
"terminal": terminal,
|
||||
}
|
||||
if terminal_status is not None:
|
||||
payload["terminal_status"] = terminal_status.value
|
||||
body = await self._request("POST", f"/api/orders/{task_id}/report", json=payload)
|
||||
return body["data"]
|
||||
|
||||
async def reclaim(self, task_id: str, worker_id: str) -> LeaseTask:
|
||||
"""恢复领取。返回的 LeaseTask 必然 lease_count > 1"""
|
||||
body = await self._request(
|
||||
"POST",
|
||||
f"/api/orders/{task_id}/reclaim",
|
||||
json={"worker_id": worker_id},
|
||||
)
|
||||
data = body["data"]
|
||||
return LeaseTask(
|
||||
task_id=data["task_id"],
|
||||
site=data["site"],
|
||||
intent=data.get("intent") or {},
|
||||
lease_expires_at=data.get("lease_expires_at", ""),
|
||||
lease_count=data.get("lease_count", 1),
|
||||
known_state=data.get("known_state"),
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""证据落盘:HTML 快照、截图与 meta.json
|
||||
|
||||
规格 §6.1:每个任务一个子目录,按步骤序号命名文件。report 里的 evidence_ref
|
||||
填 `{task_id}/{序号}-{步骤名}`,不含扩展名。
|
||||
|
||||
证据**只留本地**(约束 4),网关只存 evidence_ref 这个相对路径字符串。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EvidenceStore:
|
||||
"""把每一步的页面快照、截图与 meta 落到 {evidence_dir}/{task_id}/ 下"""
|
||||
|
||||
def __init__(self, evidence_dir: Path):
|
||||
self._root = evidence_dir
|
||||
|
||||
def step_dir(self, task_id: str) -> Path:
|
||||
path = self._root / task_id
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
def write_step(
|
||||
self,
|
||||
task_id: str,
|
||||
step_no: int,
|
||||
step_name: str,
|
||||
*,
|
||||
html: str | None = None,
|
||||
png: bytes | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""写入一步的证据文件
|
||||
|
||||
返回相对 evidence_dir 的引用路径(不含扩展名),用于 report 的 evidence_ref
|
||||
字段。文件命名:`{序号:02d}-{步骤名}.{html|png|meta.json}`。
|
||||
"""
|
||||
step_dir = self.step_dir(task_id)
|
||||
stem = f"{step_no:02d}-{step_name}"
|
||||
rel_stem = f"{task_id}/{stem}"
|
||||
|
||||
if html is not None:
|
||||
(step_dir / f"{stem}.html").write_text(html, encoding="utf-8")
|
||||
if png is not None:
|
||||
(step_dir / f"{stem}.png").write_bytes(png)
|
||||
if meta is not None:
|
||||
(step_dir / f"{stem}.meta.json").write_text(
|
||||
json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
return rel_stem
|
||||
@@ -0,0 +1,181 @@
|
||||
"""本地订单 SQLite:执行事实的权威记录
|
||||
|
||||
与网关的 tasks/task_reports 表对偶,但本地这张表才是「这单到底下没下、付没付」
|
||||
的权威。网关失联或数据丢失时,本地这张表用于补报与对账。
|
||||
|
||||
三张表:
|
||||
- orders:每笔任务一行,记录开始/结束时间与最终状态
|
||||
- order_events:append-only 的状态迁移事件(与网关 task_reports 对账)
|
||||
- evidence_index:每步证据文件的相对路径索引,便于事后翻查
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import aiosqlite
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
site TEXT NOT NULL,
|
||||
intent_json TEXT NOT NULL,
|
||||
final_state TEXT,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS order_events (
|
||||
task_id TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
detail TEXT,
|
||||
evidence_ref TEXT,
|
||||
recorded_at TEXT NOT NULL,
|
||||
PRIMARY KEY (task_id, state)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS evidence_index (
|
||||
task_id TEXT NOT NULL,
|
||||
step_no INTEGER NOT NULL,
|
||||
step_name TEXT NOT NULL,
|
||||
rel_path TEXT NOT NULL,
|
||||
saved_at TEXT NOT NULL,
|
||||
PRIMARY KEY (task_id, step_no)
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def _utcnow_iso() -> str:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class OrderRow:
|
||||
task_id: str
|
||||
site: str
|
||||
intent_json: str
|
||||
final_state: str | None
|
||||
started_at: str
|
||||
finished_at: str | None
|
||||
|
||||
@property
|
||||
def intent(self) -> dict[str, Any]:
|
||||
return json.loads(self.intent_json)
|
||||
|
||||
|
||||
class LocalDB:
|
||||
"""本地订单 SQLite 访问对象"""
|
||||
|
||||
def __init__(self, db_path: Path):
|
||||
self._db_path = db_path
|
||||
self._conn: aiosqlite.Connection | None = None
|
||||
|
||||
async def start(self) -> None:
|
||||
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._conn = await aiosqlite.connect(str(self._db_path))
|
||||
self._conn.row_factory = aiosqlite.Row
|
||||
await self._conn.executescript(SCHEMA)
|
||||
await self._conn.commit()
|
||||
logger.info("本地订单 DB 已就绪:%s", self._db_path)
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._conn is not None:
|
||||
await self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
@property
|
||||
def conn(self) -> aiosqlite.Connection:
|
||||
if self._conn is None:
|
||||
raise RuntimeError("LocalDB 未启动:先调用 start()")
|
||||
return self._conn
|
||||
|
||||
# ---- orders ----
|
||||
|
||||
async def ensure_started(self, task_id: str, site: str, intent: dict[str, Any]) -> None:
|
||||
"""记录任务开始执行。已存在则忽略(同任务被重新领回时)"""
|
||||
await self.conn.execute(
|
||||
"INSERT OR IGNORE INTO orders (task_id, site, intent_json, started_at) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(task_id, site, json.dumps(intent, ensure_ascii=False), _utcnow_iso()),
|
||||
)
|
||||
await self.conn.commit()
|
||||
|
||||
async def get_order(self, task_id: str) -> OrderRow | None:
|
||||
async with self.conn.execute(
|
||||
"SELECT * FROM orders WHERE task_id = ?",
|
||||
(task_id,),
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return OrderRow(
|
||||
task_id=row["task_id"],
|
||||
site=row["site"],
|
||||
intent_json=row["intent_json"],
|
||||
final_state=row["final_state"],
|
||||
started_at=row["started_at"],
|
||||
finished_at=row["finished_at"],
|
||||
)
|
||||
|
||||
async def has_finished(self, task_id: str) -> bool:
|
||||
"""是否已有终态记录。用于主循环里的本地幂等闸门"""
|
||||
async with self.conn.execute(
|
||||
"SELECT 1 FROM orders WHERE task_id = ? AND finished_at IS NOT NULL",
|
||||
(task_id,),
|
||||
) as cur:
|
||||
return await cur.fetchone() is not None
|
||||
|
||||
async def mark_finished(self, task_id: str, final_state: str) -> None:
|
||||
await self.conn.execute(
|
||||
"UPDATE orders SET final_state = ?, finished_at = ? WHERE task_id = ?",
|
||||
(final_state, _utcnow_iso(), task_id),
|
||||
)
|
||||
await self.conn.commit()
|
||||
|
||||
async def final_state(self, task_id: str) -> str | None:
|
||||
async with self.conn.execute(
|
||||
"SELECT final_state FROM orders WHERE task_id = ?",
|
||||
(task_id,),
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
return row["final_state"] if row else None
|
||||
|
||||
# ---- events ----
|
||||
|
||||
async def record_event(
|
||||
self,
|
||||
task_id: str,
|
||||
state: str,
|
||||
*,
|
||||
detail: str = "",
|
||||
evidence_ref: str | None = None,
|
||||
) -> None:
|
||||
"""记录一次状态迁移事件(与网关 task_reports 对账)
|
||||
|
||||
同一 (task_id, state) 重复记录视为同一次——INSERT OR IGNORE,与网关侧的
|
||||
INSERT OR REPLACE 不同:本地这张表是事件日志,不覆盖既有记录。
|
||||
"""
|
||||
await self.conn.execute(
|
||||
"INSERT OR IGNORE INTO order_events "
|
||||
"(task_id, state, detail, evidence_ref, recorded_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(task_id, state, detail, evidence_ref, _utcnow_iso()),
|
||||
)
|
||||
await self.conn.commit()
|
||||
|
||||
# ---- evidence index ----
|
||||
|
||||
async def index_evidence(
|
||||
self, task_id: str, step_no: int, step_name: str, rel_path: str
|
||||
) -> None:
|
||||
await self.conn.execute(
|
||||
"INSERT OR REPLACE INTO evidence_index "
|
||||
"(task_id, step_no, step_name, rel_path, saved_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(task_id, step_no, step_name, rel_path, _utcnow_iso()),
|
||||
)
|
||||
await self.conn.commit()
|
||||
@@ -0,0 +1,28 @@
|
||||
"""worker 侧数据模型:不引用网关的 Pydantic 模型
|
||||
|
||||
worker 通过 HTTP 与网关交互,看到的只有 JSON。这里定义 worker 自己用来表达
|
||||
「领到的任务」「核对结论」的数据类——结构上对应网关响应,但**不 import**
|
||||
app.gateway 的任何东西,保证架构边界(worker → shared,禁止 worker → gateway)。
|
||||
|
||||
OrderState / TaskStatus 枚举在 app.shared.task_state,两侧共用同一份取值表。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LeaseTask:
|
||||
"""worker 从网关领到的任务
|
||||
|
||||
对应网关 GET /api/orders/lease 的 LeaseData,但用 dataclass 表达——worker
|
||||
内部不依赖网关侧的 Pydantic 模型。
|
||||
"""
|
||||
|
||||
task_id: str
|
||||
site: str
|
||||
intent: dict[str, Any] = field(default_factory=dict)
|
||||
lease_expires_at: str = ""
|
||||
lease_count: int = 1
|
||||
known_state: str | None = None
|
||||
@@ -0,0 +1,420 @@
|
||||
"""worker 主循环与每任务执行流
|
||||
|
||||
主循环(规格 §6):
|
||||
|
||||
while running:
|
||||
task = await gateway.lease(worker_id, wait=30)
|
||||
if task is None:
|
||||
continue
|
||||
|
||||
if local_db.has_finished(task.task_id): # 本地幂等闸门
|
||||
await gateway.report(...); continue
|
||||
|
||||
if task.lease_count > 1: # 恢复领取
|
||||
verdict = await verify_on_site(task)
|
||||
if verdict.already_ordered: await report(...); continue
|
||||
if verdict.unknown: await report(needs_human); continue
|
||||
|
||||
async with renew_lease_every(60):
|
||||
await execute(task)
|
||||
|
||||
每个动作的顺序固定为:动作 → 落证据 → 写本地 SQLite → 回报 gateway。
|
||||
顺序不能颠倒——先落证据再回报,保证服务器上看到的状态一定有本地证据可查。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.shared.errors import AppError, OrderGuardError
|
||||
from app.shared.task_state import OrderState, TaskStatus
|
||||
from app.trading.worker import site_interact, verify
|
||||
from app.trading.worker.client import GatewayClient
|
||||
from app.trading.worker.evidence import EvidenceStore
|
||||
from app.trading.worker.local_db import LocalDB
|
||||
from app.trading.worker.models import LeaseTask
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.shared.config import Settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _coerce_state(value: str | None) -> OrderState:
|
||||
"""把网关返回的 state 字符串安全地包成 OrderState;None 或非法值回落到 CREATED"""
|
||||
if not value:
|
||||
return OrderState.CREATED
|
||||
try:
|
||||
return OrderState(value)
|
||||
except ValueError:
|
||||
logger.warning("未知的 order state 字符串,回落到 CREATED:%s", value)
|
||||
return OrderState.CREATED
|
||||
|
||||
|
||||
class WorkerRunner:
|
||||
"""worker 主循环与执行流
|
||||
|
||||
持有 gateway 客户端、本地 DB、证据存储。`run()` 是常驻 asyncio 任务,
|
||||
`handle()` 处理单个任务,`execute()` 是站点交互的实际调度(当前多数步骤未实现)。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
settings: "Settings",
|
||||
gateway_client: GatewayClient,
|
||||
local_db: LocalDB,
|
||||
evidence: EvidenceStore,
|
||||
):
|
||||
self._settings = settings
|
||||
self._gateway = gateway_client
|
||||
self._db = local_db
|
||||
self._evidence = evidence
|
||||
self._running = False
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
|
||||
@property
|
||||
def worker_id(self) -> str:
|
||||
return self._settings.worker_id_effective
|
||||
|
||||
# ---- 主循环 ----
|
||||
|
||||
async def run(self) -> None:
|
||||
"""常驻循环:长轮询领任务,按 §6 调度"""
|
||||
self._running = True
|
||||
logger.info("worker 启动:worker_id=%s", self.worker_id)
|
||||
while self._running:
|
||||
try:
|
||||
task = await self._gateway.lease(self.worker_id, wait=30)
|
||||
except AppError as exc:
|
||||
logger.warning("lease 失败:%s (err=%s)", exc.message, exc.err_code)
|
||||
await asyncio.sleep(5)
|
||||
continue
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("lease 异常")
|
||||
await asyncio.sleep(5)
|
||||
continue
|
||||
|
||||
if task is None:
|
||||
continue
|
||||
|
||||
try:
|
||||
await self.handle(task)
|
||||
except Exception: # noqa: BLE001
|
||||
# 任何未预期的异常都不应让 worker 退出
|
||||
logger.exception("handle 任务异常:task_id=%s", task.task_id)
|
||||
|
||||
# ---- 单任务调度 ----
|
||||
|
||||
async def handle(self, task: LeaseTask) -> None:
|
||||
"""单任务调度入口:本地幂等闸门 → 恢复核对 → 执行"""
|
||||
# 本地幂等闸门:之前已完成过的任务不再执行
|
||||
if await self._db.has_finished(task.task_id):
|
||||
final_state = await self._db.final_state(task.task_id)
|
||||
logger.info(
|
||||
"本地已完成,补报终态:task_id=%s state=%s", task.task_id, final_state
|
||||
)
|
||||
await self._report_safe(
|
||||
task,
|
||||
state=_coerce_state(final_state),
|
||||
detail="本地已完成,补报避免重复执行",
|
||||
terminal=True,
|
||||
)
|
||||
return
|
||||
|
||||
# 恢复领取:lease_count > 1 表示 stale → reclaim,必须先核对站点订单
|
||||
if task.lease_count > 1:
|
||||
await self._handle_recovery(task)
|
||||
return
|
||||
|
||||
# 常规执行
|
||||
await self._execute_with_renewal(task)
|
||||
|
||||
async def _handle_recovery(self, task: LeaseTask) -> None:
|
||||
"""lease_count > 1 的恢复路径:先核对,不直接执行"""
|
||||
logger.warning(
|
||||
"恢复领取(lease_count=%s),先核对站点订单:task_id=%s",
|
||||
task.lease_count,
|
||||
task.task_id,
|
||||
)
|
||||
verdict = await verify.verify_on_site(task)
|
||||
if verdict.verdict == verify.VerifyVerdict.ALREADY_ORDERED:
|
||||
await self._report_safe(
|
||||
task,
|
||||
state=OrderState.ORDERED,
|
||||
site_order_id=verdict.site_order_id,
|
||||
detail=f"核对结论:已下单({verdict.detail})",
|
||||
terminal=True,
|
||||
terminal_status=TaskStatus.SUCCEEDED,
|
||||
)
|
||||
await self._db.ensure_started(task.task_id, task.site, task.intent)
|
||||
await self._db.mark_finished(task.task_id, OrderState.ORDERED.value)
|
||||
return
|
||||
|
||||
# NOT_ORDERED 也走 needs_human:当前 verify 桩不会返回这个值,但留接口给
|
||||
# 未来真正能可靠核对时——那时再决定 NOT_ORDERED 是否直接重新执行
|
||||
await self._report_safe(
|
||||
task,
|
||||
state=_coerce_state(task.known_state),
|
||||
detail=f"恢复核对无法定论:{verdict.detail}",
|
||||
terminal=True,
|
||||
terminal_status=TaskStatus.NEEDS_HUMAN,
|
||||
)
|
||||
|
||||
# ---- 常规执行 ----
|
||||
|
||||
async def _execute_with_renewal(self, task: LeaseTask) -> None:
|
||||
"""在租约自动续期的上下文里执行任务"""
|
||||
async with self._renew_lease_every(task, interval=60):
|
||||
try:
|
||||
await self.execute(task)
|
||||
except NotImplementedError as exc:
|
||||
# 站点交互未实现(规格 §10):上报 needs_human,不视为 worker 失败
|
||||
logger.warning(
|
||||
"站点交互未实现,转 needs_human:task_id=%s what=%s",
|
||||
task.task_id,
|
||||
exc,
|
||||
)
|
||||
await self._report_safe(
|
||||
task,
|
||||
state=_coerce_state(task.known_state),
|
||||
detail=f"站点交互未实现:{exc}",
|
||||
terminal=True,
|
||||
terminal_status=TaskStatus.NEEDS_HUMAN,
|
||||
)
|
||||
except OrderGuardError as exc:
|
||||
# 金额守卫等闸门拦截:上报 needs_human,站点侧无提交动作
|
||||
logger.warning(
|
||||
"下单闸门拦截,转 needs_human:task_id=%s msg=%s",
|
||||
task.task_id,
|
||||
exc.message,
|
||||
)
|
||||
await self._report_safe(
|
||||
task,
|
||||
state=_coerce_state(task.known_state),
|
||||
detail=exc.message,
|
||||
terminal=True,
|
||||
terminal_status=TaskStatus.NEEDS_HUMAN,
|
||||
)
|
||||
except AppError as exc:
|
||||
logger.warning(
|
||||
"执行失败:task_id=%s code=%s msg=%s",
|
||||
task.task_id,
|
||||
exc.err_code,
|
||||
exc.message,
|
||||
)
|
||||
await self._report_safe(
|
||||
task,
|
||||
state=_coerce_state(task.known_state),
|
||||
detail=exc.message,
|
||||
terminal=True,
|
||||
terminal_status=TaskStatus.FAILED,
|
||||
)
|
||||
|
||||
async def execute(self, task: LeaseTask) -> None:
|
||||
"""站点交互的实际调度:加购 → 校验 → 确认页 → 金额守卫 → 提交 → 付款
|
||||
|
||||
每一步的顺序:动作 → 落证据 → 写本地 SQLite → 回报 gateway。
|
||||
站点交互当前未实现(site_interact 抛 NotImplementedError),第一步就会
|
||||
转到 _execute_with_renewal 的 except 分支上报 needs_human。
|
||||
"""
|
||||
await self._db.ensure_started(task.task_id, task.site, task.intent)
|
||||
|
||||
# ラクマ 加购契约未实现,首版只对乐天。其他站点直接转人工
|
||||
if task.site != "rakuten":
|
||||
raise NotImplementedError(f"site={task.site} 暂未实现下单(首版仅 rakuten)")
|
||||
|
||||
# 步骤 1:加购
|
||||
await self._run_step(
|
||||
task, step_no=1, step_name="cart-add",
|
||||
action=lambda: site_interact.add_to_cart(task),
|
||||
state=OrderState.IN_CART,
|
||||
detail="已加入购物车",
|
||||
)
|
||||
|
||||
# 步骤 2:校验购物车
|
||||
await self._run_step(
|
||||
task, step_no=2, step_name="cart-check",
|
||||
action=lambda: site_interact.verify_cart(task),
|
||||
state=OrderState.IN_CART,
|
||||
detail="购物车已校验",
|
||||
)
|
||||
|
||||
# 步骤 3:进入下单确认页 + 金额守卫
|
||||
checkout_html = await site_interact.enter_checkout(task)
|
||||
summary = await site_interact.parse_checkout(checkout_html)
|
||||
self._enforce_amount_guard(task, summary.payable_yen)
|
||||
await self._run_step(
|
||||
task, step_no=3, step_name="order-confirm",
|
||||
action=self._noop(),
|
||||
state=OrderState.CREATED,
|
||||
detail=f"下单确认页已解析:应付 {summary.payable_yen} 円",
|
||||
evidence_meta={
|
||||
"payable_yen": summary.payable_yen,
|
||||
"site_order_id": summary.site_order_id,
|
||||
"pay_deadline": summary.pay_deadline,
|
||||
},
|
||||
)
|
||||
|
||||
# 步骤 4:提交下单
|
||||
site_order_id = await site_interact.submit_order(task)
|
||||
await self._run_step(
|
||||
task, step_no=4, step_name="order-submit",
|
||||
action=self._noop(),
|
||||
state=OrderState.ORDERED,
|
||||
site_order_id=site_order_id,
|
||||
detail=f"已提交下单,站点订单号 {site_order_id}",
|
||||
)
|
||||
|
||||
# 步骤 5:付款
|
||||
await self._run_step(
|
||||
task, step_no=5, step_name="payment",
|
||||
action=lambda: site_interact.pay(task, site_order_id),
|
||||
state=OrderState.AWAITING_PAYMENT,
|
||||
site_order_id=site_order_id,
|
||||
payable_yen=summary.payable_yen,
|
||||
pay_deadline=summary.pay_deadline,
|
||||
detail="已进入付款流程",
|
||||
)
|
||||
await self._report_safe(
|
||||
task,
|
||||
state=OrderState.PAID,
|
||||
site_order_id=site_order_id,
|
||||
payable_yen=summary.payable_yen,
|
||||
pay_deadline=summary.pay_deadline,
|
||||
detail="付款完成",
|
||||
terminal=True,
|
||||
terminal_status=TaskStatus.SUCCEEDED,
|
||||
)
|
||||
await self._db.mark_finished(task.task_id, OrderState.PAID.value)
|
||||
|
||||
# 步骤 6:付款后监控(非阻塞,常驻轮询;当前未实现)
|
||||
try:
|
||||
await site_interact.monitor(task, site_order_id)
|
||||
except NotImplementedError:
|
||||
logger.info("付款后监控未实现,跳过:task_id=%s", task.task_id)
|
||||
|
||||
def _enforce_amount_guard(self, task: LeaseTask, payable_yen: int) -> None:
|
||||
"""金额守卫:实际应付超过 intent.max_total_yen 或 RAKUTEN_ORDER_MAX_TOTAL_YEN 时拦截"""
|
||||
intent = task.intent or {}
|
||||
limit = intent.get("max_total_yen")
|
||||
if limit is None or limit <= 0:
|
||||
limit = self._settings.order_max_total_yen
|
||||
if limit > 0 and payable_yen > limit:
|
||||
raise OrderGuardError(
|
||||
f"实际应付 {payable_yen} 円超过上限 {limit} 円"
|
||||
f"(task_id={task.task_id})"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _noop() -> "callable":
|
||||
async def _f() -> None:
|
||||
return None
|
||||
|
||||
return _f
|
||||
|
||||
async def _run_step(
|
||||
self,
|
||||
task: LeaseTask,
|
||||
*,
|
||||
step_no: int,
|
||||
step_name: str,
|
||||
action: "callable",
|
||||
state: OrderState,
|
||||
detail: str,
|
||||
site_order_id: str | None = None,
|
||||
payable_yen: int | None = None,
|
||||
pay_deadline: str | None = None,
|
||||
evidence_meta: dict | None = None,
|
||||
) -> None:
|
||||
"""单步执行:动作 → 落证据 → 写本地 → 回报 gateway"""
|
||||
await action()
|
||||
meta = {
|
||||
"step": step_name,
|
||||
"state": state.value,
|
||||
**(evidence_meta or {}),
|
||||
}
|
||||
evidence_ref = self._evidence.write_step(
|
||||
task.task_id, step_no, step_name, meta=meta
|
||||
)
|
||||
await self._db.index_evidence(task.task_id, step_no, step_name, evidence_ref)
|
||||
await self._db.record_event(
|
||||
task.task_id, state.value, detail=detail, evidence_ref=evidence_ref
|
||||
)
|
||||
await self._gateway.report(
|
||||
task.task_id,
|
||||
self.worker_id,
|
||||
state=state,
|
||||
payable_yen=payable_yen,
|
||||
pay_deadline=pay_deadline,
|
||||
site_order_id=site_order_id,
|
||||
evidence_ref=evidence_ref,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
async def _report_safe(
|
||||
self,
|
||||
task: LeaseTask,
|
||||
*,
|
||||
state: OrderState,
|
||||
detail: str,
|
||||
terminal: bool = False,
|
||||
terminal_status: TaskStatus | None = None,
|
||||
site_order_id: str | None = None,
|
||||
payable_yen: int | None = None,
|
||||
pay_deadline: str | None = None,
|
||||
) -> None:
|
||||
"""回报 gateway,失败只记日志不抛——主循环不能因为回报失败退出"""
|
||||
try:
|
||||
await self._gateway.report(
|
||||
task.task_id,
|
||||
self.worker_id,
|
||||
state=state,
|
||||
detail=detail,
|
||||
terminal=terminal,
|
||||
terminal_status=terminal_status,
|
||||
site_order_id=site_order_id,
|
||||
payable_yen=payable_yen,
|
||||
pay_deadline=pay_deadline,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("回报 gateway 失败:task_id=%s state=%s", task.task_id, state)
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def _renew_lease_every(self, task: LeaseTask, *, interval: int):
|
||||
"""后台任务:每隔 interval 秒续租一次。退出时停掉"""
|
||||
stop_event = asyncio.Event()
|
||||
|
||||
async def _loop() -> None:
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
await asyncio.wait_for(stop_event.wait(), timeout=interval)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
if stop_event.is_set():
|
||||
return
|
||||
try:
|
||||
await self._gateway.renew(task.task_id, self.worker_id)
|
||||
except AppError as exc:
|
||||
logger.warning(
|
||||
"续租失败:task_id=%s code=%s msg=%s",
|
||||
task.task_id,
|
||||
exc.err_code,
|
||||
exc.message,
|
||||
)
|
||||
return
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("续租异常:task_id=%s", task.task_id)
|
||||
return
|
||||
|
||||
renewal = asyncio.create_task(_loop(), name=f"renew-{task.task_id}")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
stop_event.set()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await renewal
|
||||
@@ -0,0 +1,73 @@
|
||||
"""站点交互:加购 / 校验购物车 / 进入下单确认页 / 金额守卫 / 提交 / 付款 / 监控
|
||||
|
||||
**全部未实现**。规格 docs/order-gateway.md §10 明确说明:加购 → 下单 → 付款的
|
||||
实际站点交互没有实测过,未实测前调用直接抛「未实现」,不要写猜测的提交逻辑。
|
||||
|
||||
签名刻意保留:runner 把这一层当接口缝用,等真实账号 + 真实站点把每个动作
|
||||
实现填进来后,主循环逻辑不需要改动。
|
||||
|
||||
实现方需要补齐的实测点(规格 §10):
|
||||
1. 自动付款是否触发 3D Secure / 短信验证。触发则这条路走不通,付款环节改为
|
||||
「下单到 awaiting_payment + 上报 needs_human 交人工」。
|
||||
2. 下单确认页的实际应付金额、付款方式、付款期限、站点订单号各自在哪个字段。
|
||||
3. 订单列表页能否按商品 + 时间窗口可靠地反查「这单下没下」。
|
||||
4. ラクマ 侧尚无加购契约(README 已注明未实现)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.trading.worker.models import LeaseTask
|
||||
|
||||
|
||||
_NOT_IMPLEMENTED_MSG = (
|
||||
"站点交互未实现:见 docs/order-gateway.md §10。"
|
||||
"未实测前不写猜测的提交逻辑——需要真实账号 + 真实站点把这一步实现填进来。"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CheckoutSummary:
|
||||
"""下单确认页解析结果(待实测确认字段位置)
|
||||
|
||||
payable_yen 用于金额守卫;其他字段在实测确认后补全。
|
||||
"""
|
||||
|
||||
payable_yen: int
|
||||
site_order_id: str | None = None
|
||||
pay_deadline: str | None = None
|
||||
|
||||
|
||||
async def add_to_cart(task: LeaseTask) -> None:
|
||||
"""加购。需要 auth_session 的登录 cookie + intent 里的 purchase 标识"""
|
||||
raise NotImplementedError(_NOT_IMPLEMENTED_MSG)
|
||||
|
||||
|
||||
async def verify_cart(task: LeaseTask) -> None:
|
||||
"""校验购物车:加购后购物车里有没有这件商品"""
|
||||
raise NotImplementedError(_NOT_IMPLEMENTED_MSG)
|
||||
|
||||
|
||||
async def enter_checkout(task: LeaseTask) -> str:
|
||||
"""进入下单确认页,返回页面 HTML(供解析金额守卫所需字段)"""
|
||||
raise NotImplementedError(_NOT_IMPLEMENTED_MSG)
|
||||
|
||||
|
||||
async def parse_checkout(html: str) -> CheckoutSummary:
|
||||
"""从下单确认页解析实际应付金额、付款期限、站点订单号"""
|
||||
raise NotImplementedError(_NOT_IMPLEMENTED_MSG)
|
||||
|
||||
|
||||
async def submit_order(task: LeaseTask) -> str:
|
||||
"""提交下单。返回站点订单号(site_order_id)"""
|
||||
raise NotImplementedError(_NOT_IMPLEMENTED_MSG)
|
||||
|
||||
|
||||
async def pay(task: LeaseTask, site_order_id: str) -> None:
|
||||
"""付款。检测到 3DS / 短信验证等人工环节时抛 OrderGuardError"""
|
||||
raise NotImplementedError(_NOT_IMPLEMENTED_MSG)
|
||||
|
||||
|
||||
async def monitor(task: LeaseTask, site_order_id: str) -> None:
|
||||
"""付款后监控:常驻轮询订单状态与付款期限,状态变化时继续 report"""
|
||||
raise NotImplementedError(_NOT_IMPLEMENTED_MSG)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""恢复核对:lease_count > 1 时,worker 必须先查站点订单列表确认是否已下单
|
||||
|
||||
规格 §5:租约过期 → stale → reclaim 后 worker 领回的任务 lease_count 必然 > 1。
|
||||
此时 worker 不能盲目重新提交(可能上次已经下单成功,只是回报断网),必须先查
|
||||
站点订单列表比对。
|
||||
|
||||
订单列表反查的实现需要实测(规格 §10 第 3 条:订单列表页能否按商品 + 时间窗口
|
||||
可靠地反查出「这单下没下」)。当前为桩:恒返回 `unknown`,让 runner 走
|
||||
needs_human 分支。**绝不**默认按「没下单」处理——那是猜,可能变成重复下单。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
from app.trading.worker.models import LeaseTask
|
||||
|
||||
|
||||
class VerifyVerdict(StrEnum):
|
||||
"""核对结论"""
|
||||
|
||||
ALREADY_ORDERED = "already_ordered" # 确认已下单,补报状态即可
|
||||
NOT_ORDERED = "not_ordered" # 确认未下单,可重新执行
|
||||
UNKNOWN = "unknown" # 核对不出结论,交人工
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class VerifyResult:
|
||||
verdict: VerifyVerdict
|
||||
site_order_id: str | None = None
|
||||
detail: str = ""
|
||||
|
||||
|
||||
async def verify_on_site(task: LeaseTask) -> VerifyResult:
|
||||
"""核对一笔任务是否已在站点上下过单
|
||||
|
||||
桩实现:永远返回 UNKNOWN。**绝不返回 NOT_ORDERED**——除非真实订单列表反查
|
||||
能可靠证明这一点,否则视为「无法判断」交人工,宁可卡住等人看一眼。
|
||||
|
||||
实现方需要补的实测:用 intent 里的商品 + 时间窗口(created_at 到 stale 之间)
|
||||
比对订单列表,能拿到 site_order_id 最好。
|
||||
"""
|
||||
return VerifyResult(
|
||||
verdict=VerifyVerdict.UNKNOWN,
|
||||
detail="订单列表反查未实现:见 docs/order-gateway.md §10 第 3 条",
|
||||
)
|
||||
Reference in New Issue
Block a user