972 lines
36 KiB
Python
972 lines
36 KiB
Python
"""
|
||||
|
|
顺运宝 ERP 单文件示例
|
|||
|
|
|
|||
|
|
功能:
|
|||
|
|
1. 获取 ERP 验证码图片。
|
|||
|
|
2. 调用 http://127.0.0.1:8000/ocr 识别验证码。
|
|||
|
|
3. 登录 ERP,并在同一个 requests.Session 中保留 Cookie。
|
|||
|
|
4. 获取当前用户个人信息,检测 Cookie 会话是否仍处于登录状态。
|
|||
|
|
5. 把 Cookie、登录 JWT 和最小用户标识缓存到 Redis。
|
|||
|
|
6. 下次启动时恢复 Redis 会话并通过个人信息接口验证。
|
|||
|
|
7. 使用货运管理页面的“全部单号”条件搜索订单。
|
|||
|
|
8. 使用搜索结果的 id 获取货运详情和商品明细。
|
|||
|
|
|
|||
|
|
安装依赖:
|
|||
|
|
python -m pip install requests redis
|
|||
|
|
|
|||
|
|
运行:
|
|||
|
|
python shunyunbaoerp_single.py
|
|||
|
|
|
|||
|
|
也可以提前设置账号密码,避免每次输入:
|
|||
|
|
$env:SHUNYUNBAO_USERNAME = "your_username"
|
|||
|
|
$env:SHUNYUNBAO_PASSWORD = "your_password"
|
|||
|
|
$env:SHUNYUNBAO_ORDER_NUMBER = "your_order_number"
|
|||
|
|
|
|||
|
|
验证码和查询结果默认写入被 Git 忽略的 .local/shunyunbao/。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import base64
|
|||
|
|
import getpass
|
|||
|
|
import hashlib
|
|||
|
|
import json
|
|||
|
|
import mimetypes
|
|||
|
|
import os
|
|||
|
|
import time
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import Any
|
|||
|
|
from urllib.parse import urlparse
|
|||
|
|
|
|||
|
|
import requests
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
import redis
|
|||
|
|
except ImportError:
|
|||
|
|
redis = None
|
|||
|
|
|
|||
|
|
|
|||
|
|
class ShunYunBaoERP:
|
|||
|
|
"""一个实例代表一个顺运宝 ERP 登录会话。"""
|
|||
|
|
|
|||
|
|
# HAR 中货运列表请求携带的 72 个列定义。
|
|||
|
|
# 每一项依次是:表名、数据库列名、响应字段名、是否别名、表别名。
|
|||
|
|
COLUMN_SPECS = [
|
|||
|
|
("t_stock", "created", "created", 0, "t"),
|
|||
|
|
("t_stock", "order_code", "orderCode", 0, "t"),
|
|||
|
|
("t_stock", "printer", "printer", 0, "t"),
|
|||
|
|
("t_stock", "weight_time", "weightTime", 0, "t"),
|
|||
|
|
("t_stock", "weight_inputer", "weightInputer", 0, "t"),
|
|||
|
|
("t_stock", "pkg_time", "pkgTime", 0, "t"),
|
|||
|
|
("t_stock", "code", "code", 0, "t"),
|
|||
|
|
("t_stock", "status", "status", 0, "t"),
|
|||
|
|
("t_stock", "order_status", "orderStatus", 0, "t"),
|
|||
|
|
("t_stock", "purchase_status", "purchaseStatus", 0, "t"),
|
|||
|
|
("t_stock", "exp_code", "expCode", 0, "t"),
|
|||
|
|
("t_stock", "exp_page_code", "expPageCode", 0, "t"),
|
|||
|
|
("t_stock", "exp_allow_print", "expAllowPrint", 0, "t"),
|
|||
|
|
("t_stock", "exp_page_status", "expPageStatus", 0, "t"),
|
|||
|
|
("t_stock", "page_id", "pageId", 0, "t"),
|
|||
|
|
("t_stock", "upload_time", "uploadTime", 0, "t"),
|
|||
|
|
("t_stock", "pay_time", "payTime", 0, "t"),
|
|||
|
|
("t_stock", "shop_day_to_ship", "shopDayToShip", 0, "t"),
|
|||
|
|
("t_stock", "remark9", "tsremark9", 1, "t"),
|
|||
|
|
("t_stock", "remark9", "remark9", 0, "t"),
|
|||
|
|
("t_stock", "detail_qty", "detailQty", 0, "t"),
|
|||
|
|
("t_stock", "order_qty", "orderQty", 0, "t"),
|
|||
|
|
("t_stock_detail", "inner_exp_code", "innerExpCode", 0, "t7"),
|
|||
|
|
("t_stock_detail", "shelf_code", "shelfCode", 0, "t7"),
|
|||
|
|
("t_stock", "shelf_code", "tsshelfCode", 1, "t"),
|
|||
|
|
("t_stock", "store_type", "storeType", 0, "t"),
|
|||
|
|
("t_stock", "order_bag_code", "orderBagCode", 0, "t"),
|
|||
|
|
("t_stock", "weight_cust_pkg", "weightCustPkg", 0, "t"),
|
|||
|
|
("t_stock", "weight_consign", "weightConsign", 0, "t"),
|
|||
|
|
("t_stock", "amt_order", "amtOrder", 0, "t"),
|
|||
|
|
("t_stock_append", "offline_amount", "offlineAmount", 0, "t8"),
|
|||
|
|
("t_stock_append", "escrow_amount", "escrowAmount", 0, "t8"),
|
|||
|
|
("t_stock", "exp_cod", "expCod", 0, "t"),
|
|||
|
|
("t_stock", "exp_company", "expCompany", 0, "t"),
|
|||
|
|
("t_stock", "transport", "transport", 0, "t"),
|
|||
|
|
("t_stock", "order_origin", "orderOrigin", 0, "t"),
|
|||
|
|
("t_stock", "exp_out_type", "expOutType", 0, "t"),
|
|||
|
|
("t_stock", "order_platform", "orderPlatform", 0, "t"),
|
|||
|
|
("t_stock", "exp_pkg_type", "expPkgType", 0, "t"),
|
|||
|
|
("t_stock", "exp_pkg_code", "expPkgCode", 0, "t"),
|
|||
|
|
("t_stock", "exp_batch", "expBatch", 0, "t"),
|
|||
|
|
("t_stock", "exp_ti_huo", "expTiHuo", 0, "t"),
|
|||
|
|
("t_stock", "print_time", "printTime", 0, "t"),
|
|||
|
|
("t_stock", "receiver", "receiver", 0, "t"),
|
|||
|
|
("t_stock", "receiver_tel", "receiverTel", 0, "t"),
|
|||
|
|
("t_stock", "receiver_addr", "receiverAddr", 0, "t"),
|
|||
|
|
("t_stock", "receiver_shop_name", "receiverShopName", 0, "t"),
|
|||
|
|
("t_stock", "receiver_shop_code", "receiverShopCode", 0, "t"),
|
|||
|
|
("t_stock", "product_name", "productName", 0, "t"),
|
|||
|
|
("t_stock", "is_cancel", "isCancel", 0, "t"),
|
|||
|
|
("t_stock", "err_msg", "errMsg", 0, "t"),
|
|||
|
|
("t_stock", "remark2", "remark2", 0, "t"),
|
|||
|
|
("t_stock", "remark1", "remark1", 0, "t"),
|
|||
|
|
("t_stock", "note", "note", 0, "t"),
|
|||
|
|
("t_store", "name", "name", 0, "t1"),
|
|||
|
|
("sys_user", "fullname", "sufullname", 1, "t3"),
|
|||
|
|
("sys_user", "dept_label_path", "deptLabelPath", 0, "t3"),
|
|||
|
|
("t_stock", "shop_name", "shopName", 0, "t"),
|
|||
|
|
("t_shop", "shop_id", "shopId", 0, "t6"),
|
|||
|
|
("t_stock", "remark4", "remark4", 0, "t"),
|
|||
|
|
("t_stock", "remark7", "remark7", 0, "t"),
|
|||
|
|
("t_stock", "package_time", "packageTime", 0, "t"),
|
|||
|
|
("t_stock", "packer", "packer", 0, "t"),
|
|||
|
|
("t_stock", "pack_type", "packType", 0, "t"),
|
|||
|
|
("t_stock", "track_status", "trackStatus", 0, "t"),
|
|||
|
|
("t_stock", "track_desc", "trackDesc", 0, "t"),
|
|||
|
|
("t_stock", "err_status", "errStatus", 0, "t"),
|
|||
|
|
("t_stock", "err_time", "errTime", 0, "t"),
|
|||
|
|
("t_stock", "err_msg", "tserrMsg", 1, "t"),
|
|||
|
|
("t_stock", "remark2", "tsremark2", 1, "t"),
|
|||
|
|
("t_stock", "remark1", "tsremark1", 1, "t"),
|
|||
|
|
("t_stock", "product_volume_str", "productVolumeStr", 0, "t"),
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
def __init__(
|
|||
|
|
self,
|
|||
|
|
base_url: str = "https://www.shunyunbaoerp.com",
|
|||
|
|
ocr_url: str = "http://127.0.0.1:8000/ocr",
|
|||
|
|
redis_url: str | None = None,
|
|||
|
|
) -> None:
|
|||
|
|
self.base_url = base_url.rstrip("/")
|
|||
|
|
self.ocr_url = ocr_url
|
|||
|
|
self.timeout = (5, 30) # 连接超时 5 秒,读取超时 30 秒。
|
|||
|
|
self.page_size = 20
|
|||
|
|
self.max_matches = 100
|
|||
|
|
self.redis_url = redis_url or os.getenv(
|
|||
|
|
"SHUNYUNBAO_REDIS_URL",
|
|||
|
|
"redis://127.0.0.1:6379/0",
|
|||
|
|
)
|
|||
|
|
self.redis_key_prefix = os.getenv(
|
|||
|
|
"SHUNYUNBAO_REDIS_KEY_PREFIX",
|
|||
|
|
"shunyunbaoerp:session",
|
|||
|
|
)
|
|||
|
|
self.redis_session_ttl = int(
|
|||
|
|
os.getenv("SHUNYUNBAO_REDIS_SESSION_TTL", "86400")
|
|||
|
|
)
|
|||
|
|
if self.redis_session_ttl <= 0:
|
|||
|
|
raise ValueError("SHUNYUNBAO_REDIS_SESSION_TTL 必须大于 0")
|
|||
|
|
self.redis_enabled = os.getenv(
|
|||
|
|
"SHUNYUNBAO_REDIS_ENABLED",
|
|||
|
|
"1",
|
|||
|
|
).lower() not in {"0", "false", "no", "off"}
|
|||
|
|
self.redis_client: Any | None = None
|
|||
|
|
|
|||
|
|
# ERP 的验证码、登录和查询必须使用同一个 Session。
|
|||
|
|
self.erp_session = requests.Session()
|
|||
|
|
self.erp_session.headers.update(
|
|||
|
|
{
|
|||
|
|
"Accept": "application/json, text/plain, */*",
|
|||
|
|
"Origin": self.base_url,
|
|||
|
|
"Referer": f"{self.base_url}/sys/login",
|
|||
|
|
"User-Agent": "Mozilla/5.0 shunyunbaoerp-single-script",
|
|||
|
|
"X-Requested-With": "XMLHttpRequest",
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# OCR 在本机运行,单独使用一个 Session,避免携带 ERP 的 Cookie。
|
|||
|
|
# trust_env=False 可以防止系统代理把 127.0.0.1 请求转发到代理服务器。
|
|||
|
|
self.ocr_session = requests.Session()
|
|||
|
|
self.ocr_session.trust_env = False
|
|||
|
|
|
|||
|
|
self.user: dict[str, Any] | None = None
|
|||
|
|
self.token: str | None = None
|
|||
|
|
|
|||
|
|
def get_captcha(self, save_path: str = "captcha.jpg") -> str:
|
|||
|
|
"""获取验证码并保存到文件,返回文件的绝对路径。"""
|
|||
|
|
|
|||
|
|
url = f"{self.base_url}/api/p/code1"
|
|||
|
|
response = self.erp_session.get(
|
|||
|
|
url,
|
|||
|
|
params={"_": int(time.time() * 1000)},
|
|||
|
|
timeout=self.timeout,
|
|||
|
|
)
|
|||
|
|
response.raise_for_status()
|
|||
|
|
self._capture_refreshed_token(response)
|
|||
|
|
|
|||
|
|
content_type = response.headers.get("Content-Type", "")
|
|||
|
|
if not content_type.startswith("image/"):
|
|||
|
|
raise RuntimeError(f"验证码接口没有返回图片,Content-Type={content_type!r}")
|
|||
|
|
|
|||
|
|
path = Path(save_path).resolve()
|
|||
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|||
|
|
path.write_bytes(response.content)
|
|||
|
|
return str(path)
|
|||
|
|
|
|||
|
|
def ocr_captcha(self, image_path: str) -> str:
|
|||
|
|
"""调用本地 OCR 接口识别验证码。
|
|||
|
|
|
|||
|
|
等价 curl:
|
|||
|
|
curl --request POST --url http://127.0.0.1:8000/ocr \
|
|||
|
|
--header "Content-Type: multipart/form-data" \
|
|||
|
|
--form "file=@captcha.jpg"
|
|||
|
|
|
|||
|
|
requests 会自动生成带 boundary 的 Content-Type,所以不要手动设置该请求头。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
path = Path(image_path)
|
|||
|
|
if not path.is_file():
|
|||
|
|
raise FileNotFoundError(f"验证码图片不存在: {path}")
|
|||
|
|
|
|||
|
|
mime_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
|||
|
|
with path.open("rb") as image_file:
|
|||
|
|
response = self.ocr_session.post(
|
|||
|
|
self.ocr_url,
|
|||
|
|
files={"file": (path.name, image_file, mime_type)},
|
|||
|
|
timeout=self.timeout,
|
|||
|
|
)
|
|||
|
|
response.raise_for_status()
|
|||
|
|
|
|||
|
|
# 已验证的本地接口响应为:
|
|||
|
|
# {"code": 200, "message": "Success", "data": "验证码文字"}
|
|||
|
|
try:
|
|||
|
|
body = response.json()
|
|||
|
|
except ValueError:
|
|||
|
|
# 同时兼容直接返回纯文本的 OCR 服务。
|
|||
|
|
text = response.text
|
|||
|
|
else:
|
|||
|
|
if not isinstance(body, dict):
|
|||
|
|
raise RuntimeError("OCR 响应 JSON 不是对象")
|
|||
|
|
if body.get("code") not in (None, 0, 200, "0", "200"):
|
|||
|
|
raise RuntimeError(
|
|||
|
|
f"OCR 识别失败: {body.get('message') or body.get('msg') or body}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
data = body.get("data")
|
|||
|
|
if isinstance(data, str):
|
|||
|
|
text = data
|
|||
|
|
elif isinstance(data, dict):
|
|||
|
|
text = str(
|
|||
|
|
data.get("text")
|
|||
|
|
or data.get("result")
|
|||
|
|
or data.get("code")
|
|||
|
|
or ""
|
|||
|
|
)
|
|||
|
|
else:
|
|||
|
|
text = str(
|
|||
|
|
body.get("text")
|
|||
|
|
or body.get("result")
|
|||
|
|
or body.get("message")
|
|||
|
|
or ""
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 当前验证码由英文字母/数字组成,过滤 OCR 可能带回的空格和标点。
|
|||
|
|
captcha_code = "".join(char for char in text.strip() if char.isalnum())
|
|||
|
|
if not captcha_code:
|
|||
|
|
raise RuntimeError(f"OCR 没有识别出验证码,原始响应: {response.text[:200]}")
|
|||
|
|
return captcha_code
|
|||
|
|
|
|||
|
|
def login(self, username: str, password: str, captcha_code: str) -> dict[str, Any]:
|
|||
|
|
"""提交用户名、密码和验证码登录 ERP。"""
|
|||
|
|
|
|||
|
|
username = username.strip()
|
|||
|
|
captcha_code = captcha_code.strip()
|
|||
|
|
if not username:
|
|||
|
|
raise ValueError("用户名不能为空")
|
|||
|
|
if not password:
|
|||
|
|
raise ValueError("密码不能为空")
|
|||
|
|
if not captcha_code:
|
|||
|
|
raise ValueError("验证码不能为空")
|
|||
|
|
|
|||
|
|
data = self._post_json(
|
|||
|
|
"/am/auth/login",
|
|||
|
|
{
|
|||
|
|
"username": username,
|
|||
|
|
"password": password,
|
|||
|
|
"code": captcha_code,
|
|||
|
|
},
|
|||
|
|
)
|
|||
|
|
if not isinstance(data, dict) or not isinstance(data.get("user"), dict):
|
|||
|
|
raise RuntimeError("登录响应中缺少 user 数据")
|
|||
|
|
|
|||
|
|
self.user = data["user"]
|
|||
|
|
self.token = data.get("token")
|
|||
|
|
return self.user
|
|||
|
|
|
|||
|
|
def get_user_info(
|
|||
|
|
self,
|
|||
|
|
user_id: int | str | None = None,
|
|||
|
|
) -> dict[str, Any]:
|
|||
|
|
"""获取个人信息。
|
|||
|
|
|
|||
|
|
shunyunbaoerp_userinfo.har 中的接口是:
|
|||
|
|
GET /am/user/get?id=<登录响应中的 user.id>
|
|||
|
|
|
|||
|
|
user_id 不传时,自动使用 login() 保存的当前用户 id。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
if user_id is None and self.user is not None:
|
|||
|
|
user_id = self.user.get("id")
|
|||
|
|
if user_id in (None, ""):
|
|||
|
|
raise ValueError("没有用户 id,请先登录或手动传入 user_id")
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
normalized_user_id = int(user_id)
|
|||
|
|
except (TypeError, ValueError) as exc:
|
|||
|
|
raise ValueError("user_id 必须是正整数") from exc
|
|||
|
|
if normalized_user_id <= 0:
|
|||
|
|
raise ValueError("user_id 必须是正整数")
|
|||
|
|
|
|||
|
|
data = self._get_json(
|
|||
|
|
"/am/user/get",
|
|||
|
|
params={"id": normalized_user_id},
|
|||
|
|
)
|
|||
|
|
if not isinstance(data, dict):
|
|||
|
|
raise RuntimeError("个人信息接口返回的 data 不是对象")
|
|||
|
|
if data.get("id") is None or data.get("username") in (None, ""):
|
|||
|
|
raise RuntimeError("个人信息中缺少 id 或 username")
|
|||
|
|
return data
|
|||
|
|
|
|||
|
|
def is_logged_in(
|
|||
|
|
self,
|
|||
|
|
user_id: int | str | None = None,
|
|||
|
|
) -> bool:
|
|||
|
|
"""通过获取个人信息检测当前 Session 是否已经登录。
|
|||
|
|
|
|||
|
|
返回值:
|
|||
|
|
- True:成功取得同一用户的个人信息。
|
|||
|
|
- False:没有用户 id,或服务端明确返回未登录/登录过期。
|
|||
|
|
|
|||
|
|
网络超时、服务器 5xx、响应格式错误等情况不会返回 False,
|
|||
|
|
而是抛出异常,避免把网络故障误认为退出登录。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
if user_id is None and self.user is not None:
|
|||
|
|
user_id = self.user.get("id")
|
|||
|
|
if user_id in (None, ""):
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
old_username = self.user.get("username") if self.user else None
|
|||
|
|
try:
|
|||
|
|
user_info = self.get_user_info(user_id)
|
|||
|
|
except requests.HTTPError as exc:
|
|||
|
|
status_code = exc.response.status_code if exc.response is not None else None
|
|||
|
|
if status_code in (401, 403):
|
|||
|
|
self.user = None
|
|||
|
|
self.token = None
|
|||
|
|
return False
|
|||
|
|
raise
|
|||
|
|
except RuntimeError as exc:
|
|||
|
|
message = str(exc)
|
|||
|
|
logged_out_markers = ("code=-2", "未登录", "登录过期")
|
|||
|
|
if any(marker in message for marker in logged_out_markers):
|
|||
|
|
self.user = None
|
|||
|
|
self.token = None
|
|||
|
|
return False
|
|||
|
|
raise
|
|||
|
|
|
|||
|
|
same_id = str(user_info.get("id")) == str(user_id)
|
|||
|
|
same_username = (
|
|||
|
|
old_username is None
|
|||
|
|
or str(user_info.get("username")) == str(old_username)
|
|||
|
|
)
|
|||
|
|
if not same_id or not same_username:
|
|||
|
|
self.user = None
|
|||
|
|
self.token = None
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
# userinfo 中的 token 是另一种用户 API token,不是登录响应的 JWT。
|
|||
|
|
# 检测登录状态时不允许它覆盖 self.token。
|
|||
|
|
self.user = dict(user_info)
|
|||
|
|
self.user.pop("token", None)
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
def save_session_to_redis(self, username: str) -> bool:
|
|||
|
|
"""把 Cookie、登录 JWT 和最小用户标识保存到 Redis。
|
|||
|
|
|
|||
|
|
使用 JSON 而不是 pickle,避免反序列化任意代码风险。
|
|||
|
|
Redis 只是缓存:保存失败不会让已经登录的 ERP 会话失效。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
if not self.redis_enabled:
|
|||
|
|
return False
|
|||
|
|
if self.user is None or self.user.get("id") is None:
|
|||
|
|
raise RuntimeError("没有可保存的登录用户,请先登录")
|
|||
|
|
|
|||
|
|
cookies = []
|
|||
|
|
for cookie in self.erp_session.cookies:
|
|||
|
|
if not self._cookie_belongs_to_erp(cookie.domain):
|
|||
|
|
continue
|
|||
|
|
cookies.append(
|
|||
|
|
{
|
|||
|
|
"name": cookie.name,
|
|||
|
|
"value": cookie.value,
|
|||
|
|
"domain": cookie.domain
|
|||
|
|
or (urlparse(self.base_url).hostname or ""),
|
|||
|
|
"path": cookie.path or "/",
|
|||
|
|
"secure": bool(cookie.secure),
|
|||
|
|
"expires": cookie.expires,
|
|||
|
|
"rest": {
|
|||
|
|
str(key): str(value)
|
|||
|
|
for key, value in getattr(cookie, "_rest", {}).items()
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if not cookies:
|
|||
|
|
print("警告:ERP Session 中没有可保存的 Cookie")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
ttl = self._session_ttl()
|
|||
|
|
if ttl <= 0:
|
|||
|
|
self.delete_session_from_redis(username)
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
# 只保存恢复登录所需的最小用户信息,不缓存完整个人资料。
|
|||
|
|
session_data = {
|
|||
|
|
"version": 1,
|
|||
|
|
"savedAt": int(time.time()),
|
|||
|
|
"baseUrl": self.base_url,
|
|||
|
|
"user": {
|
|||
|
|
"id": self.user.get("id"),
|
|||
|
|
"username": self.user.get("username") or username,
|
|||
|
|
},
|
|||
|
|
"token": self.token,
|
|||
|
|
"cookies": cookies,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
client = self._get_redis_client()
|
|||
|
|
client.setex(
|
|||
|
|
self._redis_key(username),
|
|||
|
|
ttl,
|
|||
|
|
json.dumps(session_data, ensure_ascii=False, separators=(",", ":")),
|
|||
|
|
)
|
|||
|
|
return True
|
|||
|
|
except Exception as exc:
|
|||
|
|
if self._is_redis_error(exc):
|
|||
|
|
print(f"警告:Redis 会话保存失败,将继续使用当前内存会话: {exc}")
|
|||
|
|
return False
|
|||
|
|
raise
|
|||
|
|
|
|||
|
|
def load_session_from_redis(self, username: str) -> bool:
|
|||
|
|
"""从 Redis 读取 Cookie、JWT 和最小用户标识到当前实例。
|
|||
|
|
|
|||
|
|
本函数只负责恢复,不代表会话一定有效;调用后还必须执行
|
|||
|
|
is_logged_in() 或 restore_login_from_redis()。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
if not self.redis_enabled:
|
|||
|
|
return False
|
|||
|
|
try:
|
|||
|
|
client = self._get_redis_client()
|
|||
|
|
raw_data = client.get(self._redis_key(username))
|
|||
|
|
except Exception as exc:
|
|||
|
|
if self._is_redis_error(exc):
|
|||
|
|
print(f"警告:Redis 不可用,将改用 OCR 登录: {exc}")
|
|||
|
|
return False
|
|||
|
|
raise
|
|||
|
|
if not raw_data:
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
session_data = json.loads(raw_data)
|
|||
|
|
except (TypeError, json.JSONDecodeError):
|
|||
|
|
self.delete_session_from_redis(username)
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
if (
|
|||
|
|
not isinstance(session_data, dict)
|
|||
|
|
or session_data.get("version") != 1
|
|||
|
|
or session_data.get("baseUrl") != self.base_url
|
|||
|
|
or not isinstance(session_data.get("user"), dict)
|
|||
|
|
or not isinstance(session_data.get("cookies"), list)
|
|||
|
|
):
|
|||
|
|
self.delete_session_from_redis(username)
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
cached_user = session_data["user"]
|
|||
|
|
if str(cached_user.get("username")) != username:
|
|||
|
|
self.delete_session_from_redis(username)
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
self.erp_session.cookies.clear()
|
|||
|
|
restored_cookie_count = 0
|
|||
|
|
now = int(time.time())
|
|||
|
|
for item in session_data["cookies"]:
|
|||
|
|
if not isinstance(item, dict):
|
|||
|
|
continue
|
|||
|
|
name = item.get("name")
|
|||
|
|
value = item.get("value")
|
|||
|
|
domain = item.get("domain")
|
|||
|
|
expires = item.get("expires")
|
|||
|
|
if not name or value is None or not self._cookie_belongs_to_erp(domain):
|
|||
|
|
continue
|
|||
|
|
if expires is not None and int(expires) <= now:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
cookie_arguments: dict[str, Any] = {
|
|||
|
|
"name": str(name),
|
|||
|
|
"value": str(value),
|
|||
|
|
"path": str(item.get("path") or "/"),
|
|||
|
|
"secure": bool(item.get("secure")),
|
|||
|
|
"rest": item.get("rest") if isinstance(item.get("rest"), dict) else {},
|
|||
|
|
}
|
|||
|
|
if domain:
|
|||
|
|
cookie_arguments["domain"] = str(domain)
|
|||
|
|
if expires is not None:
|
|||
|
|
cookie_arguments["expires"] = int(expires)
|
|||
|
|
cookie = requests.cookies.create_cookie(**cookie_arguments)
|
|||
|
|
self.erp_session.cookies.set_cookie(cookie)
|
|||
|
|
restored_cookie_count += 1
|
|||
|
|
|
|||
|
|
if restored_cookie_count == 0:
|
|||
|
|
self.delete_session_from_redis(username)
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
self.user = {
|
|||
|
|
"id": cached_user.get("id"),
|
|||
|
|
"username": cached_user.get("username"),
|
|||
|
|
}
|
|||
|
|
cached_token = session_data.get("token")
|
|||
|
|
self.token = cached_token if isinstance(cached_token, str) else None
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
def restore_login_from_redis(self, username: str) -> bool:
|
|||
|
|
"""恢复 Redis 会话,并调用个人信息接口验证是否仍然登录。"""
|
|||
|
|
|
|||
|
|
if not self.load_session_from_redis(username):
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
# is_logged_in 会区分“明确未登录”和“网络故障”:
|
|||
|
|
# 明确未登录返回 False;网络故障继续抛错,不盲目触发 OCR 登录。
|
|||
|
|
if self.is_logged_in():
|
|||
|
|
# 个人信息请求可能刷新 Cookie,验证成功后续期 Redis 缓存。
|
|||
|
|
self.save_session_to_redis(username)
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
self.delete_session_from_redis(username)
|
|||
|
|
self.erp_session.cookies.clear()
|
|||
|
|
self.user = None
|
|||
|
|
self.token = None
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
def delete_session_from_redis(self, username: str) -> bool:
|
|||
|
|
"""删除指定 ERP 用户的 Redis 会话缓存。"""
|
|||
|
|
|
|||
|
|
if not self.redis_enabled:
|
|||
|
|
return False
|
|||
|
|
try:
|
|||
|
|
client = self._get_redis_client()
|
|||
|
|
return bool(client.delete(self._redis_key(username)))
|
|||
|
|
except Exception as exc:
|
|||
|
|
if self._is_redis_error(exc):
|
|||
|
|
print(f"警告:删除 Redis 会话失败: {exc}")
|
|||
|
|
return False
|
|||
|
|
raise
|
|||
|
|
|
|||
|
|
def login_with_redis_or_ocr(
|
|||
|
|
self,
|
|||
|
|
username: str,
|
|||
|
|
password: str | None = None,
|
|||
|
|
max_attempts: int = 5,
|
|||
|
|
captcha_path: str = "captcha.jpg",
|
|||
|
|
) -> dict[str, Any]:
|
|||
|
|
"""启动时优先恢复 Redis 会话,无效时才执行 OCR 登录。"""
|
|||
|
|
|
|||
|
|
username = username.strip()
|
|||
|
|
if not username:
|
|||
|
|
raise ValueError("用户名不能为空")
|
|||
|
|
|
|||
|
|
if self.restore_login_from_redis(username):
|
|||
|
|
print(f"Redis 会话有效,已恢复登录用户: {username}")
|
|||
|
|
if self.user is None:
|
|||
|
|
raise RuntimeError("Redis 会话验证成功但用户信息为空")
|
|||
|
|
return self.user
|
|||
|
|
|
|||
|
|
print("Redis 中没有有效会话,开始获取验证码并登录 ERP")
|
|||
|
|
if not password:
|
|||
|
|
password = os.getenv("SHUNYUNBAO_PASSWORD") or getpass.getpass(
|
|||
|
|
"ERP 密码: "
|
|||
|
|
)
|
|||
|
|
if not password:
|
|||
|
|
raise ValueError("密码不能为空")
|
|||
|
|
user = self.login_with_ocr(
|
|||
|
|
username,
|
|||
|
|
password,
|
|||
|
|
max_attempts=max_attempts,
|
|||
|
|
captcha_path=captcha_path,
|
|||
|
|
)
|
|||
|
|
self.save_session_to_redis(username)
|
|||
|
|
return user
|
|||
|
|
|
|||
|
|
def login_with_ocr(
|
|||
|
|
self,
|
|||
|
|
username: str,
|
|||
|
|
password: str,
|
|||
|
|
max_attempts: int = 5,
|
|||
|
|
captcha_path: str = "captcha.jpg",
|
|||
|
|
) -> dict[str, Any]:
|
|||
|
|
"""自动执行“获取验证码 → OCR → 登录”,失败时刷新验证码重试。"""
|
|||
|
|
|
|||
|
|
last_error: Exception | None = None
|
|||
|
|
for attempt in range(1, max_attempts + 1):
|
|||
|
|
try:
|
|||
|
|
image_path = self.get_captcha(captcha_path)
|
|||
|
|
captcha_code = self.ocr_captcha(image_path)
|
|||
|
|
# HAR 中验证码长度为 4。长度明显不对时不请求登录,直接刷新图片。
|
|||
|
|
if len(captcha_code) != 4:
|
|||
|
|
raise RuntimeError(
|
|||
|
|
f"OCR 结果长度为 {len(captcha_code)},预期为 4"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
user = self.login(username, password, captcha_code)
|
|||
|
|
if not self.is_logged_in(user.get("id")):
|
|||
|
|
raise RuntimeError("登录接口成功,但个人信息接口检测为未登录")
|
|||
|
|
print(f"ERP 登录成功,用户: {user.get('username', '')}")
|
|||
|
|
return user
|
|||
|
|
except (requests.RequestException, RuntimeError) as exc:
|
|||
|
|
last_error = exc
|
|||
|
|
print(f"第 {attempt} 次登录未成功: {exc}")
|
|||
|
|
|
|||
|
|
raise RuntimeError(
|
|||
|
|
f"连续 {max_attempts} 次未能登录,请检查 OCR、账号和密码。"
|
|||
|
|
f"最后错误: {last_error}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def build_order_search_payload(
|
|||
|
|
self,
|
|||
|
|
order_number: str,
|
|||
|
|
start: int = 0,
|
|||
|
|
page_index: int = 1,
|
|||
|
|
) -> dict[str, Any]:
|
|||
|
|
"""构造货运管理页面的单号搜索请求体。"""
|
|||
|
|
|
|||
|
|
order_number = order_number.strip()
|
|||
|
|
if not order_number:
|
|||
|
|
raise ValueError("订单号不能为空")
|
|||
|
|
|
|||
|
|
columns = [
|
|||
|
|
{
|
|||
|
|
"tableName": table_name,
|
|||
|
|
"colName": column_name,
|
|||
|
|
"fieldName": field_name,
|
|||
|
|
"hasAlias": has_alias,
|
|||
|
|
"tableAlias": table_alias,
|
|||
|
|
}
|
|||
|
|
for (
|
|||
|
|
table_name,
|
|||
|
|
column_name,
|
|||
|
|
field_name,
|
|||
|
|
has_alias,
|
|||
|
|
table_alias,
|
|||
|
|
) in self.COLUMN_SPECS
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"history": 0,
|
|||
|
|
"length": self.page_size,
|
|||
|
|
"start": start,
|
|||
|
|
"pageTotal": 0,
|
|||
|
|
"pageIndex": page_index,
|
|||
|
|
"store": False,
|
|||
|
|
"columns": columns,
|
|||
|
|
"queries": [
|
|||
|
|
{
|
|||
|
|
"dvalue": order_number,
|
|||
|
|
"tableName": "t_stock",
|
|||
|
|
"colName": "allcode",
|
|||
|
|
"op": 6,
|
|||
|
|
"type": 0,
|
|||
|
|
"tableAlias": "t",
|
|||
|
|
"optType": 1,
|
|||
|
|
}
|
|||
|
|
],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def search_order(self, order_number: str) -> list[dict[str, Any]]:
|
|||
|
|
"""在货运管理中按单号搜索,返回货运列表记录。"""
|
|||
|
|
|
|||
|
|
first_payload = self.build_order_search_payload(order_number)
|
|||
|
|
total_value = self._post_json("/am/stock/listTotal", first_payload)
|
|||
|
|
try:
|
|||
|
|
total = int(total_value or 0)
|
|||
|
|
except (TypeError, ValueError) as exc:
|
|||
|
|
raise RuntimeError(f"listTotal 返回的总数无效: {total_value!r}") from exc
|
|||
|
|
|
|||
|
|
if total == 0:
|
|||
|
|
return []
|
|||
|
|
if total > self.max_matches:
|
|||
|
|
raise RuntimeError(
|
|||
|
|
f"单号搜索返回 {total} 条,超过安全上限 {self.max_matches},"
|
|||
|
|
"请确认输入的是完整单号"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
rows: list[dict[str, Any]] = []
|
|||
|
|
for start in range(0, total, self.page_size):
|
|||
|
|
page_index = start // self.page_size + 1
|
|||
|
|
payload = self.build_order_search_payload(
|
|||
|
|
order_number,
|
|||
|
|
start=start,
|
|||
|
|
page_index=page_index,
|
|||
|
|
)
|
|||
|
|
page_data = self._post_json("/am/stock/list", payload)
|
|||
|
|
if not isinstance(page_data, dict) or not isinstance(
|
|||
|
|
page_data.get("list"), list
|
|||
|
|
):
|
|||
|
|
raise RuntimeError("货运列表响应缺少 data.list")
|
|||
|
|
rows.extend(page_data["list"])
|
|||
|
|
|
|||
|
|
return rows[:total]
|
|||
|
|
|
|||
|
|
def get_freight_details_by_ids(
|
|||
|
|
self,
|
|||
|
|
stock_ids: list[int],
|
|||
|
|
) -> list[dict[str, Any]]:
|
|||
|
|
"""使用货运列表 id 批量获取货运详情。"""
|
|||
|
|
|
|||
|
|
if not stock_ids:
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
details: list[dict[str, Any]] = []
|
|||
|
|
# 一次最多请求 100 个 id,避免请求体过大。
|
|||
|
|
for start in range(0, len(stock_ids), 100):
|
|||
|
|
ids = stock_ids[start : start + 100]
|
|||
|
|
data = self._post_json(
|
|||
|
|
"/am/stock/detail/listByStock",
|
|||
|
|
{"ids": ids},
|
|||
|
|
params={"hist": 0},
|
|||
|
|
)
|
|||
|
|
if not isinstance(data, dict) or not isinstance(data.get("list"), list):
|
|||
|
|
raise RuntimeError("货运详情响应缺少 data.list")
|
|||
|
|
details.extend(data["list"])
|
|||
|
|
return details
|
|||
|
|
|
|||
|
|
def search_freight_details(self, order_number: str) -> dict[str, Any]:
|
|||
|
|
"""完成“搜索单号 → 获取详情”,返回合并后的完整结果。"""
|
|||
|
|
|
|||
|
|
if not self.is_logged_in():
|
|||
|
|
raise RuntimeError("当前 ERP 会话未登录或已经过期,请重新登录")
|
|||
|
|
|
|||
|
|
stock_rows = self.search_order(order_number)
|
|||
|
|
if not stock_rows:
|
|||
|
|
return {
|
|||
|
|
"query": order_number,
|
|||
|
|
"count": 0,
|
|||
|
|
"records": [],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
stock_ids: list[int] = []
|
|||
|
|
for row in stock_rows:
|
|||
|
|
if row.get("id") is None:
|
|||
|
|
raise RuntimeError("货运列表中存在缺少 id 的记录")
|
|||
|
|
stock_ids.append(int(row["id"]))
|
|||
|
|
|
|||
|
|
detail_rows = self.get_freight_details_by_ids(stock_ids)
|
|||
|
|
details_by_id = {
|
|||
|
|
str(detail["id"]): detail
|
|||
|
|
for detail in detail_rows
|
|||
|
|
if detail.get("id") is not None
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
records = [
|
|||
|
|
{
|
|||
|
|
"stock": stock,
|
|||
|
|
"detail": details_by_id.get(str(stock["id"])),
|
|||
|
|
}
|
|||
|
|
for stock in stock_rows
|
|||
|
|
]
|
|||
|
|
return {
|
|||
|
|
"query": order_number,
|
|||
|
|
"count": len(records),
|
|||
|
|
"records": records,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def save_result(
|
|||
|
|
self,
|
|||
|
|
result: dict[str, Any],
|
|||
|
|
save_path: str = ".local/shunyunbao/freight_detail.json",
|
|||
|
|
) -> str:
|
|||
|
|
"""把私有查询结果保存到本地忽略目录。"""
|
|||
|
|
|
|||
|
|
path = Path(save_path).resolve()
|
|||
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|||
|
|
path.write_text(
|
|||
|
|
json.dumps(result, ensure_ascii=False, indent=2, default=str),
|
|||
|
|
encoding="utf-8",
|
|||
|
|
)
|
|||
|
|
return str(path)
|
|||
|
|
|
|||
|
|
def close(self) -> None:
|
|||
|
|
"""关闭 ERP、OCR 和 Redis 的连接池。"""
|
|||
|
|
|
|||
|
|
self.erp_session.close()
|
|||
|
|
self.ocr_session.close()
|
|||
|
|
if self.redis_client is not None and hasattr(self.redis_client, "close"):
|
|||
|
|
self.redis_client.close()
|
|||
|
|
|
|||
|
|
def run(self) -> None:
|
|||
|
|
"""使用环境变量或交互输入执行一次受控查询。"""
|
|||
|
|
|
|||
|
|
username = (
|
|||
|
|
os.getenv("SHUNYUNBAO_USERNAME")
|
|||
|
|
or input("ERP 用户名: ").strip()
|
|||
|
|
)
|
|||
|
|
order_number = (
|
|||
|
|
os.getenv("SHUNYUNBAO_ORDER_NUMBER")
|
|||
|
|
or input("完整订单号: ").strip()
|
|||
|
|
)
|
|||
|
|
if not username:
|
|||
|
|
raise ValueError("ERP 用户名不能为空")
|
|||
|
|
if not order_number:
|
|||
|
|
raise ValueError("完整订单号不能为空")
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
# 只有 Redis 会话不存在或失效时,才会读取/询问 ERP 密码并 OCR 登录。
|
|||
|
|
self.login_with_redis_or_ocr(username)
|
|||
|
|
result = self.search_freight_details(order_number)
|
|||
|
|
# 查询过程中服务端可能刷新 Cookie,结束前再保存一次。
|
|||
|
|
self.save_session_to_redis(username)
|
|||
|
|
result_path = self.save_result(result)
|
|||
|
|
|
|||
|
|
print(f"查询完成,共找到 {result['count']} 条记录。")
|
|||
|
|
print(f"完整结果已保存到: {result_path}")
|
|||
|
|
finally:
|
|||
|
|
self.close()
|
|||
|
|
|
|||
|
|
def _post_json(
|
|||
|
|
self,
|
|||
|
|
path: str,
|
|||
|
|
json_body: dict[str, Any],
|
|||
|
|
params: dict[str, Any] | None = None,
|
|||
|
|
) -> Any:
|
|||
|
|
"""发送 ERP POST 请求并取出统一响应中的 data。"""
|
|||
|
|
|
|||
|
|
response = self.erp_session.post(
|
|||
|
|
f"{self.base_url}{path}",
|
|||
|
|
params=params,
|
|||
|
|
json=json_body,
|
|||
|
|
timeout=self.timeout,
|
|||
|
|
)
|
|||
|
|
response.raise_for_status()
|
|||
|
|
self._capture_refreshed_token(response)
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
body = response.json()
|
|||
|
|
except ValueError as exc:
|
|||
|
|
raise RuntimeError(f"{path} 返回的不是 JSON") from exc
|
|||
|
|
|
|||
|
|
if not isinstance(body, dict):
|
|||
|
|
raise RuntimeError(f"{path} 的 JSON 顶层不是对象")
|
|||
|
|
if body.get("status") is not True:
|
|||
|
|
message = body.get("msg") or "ERP 请求失败"
|
|||
|
|
code = body.get("code")
|
|||
|
|
raise RuntimeError(f"{path} 请求失败: {message},code={code}")
|
|||
|
|
if "data" not in body:
|
|||
|
|
raise RuntimeError(f"{path} 成功响应中缺少 data")
|
|||
|
|
return body["data"]
|
|||
|
|
|
|||
|
|
def _get_redis_client(self) -> Any:
|
|||
|
|
"""延迟创建 Redis 客户端,避免 Redis 缓存影响模块导入。"""
|
|||
|
|
|
|||
|
|
if self.redis_client is not None:
|
|||
|
|
return self.redis_client
|
|||
|
|
if redis is None:
|
|||
|
|
raise RuntimeError(
|
|||
|
|
"未安装 redis 包,请执行: python -m pip install redis"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
parsed = urlparse(self.redis_url)
|
|||
|
|
is_local = parsed.hostname in {"127.0.0.1", "localhost", "::1"}
|
|||
|
|
if parsed.scheme != "rediss" and not is_local:
|
|||
|
|
print("安全警告:远程 Redis 应使用 rediss:// TLS 连接")
|
|||
|
|
if not is_local and parsed.password is None:
|
|||
|
|
print("安全警告:远程 Redis 应配置 ACL 用户名和密码")
|
|||
|
|
|
|||
|
|
self.redis_client = redis.Redis.from_url(
|
|||
|
|
self.redis_url,
|
|||
|
|
decode_responses=True,
|
|||
|
|
socket_connect_timeout=2,
|
|||
|
|
socket_timeout=2,
|
|||
|
|
health_check_interval=30,
|
|||
|
|
)
|
|||
|
|
return self.redis_client
|
|||
|
|
|
|||
|
|
def _redis_key(self, username: str) -> str:
|
|||
|
|
"""使用用户名哈希作为 Redis key,避免 key 中直接出现账号。"""
|
|||
|
|
|
|||
|
|
username_hash = hashlib.sha256(username.encode("utf-8")).hexdigest()[:24]
|
|||
|
|
return f"{self.redis_key_prefix}:{username_hash}"
|
|||
|
|
|
|||
|
|
def _session_ttl(self) -> int:
|
|||
|
|
"""计算 Redis TTL,不超过配置值和登录 JWT 剩余时间。"""
|
|||
|
|
|
|||
|
|
ttl = self.redis_session_ttl
|
|||
|
|
if not self.token or self.token.count(".") != 2:
|
|||
|
|
return ttl
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
payload = self.token.split(".")[1]
|
|||
|
|
payload += "=" * (-len(payload) % 4)
|
|||
|
|
claims = json.loads(
|
|||
|
|
base64.urlsafe_b64decode(payload.encode("ascii")).decode("utf-8")
|
|||
|
|
)
|
|||
|
|
expires_at = int(claims["exp"])
|
|||
|
|
except (
|
|||
|
|
KeyError,
|
|||
|
|
TypeError,
|
|||
|
|
ValueError,
|
|||
|
|
UnicodeDecodeError,
|
|||
|
|
json.JSONDecodeError,
|
|||
|
|
):
|
|||
|
|
return ttl
|
|||
|
|
return max(0, min(ttl, expires_at - int(time.time())))
|
|||
|
|
|
|||
|
|
def _cookie_belongs_to_erp(self, cookie_domain: str | None) -> bool:
|
|||
|
|
"""只允许保存和恢复属于当前 ERP 主机的 Cookie。"""
|
|||
|
|
|
|||
|
|
erp_host = (urlparse(self.base_url).hostname or "").lower()
|
|||
|
|
domain = (cookie_domain or erp_host).lstrip(".").lower()
|
|||
|
|
return erp_host == domain or erp_host.endswith(f".{domain}")
|
|||
|
|
|
|||
|
|
def _capture_refreshed_token(self, response: requests.Response) -> None:
|
|||
|
|
"""保存服务端可能通过响应头刷新的登录 JWT。"""
|
|||
|
|
|
|||
|
|
refreshed_token = response.headers.get("X-Requested-With")
|
|||
|
|
if isinstance(refreshed_token, str) and refreshed_token.count(".") == 2:
|
|||
|
|
self.token = refreshed_token
|
|||
|
|
|
|||
|
|
@staticmethod
|
|||
|
|
def _is_redis_error(error: Exception) -> bool:
|
|||
|
|
"""判断 redis-py 抛出的连接、超时等 Redis 异常。"""
|
|||
|
|
|
|||
|
|
return redis is not None and isinstance(error, redis.RedisError)
|
|||
|
|
|
|||
|
|
def _get_json(
|
|||
|
|
self,
|
|||
|
|
path: str,
|
|||
|
|
params: dict[str, Any] | None = None,
|
|||
|
|
) -> Any:
|
|||
|
|
"""发送 ERP GET 请求并取出统一响应中的 data。"""
|
|||
|
|
|
|||
|
|
response = self.erp_session.get(
|
|||
|
|
f"{self.base_url}{path}",
|
|||
|
|
params=params,
|
|||
|
|
timeout=self.timeout,
|
|||
|
|
)
|
|||
|
|
response.raise_for_status()
|
|||
|
|
self._capture_refreshed_token(response)
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
body = response.json()
|
|||
|
|
except ValueError as exc:
|
|||
|
|
raise RuntimeError(f"{path} 返回的不是 JSON") from exc
|
|||
|
|
|
|||
|
|
if not isinstance(body, dict):
|
|||
|
|
raise RuntimeError(f"{path} 的 JSON 顶层不是对象")
|
|||
|
|
if body.get("status") is not True:
|
|||
|
|
message = body.get("msg") or "ERP 请求失败"
|
|||
|
|
code = body.get("code")
|
|||
|
|
raise RuntimeError(f"{path} 请求失败: {message},code={code}")
|
|||
|
|
if "data" not in body:
|
|||
|
|
raise RuntimeError(f"{path} 成功响应中缺少 data")
|
|||
|
|
return body["data"]
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
ShunYunBaoERP().run()
|