style: 统一 Ruff 代码格式
This commit is contained in:
@@ -6,7 +6,12 @@ from .login_api_wgapi import WgapiLoginAPI
|
|||||||
from .login_api_iframe import IframeLoginAPI
|
from .login_api_iframe import IframeLoginAPI
|
||||||
from .email_verifier import EmailVerifier
|
from .email_verifier import EmailVerifier
|
||||||
from .activity_client import DouyuActivityClient, DouyuActivityError
|
from .activity_client import DouyuActivityClient, DouyuActivityError
|
||||||
from .recharge_api import FishFinRechargeClient, FishFinRechargeConfig, FishFinRechargeConfigError, FishFinRechargeError
|
from .recharge_api import (
|
||||||
|
FishFinRechargeClient,
|
||||||
|
FishFinRechargeConfig,
|
||||||
|
FishFinRechargeConfigError,
|
||||||
|
FishFinRechargeError,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DouyuLogin",
|
"DouyuLogin",
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ class CookieEnricher:
|
|||||||
CSRF_API = "https://www.douyu.com/japi/carnival/nc/common/generateCsrf"
|
CSRF_API = "https://www.douyu.com/japi/carnival/nc/common/generateCsrf"
|
||||||
CSRF_REFERER = "https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0&roomId=9263298"
|
CSRF_REFERER = "https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0&roomId=9263298"
|
||||||
ACF_CCN_API = "https://www.douyu.com/curl/csrfNlApi/getCsrfCookie"
|
ACF_CCN_API = "https://www.douyu.com/curl/csrfNlApi/getCsrfCookie"
|
||||||
ACF_CCN_REFERER = "https://www.douyu.com/pages/ord-task-center?clientType=web&panelSource=1&rid=0"
|
ACF_CCN_REFERER = (
|
||||||
|
"https://www.douyu.com/pages/ord-task-center?clientType=web&panelSource=1&rid=0"
|
||||||
|
)
|
||||||
TIMEOUT = (5, 10)
|
TIMEOUT = (5, 10)
|
||||||
RETRIES = 3
|
RETRIES = 3
|
||||||
|
|
||||||
@@ -47,25 +49,27 @@ class CookieEnricher:
|
|||||||
logger.warning(f"补CK失败 {attempt}/{max_attempts}: {e}")
|
logger.warning(f"补CK失败 {attempt}/{max_attempts}: {e}")
|
||||||
if attempt < max_attempts:
|
if attempt < max_attempts:
|
||||||
self.sleep_interruptible(1)
|
self.sleep_interruptible(1)
|
||||||
raise ValueError(f"已重试 {max_attempts} 次仍未补齐CK: {last_error}") from last_error
|
raise ValueError(
|
||||||
|
f"已重试 {max_attempts} 次仍未补齐CK: {last_error}"
|
||||||
|
) from last_error
|
||||||
|
|
||||||
def generate_csrf_cookie(self) -> str:
|
def generate_csrf_cookie(self) -> str:
|
||||||
"""访问 generateCsrf 接口,从 Set-Cookie 中同步 cvl_csrf_token。"""
|
"""访问 generateCsrf 接口,从 Set-Cookie 中同步 cvl_csrf_token。"""
|
||||||
logger.info("补齐CSRF Cookie...")
|
logger.info("补齐CSRF Cookie...")
|
||||||
headers = {
|
headers = {
|
||||||
'Accept': 'application/json, text/plain, */*',
|
"Accept": "application/json, text/plain, */*",
|
||||||
'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.6,en;q=0.5',
|
"Accept-Language": "zh-CN,zh;q=0.9,en-US;q=0.6,en;q=0.5",
|
||||||
'Origin': 'https://www.douyu.com',
|
"Origin": "https://www.douyu.com",
|
||||||
'Referer': self.CSRF_REFERER,
|
"Referer": self.CSRF_REFERER,
|
||||||
'Sec-Fetch-Dest': 'empty',
|
"Sec-Fetch-Dest": "empty",
|
||||||
'Sec-Fetch-Mode': 'cors',
|
"Sec-Fetch-Mode": "cors",
|
||||||
'Sec-Fetch-Site': 'same-origin',
|
"Sec-Fetch-Site": "same-origin",
|
||||||
# 覆盖登录接口的默认表单头,尽量贴近浏览器抓包。
|
# 覆盖登录接口的默认表单头,尽量贴近浏览器抓包。
|
||||||
'Content-Type': None,
|
"Content-Type": None,
|
||||||
'X-Requested-With': None,
|
"X-Requested-With": None,
|
||||||
}
|
}
|
||||||
response = self.request(
|
response = self.request(
|
||||||
'post',
|
"post",
|
||||||
self.CSRF_API,
|
self.CSRF_API,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
timeout=self.TIMEOUT,
|
timeout=self.TIMEOUT,
|
||||||
@@ -82,11 +86,11 @@ class CookieEnricher:
|
|||||||
preview = body[:200].replace("\n", "\\n")
|
preview = body[:200].replace("\n", "\\n")
|
||||||
raise ValueError(f"生成CSRF失败: 响应不是有效 JSON: {preview}") from exc
|
raise ValueError(f"生成CSRF失败: 响应不是有效 JSON: {preview}") from exc
|
||||||
|
|
||||||
if payload.get('error') != 0:
|
if payload.get("error") != 0:
|
||||||
raise ValueError(f"生成CSRF失败: {payload.get('msg', '未知错误')}")
|
raise ValueError(f"生成CSRF失败: {payload.get('msg', '未知错误')}")
|
||||||
|
|
||||||
cookies = self.session.cookies.get_dict()
|
cookies = self.session.cookies.get_dict()
|
||||||
csrf_token = cookies.get('cvl_csrf_token', '')
|
csrf_token = cookies.get("cvl_csrf_token", "")
|
||||||
if not csrf_token:
|
if not csrf_token:
|
||||||
raise ValueError("生成CSRF失败: 响应没有 cvl_csrf_token")
|
raise ValueError("生成CSRF失败: 响应没有 cvl_csrf_token")
|
||||||
|
|
||||||
@@ -97,25 +101,25 @@ class CookieEnricher:
|
|||||||
"""访问 getCsrfCookie 接口,从 Set-Cookie 中同步 acf_ccn。"""
|
"""访问 getCsrfCookie 接口,从 Set-Cookie 中同步 acf_ccn。"""
|
||||||
logger.info("补齐 acf_ccn Cookie...")
|
logger.info("补齐 acf_ccn Cookie...")
|
||||||
headers = {
|
headers = {
|
||||||
'Accept': 'application/json, text/plain, */*',
|
"Accept": "application/json, text/plain, */*",
|
||||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||||
'Cache-Control': 'no-cache',
|
"Cache-Control": "no-cache",
|
||||||
'Pragma': 'no-cache',
|
"Pragma": "no-cache",
|
||||||
'Priority': 'u=1, i',
|
"Priority": "u=1, i",
|
||||||
'Referer': self.ACF_CCN_REFERER,
|
"Referer": self.ACF_CCN_REFERER,
|
||||||
'Sec-CH-UA': '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
|
"Sec-CH-UA": '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
|
||||||
'Sec-CH-UA-Mobile': '?0',
|
"Sec-CH-UA-Mobile": "?0",
|
||||||
'Sec-CH-UA-Platform': '"macOS"',
|
"Sec-CH-UA-Platform": '"macOS"',
|
||||||
'Sec-Fetch-Dest': 'empty',
|
"Sec-Fetch-Dest": "empty",
|
||||||
'Sec-Fetch-Mode': 'cors',
|
"Sec-Fetch-Mode": "cors",
|
||||||
'Sec-Fetch-Site': 'same-origin',
|
"Sec-Fetch-Site": "same-origin",
|
||||||
# 该接口抓包没有 Origin、表单 Content-Type 和 X-Requested-With。
|
# 该接口抓包没有 Origin、表单 Content-Type 和 X-Requested-With。
|
||||||
'Origin': None,
|
"Origin": None,
|
||||||
'Content-Type': None,
|
"Content-Type": None,
|
||||||
'X-Requested-With': None,
|
"X-Requested-With": None,
|
||||||
}
|
}
|
||||||
response = self.request(
|
response = self.request(
|
||||||
'get',
|
"get",
|
||||||
self.ACF_CCN_API,
|
self.ACF_CCN_API,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
timeout=self.TIMEOUT,
|
timeout=self.TIMEOUT,
|
||||||
@@ -123,10 +127,10 @@ class CookieEnricher:
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
cookies = self.session.cookies.get_dict()
|
cookies = self.session.cookies.get_dict()
|
||||||
acf_ccn = cookies.get('acf_ccn', '') or response.cookies.get('acf_ccn', '')
|
acf_ccn = cookies.get("acf_ccn", "") or response.cookies.get("acf_ccn", "")
|
||||||
if acf_ccn and not cookies.get('acf_ccn'):
|
if acf_ccn and not cookies.get("acf_ccn"):
|
||||||
# 极少数情况下响应 Cookie 未合入 get_dict,手动补到斗鱼域名下。
|
# 极少数情况下响应 Cookie 未合入 get_dict,手动补到斗鱼域名下。
|
||||||
self.session.cookies.set('acf_ccn', acf_ccn, domain='.douyu.com', path='/')
|
self.session.cookies.set("acf_ccn", acf_ccn, domain=".douyu.com", path="/")
|
||||||
|
|
||||||
if not acf_ccn:
|
if not acf_ccn:
|
||||||
raise ValueError("补齐 acf_ccn 失败: 响应没有 acf_ccn")
|
raise ValueError("补齐 acf_ccn 失败: 响应没有 acf_ccn")
|
||||||
|
|||||||
@@ -25,21 +25,21 @@ DOUYU_RC4_KEY = "7TkbRSEWvVWebXbr"
|
|||||||
|
|
||||||
def md5(text: str) -> str:
|
def md5(text: str) -> str:
|
||||||
"""MD5加密"""
|
"""MD5加密"""
|
||||||
return hashlib.md5(text.encode('utf-8')).hexdigest()
|
return hashlib.md5(text.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def rsa_encrypt(text: str, public_key: str = DOUYU_RSA_PUBLIC_KEY) -> str:
|
def rsa_encrypt(text: str, public_key: str = DOUYU_RSA_PUBLIC_KEY) -> str:
|
||||||
"""RSA加密"""
|
"""RSA加密"""
|
||||||
key = RSA.import_key(public_key)
|
key = RSA.import_key(public_key)
|
||||||
cipher = PKCS1_v1_5.new(key)
|
cipher = PKCS1_v1_5.new(key)
|
||||||
encrypted = cipher.encrypt(text.encode('utf-8'))
|
encrypted = cipher.encrypt(text.encode("utf-8"))
|
||||||
return base64.b64encode(encrypted).decode('utf-8')
|
return base64.b64encode(encrypted).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
def aes_encrypt(text: str, key: str = DOUYU_AES_KEY) -> str:
|
def aes_encrypt(text: str, key: str = DOUYU_AES_KEY) -> str:
|
||||||
"""AES加密"""
|
"""AES加密"""
|
||||||
key_bytes = key.encode('utf-8')
|
key_bytes = key.encode("utf-8")
|
||||||
text_bytes = text.encode('utf-8')
|
text_bytes = text.encode("utf-8")
|
||||||
|
|
||||||
# 填充到16的倍数
|
# 填充到16的倍数
|
||||||
padding_len = 16 - (len(text_bytes) % 16)
|
padding_len = 16 - (len(text_bytes) % 16)
|
||||||
@@ -47,7 +47,7 @@ def aes_encrypt(text: str, key: str = DOUYU_AES_KEY) -> str:
|
|||||||
|
|
||||||
cipher = AES.new(key_bytes, AES.MODE_ECB)
|
cipher = AES.new(key_bytes, AES.MODE_ECB)
|
||||||
encrypted = cipher.encrypt(text_bytes)
|
encrypted = cipher.encrypt(text_bytes)
|
||||||
return base64.b64encode(encrypted).decode('utf-8')
|
return base64.b64encode(encrypted).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
def encrypt_username(username: str) -> str:
|
def encrypt_username(username: str) -> str:
|
||||||
@@ -62,7 +62,7 @@ def encrypt_password(password: str) -> str:
|
|||||||
|
|
||||||
def encrypt_nickname_or_phone(text: str) -> str:
|
def encrypt_nickname_or_phone(text: str) -> str:
|
||||||
"""加密昵称或手机号"""
|
"""加密昵称或手机号"""
|
||||||
key = DOUYU_RC4_KEY.encode('utf-8')
|
key = DOUYU_RC4_KEY.encode("utf-8")
|
||||||
cipher = ARC4.new(key)
|
cipher = ARC4.new(key)
|
||||||
encrypted = cipher.encrypt(text.encode('utf-8'))
|
encrypted = cipher.encrypt(text.encode("utf-8"))
|
||||||
return base64.b64encode(encrypted).decode('utf-8')
|
return base64.b64encode(encrypted).decode("utf-8")
|
||||||
|
|||||||
+20
-18
@@ -47,7 +47,9 @@ class LoginAPIStrategy(ABC):
|
|||||||
...
|
...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def build_first_login_data(self, username: str, password: str, referer: str) -> dict:
|
def build_first_login_data(
|
||||||
|
self, username: str, password: str, referer: str
|
||||||
|
) -> dict:
|
||||||
"""构建第一次登录请求参数(获取极验参数)"""
|
"""构建第一次登录请求参数(获取极验参数)"""
|
||||||
...
|
...
|
||||||
|
|
||||||
@@ -85,11 +87,11 @@ class LoginAPIStrategy(ABC):
|
|||||||
Returns:
|
Returns:
|
||||||
(gt, challenge, code_token)
|
(gt, challenge, code_token)
|
||||||
"""
|
"""
|
||||||
geetest_data = payload.get('data', {}).get('geetest', {})
|
geetest_data = payload.get("data", {}).get("geetest", {})
|
||||||
code_data = geetest_data.get('code_data', {})
|
code_data = geetest_data.get("code_data", {})
|
||||||
gt = code_data.get('gt', '')
|
gt = code_data.get("gt", "")
|
||||||
challenge = code_data.get('challenge', '')
|
challenge = code_data.get("challenge", "")
|
||||||
code_token = geetest_data.get('code_token', '')
|
code_token = geetest_data.get("code_token", "")
|
||||||
return gt, challenge, code_token
|
return gt, challenge, code_token
|
||||||
|
|
||||||
def extract_remote_code(self, payload: dict) -> str:
|
def extract_remote_code(self, payload: dict) -> str:
|
||||||
@@ -101,7 +103,7 @@ class LoginAPIStrategy(ABC):
|
|||||||
Returns:
|
Returns:
|
||||||
remote_code 字符串
|
remote_code 字符串
|
||||||
"""
|
"""
|
||||||
return payload.get('data', {}).get('remoteLogin', {}).get('code', '')
|
return payload.get("data", {}).get("remoteLogin", {}).get("code", "")
|
||||||
|
|
||||||
def extract_login_url(self, payload: dict) -> str:
|
def extract_login_url(self, payload: dict) -> str:
|
||||||
"""从验证码响应提取登录 URL
|
"""从验证码响应提取登录 URL
|
||||||
@@ -112,27 +114,27 @@ class LoginAPIStrategy(ABC):
|
|||||||
Returns:
|
Returns:
|
||||||
登录回调 URL
|
登录回调 URL
|
||||||
"""
|
"""
|
||||||
data = payload.get('data', {})
|
data = payload.get("data", {})
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
return ''
|
return ""
|
||||||
|
|
||||||
# `remoteLogin` 与“跳过绑定手机号”完成后都会返回同类回调地址,
|
# `remoteLogin` 与“跳过绑定手机号”完成后都会返回同类回调地址,
|
||||||
# 但不同版本接口的字段命名不完全一致。
|
# 但不同版本接口的字段命名不完全一致。
|
||||||
for key in ('url', 'loginUrl', 'login_url', 'redirectUrl', 'redirect_url'):
|
for key in ("url", "loginUrl", "login_url", "redirectUrl", "redirect_url"):
|
||||||
value = data.get(key)
|
value = data.get(key)
|
||||||
if isinstance(value, str) and value:
|
if isinstance(value, str) and value:
|
||||||
return value
|
return value
|
||||||
return ''
|
return ""
|
||||||
|
|
||||||
def extract_mobile_bind_unique_key(self, payload: dict) -> str:
|
def extract_mobile_bind_unique_key(self, payload: dict) -> str:
|
||||||
"""提取服务端要求绑定手机号时返回的一次性继续登录标识。"""
|
"""提取服务端要求绑定手机号时返回的一次性继续登录标识。"""
|
||||||
data = payload.get('data', {})
|
data = payload.get("data", {})
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
return ''
|
return ""
|
||||||
|
|
||||||
def find_unique_key(value: object) -> str:
|
def find_unique_key(value: object) -> str:
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
unique_key = value.get('uniqueKey') or value.get('unique_key')
|
unique_key = value.get("uniqueKey") or value.get("unique_key")
|
||||||
if isinstance(unique_key, str) and unique_key:
|
if isinstance(unique_key, str) and unique_key:
|
||||||
return unique_key
|
return unique_key
|
||||||
for child in value.values():
|
for child in value.values():
|
||||||
@@ -144,14 +146,14 @@ class LoginAPIStrategy(ABC):
|
|||||||
result = find_unique_key(child)
|
result = find_unique_key(child)
|
||||||
if result:
|
if result:
|
||||||
return result
|
return result
|
||||||
return ''
|
return ""
|
||||||
|
|
||||||
return find_unique_key(data)
|
return find_unique_key(data)
|
||||||
|
|
||||||
def build_skip_mobile_bind_data(self, unique_key: str) -> dict:
|
def build_skip_mobile_bind_data(self, unique_key: str) -> dict:
|
||||||
"""构建跳过手机号绑定、继续完成网页登录的请求参数。"""
|
"""构建跳过手机号绑定、继续完成网页登录的请求参数。"""
|
||||||
return {
|
return {
|
||||||
'type': '3',
|
"type": "3",
|
||||||
'uniqueKey': unique_key,
|
"uniqueKey": unique_key,
|
||||||
'biz_type': '1',
|
"biz_type": "1",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,9 +42,11 @@ class IframeLoginAPI(LoginAPIStrategy):
|
|||||||
def encrypt_username(self, username: str) -> str:
|
def encrypt_username(self, username: str) -> str:
|
||||||
"""RC4 加密后 URL 编码"""
|
"""RC4 加密后 URL 编码"""
|
||||||
encrypted = encrypt_nickname_or_phone(username)
|
encrypted = encrypt_nickname_or_phone(username)
|
||||||
return urllib.parse.quote(encrypted, safe='')
|
return urllib.parse.quote(encrypted, safe="")
|
||||||
|
|
||||||
def build_first_login_data(self, username: str, password: str, referer: str) -> dict:
|
def build_first_login_data(
|
||||||
|
self, username: str, password: str, referer: str
|
||||||
|
) -> dict:
|
||||||
"""构建第一次登录参数(获取极验参数)
|
"""构建第一次登录参数(获取极验参数)
|
||||||
|
|
||||||
注:第一次登录使用 wgapi 接口,所以用户名用 base64 编码(而非 URL 编码)。
|
注:第一次登录使用 wgapi 接口,所以用户名用 base64 编码(而非 URL 编码)。
|
||||||
@@ -52,17 +54,17 @@ class IframeLoginAPI(LoginAPIStrategy):
|
|||||||
# 第一次登录用 wgapi 接口,需要 base64 编码的用户名
|
# 第一次登录用 wgapi 接口,需要 base64 编码的用户名
|
||||||
encrypted_username = encrypt_nickname_or_phone(username)
|
encrypted_username = encrypt_nickname_or_phone(username)
|
||||||
return {
|
return {
|
||||||
'type': '1',
|
"type": "1",
|
||||||
'nicknameOrPhoneEncrypt': encrypted_username,
|
"nicknameOrPhoneEncrypt": encrypted_username,
|
||||||
'password': encrypt_password(password),
|
"password": encrypt_password(password),
|
||||||
'biz_type': '1',
|
"biz_type": "1",
|
||||||
'room_id': '0',
|
"room_id": "0",
|
||||||
'redirect_url': referer,
|
"redirect_url": referer,
|
||||||
't': str(int(time.time() * 1000)),
|
"t": str(int(time.time() * 1000)),
|
||||||
'client_id': '1',
|
"client_id": "1",
|
||||||
'did': '',
|
"did": "",
|
||||||
'lang': '',
|
"lang": "",
|
||||||
'isMultiAccount': '0',
|
"isMultiAccount": "0",
|
||||||
}
|
}
|
||||||
|
|
||||||
def build_second_login_data(
|
def build_second_login_data(
|
||||||
@@ -81,46 +83,46 @@ class IframeLoginAPI(LoginAPIStrategy):
|
|||||||
登录使用 wgapi 接口,参数格式与 wgapi 策略一致。
|
登录使用 wgapi 接口,参数格式与 wgapi 策略一致。
|
||||||
"""
|
"""
|
||||||
# seccode 格式: validate|jordan(requests 会自动 URL 编码)
|
# seccode 格式: validate|jordan(requests 会自动 URL 编码)
|
||||||
seccode_value = validate + '|jordan'
|
seccode_value = validate + "|jordan"
|
||||||
|
|
||||||
# 使用 base64 编码(wgapi 接口要求),而不是 URL 编码
|
# 使用 base64 编码(wgapi 接口要求),而不是 URL 编码
|
||||||
encrypted_username = encrypt_nickname_or_phone(username)
|
encrypted_username = encrypt_nickname_or_phone(username)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'type': '1',
|
"type": "1",
|
||||||
'nicknameOrPhoneEncrypt': encrypted_username,
|
"nicknameOrPhoneEncrypt": encrypted_username,
|
||||||
'password': encrypt_password(password),
|
"password": encrypt_password(password),
|
||||||
'room_id': '0',
|
"room_id": "0",
|
||||||
'code_type': '1',
|
"code_type": "1",
|
||||||
'code_token': code_token,
|
"code_token": code_token,
|
||||||
'gt_version': 'v3',
|
"gt_version": "v3",
|
||||||
'geetest_challenge': challenge,
|
"geetest_challenge": challenge,
|
||||||
'geetest_validate': validate,
|
"geetest_validate": validate,
|
||||||
'geetest_seccode': seccode_value,
|
"geetest_seccode": seccode_value,
|
||||||
'code_data[geetest_challenge]': challenge,
|
"code_data[geetest_challenge]": challenge,
|
||||||
'code_data[geetest_validate]': validate,
|
"code_data[geetest_validate]": validate,
|
||||||
'code_data[geetest_seccode]': seccode_value,
|
"code_data[geetest_seccode]": seccode_value,
|
||||||
'code_data[gt_version]': 'v3',
|
"code_data[gt_version]": "v3",
|
||||||
'code_data[code]': '',
|
"code_data[code]": "",
|
||||||
'redirect_url': referer,
|
"redirect_url": referer,
|
||||||
't': str(int(time.time() * 1000)),
|
"t": str(int(time.time() * 1000)),
|
||||||
'client_id': '1',
|
"client_id": "1",
|
||||||
'did': '',
|
"did": "",
|
||||||
'lang': '',
|
"lang": "",
|
||||||
'isMultiAccount': '0',
|
"isMultiAccount": "0",
|
||||||
'biz_type': '1',
|
"biz_type": "1",
|
||||||
}
|
}
|
||||||
|
|
||||||
def build_send_email_data(self, code: str) -> dict:
|
def build_send_email_data(self, code: str) -> dict:
|
||||||
"""构建发送邮箱验证参数"""
|
"""构建发送邮箱验证参数"""
|
||||||
return {'cacheId': code}
|
return {"cacheId": code}
|
||||||
|
|
||||||
def build_verify_data(self, code: str, captcha: str) -> dict:
|
def build_verify_data(self, code: str, captcha: str) -> dict:
|
||||||
"""构建提交验证码参数"""
|
"""构建提交验证码参数"""
|
||||||
return {
|
return {
|
||||||
'veify_type': '2', # e 语言原文如此,斗鱼接口拼写
|
"veify_type": "2", # e 语言原文如此,斗鱼接口拼写
|
||||||
'phoneCaptcha': captcha,
|
"phoneCaptcha": captcha,
|
||||||
'client_id': '1',
|
"client_id": "1",
|
||||||
'isMultiAccount': '0',
|
"isMultiAccount": "0",
|
||||||
'cacheId': code,
|
"cacheId": code,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,20 +36,22 @@ class WgapiLoginAPI(LoginAPIStrategy):
|
|||||||
"""RC4 加密后 base64 编码"""
|
"""RC4 加密后 base64 编码"""
|
||||||
return encrypt_nickname_or_phone(username)
|
return encrypt_nickname_or_phone(username)
|
||||||
|
|
||||||
def build_first_login_data(self, username: str, password: str, referer: str) -> dict:
|
def build_first_login_data(
|
||||||
|
self, username: str, password: str, referer: str
|
||||||
|
) -> dict:
|
||||||
"""构建第一次登录参数"""
|
"""构建第一次登录参数"""
|
||||||
return {
|
return {
|
||||||
'type': '1',
|
"type": "1",
|
||||||
'nicknameOrPhoneEncrypt': self.encrypt_username(username),
|
"nicknameOrPhoneEncrypt": self.encrypt_username(username),
|
||||||
'password': encrypt_password(password),
|
"password": encrypt_password(password),
|
||||||
'biz_type': '1',
|
"biz_type": "1",
|
||||||
'room_id': '0',
|
"room_id": "0",
|
||||||
'redirect_url': referer,
|
"redirect_url": referer,
|
||||||
't': str(int(time.time() * 1000)),
|
"t": str(int(time.time() * 1000)),
|
||||||
'client_id': '1',
|
"client_id": "1",
|
||||||
'did': '',
|
"did": "",
|
||||||
'lang': '',
|
"lang": "",
|
||||||
'isMultiAccount': '0',
|
"isMultiAccount": "0",
|
||||||
}
|
}
|
||||||
|
|
||||||
def build_second_login_data(
|
def build_second_login_data(
|
||||||
@@ -65,44 +67,44 @@ class WgapiLoginAPI(LoginAPIStrategy):
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""构建第二次登录参数(带极验验证)"""
|
"""构建第二次登录参数(带极验验证)"""
|
||||||
return {
|
return {
|
||||||
'type': '1',
|
"type": "1",
|
||||||
'nicknameOrPhoneEncrypt': self.encrypt_username(username),
|
"nicknameOrPhoneEncrypt": self.encrypt_username(username),
|
||||||
'password': encrypt_password(password),
|
"password": encrypt_password(password),
|
||||||
'room_id': '0',
|
"room_id": "0",
|
||||||
'code_type': '1',
|
"code_type": "1",
|
||||||
'code_token': code_token,
|
"code_token": code_token,
|
||||||
'gt_version': 'v3',
|
"gt_version": "v3",
|
||||||
'geetest_challenge': challenge,
|
"geetest_challenge": challenge,
|
||||||
'geetest_validate': validate,
|
"geetest_validate": validate,
|
||||||
'geetest_seccode': seccode,
|
"geetest_seccode": seccode,
|
||||||
'code_data[geetest_challenge]': challenge,
|
"code_data[geetest_challenge]": challenge,
|
||||||
'code_data[geetest_validate]': validate,
|
"code_data[geetest_validate]": validate,
|
||||||
'code_data[geetest_seccode]': seccode,
|
"code_data[geetest_seccode]": seccode,
|
||||||
'code_data[gt_version]': 'v3',
|
"code_data[gt_version]": "v3",
|
||||||
'code_data[code]': '',
|
"code_data[code]": "",
|
||||||
'redirect_url': referer,
|
"redirect_url": referer,
|
||||||
't': str(int(time.time() * 1000)),
|
"t": str(int(time.time() * 1000)),
|
||||||
'client_id': '1',
|
"client_id": "1",
|
||||||
'did': '',
|
"did": "",
|
||||||
'lang': '',
|
"lang": "",
|
||||||
'isMultiAccount': '0',
|
"isMultiAccount": "0",
|
||||||
'biz_type': '1',
|
"biz_type": "1",
|
||||||
}
|
}
|
||||||
|
|
||||||
def build_send_email_data(self, code: str) -> dict:
|
def build_send_email_data(self, code: str) -> dict:
|
||||||
"""构建发送邮箱验证参数"""
|
"""构建发送邮箱验证参数"""
|
||||||
return {
|
return {
|
||||||
'code': code,
|
"code": code,
|
||||||
'client_id': '1',
|
"client_id": "1",
|
||||||
}
|
}
|
||||||
|
|
||||||
def build_verify_data(self, code: str, captcha: str) -> dict:
|
def build_verify_data(self, code: str, captcha: str) -> dict:
|
||||||
"""构建提交验证码参数"""
|
"""构建提交验证码参数"""
|
||||||
return {
|
return {
|
||||||
'verify_type': '2',
|
"verify_type": "2",
|
||||||
'captcha': captcha,
|
"captcha": captcha,
|
||||||
'isMultiAccount': '0',
|
"isMultiAccount": "0",
|
||||||
'code': code,
|
"code": code,
|
||||||
'client_id': '1',
|
"client_id": "1",
|
||||||
'redirect_url': '//www.douyu.com/api/passport/login',
|
"redirect_url": "//www.douyu.com/api/passport/login",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ def parse_proxy_response(text: str) -> tuple[list[str], Optional[str]]:
|
|||||||
code = data.get("code")
|
code = data.get("code")
|
||||||
msg = data.get("msg", "") or ""
|
msg = data.get("msg", "") or ""
|
||||||
if code != 0 and ("白名单" in msg or "添加白名单" in msg):
|
if code != 0 and ("白名单" in msg or "添加白名单" in msg):
|
||||||
ip_match = re.search(r'(\d+\.\d+\.\d+\.\d+)', msg)
|
ip_match = re.search(r"(\d+\.\d+\.\d+\.\d+)", msg)
|
||||||
if ip_match:
|
if ip_match:
|
||||||
return [], ip_match.group(1)
|
return [], ip_match.group(1)
|
||||||
|
|
||||||
@@ -46,12 +46,12 @@ def parse_proxy_response(text: str) -> tuple[list[str], Optional[str]]:
|
|||||||
except (json.JSONDecodeError, ValueError):
|
except (json.JSONDecodeError, ValueError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if '添加白名单' in text or '白名单' in text:
|
if "添加白名单" in text or "白名单" in text:
|
||||||
ip_match = re.search(r'(\d+\.\d+\.\d+\.\d+)', text)
|
ip_match = re.search(r"(\d+\.\d+\.\d+\.\d+)", text)
|
||||||
if ip_match:
|
if ip_match:
|
||||||
return [], ip_match.group(1)
|
return [], ip_match.group(1)
|
||||||
|
|
||||||
matches = re.findall(r'(\d+\.\d+\.\d+\.\d+):(\d+)', text)
|
matches = re.findall(r"(\d+\.\d+\.\d+\.\d+):(\d+)", text)
|
||||||
proxies = [f"http://{ip}:{port}" for ip, port in matches]
|
proxies = [f"http://{ip}:{port}" for ip, port in matches]
|
||||||
if proxies:
|
if proxies:
|
||||||
return proxies, None
|
return proxies, None
|
||||||
|
|||||||
@@ -67,7 +67,10 @@ class XiequAdapter(BaseWhitelistAdapter):
|
|||||||
|
|
||||||
# 映射到统一格式
|
# 映射到统一格式
|
||||||
return [
|
return [
|
||||||
{"ip": r.get("IP", r.get("ip", "")), "memo": r.get("MEMO", r.get("memo", ""))}
|
{
|
||||||
|
"ip": r.get("IP", r.get("ip", "")),
|
||||||
|
"memo": r.get("MEMO", r.get("memo", "")),
|
||||||
|
}
|
||||||
for r in data
|
for r in data
|
||||||
if r.get("IP") or r.get("ip")
|
if r.get("IP") or r.get("ip")
|
||||||
]
|
]
|
||||||
@@ -97,7 +100,7 @@ class XiequAdapter(BaseWhitelistAdapter):
|
|||||||
# 频率限制,等待后重试一次
|
# 频率限制,等待后重试一次
|
||||||
if retry and ("频率过快" in text or "稍后" in text):
|
if retry and ("频率过快" in text or "稍后" in text):
|
||||||
wait = 5
|
wait = 5
|
||||||
match = re.search(r'(\d+)\s*秒', text)
|
match = re.search(r"(\d+)\s*秒", text)
|
||||||
if match:
|
if match:
|
||||||
wait = int(match.group(1))
|
wait = int(match.group(1))
|
||||||
logger.info(f"白名单添加被限流,等待 {wait} 秒后重试...")
|
logger.info(f"白名单添加被限流,等待 {wait} 秒后重试...")
|
||||||
@@ -147,9 +150,13 @@ class XiequAdapter(BaseWhitelistAdapter):
|
|||||||
try:
|
try:
|
||||||
records = self.get_whitelist()
|
records = self.get_whitelist()
|
||||||
count = len(records)
|
count = len(records)
|
||||||
my_records = [r for r in records if r.get("memo", "").startswith(self.MEMO_PREFIX)]
|
my_records = [
|
||||||
|
r for r in records if r.get("memo", "").startswith(self.MEMO_PREFIX)
|
||||||
|
]
|
||||||
|
|
||||||
msg = f"连接成功,白名单共 {count} 条记录,其中本机相关 {len(my_records)} 条"
|
msg = (
|
||||||
|
f"连接成功,白名单共 {count} 条记录,其中本机相关 {len(my_records)} 条"
|
||||||
|
)
|
||||||
logger.info(msg)
|
logger.info(msg)
|
||||||
return True, msg
|
return True, msg
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ class XkdailiAdapter(BaseWhitelistAdapter):
|
|||||||
}
|
}
|
||||||
base_params.update(params)
|
base_params.update(params)
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
return f"{self.BASE_URL}?{urlencode(base_params)}"
|
return f"{self.BASE_URL}?{urlencode(base_params)}"
|
||||||
|
|
||||||
def _parse_response(self, text: str) -> tuple[bool, str]:
|
def _parse_response(self, text: str) -> tuple[bool, str]:
|
||||||
|
|||||||
@@ -17,11 +17,9 @@ LogFunc = Callable[[str, str], None]
|
|||||||
class WhitelistSyncer(Protocol):
|
class WhitelistSyncer(Protocol):
|
||||||
"""代理解析流程需要的白名单能力。"""
|
"""代理解析流程需要的白名单能力。"""
|
||||||
|
|
||||||
def sync_ip(self, ip: str) -> tuple[bool, str]:
|
def sync_ip(self, ip: str) -> tuple[bool, str]: ...
|
||||||
...
|
|
||||||
|
|
||||||
def get_local_exit_ip(self) -> Optional[str]:
|
def get_local_exit_ip(self) -> Optional[str]: ...
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
class ProxyResolver:
|
class ProxyResolver:
|
||||||
@@ -62,14 +60,16 @@ class ProxyResolver:
|
|||||||
return
|
return
|
||||||
log_method = getattr(
|
log_method = getattr(
|
||||||
logger,
|
logger,
|
||||||
level if level in ('debug', 'info', 'warning', 'error', 'success') else 'info',
|
level
|
||||||
|
if level in ("debug", "info", "warning", "error", "success")
|
||||||
|
else "info",
|
||||||
logger.info,
|
logger.info,
|
||||||
)
|
)
|
||||||
log_method(message)
|
log_method(message)
|
||||||
|
|
||||||
def _sync_ip(self, ip: str) -> tuple[bool, str]:
|
def _sync_ip(self, ip: str) -> tuple[bool, str]:
|
||||||
if not self.whitelist_syncer:
|
if not self.whitelist_syncer:
|
||||||
return False, '未配置白名单 UID/UKEY'
|
return False, "未配置白名单 UID/UKEY"
|
||||||
ok, sync_msg = self.whitelist_syncer.sync_ip(ip)
|
ok, sync_msg = self.whitelist_syncer.sync_ip(ip)
|
||||||
if ok:
|
if ok:
|
||||||
self._last_synced_ip = ip
|
self._last_synced_ip = ip
|
||||||
@@ -82,14 +82,14 @@ class ProxyResolver:
|
|||||||
|
|
||||||
local_ip = self.whitelist_syncer.get_local_exit_ip()
|
local_ip = self.whitelist_syncer.get_local_exit_ip()
|
||||||
if local_ip and local_ip != self._last_synced_ip:
|
if local_ip and local_ip != self._last_synced_ip:
|
||||||
self._log('info', f"[尝试 {attempt}] 检测出口IP: {local_ip},同步白名单...")
|
self._log("info", f"[尝试 {attempt}] 检测出口IP: {local_ip},同步白名单...")
|
||||||
ok, sync_msg = self._sync_ip(local_ip)
|
ok, sync_msg = self._sync_ip(local_ip)
|
||||||
if ok:
|
if ok:
|
||||||
self._log('info', f"白名单同步成功: {sync_msg}")
|
self._log("info", f"白名单同步成功: {sync_msg}")
|
||||||
else:
|
else:
|
||||||
self._log('warning', f"白名单同步失败: {sync_msg}")
|
self._log("warning", f"白名单同步失败: {sync_msg}")
|
||||||
elif local_ip == self._last_synced_ip:
|
elif local_ip == self._last_synced_ip:
|
||||||
self._log('debug', f"[尝试 {attempt}] 出口IP未变: {local_ip}")
|
self._log("debug", f"[尝试 {attempt}] 出口IP未变: {local_ip}")
|
||||||
|
|
||||||
def fetch_verified(
|
def fetch_verified(
|
||||||
self,
|
self,
|
||||||
@@ -107,18 +107,18 @@ class ProxyResolver:
|
|||||||
|
|
||||||
for attempt in range(1, max_attempts + 1):
|
for attempt in range(1, max_attempts + 1):
|
||||||
if self._is_stopped():
|
if self._is_stopped():
|
||||||
return None, '任务已停止'
|
return None, "任务已停止"
|
||||||
if attempt > 1:
|
if attempt > 1:
|
||||||
delay = min(attempt - 1, 2)
|
delay = min(attempt - 1, 2)
|
||||||
if self.log_func:
|
if self.log_func:
|
||||||
self._log('info', f'等待 {delay}s 后重试...')
|
self._log("info", f"等待 {delay}s 后重试...")
|
||||||
if self._wait_or_stopped(delay):
|
if self._wait_or_stopped(delay):
|
||||||
return None, '任务已停止'
|
return None, "任务已停止"
|
||||||
|
|
||||||
self._sync_local_exit_ip_if_needed(attempt)
|
self._sync_local_exit_ip_if_needed(attempt)
|
||||||
if self._is_stopped():
|
if self._is_stopped():
|
||||||
return None, '任务已停止'
|
return None, "任务已停止"
|
||||||
self._log('info', f'代理预检 {attempt}/{max_attempts}: 正在获取代理')
|
self._log("info", f"代理预检 {attempt}/{max_attempts}: 正在获取代理")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.get(self.api_url, timeout=10)
|
response = requests.get(self.api_url, timeout=10)
|
||||||
@@ -128,38 +128,49 @@ class ProxyResolver:
|
|||||||
proxy_urls, whitelist_ip = parse_proxy_response(text)
|
proxy_urls, whitelist_ip = parse_proxy_response(text)
|
||||||
|
|
||||||
if proxy_urls:
|
if proxy_urls:
|
||||||
self._log('info', f'获取到 {len(proxy_urls)} 个代理,并发验证')
|
self._log("info", f"获取到 {len(proxy_urls)} 个代理,并发验证")
|
||||||
available, msg = verify_proxies_concurrent(proxy_urls, return_all=return_all)
|
available, msg = verify_proxies_concurrent(
|
||||||
|
proxy_urls, return_all=return_all
|
||||||
|
)
|
||||||
if available:
|
if available:
|
||||||
if not return_all:
|
if not return_all:
|
||||||
self._log('success', f'代理预检成功: {available}')
|
self._log("success", f"代理预检成功: {available}")
|
||||||
return available, msg
|
return available, msg
|
||||||
last_error = msg
|
last_error = msg
|
||||||
self._log('warning', f"代理预检 {attempt}/{max_attempts}: {last_error}")
|
self._log(
|
||||||
|
"warning", f"代理预检 {attempt}/{max_attempts}: {last_error}"
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if whitelist_ip and self.whitelist_syncer:
|
if whitelist_ip and self.whitelist_syncer:
|
||||||
if self.sync_whitelist_once and self._has_synced_whitelist:
|
if self.sync_whitelist_once and self._has_synced_whitelist:
|
||||||
last_error = f'白名单已同步但代理API仍返回白名单错误: {whitelist_ip}'
|
last_error = (
|
||||||
self._log('warning', last_error)
|
f"白名单已同步但代理API仍返回白名单错误: {whitelist_ip}"
|
||||||
|
)
|
||||||
|
self._log("warning", last_error)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
self._log('warning', f'代理需要白名单IP: {whitelist_ip},自动同步...')
|
self._log(
|
||||||
|
"warning", f"代理需要白名单IP: {whitelist_ip},自动同步..."
|
||||||
|
)
|
||||||
ok, sync_msg = self._sync_ip(whitelist_ip)
|
ok, sync_msg = self._sync_ip(whitelist_ip)
|
||||||
self._log('success' if ok else 'error', f'白名单同步: {sync_msg}')
|
self._log("success" if ok else "error", f"白名单同步: {sync_msg}")
|
||||||
if ok:
|
if ok:
|
||||||
self._log('info', '白名单已更新,立即重试...')
|
self._log("info", "白名单已更新,立即重试...")
|
||||||
continue
|
continue
|
||||||
return None, f'白名单同步失败: {sync_msg}'
|
return None, f"白名单同步失败: {sync_msg}"
|
||||||
|
|
||||||
last_error = '代理API响应无法解析'
|
last_error = "代理API响应无法解析"
|
||||||
self._log('warning', f"代理预检 {attempt}/{max_attempts}: {last_error}: {text[:80]}")
|
self._log(
|
||||||
|
"warning",
|
||||||
|
f"代理预检 {attempt}/{max_attempts}: {last_error}: {text[:80]}",
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
last_error = f'代理API请求失败: {exc}'
|
last_error = f"代理API请求失败: {exc}"
|
||||||
self._log('warning', f"代理预检 {attempt}/{max_attempts}: {last_error}")
|
self._log("warning", f"代理预检 {attempt}/{max_attempts}: {last_error}")
|
||||||
|
|
||||||
return None, f'代理预检失败({max_attempts}次尝试均失败): {last_error}'
|
return None, f"代理预检失败({max_attempts}次尝试均失败): {last_error}"
|
||||||
|
|
||||||
|
|
||||||
def resolve_working_proxy(
|
def resolve_working_proxy(
|
||||||
|
|||||||
@@ -16,24 +16,24 @@ def verify_proxy_url(proxy_url: str, timeout: tuple = (3, 5)) -> tuple[bool, str
|
|||||||
Returns:
|
Returns:
|
||||||
(是否可用, 消息)
|
(是否可用, 消息)
|
||||||
"""
|
"""
|
||||||
proxies = {'http': proxy_url, 'https': proxy_url}
|
proxies = {"http": proxy_url, "https": proxy_url}
|
||||||
|
|
||||||
# ── 斗鱼主站可达性验证 ──
|
# ── 斗鱼主站可达性验证 ──
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
'https://www.douyu.com',
|
"https://www.douyu.com",
|
||||||
proxies=proxies,
|
proxies=proxies,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
headers={'User-Agent': 'Mozilla/5.0'},
|
headers={"User-Agent": "Mozilla/5.0"},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return True, '代理可用 → 斗鱼可达'
|
return True, "代理可用 → 斗鱼可达"
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
err_msg = str(exc)
|
err_msg = str(exc)
|
||||||
if 'Tunnel connection failed' in err_msg or '503' in err_msg:
|
if "Tunnel connection failed" in err_msg or "503" in err_msg:
|
||||||
detail = '代理拒绝连接(白名单可能未生效)'
|
detail = "代理拒绝连接(白名单可能未生效)"
|
||||||
elif 'timed out' in err_msg.lower():
|
elif "timed out" in err_msg.lower():
|
||||||
detail = '连接超时'
|
detail = "连接超时"
|
||||||
else:
|
else:
|
||||||
detail = type(exc).__name__
|
detail = type(exc).__name__
|
||||||
logger.debug(f"代理验证失败 [{proxy_url}]: 斗鱼主站不可达: {detail}")
|
logger.debug(f"代理验证失败 [{proxy_url}]: 斗鱼主站不可达: {detail}")
|
||||||
@@ -54,7 +54,7 @@ def verify_proxies_concurrent(
|
|||||||
return_all=True: (可用代理 URL 列表或 None, 消息)
|
return_all=True: (可用代理 URL 列表或 None, 消息)
|
||||||
"""
|
"""
|
||||||
if not proxy_urls:
|
if not proxy_urls:
|
||||||
return None, '无代理可验证'
|
return None, "无代理可验证"
|
||||||
|
|
||||||
if len(proxy_urls) == 1:
|
if len(proxy_urls) == 1:
|
||||||
ok, msg = verify_proxy_url(proxy_urls[0], timeout)
|
ok, msg = verify_proxy_url(proxy_urls[0], timeout)
|
||||||
@@ -64,7 +64,9 @@ def verify_proxies_concurrent(
|
|||||||
|
|
||||||
if return_all:
|
if return_all:
|
||||||
available: list[str] = []
|
available: list[str] = []
|
||||||
with ThreadPoolExecutor(max_workers=min(max_workers, len(proxy_urls))) as executor:
|
with ThreadPoolExecutor(
|
||||||
|
max_workers=min(max_workers, len(proxy_urls))
|
||||||
|
) as executor:
|
||||||
future_map = {
|
future_map = {
|
||||||
executor.submit(verify_proxy_url, proxy, timeout): proxy
|
executor.submit(verify_proxy_url, proxy, timeout): proxy
|
||||||
for proxy in proxy_urls
|
for proxy in proxy_urls
|
||||||
@@ -77,9 +79,11 @@ def verify_proxies_concurrent(
|
|||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
if available:
|
if available:
|
||||||
logger.success(f"并发验证找到 {len(available)}/{len(proxy_urls)} 个可用代理")
|
logger.success(
|
||||||
return available, f'找到 {len(available)} 个可用代理'
|
f"并发验证找到 {len(available)}/{len(proxy_urls)} 个可用代理"
|
||||||
return None, f'共 {len(proxy_urls)} 个代理均不可用'
|
)
|
||||||
|
return available, f"找到 {len(available)} 个可用代理"
|
||||||
|
return None, f"共 {len(proxy_urls)} 个代理均不可用"
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=min(max_workers, len(proxy_urls))) as executor:
|
with ThreadPoolExecutor(max_workers=min(max_workers, len(proxy_urls))) as executor:
|
||||||
future_map = {
|
future_map = {
|
||||||
@@ -99,4 +103,4 @@ def verify_proxies_concurrent(
|
|||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return None, f'共 {len(proxy_urls)} 个代理均不可用'
|
return None, f"共 {len(proxy_urls)} 个代理均不可用"
|
||||||
|
|||||||
+53
-28
@@ -44,7 +44,8 @@ class FishFinRechargeConfig:
|
|||||||
app_key=os.getenv("FISH_FIN_RECHARGE_APP_KEY", "").strip(),
|
app_key=os.getenv("FISH_FIN_RECHARGE_APP_KEY", "").strip(),
|
||||||
notify_url=os.getenv("FISH_FIN_RECHARGE_NOTIFY_URL", "").strip(),
|
notify_url=os.getenv("FISH_FIN_RECHARGE_NOTIFY_URL", "").strip(),
|
||||||
timeout=(8, timeout),
|
timeout=(8, timeout),
|
||||||
debug=os.getenv("FISH_FIN_RECHARGE_DEBUG", "false").strip().lower() in {"1", "true", "yes"},
|
debug=os.getenv("FISH_FIN_RECHARGE_DEBUG", "false").strip().lower()
|
||||||
|
in {"1", "true", "yes"},
|
||||||
)
|
)
|
||||||
|
|
||||||
def validate(self) -> None:
|
def validate(self) -> None:
|
||||||
@@ -86,7 +87,9 @@ class FishFinRechargeClient:
|
|||||||
def _sign_value(value: Any) -> str:
|
def _sign_value(value: Any) -> str:
|
||||||
"""将参数转为待签名文本;对象按稳定紧凑 JSON 表示。"""
|
"""将参数转为待签名文本;对象按稳定紧凑 JSON 表示。"""
|
||||||
if isinstance(value, (dict, list, tuple)):
|
if isinstance(value, (dict, list, tuple)):
|
||||||
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
return json.dumps(
|
||||||
|
value, ensure_ascii=False, separators=(",", ":"), sort_keys=True
|
||||||
|
)
|
||||||
if isinstance(value, bool):
|
if isinstance(value, bool):
|
||||||
return "true" if value else "false"
|
return "true" if value else "false"
|
||||||
return str(value)
|
return str(value)
|
||||||
@@ -143,7 +146,9 @@ class FishFinRechargeClient:
|
|||||||
received = str(payload.get("sign") or "").strip().lower()
|
received = str(payload.get("sign") or "").strip().lower()
|
||||||
return bool(received) and received == self.sign(payload, method)
|
return bool(received) and received == self.sign(payload, method)
|
||||||
|
|
||||||
def _request(self, method: str, path: str, params: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
def _request(
|
||||||
|
self, method: str, path: str, params: Mapping[str, Any] | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
"""补齐公共参数、签名并执行一次 JSON 请求。"""
|
"""补齐公共参数、签名并执行一次 JSON 请求。"""
|
||||||
request_params: dict[str, Any] = {
|
request_params: dict[str, Any] = {
|
||||||
"app_id": self.config.app_id,
|
"app_id": self.config.app_id,
|
||||||
@@ -167,27 +172,37 @@ class FishFinRechargeClient:
|
|||||||
if key != "sign"
|
if key != "sign"
|
||||||
},
|
},
|
||||||
# 只记录摘要,便于关联排查而不暴露可重放的签名。
|
# 只记录摘要,便于关联排查而不暴露可重放的签名。
|
||||||
"sign_digest": hashlib.sha256(request_params["sign"].encode("utf-8")).hexdigest()[:12],
|
"sign_digest": hashlib.sha256(
|
||||||
|
request_params["sign"].encode("utf-8")
|
||||||
|
).hexdigest()[:12],
|
||||||
}
|
}
|
||||||
if self.config.debug:
|
if self.config.debug:
|
||||||
sign_params = self.normalized_params(request_params)
|
sign_params = self.normalized_params(request_params)
|
||||||
sign_query = "&".join(f"{key}={sign_params[key]}" for key in sorted(sign_params))
|
sign_query = "&".join(
|
||||||
trace_event.update({
|
f"{key}={sign_params[key]}" for key in sorted(sign_params)
|
||||||
"url": url,
|
)
|
||||||
"content_type": "application/json",
|
trace_event.update(
|
||||||
"json_body": self._debug_payload(request_params),
|
{
|
||||||
"sign_params": sign_params,
|
"url": url,
|
||||||
# 不把可重放签名原文或 AppSecret 写入日志,只记录待签名串摘要。
|
"content_type": "application/json",
|
||||||
"sign_source_digest": hashlib.sha256(
|
"json_body": self._debug_payload(request_params),
|
||||||
f"{sign_query}{method.upper()}".encode("utf-8")
|
"sign_params": sign_params,
|
||||||
).hexdigest()[:12],
|
# 不把可重放签名原文或 AppSecret 写入日志,只记录待签名串摘要。
|
||||||
})
|
"sign_source_digest": hashlib.sha256(
|
||||||
|
f"{sign_query}{method.upper()}".encode("utf-8")
|
||||||
|
).hexdigest()[:12],
|
||||||
|
}
|
||||||
|
)
|
||||||
self.trace(trace_event)
|
self.trace(trace_event)
|
||||||
try:
|
try:
|
||||||
if method.upper() == "GET":
|
if method.upper() == "GET":
|
||||||
response = self.session.get(url, params=request_params, timeout=self.config.timeout)
|
response = self.session.get(
|
||||||
|
url, params=request_params, timeout=self.config.timeout
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
response = self.session.post(url, json=request_params, timeout=self.config.timeout)
|
response = self.session.post(
|
||||||
|
url, json=request_params, timeout=self.config.timeout
|
||||||
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
payload = response.json()
|
payload = response.json()
|
||||||
except requests.RequestException as exc:
|
except requests.RequestException as exc:
|
||||||
@@ -205,18 +220,22 @@ class FishFinRechargeClient:
|
|||||||
"message": payload.get("msg") or payload.get("message") or "",
|
"message": payload.get("msg") or payload.get("message") or "",
|
||||||
"out_order_id": self._response_value(payload, "out_order_id", "outOrderId"),
|
"out_order_id": self._response_value(payload, "out_order_id", "outOrderId"),
|
||||||
"order_id": self._response_value(payload, "order_id", "orderId"),
|
"order_id": self._response_value(payload, "order_id", "orderId"),
|
||||||
"order_status": self._response_value(payload, "order_status", "orderStatus"),
|
"order_status": self._response_value(
|
||||||
|
payload, "order_status", "orderStatus"
|
||||||
|
),
|
||||||
"fail_reason": self._response_value(payload, "fail_reason", "failReason"),
|
"fail_reason": self._response_value(payload, "fail_reason", "failReason"),
|
||||||
}
|
}
|
||||||
if self.config.debug:
|
if self.config.debug:
|
||||||
trace_event.update({
|
trace_event.update(
|
||||||
"response_headers": {
|
{
|
||||||
key: value
|
"response_headers": {
|
||||||
for key, value in response.headers.items()
|
key: value
|
||||||
if key.lower() in {"content-type", "x-request-id", "request-id"}
|
for key, value in response.headers.items()
|
||||||
},
|
if key.lower() in {"content-type", "x-request-id", "request-id"}
|
||||||
"response_body": self._debug_payload(payload),
|
},
|
||||||
})
|
"response_body": self._debug_payload(payload),
|
||||||
|
}
|
||||||
|
)
|
||||||
self.trace(trace_event)
|
self.trace(trace_event)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
@@ -263,7 +282,11 @@ class FishFinRechargeClient:
|
|||||||
raise ValueError("product_id 不能为空")
|
raise ValueError("product_id 不能为空")
|
||||||
if not isinstance(buy_num, int) or isinstance(buy_num, bool) or buy_num < 1:
|
if not isinstance(buy_num, int) or isinstance(buy_num, bool) or buy_num < 1:
|
||||||
raise ValueError("buy_num 必须是不小于 1 的整数")
|
raise ValueError("buy_num 必须是不小于 1 的整数")
|
||||||
if not isinstance(order_type, int) or isinstance(order_type, bool) or order_type not in {0, 1, 2, 3}:
|
if (
|
||||||
|
not isinstance(order_type, int)
|
||||||
|
or isinstance(order_type, bool)
|
||||||
|
or order_type not in {0, 1, 2, 3}
|
||||||
|
):
|
||||||
raise ValueError("order_type 必须是 0 至 3 的整数")
|
raise ValueError("order_type 必须是 0 至 3 的整数")
|
||||||
if not isinstance(recharge_arg, list) or not recharge_arg:
|
if not isinstance(recharge_arg, list) or not recharge_arg:
|
||||||
raise ValueError("recharge_arg 必须是非空数组")
|
raise ValueError("recharge_arg 必须是非空数组")
|
||||||
@@ -287,7 +310,9 @@ class FishFinRechargeClient:
|
|||||||
out_order_id = str(out_order_id).strip()
|
out_order_id = str(out_order_id).strip()
|
||||||
if not out_order_id:
|
if not out_order_id:
|
||||||
raise ValueError("out_order_id 不能为空")
|
raise ValueError("out_order_id 不能为空")
|
||||||
return self._request("GET", self.QUERY_ORDER_PATH, {"out_order_id": out_order_id})
|
return self._request(
|
||||||
|
"GET", self.QUERY_ORDER_PATH, {"out_order_id": out_order_id}
|
||||||
|
)
|
||||||
|
|
||||||
def account_info(self) -> dict[str, Any]:
|
def account_info(self) -> dict[str, Any]:
|
||||||
"""查询商户账户信息。"""
|
"""查询商户账户信息。"""
|
||||||
|
|||||||
+192
-51
@@ -4,46 +4,78 @@ from typing import Any
|
|||||||
from Crypto.PublicKey import RSA
|
from Crypto.PublicKey import RSA
|
||||||
from Crypto.Cipher import PKCS1_v1_5, AES
|
from Crypto.Cipher import PKCS1_v1_5, AES
|
||||||
|
|
||||||
|
|
||||||
# 随机产生4个字符组成的字符串
|
# 随机产生4个字符组成的字符串
|
||||||
def four_random_chart() -> str:
|
def four_random_chart() -> str:
|
||||||
return hex(int(65536 * (1 + random.random())))[2:][1:]
|
return hex(int(65536 * (1 + random.random())))[2:][1:]
|
||||||
|
|
||||||
|
|
||||||
# PKCS#1 v1.5 填充 + RSA 加密
|
# PKCS#1 v1.5 填充 + RSA 加密
|
||||||
def parse_jsbn_bigint(n_obj:dict[Any, int]) -> int:
|
def parse_jsbn_bigint(n_obj: dict[Any, int]) -> int:
|
||||||
DB = 28
|
DB = 28
|
||||||
DV = 1 << DB
|
DV = 1 << DB
|
||||||
t = n_obj['t']
|
t = n_obj["t"]
|
||||||
|
|
||||||
result = 0
|
result = 0
|
||||||
for i in range(t):
|
for i in range(t):
|
||||||
result += n_obj[i] * (DV ** i)
|
result += n_obj[i] * (DV**i)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def encrypt_data(plaintext:str) -> str:
|
|
||||||
|
def encrypt_data(plaintext: str) -> str:
|
||||||
cipher = PKCS1_v1_5.new(public_key)
|
cipher = PKCS1_v1_5.new(public_key)
|
||||||
encrypted = cipher.encrypt(plaintext.encode('utf-8'))
|
encrypted = cipher.encrypt(plaintext.encode("utf-8"))
|
||||||
# 转换为十六进制,确保偶数长度
|
# 转换为十六进制,确保偶数长度
|
||||||
hex_result = encrypted.hex()
|
hex_result = encrypted.hex()
|
||||||
if len(hex_result) % 2 == 1:
|
if len(hex_result) % 2 == 1:
|
||||||
hex_result = '0' + hex_result
|
hex_result = "0" + hex_result
|
||||||
return hex_result
|
return hex_result
|
||||||
|
|
||||||
def RSA_jiami_r(str_16:str) -> str:
|
|
||||||
|
def RSA_jiami_r(str_16: str) -> str:
|
||||||
global public_key
|
global public_key
|
||||||
# 你的数据
|
# 你的数据
|
||||||
n_data = {
|
n_data = {
|
||||||
0: 134982529, 1: 254232810, 2: 164556709, 3: 234907349,
|
0: 134982529,
|
||||||
4: 134685994, 5: 35463984, 6: 258277946, 7: 12518857,
|
1: 254232810,
|
||||||
8: 44638621, 9: 93783641, 10: 212253739, 11: 62792472,
|
2: 164556709,
|
||||||
12: 186688352, 13: 109500232, 14: 182488077, 15: 261196188,
|
3: 234907349,
|
||||||
16: 26354094, 17: 103248217, 18: 106891695, 19: 165771045,
|
4: 134685994,
|
||||||
20: 41530993, 21: 263704736, 22: 111785174, 23: 12753611,
|
5: 35463984,
|
||||||
24: 232116673, 25: 155524985, 26: 218291229, 27: 122452343,
|
6: 258277946,
|
||||||
28: 248250238, 29: 118739550, 30: 251169095, 31: 129059733,
|
7: 12518857,
|
||||||
32: 149835464, 33: 5498868, 34: 71719731, 35: 154456417,
|
8: 44638621,
|
||||||
|
9: 93783641,
|
||||||
|
10: 212253739,
|
||||||
|
11: 62792472,
|
||||||
|
12: 186688352,
|
||||||
|
13: 109500232,
|
||||||
|
14: 182488077,
|
||||||
|
15: 261196188,
|
||||||
|
16: 26354094,
|
||||||
|
17: 103248217,
|
||||||
|
18: 106891695,
|
||||||
|
19: 165771045,
|
||||||
|
20: 41530993,
|
||||||
|
21: 263704736,
|
||||||
|
22: 111785174,
|
||||||
|
23: 12753611,
|
||||||
|
24: 232116673,
|
||||||
|
25: 155524985,
|
||||||
|
26: 218291229,
|
||||||
|
27: 122452343,
|
||||||
|
28: 248250238,
|
||||||
|
29: 118739550,
|
||||||
|
30: 251169095,
|
||||||
|
31: 129059733,
|
||||||
|
32: 149835464,
|
||||||
|
33: 5498868,
|
||||||
|
34: 71719731,
|
||||||
|
35: 154456417,
|
||||||
36: 49635,
|
36: 49635,
|
||||||
't': 37, 's': 0
|
"t": 37,
|
||||||
|
"s": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
e = 65537
|
e = 65537
|
||||||
@@ -54,11 +86,12 @@ def RSA_jiami_r(str_16:str) -> str:
|
|||||||
encrypted = encrypt_data(str_16)
|
encrypted = encrypt_data(str_16)
|
||||||
return encrypted
|
return encrypted
|
||||||
|
|
||||||
|
|
||||||
# AES加密
|
# AES加密
|
||||||
# 加密模式: AES-CBC
|
# 加密模式: AES-CBC
|
||||||
# 密钥长度: 128位
|
# 密钥长度: 128位
|
||||||
# IV: 固定为 "0000000000000000"
|
# IV: 固定为 "0000000000000000"
|
||||||
def parse_string_to_wordarray(text:str) -> list[int]:
|
def parse_string_to_wordarray(text: str) -> list[int]:
|
||||||
"""将字符串转换为 WordArray 格式"""
|
"""将字符串转换为 WordArray 格式"""
|
||||||
length = len(text)
|
length = len(text)
|
||||||
words = []
|
words = []
|
||||||
@@ -81,13 +114,15 @@ def parse_string_to_wordarray(text:str) -> list[int]:
|
|||||||
words[word_index] |= char_code << shift
|
words[word_index] |= char_code << shift
|
||||||
|
|
||||||
return words
|
return words
|
||||||
def AES_O(plaintext:str, str_16:str) -> list[int]:
|
|
||||||
|
|
||||||
|
def AES_O(plaintext: str, str_16: str) -> list[int]:
|
||||||
# 密钥
|
# 密钥
|
||||||
key_words = parse_string_to_wordarray(str_16)
|
key_words = parse_string_to_wordarray(str_16)
|
||||||
key = b''.join(w.to_bytes(4, 'big') for w in key_words)
|
key = b"".join(w.to_bytes(4, "big") for w in key_words)
|
||||||
|
|
||||||
# IV
|
# IV
|
||||||
iv = b'0000' * 4 # "0000000000000000"
|
iv = b"0000" * 4 # "0000000000000000"
|
||||||
|
|
||||||
# 填充(PKCS7)
|
# 填充(PKCS7)
|
||||||
pad_len = 16 - len(plaintext) % 16
|
pad_len = 16 - len(plaintext) % 16
|
||||||
@@ -100,13 +135,14 @@ def AES_O(plaintext:str, str_16:str) -> list[int]:
|
|||||||
# 结果是字节数组
|
# 结果是字节数组
|
||||||
return list(ciphertext)
|
return list(ciphertext)
|
||||||
|
|
||||||
|
|
||||||
# 自定义base64编码
|
# 自定义base64编码
|
||||||
def geetest_base64_encode(data:list[int]) -> dict[str, Any]:
|
def geetest_base64_encode(data: list[int]) -> dict[str, Any]:
|
||||||
"""极验自定义Base64编码"""
|
"""极验自定义Base64编码"""
|
||||||
|
|
||||||
# 配置
|
# 配置
|
||||||
charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789()'
|
charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789()"
|
||||||
pad_char = '.'
|
pad_char = "."
|
||||||
|
|
||||||
# 位掩码 (这些是打乱的)
|
# 位掩码 (这些是打乱的)
|
||||||
masks = [7274496, 9483264, 19220, 235]
|
masks = [7274496, 9483264, 19220, 235]
|
||||||
@@ -159,13 +195,10 @@ def geetest_base64_encode(data:list[int]) -> dict[str, Any]:
|
|||||||
|
|
||||||
break
|
break
|
||||||
|
|
||||||
return {
|
return {"res": encoded, "end": padding}
|
||||||
"res": encoded,
|
|
||||||
"end": padding
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def encrypt_string(e:str, t:list[int], n:str) -> str:
|
def encrypt_string(e: str, t: list[int], n: str) -> str:
|
||||||
"""
|
"""
|
||||||
JS加密函数的Python实现
|
JS加密函数的Python实现
|
||||||
|
|
||||||
@@ -185,7 +218,7 @@ def encrypt_string(e:str, t:list[int], n:str) -> str:
|
|||||||
|
|
||||||
# 每次读取2个字符(十六进制)
|
# 每次读取2个字符(十六进制)
|
||||||
while o < len(n):
|
while o < len(n):
|
||||||
r = n[o:o + 2] # 取2个字符
|
r = n[o : o + 2] # 取2个字符
|
||||||
if len(r) < 2:
|
if len(r) < 2:
|
||||||
break
|
break
|
||||||
o += 2
|
o += 2
|
||||||
@@ -205,7 +238,7 @@ def encrypt_string(e:str, t:list[int], n:str) -> str:
|
|||||||
return i
|
return i
|
||||||
|
|
||||||
|
|
||||||
def simple_md5(message:str) -> str:
|
def simple_md5(message: str) -> str:
|
||||||
"""
|
"""
|
||||||
简化版MD5实现,结构更清晰
|
简化版MD5实现,结构更清晰
|
||||||
"""
|
"""
|
||||||
@@ -216,30 +249,138 @@ def simple_md5(message:str) -> str:
|
|||||||
|
|
||||||
# 轮移位常量
|
# 轮移位常量
|
||||||
shifts = [
|
shifts = [
|
||||||
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,
|
7,
|
||||||
5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,
|
12,
|
||||||
4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23,
|
17,
|
||||||
6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21
|
22,
|
||||||
|
7,
|
||||||
|
12,
|
||||||
|
17,
|
||||||
|
22,
|
||||||
|
7,
|
||||||
|
12,
|
||||||
|
17,
|
||||||
|
22,
|
||||||
|
7,
|
||||||
|
12,
|
||||||
|
17,
|
||||||
|
22,
|
||||||
|
5,
|
||||||
|
9,
|
||||||
|
14,
|
||||||
|
20,
|
||||||
|
5,
|
||||||
|
9,
|
||||||
|
14,
|
||||||
|
20,
|
||||||
|
5,
|
||||||
|
9,
|
||||||
|
14,
|
||||||
|
20,
|
||||||
|
5,
|
||||||
|
9,
|
||||||
|
14,
|
||||||
|
20,
|
||||||
|
4,
|
||||||
|
11,
|
||||||
|
16,
|
||||||
|
23,
|
||||||
|
4,
|
||||||
|
11,
|
||||||
|
16,
|
||||||
|
23,
|
||||||
|
4,
|
||||||
|
11,
|
||||||
|
16,
|
||||||
|
23,
|
||||||
|
4,
|
||||||
|
11,
|
||||||
|
16,
|
||||||
|
23,
|
||||||
|
6,
|
||||||
|
10,
|
||||||
|
15,
|
||||||
|
21,
|
||||||
|
6,
|
||||||
|
10,
|
||||||
|
15,
|
||||||
|
21,
|
||||||
|
6,
|
||||||
|
10,
|
||||||
|
15,
|
||||||
|
21,
|
||||||
|
6,
|
||||||
|
10,
|
||||||
|
15,
|
||||||
|
21,
|
||||||
]
|
]
|
||||||
|
|
||||||
# K常数(与JavaScript版本中的常数对应)
|
# K常数(与JavaScript版本中的常数对应)
|
||||||
K = [
|
K = [
|
||||||
0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee,
|
0xD76AA478,
|
||||||
0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501,
|
0xE8C7B756,
|
||||||
0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be,
|
0x242070DB,
|
||||||
0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821,
|
0xC1BDCEEE,
|
||||||
0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa,
|
0xF57C0FAF,
|
||||||
0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8,
|
0x4787C62A,
|
||||||
0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed,
|
0xA8304613,
|
||||||
0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a,
|
0xFD469501,
|
||||||
0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c,
|
0x698098D8,
|
||||||
0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70,
|
0x8B44F7AF,
|
||||||
0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05,
|
0xFFFF5BB1,
|
||||||
0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665,
|
0x895CD7BE,
|
||||||
0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039,
|
0x6B901122,
|
||||||
0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1,
|
0xFD987193,
|
||||||
0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1,
|
0xA679438E,
|
||||||
0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391
|
0x49B40821,
|
||||||
|
0xF61E2562,
|
||||||
|
0xC040B340,
|
||||||
|
0x265E5A51,
|
||||||
|
0xE9B6C7AA,
|
||||||
|
0xD62F105D,
|
||||||
|
0x02441453,
|
||||||
|
0xD8A1E681,
|
||||||
|
0xE7D3FBC8,
|
||||||
|
0x21E1CDE6,
|
||||||
|
0xC33707D6,
|
||||||
|
0xF4D50D87,
|
||||||
|
0x455A14ED,
|
||||||
|
0xA9E3E905,
|
||||||
|
0xFCEFA3F8,
|
||||||
|
0x676F02D9,
|
||||||
|
0x8D2A4C8A,
|
||||||
|
0xFFFA3942,
|
||||||
|
0x8771F681,
|
||||||
|
0x6D9D6122,
|
||||||
|
0xFDE5380C,
|
||||||
|
0xA4BEEA44,
|
||||||
|
0x4BDECFA9,
|
||||||
|
0xF6BB4B60,
|
||||||
|
0xBEBFBC70,
|
||||||
|
0x289B7EC6,
|
||||||
|
0xEAA127FA,
|
||||||
|
0xD4EF3085,
|
||||||
|
0x04881D05,
|
||||||
|
0xD9D4D039,
|
||||||
|
0xE6DB99E5,
|
||||||
|
0x1FA27CF8,
|
||||||
|
0xC4AC5665,
|
||||||
|
0xF4292244,
|
||||||
|
0x432AFF97,
|
||||||
|
0xAB9423A7,
|
||||||
|
0xFC93A039,
|
||||||
|
0x655B59C3,
|
||||||
|
0x8F0CCC92,
|
||||||
|
0xFFEFF47D,
|
||||||
|
0x85845DD1,
|
||||||
|
0x6FA87E4F,
|
||||||
|
0xFE2CE6E0,
|
||||||
|
0xA3014314,
|
||||||
|
0x4E0811A1,
|
||||||
|
0xF7537E82,
|
||||||
|
0xBD3AF235,
|
||||||
|
0x2AD7D2BB,
|
||||||
|
0xEB86D391,
|
||||||
]
|
]
|
||||||
|
|
||||||
# 实际实现...
|
# 实际实现...
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ REQUEST_TIMEOUT = (3.05, 12)
|
|||||||
def pilImgToCv2(img: Image.Image, flag=cv2.COLOR_RGB2BGR):
|
def pilImgToCv2(img: Image.Image, flag=cv2.COLOR_RGB2BGR):
|
||||||
return cv2.cvtColor(np.asarray(img), flag)
|
return cv2.cvtColor(np.asarray(img), flag)
|
||||||
|
|
||||||
|
|
||||||
# 识别图片缺口返回滑块距离
|
# 识别图片缺口返回滑块距离
|
||||||
def shibie(img: Image.Image, slice: Image.Image):
|
def shibie(img: Image.Image, slice: Image.Image):
|
||||||
# 通过 pilImgToCv2 将图片置灰
|
# 通过 pilImgToCv2 将图片置灰
|
||||||
@@ -38,20 +39,70 @@ def shibie(img: Image.Image, slice: Image.Image):
|
|||||||
# showImg(resultBg) # 可以通过它来看处理后的图片效果
|
# showImg(resultBg) # 可以通过它来看处理后的图片效果
|
||||||
return distance
|
return distance
|
||||||
|
|
||||||
|
|
||||||
# 还原图片
|
# 还原图片
|
||||||
def restore_geetest_image(input_path:str, output_path:str) -> None:
|
def restore_geetest_image(input_path: str, output_path: str) -> None:
|
||||||
"""
|
"""
|
||||||
还原极验打乱的验证码图像
|
还原极验打乱的验证码图像
|
||||||
"""
|
"""
|
||||||
Ut = [
|
Ut = [
|
||||||
39, 38, 48, 49, 41, 40, 46, 47, 35, 34, 50, 51, 33, 32, 28, 29,
|
39,
|
||||||
27, 26, 36, 37, 31, 30, 44, 45, 43, 42, 12, 13, 23, 22, 14, 15,
|
38,
|
||||||
21, 20, 8, 9, 25, 24, 6, 7, 3, 2, 0, 1, 11, 10, 4, 5, 19, 18, 16, 17
|
48,
|
||||||
|
49,
|
||||||
|
41,
|
||||||
|
40,
|
||||||
|
46,
|
||||||
|
47,
|
||||||
|
35,
|
||||||
|
34,
|
||||||
|
50,
|
||||||
|
51,
|
||||||
|
33,
|
||||||
|
32,
|
||||||
|
28,
|
||||||
|
29,
|
||||||
|
27,
|
||||||
|
26,
|
||||||
|
36,
|
||||||
|
37,
|
||||||
|
31,
|
||||||
|
30,
|
||||||
|
44,
|
||||||
|
45,
|
||||||
|
43,
|
||||||
|
42,
|
||||||
|
12,
|
||||||
|
13,
|
||||||
|
23,
|
||||||
|
22,
|
||||||
|
14,
|
||||||
|
15,
|
||||||
|
21,
|
||||||
|
20,
|
||||||
|
8,
|
||||||
|
9,
|
||||||
|
25,
|
||||||
|
24,
|
||||||
|
6,
|
||||||
|
7,
|
||||||
|
3,
|
||||||
|
2,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
11,
|
||||||
|
10,
|
||||||
|
4,
|
||||||
|
5,
|
||||||
|
19,
|
||||||
|
18,
|
||||||
|
16,
|
||||||
|
17,
|
||||||
]
|
]
|
||||||
|
|
||||||
# 打开混淆图像
|
# 打开混淆图像
|
||||||
img = Image.open(input_path)
|
img = Image.open(input_path)
|
||||||
new_img = Image.new("RGB", (260,160))
|
new_img = Image.new("RGB", (260, 160))
|
||||||
r = 160
|
r = 160
|
||||||
for _ in range(len(Ut)):
|
for _ in range(len(Ut)):
|
||||||
a = r / 2
|
a = r / 2
|
||||||
@@ -63,11 +114,12 @@ def restore_geetest_image(input_path:str, output_path:str) -> None:
|
|||||||
new_img.save(output_path)
|
new_img.save(output_path)
|
||||||
logger.debug("图像已还原并保存到: {}", output_path)
|
logger.debug("图像已还原并保存到: {}", output_path)
|
||||||
|
|
||||||
|
|
||||||
# 下载图片
|
# 下载图片
|
||||||
def download_picture(bg:str, fullbg:str, slice:str) -> int:
|
def download_picture(bg: str, fullbg: str, slice: str) -> int:
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
if i == 0:
|
if i == 0:
|
||||||
url = "https://static.geetest.com/"+bg
|
url = "https://static.geetest.com/" + bg
|
||||||
response = requests.get(url, timeout=REQUEST_TIMEOUT)
|
response = requests.get(url, timeout=REQUEST_TIMEOUT)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
with open("bg.jpg", "wb") as f:
|
with open("bg.jpg", "wb") as f:
|
||||||
@@ -87,4 +139,4 @@ def download_picture(bg:str, fullbg:str, slice:str) -> int:
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
with open("slice.jpg", "wb") as f:
|
with open("slice.jpg", "wb") as f:
|
||||||
f.write(response.content)
|
f.write(response.content)
|
||||||
return shibie(Image.open('fullbg.jpg'), Image.open('slice.jpg'))
|
return shibie(Image.open("fullbg.jpg"), Image.open("slice.jpg"))
|
||||||
|
|||||||
+173
-143
@@ -29,13 +29,15 @@ def _get(
|
|||||||
def _parse_jsonp_response(response: requests.Response, source: str) -> dict:
|
def _parse_jsonp_response(response: requests.Response, source: str) -> dict:
|
||||||
"""解析极验 JSONP 响应。"""
|
"""解析极验 JSONP 响应。"""
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
match = re.search(r'\((.*)\)$', response.text)
|
match = re.search(r"\((.*)\)$", response.text)
|
||||||
if not match:
|
if not match:
|
||||||
raise ValueError(f"{source} 无法解析 JSONP 响应")
|
raise ValueError(f"{source} 无法解析 JSONP 响应")
|
||||||
|
|
||||||
data = json.loads(match.group(1))
|
data = json.loads(match.group(1))
|
||||||
if data.get('status') == 'error':
|
if data.get("status") == "error":
|
||||||
raise ValueError(f"{source} 失败: {data.get('user_error', data.get('error', '未知错误'))}")
|
raise ValueError(
|
||||||
|
f"{source} 失败: {data.get('user_error', data.get('error', '未知错误'))}"
|
||||||
|
)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -55,26 +57,26 @@ def _parse_json_response(response: requests.Response, source: str) -> dict:
|
|||||||
|
|
||||||
def get_challenge_gt_bak() -> Tuple[str, str]:
|
def get_challenge_gt_bak() -> Tuple[str, str]:
|
||||||
headers = {
|
headers = {
|
||||||
'accept': 'application/json, text/javascript, */*; q=0.01',
|
"accept": "application/json, text/javascript, */*; q=0.01",
|
||||||
'accept-language': 'zh-CN,zh;q=0.9',
|
"accept-language": "zh-CN,zh;q=0.9",
|
||||||
'priority': 'u=1, i',
|
"priority": "u=1, i",
|
||||||
'referer': 'https://demos.geetest.com/slide-float.html',
|
"referer": "https://demos.geetest.com/slide-float.html",
|
||||||
'sec-ch-ua': '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"',
|
"sec-ch-ua": '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"',
|
||||||
'sec-ch-ua-mobile': '?0',
|
"sec-ch-ua-mobile": "?0",
|
||||||
'sec-ch-ua-platform': '"Windows"',
|
"sec-ch-ua-platform": '"Windows"',
|
||||||
'sec-fetch-dest': 'empty',
|
"sec-fetch-dest": "empty",
|
||||||
'sec-fetch-mode': 'cors',
|
"sec-fetch-mode": "cors",
|
||||||
'sec-fetch-site': 'same-origin',
|
"sec-fetch-site": "same-origin",
|
||||||
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36',
|
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
|
||||||
'x-requested-with': 'XMLHttpRequest',
|
"x-requested-with": "XMLHttpRequest",
|
||||||
}
|
}
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
't': str(int(round(time.time() * 1000))),
|
"t": str(int(round(time.time() * 1000))),
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
'https://demos.geetest.com/gt/register-slide',
|
"https://demos.geetest.com/gt/register-slide",
|
||||||
params=params,
|
params=params,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
timeout=10,
|
timeout=10,
|
||||||
@@ -85,21 +87,21 @@ def get_challenge_gt_bak() -> Tuple[str, str]:
|
|||||||
|
|
||||||
def get_challenge_gt() -> Tuple[str, str]:
|
def get_challenge_gt() -> Tuple[str, str]:
|
||||||
headers = {
|
headers = {
|
||||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36',
|
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
|
||||||
'Accept': 'application/json, text/javascript, */*; q=0.01',
|
"Accept": "application/json, text/javascript, */*; q=0.01",
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
'referer': 'https://passport.douyu.com/member/login?state=https%3A%2F%2Fwww.douyu.com%2Fmember%2FcpSecurity%2Fcheck_geetest_status',
|
"referer": "https://passport.douyu.com/member/login?state=https%3A%2F%2Fwww.douyu.com%2Fmember%2FcpSecurity%2Fcheck_geetest_status",
|
||||||
}
|
}
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'type': '1',
|
"type": "1",
|
||||||
'nicknameOrPhoneEncrypt': '03ILaBwtmmCm0A==',
|
"nicknameOrPhoneEncrypt": "03ILaBwtmmCm0A==",
|
||||||
'password': '57219dddec71c31b7647683fa5306103',
|
"password": "57219dddec71c31b7647683fa5306103",
|
||||||
'biz_type': '1',
|
"biz_type": "1",
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
'https://passport.douyu.com/wgapi/member/passport/login',
|
"https://passport.douyu.com/wgapi/member/passport/login",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
data=data,
|
data=data,
|
||||||
timeout=10,
|
timeout=10,
|
||||||
@@ -114,28 +116,27 @@ def get_challenge_gt() -> Tuple[str, str]:
|
|||||||
raise ValueError(f"斗鱼登录接口返回中缺少极验参数: {preview}") from exc
|
raise ValueError(f"斗鱼登录接口返回中缺少极验参数: {preview}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def get_js_address(gt: str, proxies: Optional[Mapping[str, str]] = None) -> dict:
|
def get_js_address(gt: str, proxies: Optional[Mapping[str, str]] = None) -> dict:
|
||||||
headers = {
|
headers = {
|
||||||
'accept': '*/*',
|
"accept": "*/*",
|
||||||
'accept-language': 'zh-CN,zh;q=0.9',
|
"accept-language": "zh-CN,zh;q=0.9",
|
||||||
'referer': PASSPORT_REFERER,
|
"referer": PASSPORT_REFERER,
|
||||||
'sec-ch-ua': '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
|
"sec-ch-ua": '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
|
||||||
'sec-ch-ua-mobile': '?0',
|
"sec-ch-ua-mobile": "?0",
|
||||||
'sec-ch-ua-platform': '"macOS"',
|
"sec-ch-ua-platform": '"macOS"',
|
||||||
'sec-fetch-dest': 'script',
|
"sec-fetch-dest": "script",
|
||||||
'sec-fetch-mode': 'no-cors',
|
"sec-fetch-mode": "no-cors",
|
||||||
'sec-fetch-site': 'cross-site',
|
"sec-fetch-site": "cross-site",
|
||||||
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36',
|
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
|
||||||
}
|
}
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
'gt': gt,
|
"gt": gt,
|
||||||
'callback': 'geetest_' + str(int(round(time.time() * 1000))),
|
"callback": "geetest_" + str(int(round(time.time() * 1000))),
|
||||||
}
|
}
|
||||||
|
|
||||||
response = _get(
|
response = _get(
|
||||||
'https://api.geetest.com/gettype.php',
|
"https://api.geetest.com/gettype.php",
|
||||||
params=params,
|
params=params,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
proxies=proxies,
|
proxies=proxies,
|
||||||
@@ -143,6 +144,7 @@ def get_js_address(gt: str, proxies: Optional[Mapping[str, str]] = None) -> dict
|
|||||||
|
|
||||||
return _parse_jsonp_response(response, "极验 gettype")
|
return _parse_jsonp_response(response, "极验 gettype")
|
||||||
|
|
||||||
|
|
||||||
def get_c_s(
|
def get_c_s(
|
||||||
gt: str,
|
gt: str,
|
||||||
challenge: str,
|
challenge: str,
|
||||||
@@ -150,26 +152,32 @@ def get_c_s(
|
|||||||
proxies: Optional[Mapping[str, str]] = None,
|
proxies: Optional[Mapping[str, str]] = None,
|
||||||
) -> Tuple[list[int], str]:
|
) -> Tuple[list[int], str]:
|
||||||
headers = {
|
headers = {
|
||||||
'accept': '*/*',
|
"accept": "*/*",
|
||||||
'accept-language': 'zh-CN,zh;q=0.9',
|
"accept-language": "zh-CN,zh;q=0.9",
|
||||||
'referer': PASSPORT_REFERER,
|
"referer": PASSPORT_REFERER,
|
||||||
'sec-ch-ua': '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
|
"sec-ch-ua": '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
|
||||||
'sec-ch-ua-mobile': '?0',
|
"sec-ch-ua-mobile": "?0",
|
||||||
'sec-ch-ua-platform': '"macOS"',
|
"sec-ch-ua-platform": '"macOS"',
|
||||||
'sec-fetch-dest': 'script',
|
"sec-fetch-dest": "script",
|
||||||
'sec-fetch-mode': 'no-cors',
|
"sec-fetch-mode": "no-cors",
|
||||||
'sec-fetch-site': 'cross-site',
|
"sec-fetch-site": "cross-site",
|
||||||
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36',
|
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
|
||||||
}
|
}
|
||||||
|
|
||||||
response = _get(
|
response = _get(
|
||||||
'https://api.geetest.com/get.php?gt=' + gt + '&challenge=' + challenge + '&lang=zh-cn&pt=0&client_type=web&w=' + w + '&callback=geetest_' + str(
|
"https://api.geetest.com/get.php?gt="
|
||||||
int(round(time.time() * 1000))),
|
+ gt
|
||||||
|
+ "&challenge="
|
||||||
|
+ challenge
|
||||||
|
+ "&lang=zh-cn&pt=0&client_type=web&w="
|
||||||
|
+ w
|
||||||
|
+ "&callback=geetest_"
|
||||||
|
+ str(int(round(time.time() * 1000))),
|
||||||
headers=headers,
|
headers=headers,
|
||||||
proxies=proxies,
|
proxies=proxies,
|
||||||
)
|
)
|
||||||
data = _parse_jsonp_response(response, "极验 get.php")
|
data = _parse_jsonp_response(response, "极验 get.php")
|
||||||
return data['data']['c'], data['data']['s']
|
return data["data"]["c"], data["data"]["s"]
|
||||||
|
|
||||||
|
|
||||||
def req_fullpage_validate(
|
def req_fullpage_validate(
|
||||||
@@ -180,159 +188,181 @@ def req_fullpage_validate(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""HAR 中的 fullpage 最终校验,成功后直接返回 validate。"""
|
"""HAR 中的 fullpage 最终校验,成功后直接返回 validate。"""
|
||||||
headers = {
|
headers = {
|
||||||
'Accept': '*/*',
|
"Accept": "*/*",
|
||||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||||
'Connection': 'keep-alive',
|
"Connection": "keep-alive",
|
||||||
'Referer': PASSPORT_REFERER,
|
"Referer": PASSPORT_REFERER,
|
||||||
'Sec-Fetch-Dest': 'script',
|
"Sec-Fetch-Dest": "script",
|
||||||
'Sec-Fetch-Mode': 'no-cors',
|
"Sec-Fetch-Mode": "no-cors",
|
||||||
'Sec-Fetch-Site': 'cross-site',
|
"Sec-Fetch-Site": "cross-site",
|
||||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36',
|
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
|
||||||
'sec-ch-ua': '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
|
"sec-ch-ua": '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
|
||||||
'sec-ch-ua-mobile': '?0',
|
"sec-ch-ua-mobile": "?0",
|
||||||
'sec-ch-ua-platform': '"macOS"',
|
"sec-ch-ua-platform": '"macOS"',
|
||||||
}
|
}
|
||||||
|
|
||||||
response = _get(
|
response = _get(
|
||||||
'https://api.geetest.com/ajax.php?gt=' + gt + '&challenge=' + challenge + '&lang=zh-cn&pt=0&client_type=web&w=' + w + '&callback=geetest_' + str(
|
"https://api.geetest.com/ajax.php?gt="
|
||||||
int(round(time.time() * 1000))),
|
+ gt
|
||||||
|
+ "&challenge="
|
||||||
|
+ challenge
|
||||||
|
+ "&lang=zh-cn&pt=0&client_type=web&w="
|
||||||
|
+ w
|
||||||
|
+ "&callback=geetest_"
|
||||||
|
+ str(int(round(time.time() * 1000))),
|
||||||
headers=headers,
|
headers=headers,
|
||||||
proxies=proxies,
|
proxies=proxies,
|
||||||
)
|
)
|
||||||
return _parse_jsonp_response(response, "极验 fullpage ajax.php")
|
return _parse_jsonp_response(response, "极验 fullpage ajax.php")
|
||||||
|
|
||||||
def req_slide(gt:str, challenge:str, w2:str) -> None:
|
|
||||||
|
def req_slide(gt: str, challenge: str, w2: str) -> None:
|
||||||
headers = {
|
headers = {
|
||||||
'Accept': '*/*',
|
"Accept": "*/*",
|
||||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||||
'Connection': 'keep-alive',
|
"Connection": "keep-alive",
|
||||||
'Referer': 'https://demos.geetest.com/',
|
"Referer": "https://demos.geetest.com/",
|
||||||
'Sec-Fetch-Dest': 'script',
|
"Sec-Fetch-Dest": "script",
|
||||||
'Sec-Fetch-Mode': 'no-cors',
|
"Sec-Fetch-Mode": "no-cors",
|
||||||
'Sec-Fetch-Site': 'cross-site',
|
"Sec-Fetch-Site": "cross-site",
|
||||||
'Sec-Fetch-Storage-Access': 'active',
|
"Sec-Fetch-Storage-Access": "active",
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36',
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
|
||||||
'sec-ch-ua': '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"',
|
"sec-ch-ua": '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"',
|
||||||
'sec-ch-ua-mobile': '?0',
|
"sec-ch-ua-mobile": "?0",
|
||||||
'sec-ch-ua-platform': '"Windows"',
|
"sec-ch-ua-platform": '"Windows"',
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
'https://api.geevisit.com/ajax.php?gt=' + gt + '&challenge=' + challenge + '&lang=zh-cn&pt=0&client_type=web&w=' + w2 + '&callback=geetest_' + str(
|
"https://api.geevisit.com/ajax.php?gt="
|
||||||
int(round(time.time() * 1000))),
|
+ gt
|
||||||
|
+ "&challenge="
|
||||||
|
+ challenge
|
||||||
|
+ "&lang=zh-cn&pt=0&client_type=web&w="
|
||||||
|
+ w2
|
||||||
|
+ "&callback=geetest_"
|
||||||
|
+ str(int(round(time.time() * 1000))),
|
||||||
headers=headers,
|
headers=headers,
|
||||||
timeout=REQUEST_TIMEOUT,
|
timeout=REQUEST_TIMEOUT,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
def get_picture(gt:str, challenge:str) -> tuple[str, str, list[int], str, str, str]:
|
|
||||||
|
def get_picture(gt: str, challenge: str) -> tuple[str, str, list[int], str, str, str]:
|
||||||
headers = {
|
headers = {
|
||||||
'Accept': '*/*',
|
"Accept": "*/*",
|
||||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||||
'Connection': 'keep-alive',
|
"Connection": "keep-alive",
|
||||||
'Referer': 'https://demos.geetest.com/',
|
"Referer": "https://demos.geetest.com/",
|
||||||
'Sec-Fetch-Dest': 'script',
|
"Sec-Fetch-Dest": "script",
|
||||||
'Sec-Fetch-Mode': 'no-cors',
|
"Sec-Fetch-Mode": "no-cors",
|
||||||
'Sec-Fetch-Site': 'cross-site',
|
"Sec-Fetch-Site": "cross-site",
|
||||||
'Sec-Fetch-Storage-Access': 'active',
|
"Sec-Fetch-Storage-Access": "active",
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36',
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
|
||||||
'sec-ch-ua': '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"',
|
"sec-ch-ua": '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"',
|
||||||
'sec-ch-ua-mobile': '?0',
|
"sec-ch-ua-mobile": "?0",
|
||||||
'sec-ch-ua-platform': '"Windows"',
|
"sec-ch-ua-platform": '"Windows"',
|
||||||
}
|
}
|
||||||
params = {
|
params = {
|
||||||
'is_next': 'true',
|
"is_next": "true",
|
||||||
'type': 'slide3',
|
"type": "slide3",
|
||||||
'gt': gt,
|
"gt": gt,
|
||||||
'challenge': challenge,
|
"challenge": challenge,
|
||||||
'lang': 'zh-cn',
|
"lang": "zh-cn",
|
||||||
'https': 'true',
|
"https": "true",
|
||||||
'protocol': 'https://',
|
"protocol": "https://",
|
||||||
'offline': 'false',
|
"offline": "false",
|
||||||
'product': 'embed',
|
"product": "embed",
|
||||||
'api_server': 'api.geevisit.com',
|
"api_server": "api.geevisit.com",
|
||||||
'isPC': 'true',
|
"isPC": "true",
|
||||||
'autoReset': 'true',
|
"autoReset": "true",
|
||||||
'width': '100%',
|
"width": "100%",
|
||||||
'callback': 'geetest_'+str(int(round(time.time() * 1000))),
|
"callback": "geetest_" + str(int(round(time.time() * 1000))),
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
'https://api.geevisit.com/get.php',
|
"https://api.geevisit.com/get.php",
|
||||||
params=params,
|
params=params,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
timeout=REQUEST_TIMEOUT,
|
timeout=REQUEST_TIMEOUT,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
logger.debug("极验 get.php 原始响应: {}", response.text[:500])
|
logger.debug("极验 get.php 原始响应: {}", response.text[:500])
|
||||||
match = re.search(r'\((.*)\)$', response.text)
|
match = re.search(r"\((.*)\)$", response.text)
|
||||||
if match:
|
if match:
|
||||||
json_str = match.group(1)
|
json_str = match.group(1)
|
||||||
data = json.loads(json_str)
|
data = json.loads(json_str)
|
||||||
|
|
||||||
if 'data' in data and isinstance(data['data'], dict):
|
if "data" in data and isinstance(data["data"], dict):
|
||||||
# 新版极验格式
|
# 新版极验格式
|
||||||
inner_data = data['data']
|
inner_data = data["data"]
|
||||||
return (
|
return (
|
||||||
inner_data.get('bg', ''),
|
inner_data.get("bg", ""),
|
||||||
inner_data.get('fullbg', ''),
|
inner_data.get("fullbg", ""),
|
||||||
inner_data.get('c', []),
|
inner_data.get("c", []),
|
||||||
inner_data.get('s', ''),
|
inner_data.get("s", ""),
|
||||||
inner_data.get('slice', ''),
|
inner_data.get("slice", ""),
|
||||||
inner_data.get('challenge', '')
|
inner_data.get("challenge", ""),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# 旧版极验格式
|
# 旧版极验格式
|
||||||
return (
|
return (
|
||||||
data.get('bg', ''),
|
data.get("bg", ""),
|
||||||
data.get('fullbg', ''),
|
data.get("fullbg", ""),
|
||||||
data.get('c', []),
|
data.get("c", []),
|
||||||
data.get('s', ''),
|
data.get("s", ""),
|
||||||
data.get('slice', ''),
|
data.get("slice", ""),
|
||||||
data.get('challenge', '')
|
data.get("challenge", ""),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise ValueError("无法解析 JSONP 响应")
|
raise ValueError("无法解析 JSONP 响应")
|
||||||
|
|
||||||
def req_end(gt:str, challenge:str, w:str) -> dict:
|
|
||||||
|
def req_end(gt: str, challenge: str, w: str) -> dict:
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
'Accept': '*/*',
|
"Accept": "*/*",
|
||||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||||
'Connection': 'keep-alive',
|
"Connection": "keep-alive",
|
||||||
'Referer': 'https://demos.geetest.com/',
|
"Referer": "https://demos.geetest.com/",
|
||||||
'Sec-Fetch-Dest': 'script',
|
"Sec-Fetch-Dest": "script",
|
||||||
'Sec-Fetch-Mode': 'no-cors',
|
"Sec-Fetch-Mode": "no-cors",
|
||||||
'Sec-Fetch-Site': 'cross-site',
|
"Sec-Fetch-Site": "cross-site",
|
||||||
'Sec-Fetch-Storage-Access': 'active',
|
"Sec-Fetch-Storage-Access": "active",
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36',
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
|
||||||
'sec-ch-ua': '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"',
|
"sec-ch-ua": '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"',
|
||||||
'sec-ch-ua-mobile': '?0',
|
"sec-ch-ua-mobile": "?0",
|
||||||
'sec-ch-ua-platform': '"Windows"',
|
"sec-ch-ua-platform": '"Windows"',
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
'https://api.geevisit.com/ajax.php?gt='+gt+'&challenge='+challenge+'&lang=zh-cn&%24_BCm=0&client_type=web&w='+w+'&callback=geetest_'+str(int(round(time.time() * 1000))),
|
"https://api.geevisit.com/ajax.php?gt="
|
||||||
|
+ gt
|
||||||
|
+ "&challenge="
|
||||||
|
+ challenge
|
||||||
|
+ "&lang=zh-cn&%24_BCm=0&client_type=web&w="
|
||||||
|
+ w
|
||||||
|
+ "&callback=geetest_"
|
||||||
|
+ str(int(round(time.time() * 1000))),
|
||||||
headers=headers,
|
headers=headers,
|
||||||
timeout=REQUEST_TIMEOUT,
|
timeout=REQUEST_TIMEOUT,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
logger.debug("极验 ajax.php 原始响应: {}", response.text[:500])
|
logger.debug("极验 ajax.php 原始响应: {}", response.text[:500])
|
||||||
match = re.search(r'\((.*)\)$', response.text)
|
match = re.search(r"\((.*)\)$", response.text)
|
||||||
if match:
|
if match:
|
||||||
json_str = match.group(1)
|
json_str = match.group(1)
|
||||||
data = json.loads(json_str)
|
data = json.loads(json_str)
|
||||||
logger.debug("极验验证响应: {}", data)
|
logger.debug("极验验证响应: {}", data)
|
||||||
|
|
||||||
# 检查验证是否成功
|
# 检查验证是否成功
|
||||||
if data.get('success') == 1:
|
if data.get("success") == 1:
|
||||||
return data
|
return data
|
||||||
else:
|
else:
|
||||||
# 如果验证失败,返回错误信息
|
# 如果验证失败,返回错误信息
|
||||||
return {
|
return {
|
||||||
'success': 0,
|
"success": 0,
|
||||||
'message': data.get('message', '验证失败'),
|
"message": data.get("message", "验证失败"),
|
||||||
'validate': ''
|
"validate": "",
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
raise ValueError("无法解析 JSONP 响应")
|
raise ValueError("无法解析 JSONP 响应")
|
||||||
|
|||||||
@@ -18,63 +18,70 @@ def generate_fake_performance_timing(base_time: Optional[int] = None) -> dict[st
|
|||||||
|
|
||||||
# 定义合理的时间间隔范围(毫秒)
|
# 定义合理的时间间隔范围(毫秒)
|
||||||
intervals = {
|
intervals = {
|
||||||
'fetch': random.randint(1, 2),
|
"fetch": random.randint(1, 2),
|
||||||
'domain_lookup_start': random.randint(3, 5),
|
"domain_lookup_start": random.randint(3, 5),
|
||||||
'domain_lookup': random.randint(5, 15),
|
"domain_lookup": random.randint(5, 15),
|
||||||
'connect': random.randint(50, 150),
|
"connect": random.randint(50, 150),
|
||||||
'ssl_offset': random.randint(30, 50),
|
"ssl_offset": random.randint(30, 50),
|
||||||
'request': random.randint(1, 5),
|
"request": random.randint(1, 5),
|
||||||
'response': random.randint(20, 100),
|
"response": random.randint(20, 100),
|
||||||
'response_end': random.randint(1, 3),
|
"response_end": random.randint(1, 3),
|
||||||
'unload_start': random.randint(1, 3),
|
"unload_start": random.randint(1, 3),
|
||||||
'unload': random.randint(1, 5),
|
"unload": random.randint(1, 5),
|
||||||
'dom_loading': random.randint(1, 3),
|
"dom_loading": random.randint(1, 3),
|
||||||
'dom_interactive': random.randint(50, 200),
|
"dom_interactive": random.randint(50, 200),
|
||||||
'dom_content_loaded': random.randint(1, 3),
|
"dom_content_loaded": random.randint(1, 3),
|
||||||
'load_event': random.randint(0, 5)
|
"load_event": random.randint(0, 5),
|
||||||
}
|
}
|
||||||
|
|
||||||
timing = {}
|
timing = {}
|
||||||
|
|
||||||
# 按照时间顺序构建
|
# 按照时间顺序构建
|
||||||
timing['navigationStart'] = base_time
|
timing["navigationStart"] = base_time
|
||||||
timing['fetchStart'] = timing['navigationStart'] + intervals['fetch']
|
timing["fetchStart"] = timing["navigationStart"] + intervals["fetch"]
|
||||||
timing['domainLookupStart'] = timing['fetchStart'] + intervals['domain_lookup_start']
|
timing["domainLookupStart"] = (
|
||||||
timing['domainLookupEnd'] = timing['domainLookupStart'] + intervals['domain_lookup']
|
timing["fetchStart"] + intervals["domain_lookup_start"]
|
||||||
|
)
|
||||||
|
timing["domainLookupEnd"] = timing["domainLookupStart"] + intervals["domain_lookup"]
|
||||||
|
|
||||||
timing['connectStart'] = timing['domainLookupEnd']
|
timing["connectStart"] = timing["domainLookupEnd"]
|
||||||
timing['secureConnectionStart'] = timing['connectStart'] + intervals['ssl_offset']
|
timing["secureConnectionStart"] = timing["connectStart"] + intervals["ssl_offset"]
|
||||||
timing['connectEnd'] = timing['connectStart'] + intervals['connect']
|
timing["connectEnd"] = timing["connectStart"] + intervals["connect"]
|
||||||
|
|
||||||
timing['requestStart'] = timing['connectEnd'] + intervals['request']
|
timing["requestStart"] = timing["connectEnd"] + intervals["request"]
|
||||||
timing['responseStart'] = timing['requestStart'] + intervals['response']
|
timing["responseStart"] = timing["requestStart"] + intervals["response"]
|
||||||
timing['responseEnd'] = timing['responseStart'] + intervals['response_end']
|
timing["responseEnd"] = timing["responseStart"] + intervals["response_end"]
|
||||||
|
|
||||||
timing['unloadEventStart'] = timing['responseEnd'] + intervals['unload_start']
|
timing["unloadEventStart"] = timing["responseEnd"] + intervals["unload_start"]
|
||||||
timing['unloadEventEnd'] = timing['unloadEventStart'] + intervals['unload']
|
timing["unloadEventEnd"] = timing["unloadEventStart"] + intervals["unload"]
|
||||||
|
|
||||||
timing['domLoading'] = timing['unloadEventEnd'] + intervals['dom_loading']
|
timing["domLoading"] = timing["unloadEventEnd"] + intervals["dom_loading"]
|
||||||
timing['domInteractive'] = timing['domLoading'] + intervals['dom_interactive']
|
timing["domInteractive"] = timing["domLoading"] + intervals["dom_interactive"]
|
||||||
timing['domContentLoadedEventStart'] = timing['domInteractive']
|
timing["domContentLoadedEventStart"] = timing["domInteractive"]
|
||||||
timing['domContentLoadedEventEnd'] = timing['domInteractive'] + intervals['dom_content_loaded']
|
timing["domContentLoadedEventEnd"] = (
|
||||||
timing['domComplete'] = timing['domContentLoadedEventEnd']
|
timing["domInteractive"] + intervals["dom_content_loaded"]
|
||||||
timing['loadEventStart'] = timing['domComplete']
|
)
|
||||||
timing['loadEventEnd'] = timing['loadEventStart'] + intervals['load_event']
|
timing["domComplete"] = timing["domContentLoadedEventEnd"]
|
||||||
|
timing["loadEventStart"] = timing["domComplete"]
|
||||||
|
timing["loadEventEnd"] = timing["loadEventStart"] + intervals["load_event"]
|
||||||
|
|
||||||
# 无重定向的情况
|
# 无重定向的情况
|
||||||
timing['redirectStart'] = 0
|
timing["redirectStart"] = 0
|
||||||
timing['redirectEnd'] = 0
|
timing["redirectEnd"] = 0
|
||||||
|
|
||||||
return timing
|
return timing
|
||||||
|
|
||||||
|
|
||||||
def __ease_out_expo(sep):
|
def __ease_out_expo(sep):
|
||||||
'''
|
"""
|
||||||
轨迹相关操作
|
轨迹相关操作
|
||||||
'''
|
"""
|
||||||
if sep == 1:
|
if sep == 1:
|
||||||
return 1
|
return 1
|
||||||
else:
|
else:
|
||||||
return 1 - pow(2, -10 * sep)
|
return 1 - pow(2, -10 * sep)
|
||||||
|
|
||||||
|
|
||||||
def get_slide_track(distance):
|
def get_slide_track(distance):
|
||||||
"""
|
"""
|
||||||
根据滑动距离生成滑动轨迹
|
根据滑动距离生成滑动轨迹
|
||||||
@@ -86,7 +93,9 @@ def get_slide_track(distance):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
if not isinstance(distance, int) or distance < 0:
|
if not isinstance(distance, int) or distance < 0:
|
||||||
raise ValueError(f"distance类型必须是大于等于0的整数: distance: {distance}, type: {type(distance)}")
|
raise ValueError(
|
||||||
|
f"distance类型必须是大于等于0的整数: distance: {distance}, type: {type(distance)}"
|
||||||
|
)
|
||||||
# 初始化轨迹列表
|
# 初始化轨迹列表
|
||||||
slide_track = [
|
slide_track = [
|
||||||
[random.randint(-50, -10), random.randint(-50, -10), 0],
|
[random.randint(-50, -10), random.randint(-50, -10), 0],
|
||||||
|
|||||||
@@ -2,25 +2,60 @@ import time
|
|||||||
import random
|
import random
|
||||||
import json
|
import json
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from core.geetest.common.trajectory import generate_realistic_trajectory, process_mouse_trajectory, compress_trajectory, TrajectoryEncoder, \
|
from core.geetest.common.trajectory import (
|
||||||
H
|
generate_realistic_trajectory,
|
||||||
from core.geetest.common.crypto import four_random_chart, RSA_jiami_r, AES_O, geetest_base64_encode, encrypt_string, simple_md5
|
process_mouse_trajectory,
|
||||||
|
compress_trajectory,
|
||||||
|
TrajectoryEncoder,
|
||||||
|
H,
|
||||||
|
)
|
||||||
|
from core.geetest.common.crypto import (
|
||||||
|
four_random_chart,
|
||||||
|
RSA_jiami_r,
|
||||||
|
AES_O,
|
||||||
|
geetest_base64_encode,
|
||||||
|
encrypt_string,
|
||||||
|
simple_md5,
|
||||||
|
)
|
||||||
from core.geetest.common.imaging import download_picture
|
from core.geetest.common.imaging import download_picture
|
||||||
from core.geetest.common.network import get_challenge_gt, get_js_address, get_c_s, req_slide, get_picture, req_end
|
from core.geetest.common.network import (
|
||||||
from core.geetest.common.performance import generate_fake_performance_timing, get_slide_track
|
get_challenge_gt,
|
||||||
|
get_js_address,
|
||||||
|
get_c_s,
|
||||||
|
req_slide,
|
||||||
|
get_picture,
|
||||||
|
req_end,
|
||||||
|
)
|
||||||
|
from core.geetest.common.performance import (
|
||||||
|
generate_fake_performance_timing,
|
||||||
|
get_slide_track,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _generate_seed() -> str:
|
def _generate_seed() -> str:
|
||||||
return four_random_chart() + four_random_chart() + four_random_chart() + four_random_chart()
|
return (
|
||||||
|
four_random_chart()
|
||||||
|
+ four_random_chart()
|
||||||
|
+ four_random_chart()
|
||||||
|
+ four_random_chart()
|
||||||
|
)
|
||||||
|
|
||||||
def get_w1(gt:str, challenge:str, str_16:str) -> str:
|
|
||||||
|
def get_w1(gt: str, challenge: str, str_16: str) -> str:
|
||||||
r = RSA_jiami_r(str_16)
|
r = RSA_jiami_r(str_16)
|
||||||
plaintext = '{"gt":"' + gt + '","challenge":"' + challenge + '","offline":false,"new_captcha":true,"product":"float","width":"300px","https":true,"api_server":"apiv6.geetest.com","protocol":"https://","type":"fullpage","static_servers":["static.geetest.com/","static.geevisit.com/"],"voice":"/static/js/voice.1.2.6.js","click":"/static/js/click.3.1.2.js","beeline":"/static/js/beeline.1.0.1.js","fullpage":"/static/js/fullpage.9.2.0-guwyxh.js","slide":"/static/js/slide.7.9.3.js","geetest":"/static/js/geetest.6.0.9.js","aspect_radio":{"slide":103,"click":128,"voice":128,"beeline":50},"cc":16,"ww":true,"i":"-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1"}'
|
plaintext = (
|
||||||
|
'{"gt":"'
|
||||||
|
+ gt
|
||||||
|
+ '","challenge":"'
|
||||||
|
+ challenge
|
||||||
|
+ '","offline":false,"new_captcha":true,"product":"float","width":"300px","https":true,"api_server":"apiv6.geetest.com","protocol":"https://","type":"fullpage","static_servers":["static.geetest.com/","static.geevisit.com/"],"voice":"/static/js/voice.1.2.6.js","click":"/static/js/click.3.1.2.js","beeline":"/static/js/beeline.1.0.1.js","fullpage":"/static/js/fullpage.9.2.0-guwyxh.js","slide":"/static/js/slide.7.9.3.js","geetest":"/static/js/geetest.6.0.9.js","aspect_radio":{"slide":103,"click":128,"voice":128,"beeline":50},"cc":16,"ww":true,"i":"-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1!!-1"}'
|
||||||
|
)
|
||||||
o = AES_O(plaintext, str_16)
|
o = AES_O(plaintext, str_16)
|
||||||
i = geetest_base64_encode(o)
|
i = geetest_base64_encode(o)
|
||||||
return i['res'] + i['end'] + r
|
return i["res"] + i["end"] + r
|
||||||
|
|
||||||
def get_w2(gt:str, challenge:str, c:list[int], s:str,str_16:str) -> str:
|
|
||||||
|
def get_w2(gt: str, challenge: str, c: list[int], s: str, str_16: str) -> str:
|
||||||
# 伪造浏览器性能数据
|
# 伪造浏览器性能数据
|
||||||
fake_timing = generate_fake_performance_timing()
|
fake_timing = generate_fake_performance_timing()
|
||||||
# 映射
|
# 映射
|
||||||
@@ -45,7 +80,7 @@ def get_w2(gt:str, challenge:str, c:list[int], s:str,str_16:str) -> str:
|
|||||||
"r": fake_timing["domContentLoadedEventEnd"],
|
"r": fake_timing["domContentLoadedEventEnd"],
|
||||||
"s": fake_timing["domComplete"],
|
"s": fake_timing["domComplete"],
|
||||||
"t": fake_timing["loadEventStart"],
|
"t": fake_timing["loadEventStart"],
|
||||||
"u": fake_timing["loadEventEnd"]
|
"u": fake_timing["loadEventEnd"],
|
||||||
}
|
}
|
||||||
|
|
||||||
first_time = int(round(time.time() * 1000)) # 伪造脚本开始运行时间
|
first_time = int(round(time.time() * 1000)) # 伪造脚本开始运行时间
|
||||||
@@ -55,10 +90,9 @@ def get_w2(gt:str, challenge:str, c:list[int], s:str,str_16:str) -> str:
|
|||||||
start_y=random.randint(400, 500),
|
start_y=random.randint(400, 500),
|
||||||
end_x=853,
|
end_x=853,
|
||||||
end_y=288,
|
end_y=288,
|
||||||
start_time=first_time
|
start_time=first_time,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
trajectory = process_mouse_trajectory(guiji_yuanshu_shuzu)["data"]
|
trajectory = process_mouse_trajectory(guiji_yuanshu_shuzu)["data"]
|
||||||
compressed = compress_trajectory(trajectory)
|
compressed = compress_trajectory(trajectory)
|
||||||
tt = encrypt_string(compressed, c, s)
|
tt = encrypt_string(compressed, c, s)
|
||||||
@@ -67,14 +101,30 @@ def get_w2(gt:str, challenge:str, c:list[int], s:str,str_16:str) -> str:
|
|||||||
|
|
||||||
rp = simple_md5(gt + challenge + passtime)
|
rp = simple_md5(gt + challenge + passtime)
|
||||||
|
|
||||||
plaintext = '{"lang":"zh-cn","type":"fullpage","tt":"'+tt+'","light":"DIV_0","s":"c7c3e21112fe4f741921cb3e4ff9f7cb","h":"321f9af1e098233dbd03f250fd2b5e21","hh":"39bd9cad9e425c3a8f51610fd506e3b3","hi":"09eb21b3ae9542a9bc1e8b63b3d9a467","vip_order":-1,"ct":-1,"ep":{"v":"9.2.0-guwyxh","te":false,"$_BBn":true,"ven":"Google Inc. (AMD)","ren":"ANGLE (AMD, AMD Radeon RX 6750 GRE 12GB (0x000073DF) Direct3D11 vs_5_0 ps_5_0, D3D11)","fp":'+json.dumps(guiji_yuanshu_shuzu[0], separators=(',', ':'))+',"lp":'+json.dumps(guiji_yuanshu_shuzu[-1], separators=(',', ':'))+',"em":{"ph":0,"cp":0,"ek":"11","wd":1,"nt":0,"si":0,"sc":0},"tm":'+json.dumps(web_load_time, separators=(',', ':'))+',"dnf":"dnf","by":0},"passtime":'+passtime+',"rp":"'+rp+'","captcha_token":"112439067","tsfq":"xovrayel"}'
|
plaintext = (
|
||||||
|
'{"lang":"zh-cn","type":"fullpage","tt":"'
|
||||||
|
+ tt
|
||||||
|
+ '","light":"DIV_0","s":"c7c3e21112fe4f741921cb3e4ff9f7cb","h":"321f9af1e098233dbd03f250fd2b5e21","hh":"39bd9cad9e425c3a8f51610fd506e3b3","hi":"09eb21b3ae9542a9bc1e8b63b3d9a467","vip_order":-1,"ct":-1,"ep":{"v":"9.2.0-guwyxh","te":false,"$_BBn":true,"ven":"Google Inc. (AMD)","ren":"ANGLE (AMD, AMD Radeon RX 6750 GRE 12GB (0x000073DF) Direct3D11 vs_5_0 ps_5_0, D3D11)","fp":'
|
||||||
|
+ json.dumps(guiji_yuanshu_shuzu[0], separators=(",", ":"))
|
||||||
|
+ ',"lp":'
|
||||||
|
+ json.dumps(guiji_yuanshu_shuzu[-1], separators=(",", ":"))
|
||||||
|
+ ',"em":{"ph":0,"cp":0,"ek":"11","wd":1,"nt":0,"si":0,"sc":0},"tm":'
|
||||||
|
+ json.dumps(web_load_time, separators=(",", ":"))
|
||||||
|
+ ',"dnf":"dnf","by":0},"passtime":'
|
||||||
|
+ passtime
|
||||||
|
+ ',"rp":"'
|
||||||
|
+ rp
|
||||||
|
+ '","captcha_token":"112439067","tsfq":"xovrayel"}'
|
||||||
|
)
|
||||||
|
|
||||||
result = geetest_base64_encode(AES_O(plaintext, str_16))
|
result = geetest_base64_encode(AES_O(plaintext, str_16))
|
||||||
|
|
||||||
return result['res']+result['end']+result['end']
|
return result["res"] + result["end"] + result["end"]
|
||||||
|
|
||||||
def get_w3(str_16:str, challenge:str, hkjl:int, c:list[int], s:str, gt:str) -> str:
|
|
||||||
|
def get_w3(
|
||||||
|
str_16: str, challenge: str, hkjl: int, c: list[int], s: str, gt: str
|
||||||
|
) -> str:
|
||||||
encoder = TrajectoryEncoder()
|
encoder = TrajectoryEncoder()
|
||||||
u = RSA_jiami_r(str_16)
|
u = RSA_jiami_r(str_16)
|
||||||
# 伪造浏览器性能数据
|
# 伪造浏览器性能数据
|
||||||
@@ -101,11 +151,9 @@ def get_w3(str_16:str, challenge:str, hkjl:int, c:list[int], s:str, gt:str) -> s
|
|||||||
"r": fake_timing["domContentLoadedEventEnd"],
|
"r": fake_timing["domContentLoadedEventEnd"],
|
||||||
"s": fake_timing["domComplete"],
|
"s": fake_timing["domComplete"],
|
||||||
"t": fake_timing["loadEventStart"],
|
"t": fake_timing["loadEventStart"],
|
||||||
"u": fake_timing["loadEventEnd"]
|
"u": fake_timing["loadEventEnd"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
trajectory = get_slide_track(hkjl)[0]
|
trajectory = get_slide_track(hkjl)[0]
|
||||||
logger.debug("滑动轨迹: {}", trajectory)
|
logger.debug("滑动轨迹: {}", trajectory)
|
||||||
|
|
||||||
@@ -117,13 +165,25 @@ def get_w3(str_16:str, challenge:str, hkjl:int, c:list[int], s:str, gt:str) -> s
|
|||||||
|
|
||||||
passtime = str(trajectory[-1][2])
|
passtime = str(trajectory[-1][2])
|
||||||
|
|
||||||
|
|
||||||
rp = simple_md5(gt + challenge[:32] + passtime)
|
rp = simple_md5(gt + challenge[:32] + passtime)
|
||||||
|
|
||||||
plaintext = '{"lang":"zh-cn","userresponse":"'+userresponse+'","passtime":'+passtime+',"imgload":50,"aa":"'+aa+'","ep":{"v":"7.9.3","$_BIT":false,"me":true,"tm":'+json.dumps(web_load_time, separators=(',', ':'))+',"td":-1},"h9s9":"1816378497","rp":"'+rp+'"}'
|
plaintext = (
|
||||||
|
'{"lang":"zh-cn","userresponse":"'
|
||||||
|
+ userresponse
|
||||||
|
+ '","passtime":'
|
||||||
|
+ passtime
|
||||||
|
+ ',"imgload":50,"aa":"'
|
||||||
|
+ aa
|
||||||
|
+ '","ep":{"v":"7.9.3","$_BIT":false,"me":true,"tm":'
|
||||||
|
+ json.dumps(web_load_time, separators=(",", ":"))
|
||||||
|
+ ',"td":-1},"h9s9":"1816378497","rp":"'
|
||||||
|
+ rp
|
||||||
|
+ '"}'
|
||||||
|
)
|
||||||
|
|
||||||
|
h = geetest_base64_encode(AES_O(plaintext, str_16))["res"]
|
||||||
|
return h + u
|
||||||
|
|
||||||
h = geetest_base64_encode(AES_O(plaintext, str_16))['res']
|
|
||||||
return h+u
|
|
||||||
|
|
||||||
def run_solver() -> None:
|
def run_solver() -> None:
|
||||||
# 16位字符串
|
# 16位字符串
|
||||||
@@ -154,7 +214,7 @@ def run_solver() -> None:
|
|||||||
hkjl = download_picture(bg, fullbg, slice)
|
hkjl = download_picture(bg, fullbg, slice)
|
||||||
|
|
||||||
# 获取第三个w值
|
# 获取第三个w值
|
||||||
w3 = get_w3(str_16,challenge,hkjl,c,s,gt)
|
w3 = get_w3(str_16, challenge, hkjl, c, s, gt)
|
||||||
|
|
||||||
# 最后的验证
|
# 最后的验证
|
||||||
message = req_end(gt, challenge, w3)
|
message = req_end(gt, challenge, w3)
|
||||||
|
|||||||
+29
-13
@@ -26,6 +26,7 @@
|
|||||||
python -m core.huya.account_env <账号> <密码> --new-device # 抛弃旧环境, 换新设备
|
python -m core.huya.account_env <账号> <密码> --new-device # 抛弃旧环境, 换新设备
|
||||||
python -m core.huya.account_env <账号> --show # 只查看该账号绑定
|
python -m core.huya.account_env <账号> --show # 只查看该账号绑定
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -35,10 +36,10 @@ import sys
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from .device_profile import (
|
from .device_profile import (
|
||||||
HDID32, # 登录帧 t1.t0 32hex 常量 (R15 公式结果, k1 来源见 R39; device_profile.py)
|
HDID32, # 登录帧 t1.t0 32hex 常量 (R15 公式结果, k1 来源见 R39; device_profile.py)
|
||||||
_load_db,
|
_load_db,
|
||||||
_save_db,
|
_save_db,
|
||||||
get_profile, # 幂等画像: 同账号永远复用同一套 (data/huya_device_profiles.json)
|
get_profile, # 幂等画像: 同账号永远复用同一套 (data/huya_device_profiles.json)
|
||||||
)
|
)
|
||||||
from .app_login import HuyaAppPasswordLogin
|
from .app_login import HuyaAppPasswordLogin
|
||||||
|
|
||||||
@@ -77,7 +78,9 @@ def get_or_create_env(account: str, force_new: bool = False) -> dict:
|
|||||||
record["guid32"] = _rand_hex(16, b"guid")
|
record["guid32"] = _rand_hex(16, b"guid")
|
||||||
changed = True
|
changed = True
|
||||||
if "hebe" not in record:
|
if "hebe" not in record:
|
||||||
record["hebe"] = {f"Hebe_D{i}": _rand_hex(16, f"hebe{i}".encode()) for i in range(1, 6)}
|
record["hebe"] = {
|
||||||
|
f"Hebe_D{i}": _rand_hex(16, f"hebe{i}".encode()) for i in range(1, 6)
|
||||||
|
}
|
||||||
changed = True
|
changed = True
|
||||||
if changed:
|
if changed:
|
||||||
db[account] = record
|
db[account] = record
|
||||||
@@ -88,8 +91,12 @@ def get_or_create_env(account: str, force_new: bool = False) -> dict:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 绑定 + 登录: 主流程
|
# 绑定 + 登录: 主流程
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
def bind_and_login(account: str, password: str,
|
def bind_and_login(
|
||||||
force_new_device: bool = False, proxies: dict | None = None) -> dict:
|
account: str,
|
||||||
|
password: str,
|
||||||
|
force_new_device: bool = False,
|
||||||
|
proxies: dict | None = None,
|
||||||
|
) -> dict:
|
||||||
"""账号 ↔ 环境绑定并登录。
|
"""账号 ↔ 环境绑定并登录。
|
||||||
|
|
||||||
注册链在 login_cred_with_flow 内部执行一次 (register_device 用的 fingerprint
|
注册链在 login_cred_with_flow 内部执行一次 (register_device 用的 fingerprint
|
||||||
@@ -97,9 +104,11 @@ def bind_and_login(account: str, password: str,
|
|||||||
沿用同一组设备字段 (app_login.login_cred_with_flow)。
|
沿用同一组设备字段 (app_login.login_cred_with_flow)。
|
||||||
"""
|
"""
|
||||||
env = get_or_create_env(account, force_new=force_new_device)
|
env = get_or_create_env(account, force_new=force_new_device)
|
||||||
print(f"[env] {account} ↔ {env.get('vendor')}/{env.get('model')} "
|
print(
|
||||||
f"fp40={env.get('fingerprint', '')[:12]}... guid32={env.get('guid32', '')[:12]}... "
|
f"[env] {account} ↔ {env.get('vendor')}/{env.get('model')} "
|
||||||
f"t1.t0={env.get('hdid', '')[:12]}...")
|
f"fp40={env.get('fingerprint', '')[:12]}... guid32={env.get('guid32', '')[:12]}... "
|
||||||
|
f"t1.t0={env.get('hdid', '')[:12]}..."
|
||||||
|
)
|
||||||
|
|
||||||
# device_info 显式传入本环境 (否则 HuyaAppPasswordLogin 内部会再走一次
|
# device_info 显式传入本环境 (否则 HuyaAppPasswordLogin 内部会再走一次
|
||||||
# get_profile —— 结果相同, 但显式传入让"环境→注册→登录"的数据流向可读);
|
# get_profile —— 结果相同, 但显式传入让"环境→注册→登录"的数据流向可读);
|
||||||
@@ -108,13 +117,16 @@ def bind_and_login(account: str, password: str,
|
|||||||
login_env.setdefault("device_id", env.get("device_id"))
|
login_env.setdefault("device_id", env.get("device_id"))
|
||||||
|
|
||||||
result = HuyaAppPasswordLogin(
|
result = HuyaAppPasswordLogin(
|
||||||
account, password, proxies=proxies, device_info=login_env,
|
account,
|
||||||
|
password,
|
||||||
|
proxies=proxies,
|
||||||
|
device_info=login_env,
|
||||||
).login()
|
).login()
|
||||||
|
|
||||||
# ---- 绑定元数据回写 (令牌不入库: t2/t5 每次登录实时签发, 环境才是长期身份) ----
|
# ---- 绑定元数据回写 (令牌不入库: t2/t5 每次登录实时签发, 环境才是长期身份) ----
|
||||||
db = _load_db()
|
db = _load_db()
|
||||||
record = dict(db.get(account) or env)
|
record = dict(db.get(account) or env)
|
||||||
record.setdefault("bound_at", int(time.time())) # 首次绑定时间
|
record.setdefault("bound_at", int(time.time())) # 首次绑定时间
|
||||||
record["last_login"] = {
|
record["last_login"] = {
|
||||||
"ok": result.success,
|
"ok": result.success,
|
||||||
"msg": result.message[:120],
|
"msg": result.message[:120],
|
||||||
@@ -125,9 +137,13 @@ def bind_and_login(account: str, password: str,
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"account": account,
|
"account": account,
|
||||||
"env": {"model": env.get("model"), "vendor": env.get("vendor"),
|
"env": {
|
||||||
"fingerprint": env.get("fingerprint"), "guid32": env.get("guid32"),
|
"model": env.get("model"),
|
||||||
"hdid_t1t0": env.get("hdid")},
|
"vendor": env.get("vendor"),
|
||||||
|
"fingerprint": env.get("fingerprint"),
|
||||||
|
"guid32": env.get("guid32"),
|
||||||
|
"hdid_t1t0": env.get("hdid"),
|
||||||
|
},
|
||||||
"login_success": result.success,
|
"login_success": result.success,
|
||||||
"login_message": result.message,
|
"login_message": result.message,
|
||||||
"code": getattr(result, "code", None),
|
"code": getattr(result, "code", None),
|
||||||
|
|||||||
@@ -245,7 +245,9 @@ class ScoreExchangePrizeResp(TafStruct):
|
|||||||
self.msg = ins.read_string(1, default=self.msg)
|
self.msg = ins.read_string(1, default=self.msg)
|
||||||
self.orderId = ins.read_string(3, default=self.orderId)
|
self.orderId = ins.read_string(3, default=self.orderId)
|
||||||
self.exchangeInfo = ins.read_struct(4, ExchangeInfo) or self.exchangeInfo
|
self.exchangeInfo = ins.read_struct(4, ExchangeInfo) or self.exchangeInfo
|
||||||
self.actPreCondition = ins.read_struct(5, ExchangeActPreCondition) or self.actPreCondition
|
self.actPreCondition = (
|
||||||
|
ins.read_struct(5, ExchangeActPreCondition) or self.actPreCondition
|
||||||
|
)
|
||||||
|
|
||||||
def write_to(self, os: TafOutputStream):
|
def write_to(self, os: TafOutputStream):
|
||||||
os.write_int32(0, self.status)
|
os.write_int32(0, self.status)
|
||||||
@@ -512,7 +514,9 @@ class ActTaskDetailItem(TafStruct):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def spu_id(self) -> str:
|
def spu_id(self) -> str:
|
||||||
return self._extract_spu_id(self.taskUrl) or self._extract_spu_id(self.taskParams)
|
return self._extract_spu_id(self.taskUrl) or self._extract_spu_id(
|
||||||
|
self.taskParams
|
||||||
|
)
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -172,7 +172,11 @@ def register_huya_with_sms_line(
|
|||||||
if login_result.success and login_result.cookie:
|
if login_result.success and login_result.cookie:
|
||||||
cookie = login_result.cookie
|
cookie = login_result.cookie
|
||||||
uid = cookie_value(cookie, "udb_uid") or cookie_value(cookie, "yyuid")
|
uid = cookie_value(cookie, "udb_uid") or cookie_value(cookie, "yyuid")
|
||||||
username = cookie_value(cookie, "udb_passport") or cookie_value(cookie, "username") or uid
|
username = (
|
||||||
|
cookie_value(cookie, "udb_passport")
|
||||||
|
or cookie_value(cookie, "username")
|
||||||
|
or uid
|
||||||
|
)
|
||||||
if not change_password:
|
if not change_password:
|
||||||
return HuyaAutoRegisterResult(
|
return HuyaAutoRegisterResult(
|
||||||
phone=phone,
|
phone=phone,
|
||||||
@@ -207,7 +211,9 @@ def register_huya_with_sms_line(
|
|||||||
attempts=attempts,
|
attempts=attempts,
|
||||||
)
|
)
|
||||||
|
|
||||||
password = fixed_password.strip() or generate_huya_password(password_prefix)
|
password = fixed_password.strip() or generate_huya_password(
|
||||||
|
password_prefix
|
||||||
|
)
|
||||||
change_result = change_huya_password_with_sms_line(
|
change_result = change_huya_password_with_sms_line(
|
||||||
uid=uid,
|
uid=uid,
|
||||||
cookie=cookie,
|
cookie=cookie,
|
||||||
@@ -220,7 +226,9 @@ def register_huya_with_sms_line(
|
|||||||
stop_event=stop_event,
|
stop_event=stop_event,
|
||||||
)
|
)
|
||||||
if not change_result.success:
|
if not change_result.success:
|
||||||
failed_status = "stopped" if change_result.message == "已停止" else "error"
|
failed_status = (
|
||||||
|
"stopped" if change_result.message == "已停止" else "error"
|
||||||
|
)
|
||||||
return HuyaAutoRegisterResult(
|
return HuyaAutoRegisterResult(
|
||||||
phone=phone,
|
phone=phone,
|
||||||
provider=item.provider,
|
provider=item.provider,
|
||||||
@@ -230,7 +238,9 @@ def register_huya_with_sms_line(
|
|||||||
cookie=cookie,
|
cookie=cookie,
|
||||||
code=poll_result.code,
|
code=poll_result.code,
|
||||||
change_code=change_result.code,
|
change_code=change_result.code,
|
||||||
sdid=change_result.sdid or login_result.sdid or code_result.sdid,
|
sdid=change_result.sdid
|
||||||
|
or login_result.sdid
|
||||||
|
or code_result.sdid,
|
||||||
normalized_phone=normalized_phone,
|
normalized_phone=normalized_phone,
|
||||||
username=username,
|
username=username,
|
||||||
uid=uid,
|
uid=uid,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
证书格式:base64( [0x0c][key_idx][AES-128-ECB(key16, zeropad(P1))] )
|
证书格式:base64( [0x0c][key_idx][AES-128-ECB(key16, zeropad(P1))] )
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
@@ -48,7 +49,7 @@ def parse_p1(data: bytes) -> dict:
|
|||||||
|
|
||||||
def tk(n: int) -> bytes:
|
def tk(n: int) -> bytes:
|
||||||
nonlocal o
|
nonlocal o
|
||||||
b = data[o:o + n]
|
b = data[o : o + n]
|
||||||
o += n
|
o += n
|
||||||
return b
|
return b
|
||||||
|
|
||||||
|
|||||||
@@ -29,9 +29,12 @@ FP_STATE_ROOT = Path(__file__).resolve().parents[2] / "data" / "huya_fp_states"
|
|||||||
|
|
||||||
def account_state_dir(account: str) -> Path:
|
def account_state_dir(account: str) -> Path:
|
||||||
"""账号专属指纹状态目录 (持久化, 保证同一账号多次登录是同一台'设备')。"""
|
"""账号专属指纹状态目录 (持久化, 保证同一账号多次登录是同一台'设备')。"""
|
||||||
safe = "".join(c if c.isalnum() or c in "-_." else "_" for c in (account or "anon"))[:64]
|
safe = "".join(
|
||||||
|
c if c.isalnum() or c in "-_." else "_" for c in (account or "anon")
|
||||||
|
)[:64]
|
||||||
return FP_STATE_ROOT / safe
|
return FP_STATE_ROOT / safe
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_TIMEOUT = (8, 40)
|
DEFAULT_TIMEOUT = (8, 40)
|
||||||
|
|
||||||
|
|
||||||
@@ -52,7 +55,9 @@ class HuyaSdidResult:
|
|||||||
HDID_PREFIX = "__HDID__"
|
HDID_PREFIX = "__HDID__"
|
||||||
|
|
||||||
|
|
||||||
def _run_node_runner(state_dir: Path, app_id: str, timeout: tuple[float, float]) -> tuple[str, str]:
|
def _run_node_runner(
|
||||||
|
state_dir: Path, app_id: str, timeout: tuple[float, float]
|
||||||
|
) -> tuple[str, str]:
|
||||||
"""调用 node runner,返回 (sdid, hdid)。
|
"""调用 node runner,返回 (sdid, hdid)。
|
||||||
|
|
||||||
若 state_dir/device.json 存在 (账号画像派生的设备覆盖参数), runner 会以该
|
若 state_dir/device.json 存在 (账号画像派生的设备覆盖参数), runner 会以该
|
||||||
@@ -79,9 +84,9 @@ def _run_node_runner(state_dir: Path, app_id: str, timeout: tuple[float, float])
|
|||||||
for line in (proc.stdout or "").splitlines():
|
for line in (proc.stdout or "").splitlines():
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if line.startswith(SDID_PREFIX) and len(line) > len(SDID_PREFIX) + 20:
|
if line.startswith(SDID_PREFIX) and len(line) > len(SDID_PREFIX) + 20:
|
||||||
sdid = line[len(SDID_PREFIX):]
|
sdid = line[len(SDID_PREFIX) :]
|
||||||
if line.startswith(HDID_PREFIX) and len(line) > len(HDID_PREFIX) + 20:
|
if line.startswith(HDID_PREFIX) and len(line) > len(HDID_PREFIX) + 20:
|
||||||
hdid = line[len(HDID_PREFIX):]
|
hdid = line[len(HDID_PREFIX) :]
|
||||||
if sdid:
|
if sdid:
|
||||||
return sdid, hdid
|
return sdid, hdid
|
||||||
stderr_tail = (proc.stderr or "").strip().splitlines()
|
stderr_tail = (proc.stderr or "").strip().splitlines()
|
||||||
@@ -168,8 +173,9 @@ def get_huya_sdid(
|
|||||||
try:
|
try:
|
||||||
sdid, hdid = _run_node_runner(state_dir, app_id, timeout)
|
sdid, hdid = _run_node_runner(state_dir, app_id, timeout)
|
||||||
if sdid:
|
if sdid:
|
||||||
logger.debug("虎牙设备指纹成功(node): sdid={}... hdid={}...",
|
logger.debug(
|
||||||
sdid[:24], hdid[:10])
|
"虎牙设备指纹成功(node): sdid={}... hdid={}...", sdid[:24], hdid[:10]
|
||||||
|
)
|
||||||
return HuyaSdidResult(sdid=sdid, hdid=hdid, source="fingerprint")
|
return HuyaSdidResult(sdid=sdid, hdid=hdid, source="fingerprint")
|
||||||
except HuyaFingerprintError as exc:
|
except HuyaFingerprintError as exc:
|
||||||
logger.warning("虎牙 hydevice 指纹失败: {}", exc)
|
logger.warning("虎牙 hydevice 指纹失败: {}", exc)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
- 固定: hdid(32hex 硬锚,全账号同一) / app_version / sdk_version;
|
- 固定: hdid(32hex 硬锚,全账号同一) / app_version / sdk_version;
|
||||||
- 动态签发: safedeviceid、登录帧 device_id。
|
- 动态签发: safedeviceid、登录帧 device_id。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -85,6 +86,7 @@ def record_login(account: str, ok: bool, message: str = "") -> None:
|
|||||||
供 app_login 登录流程调用, GUI 设备绑定页读取展示。
|
供 app_login 登录流程调用, GUI 设备绑定页读取展示。
|
||||||
"""
|
"""
|
||||||
import time as _time
|
import time as _time
|
||||||
|
|
||||||
db = _load_db()
|
db = _load_db()
|
||||||
rec = db.get(account)
|
rec = db.get(account)
|
||||||
if rec is None:
|
if rec is None:
|
||||||
@@ -112,7 +114,9 @@ def _load_db() -> dict:
|
|||||||
def _save_db(db: dict) -> None:
|
def _save_db(db: dict) -> None:
|
||||||
try:
|
try:
|
||||||
PRIMARY_PROFILE_DB.parent.mkdir(parents=True, exist_ok=True)
|
PRIMARY_PROFILE_DB.parent.mkdir(parents=True, exist_ok=True)
|
||||||
PRIMARY_PROFILE_DB.write_text(json.dumps(db, indent=2, ensure_ascii=False), encoding="utf-8")
|
PRIMARY_PROFILE_DB.write_text(
|
||||||
|
json.dumps(db, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -130,8 +134,12 @@ def _enrich_profile(profile: dict) -> tuple[dict, bool]:
|
|||||||
out["guid32"] = hashlib.sha256(os.urandom(16) + b"guid").hexdigest()
|
out["guid32"] = hashlib.sha256(os.urandom(16) + b"guid").hexdigest()
|
||||||
changed = True
|
changed = True
|
||||||
if len(out.get("hebe") or {}) < 5:
|
if len(out.get("hebe") or {}) < 5:
|
||||||
out["hebe"] = {f"Hebe_D{i}": hashlib.sha256(os.urandom(16) + f"hebe{i}".encode()).hexdigest()
|
out["hebe"] = {
|
||||||
for i in range(1, 6)}
|
f"Hebe_D{i}": hashlib.sha256(
|
||||||
|
os.urandom(16) + f"hebe{i}".encode()
|
||||||
|
).hexdigest()
|
||||||
|
for i in range(1, 6)
|
||||||
|
}
|
||||||
changed = True
|
changed = True
|
||||||
return out, changed
|
return out, changed
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
基于 XXTEA 算法与 uid + k1 派生密钥。
|
基于 XXTEA 算法与 uid + k1 派生密钥。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -27,13 +28,23 @@ def _xxtea_encrypt_words(v: list[int], k: list[int]) -> list[int]:
|
|||||||
p = 0
|
p = 0
|
||||||
while p < n - 1:
|
while p < n - 1:
|
||||||
y = v[p + 1]
|
y = v[p + 1]
|
||||||
z = (v[p] + ((((z >> 5) ^ (y << 2)) + ((y >> 3) ^ (z << 4)))
|
z = (
|
||||||
^ ((s ^ y) + (k[(p & 3) ^ e] ^ z)))) & 0xFFFFFFFF
|
v[p]
|
||||||
|
+ (
|
||||||
|
(((z >> 5) ^ (y << 2)) + ((y >> 3) ^ (z << 4)))
|
||||||
|
^ ((s ^ y) + (k[(p & 3) ^ e] ^ z))
|
||||||
|
)
|
||||||
|
) & 0xFFFFFFFF
|
||||||
v[p] = z
|
v[p] = z
|
||||||
p += 1
|
p += 1
|
||||||
y = v[0]
|
y = v[0]
|
||||||
z = (v[n - 1] + ((((z >> 5) ^ (y << 2)) + ((y >> 3) ^ (z << 4)))
|
z = (
|
||||||
^ ((s ^ y) + (k[((n - 1) & 3) ^ e] ^ z)))) & 0xFFFFFFFF
|
v[n - 1]
|
||||||
|
+ (
|
||||||
|
(((z >> 5) ^ (y << 2)) + ((y >> 3) ^ (z << 4)))
|
||||||
|
^ ((s ^ y) + (k[((n - 1) & 3) ^ e] ^ z))
|
||||||
|
)
|
||||||
|
) & 0xFFFFFFFF
|
||||||
v[n - 1] = z
|
v[n - 1] = z
|
||||||
q -= 1
|
q -= 1
|
||||||
return v
|
return v
|
||||||
@@ -45,9 +56,9 @@ def xxtea_encrypt(data: bytes, key16: bytes) -> bytes:
|
|||||||
nwords = (n // 4) + 1
|
nwords = (n // 4) + 1
|
||||||
v = [0] * nwords
|
v = [0] * nwords
|
||||||
for i in range(n // 4):
|
for i in range(n // 4):
|
||||||
v[i] = struct.unpack("<I", data[i * 4:i * 4 + 4])[0]
|
v[i] = struct.unpack("<I", data[i * 4 : i * 4 + 4])[0]
|
||||||
v[nwords - 1] = n
|
v[nwords - 1] = n
|
||||||
k = [struct.unpack("<I", key16[i * 4:i * 4 + 4])[0] for i in range(4)]
|
k = [struct.unpack("<I", key16[i * 4 : i * 4 + 4])[0] for i in range(4)]
|
||||||
_xxtea_encrypt_words(v, k)
|
_xxtea_encrypt_words(v, k)
|
||||||
return b"".join(struct.pack("<I", w & 0xFFFFFFFF) for w in v)
|
return b"".join(struct.pack("<I", w & 0xFFFFFFFF) for w in v)
|
||||||
|
|
||||||
|
|||||||
+145
-120
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
来源:m-shop.yaoguo.com 的 jce/ShopFacade.js、api/orderui.ts、api/mall/PlayMallNewHome.ts
|
来源:m-shop.yaoguo.com 的 jce/ShopFacade.js、api/orderui.ts、api/mall/PlayMallNewHome.ts
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
from .taf_protocol import TafOutputStream, TafInputStream, TafStruct, TafType
|
from .taf_protocol import TafOutputStream, TafInputStream, TafStruct, TafType
|
||||||
|
|
||||||
@@ -78,17 +79,19 @@ def _skip_to_struct_end(ins: TafInputStream):
|
|||||||
# 基础结构
|
# 基础结构
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
class UserId(TafStruct):
|
class UserId(TafStruct):
|
||||||
"""用户标识(cookie 在这里)"""
|
"""用户标识(cookie 在这里)"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.lUid: int = 0 # tag 0
|
self.lUid: int = 0 # tag 0
|
||||||
self.sGuid: str = "" # tag 1
|
self.sGuid: str = "" # tag 1
|
||||||
self.sToken: str = "" # tag 2
|
self.sToken: str = "" # tag 2
|
||||||
self.sHuYaUA: str = "" # tag 3 "webh5&0.0.1&websocket&&diypc_52775"
|
self.sHuYaUA: str = "" # tag 3 "webh5&0.0.1&websocket&&diypc_52775"
|
||||||
self.sCookie: str = "" # tag 4 完整 cookie
|
self.sCookie: str = "" # tag 4 完整 cookie
|
||||||
self.iTokenType: int = 0 # tag 5
|
self.iTokenType: int = 0 # tag 5
|
||||||
self.sDeviceInfo: str = "" # tag 6
|
self.sDeviceInfo: str = "" # tag 6
|
||||||
self.sQIMEI: str = "" # tag 7
|
self.sQIMEI: str = "" # tag 7
|
||||||
|
|
||||||
def write_to(self, os: TafOutputStream):
|
def write_to(self, os: TafOutputStream):
|
||||||
# HAR2 实证: 浏览器不优化空串/0值, 全写字段
|
# HAR2 实证: 浏览器不优化空串/0值, 全写字段
|
||||||
@@ -114,11 +117,12 @@ class UserId(TafStruct):
|
|||||||
|
|
||||||
class ShopAppInfo(TafStruct):
|
class ShopAppInfo(TafStruct):
|
||||||
"""应用信息(HAR2 实证字段顺序: tag0 sAppId, tag1 sBizType, tag4 scene, tag5 sourceId)"""
|
"""应用信息(HAR2 实证字段顺序: tag0 sAppId, tag1 sBizType, tag4 scene, tag5 sourceId)"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.sAppId: str = "huya" # tag 0
|
self.sAppId: str = "huya" # tag 0
|
||||||
self.sBizType: str = "" # tag 1
|
self.sBizType: str = "" # tag 1
|
||||||
self.scene: int = 0 # tag 4
|
self.scene: int = 0 # tag 4
|
||||||
self.sourceId: str = "" # tag 5
|
self.sourceId: str = "" # tag 5
|
||||||
|
|
||||||
def write_to(self, os: TafOutputStream):
|
def write_to(self, os: TafOutputStream):
|
||||||
# HAR2 实证: 全写字段(含空串/0)
|
# HAR2 实证: 全写字段(含空串/0)
|
||||||
@@ -138,14 +142,16 @@ class ShopAppInfo(TafStruct):
|
|||||||
# wsLaunch 初始化
|
# wsLaunch 初始化
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
class WsLaunchSubStruct(TafStruct):
|
class WsLaunchSubStruct(TafStruct):
|
||||||
"""wsLaunch tag4 子结构(5个空字符串字段,浏览器强制写)"""
|
"""wsLaunch tag4 子结构(5个空字符串字段,浏览器强制写)"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.s0: str = "" # tag 0
|
self.s0: str = "" # tag 0
|
||||||
self.s1: str = "" # tag 1
|
self.s1: str = "" # tag 1
|
||||||
self.s2: str = "" # tag 2
|
self.s2: str = "" # tag 2
|
||||||
self.s3: str = "" # tag 3
|
self.s3: str = "" # tag 3
|
||||||
self.s4: str = "" # tag 4
|
self.s4: str = "" # tag 4
|
||||||
|
|
||||||
def write_to(self, os: TafOutputStream):
|
def write_to(self, os: TafOutputStream):
|
||||||
# 浏览器写空字符串(STRING1 length0),这里强制写以精确匹配
|
# 浏览器写空字符串(STRING1 length0),这里强制写以精确匹配
|
||||||
@@ -169,11 +175,12 @@ class WsLaunchReq(TafStruct):
|
|||||||
tag3: appSrc "HUYA&ZH&2052"
|
tag3: appSrc "HUYA&ZH&2052"
|
||||||
tag4: 子struct (5个空字符串)
|
tag4: 子struct (5个空字符串)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.lUid: int = 0 # tag 0
|
self.lUid: int = 0 # tag 0
|
||||||
self.s1: str = "" # tag 1
|
self.s1: str = "" # tag 1
|
||||||
self.sHuYaUA: str = "webh5&1.0.0&huya" # tag 2
|
self.sHuYaUA: str = "webh5&1.0.0&huya" # tag 2
|
||||||
self.appSrc: str = "HUYA&ZH&2052" # tag 3
|
self.appSrc: str = "HUYA&ZH&2052" # tag 3
|
||||||
self.sub: WsLaunchSubStruct = WsLaunchSubStruct() # tag 4
|
self.sub: WsLaunchSubStruct = WsLaunchSubStruct() # tag 4
|
||||||
|
|
||||||
def write_to(self, os: TafOutputStream):
|
def write_to(self, os: TafOutputStream):
|
||||||
@@ -192,18 +199,20 @@ class WsLaunchReq(TafStruct):
|
|||||||
# 商品查询
|
# 商品查询
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
class GetGoodsInfoReqV5(TafStruct):
|
class GetGoodsInfoReqV5(TafStruct):
|
||||||
"""商品查询请求 (shopMiddleUI.getGoodsInfoV5)"""
|
"""商品查询请求 (shopMiddleUI.getGoodsInfoV5)"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.userId = UserId() # tag 0
|
self.userId = UserId() # tag 0
|
||||||
self.shopAppInfo = ShopAppInfo() # tag 1
|
self.shopAppInfo = ShopAppInfo() # tag 1
|
||||||
self.pid: int = 0 # tag 2
|
self.pid: int = 0 # tag 2
|
||||||
self.gameId: str = "" # tag 3
|
self.gameId: str = "" # tag 3
|
||||||
self.spuId: str = "" # tag 4
|
self.spuId: str = "" # tag 4
|
||||||
self.channelStockCode: str = "" # tag 5
|
self.channelStockCode: str = "" # tag 5
|
||||||
self.skuId: int = 0 # tag 6
|
self.skuId: int = 0 # tag 6
|
||||||
self.inviterUid: int = 0 # tag 7
|
self.inviterUid: int = 0 # tag 7
|
||||||
self.userModifyPriceId: int = 0 # tag 8
|
self.userModifyPriceId: int = 0 # tag 8
|
||||||
|
|
||||||
def write_to(self, os: TafOutputStream):
|
def write_to(self, os: TafOutputStream):
|
||||||
# HAR2 实证: 全写字段
|
# HAR2 实证: 全写字段
|
||||||
@@ -224,13 +233,14 @@ class GetGoodsInfoReqV5(TafStruct):
|
|||||||
|
|
||||||
class GoodsInfoRsp(TafStruct):
|
class GoodsInfoRsp(TafStruct):
|
||||||
"""商品查询响应。"""
|
"""商品查询响应。"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.code: int = 0 # tag 0
|
self.code: int = 0 # tag 0
|
||||||
self.message: str = "" # tag 1
|
self.message: str = "" # tag 1
|
||||||
self.goodsInfo = None # tag 2
|
self.goodsInfo = None # tag 2
|
||||||
self.selfGoods: int = 0 # tag 3
|
self.selfGoods: int = 0 # tag 3
|
||||||
self.marketStatus: int = 0 # tag 5
|
self.marketStatus: int = 0 # tag 5
|
||||||
self.timestamp: int = 0 # tag 6
|
self.timestamp: int = 0 # tag 6
|
||||||
|
|
||||||
def read_from(self, ins: TafInputStream):
|
def read_from(self, ins: TafInputStream):
|
||||||
self.code = ins.read_int32(0, default=self.code)
|
self.code = ins.read_int32(0, default=self.code)
|
||||||
@@ -293,6 +303,7 @@ class GoodsInfoRsp(TafStruct):
|
|||||||
|
|
||||||
class GoodsBaseInfo(TafStruct):
|
class GoodsBaseInfo(TafStruct):
|
||||||
"""商品基础信息(getGoodsInfoV5 tag2.tag0)。"""
|
"""商品基础信息(getGoodsInfoV5 tag2.tag0)。"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.spuId: str = ""
|
self.spuId: str = ""
|
||||||
self.appId: str = ""
|
self.appId: str = ""
|
||||||
@@ -328,6 +339,7 @@ class GoodsBaseInfo(TafStruct):
|
|||||||
|
|
||||||
class GoodsSkuItem(TafStruct):
|
class GoodsSkuItem(TafStruct):
|
||||||
"""商品 SKU 信息(getGoodsInfoV5 tag2.tag4.tag3 map value)。"""
|
"""商品 SKU 信息(getGoodsInfoV5 tag2.tag4.tag3 map value)。"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.skuId: int = 0
|
self.skuId: int = 0
|
||||||
self.spuId: str = ""
|
self.spuId: str = ""
|
||||||
@@ -370,6 +382,7 @@ class GoodsSkuItem(TafStruct):
|
|||||||
|
|
||||||
class GoodsPriceInfo(TafStruct):
|
class GoodsPriceInfo(TafStruct):
|
||||||
"""商品价格与 SKU 信息(getGoodsInfoV5 tag2.tag4)。"""
|
"""商品价格与 SKU 信息(getGoodsInfoV5 tag2.tag4)。"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.spuId: str = ""
|
self.spuId: str = ""
|
||||||
self.minPrice: int = 0
|
self.minPrice: int = 0
|
||||||
@@ -435,6 +448,7 @@ class GoodsPriceInfo(TafStruct):
|
|||||||
|
|
||||||
class GoodsInfoDetail(TafStruct):
|
class GoodsInfoDetail(TafStruct):
|
||||||
"""getGoodsInfoV5 响应里的 goodsInfo 主体。"""
|
"""getGoodsInfoV5 响应里的 goodsInfo 主体。"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.baseInfo = GoodsBaseInfo()
|
self.baseInfo = GoodsBaseInfo()
|
||||||
self.priceInfo = GoodsPriceInfo()
|
self.priceInfo = GoodsPriceInfo()
|
||||||
@@ -457,8 +471,10 @@ class GoodsInfoDetail(TafStruct):
|
|||||||
# 订单历史
|
# 订单历史
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
class OrderListShopInfo(TafStruct):
|
class OrderListShopInfo(TafStruct):
|
||||||
"""订单明细里的店铺信息(只取展示需要的字段)"""
|
"""订单明细里的店铺信息(只取展示需要的字段)"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.shopName: str = "" # tag 0
|
self.shopName: str = "" # tag 0
|
||||||
|
|
||||||
@@ -477,14 +493,15 @@ class OrderListShopInfo(TafStruct):
|
|||||||
|
|
||||||
class OrderListGoodsDetail(TafStruct):
|
class OrderListGoodsDetail(TafStruct):
|
||||||
"""订单明细(queryUserOrderList 响应 tag16)"""
|
"""订单明细(queryUserOrderList 响应 tag16)"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.spuId: str = "" # tag 4
|
self.spuId: str = "" # tag 4
|
||||||
self.skuId: int = 0 # tag 16
|
self.skuId: int = 0 # tag 16
|
||||||
self.buyerUid: int = 0 # tag 18
|
self.buyerUid: int = 0 # tag 18
|
||||||
self.virtualType: int = 0 # tag 19
|
self.virtualType: int = 0 # tag 19
|
||||||
self.quantity: int = 0 # tag 20
|
self.quantity: int = 0 # tag 20
|
||||||
self.shopInfo: Optional[OrderListShopInfo] = None # tag 21
|
self.shopInfo: Optional[OrderListShopInfo] = None # tag 21
|
||||||
self.points: int = 0 # tag 23
|
self.points: int = 0 # tag 23
|
||||||
|
|
||||||
def read_from(self, ins: TafInputStream):
|
def read_from(self, ins: TafInputStream):
|
||||||
self.spuId = ins.read_string(4, default=self.spuId)
|
self.spuId = ins.read_string(4, default=self.spuId)
|
||||||
@@ -513,19 +530,20 @@ class OrderListGoodsDetail(TafStruct):
|
|||||||
|
|
||||||
class OrderListItem(TafStruct):
|
class OrderListItem(TafStruct):
|
||||||
"""订单列表项(queryUserOrderList 响应 tag3 的 list 元素)"""
|
"""订单列表项(queryUserOrderList 响应 tag3 的 list 元素)"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.bizOrderId: str = "" # tag 0 shop10148750
|
self.bizOrderId: str = "" # tag 0 shop10148750
|
||||||
self.appId: str = "" # tag 1 shop
|
self.appId: str = "" # tag 1 shop
|
||||||
self.orderId: str = "" # tag 2
|
self.orderId: str = "" # tag 2
|
||||||
self.pid: int = 0 # tag 3
|
self.pid: int = 0 # tag 3
|
||||||
self.shopName: str = "" # tag 4
|
self.shopName: str = "" # tag 4
|
||||||
self.orderStatus: int = 0 # tag 5
|
self.orderStatus: int = 0 # tag 5
|
||||||
self.itemName: str = "" # tag 8
|
self.itemName: str = "" # tag 8
|
||||||
self.unitPrice: int = 0 # tag 9 分
|
self.unitPrice: int = 0 # tag 9 分
|
||||||
self.quantity: int = 0 # tag 10
|
self.quantity: int = 0 # tag 10
|
||||||
self.totalPrice: int = 0 # tag 12 分
|
self.totalPrice: int = 0 # tag 12 分
|
||||||
self.createTime: int = 0 # tag 14 毫秒时间戳
|
self.createTime: int = 0 # tag 14 毫秒时间戳
|
||||||
self.payTime: int = 0 # tag 15 毫秒时间戳
|
self.payTime: int = 0 # tag 15 毫秒时间戳
|
||||||
self.goodsDetail: Optional[OrderListGoodsDetail] = None # tag 16
|
self.goodsDetail: Optional[OrderListGoodsDetail] = None # tag 16
|
||||||
|
|
||||||
def read_from(self, ins: TafInputStream):
|
def read_from(self, ins: TafInputStream):
|
||||||
@@ -567,12 +585,13 @@ class OrderListItem(TafStruct):
|
|||||||
|
|
||||||
class QueryUserOrderListReq(TafStruct):
|
class QueryUserOrderListReq(TafStruct):
|
||||||
"""查询购买历史订单 (revenueWebUI.queryUserOrderList)"""
|
"""查询购买历史订单 (revenueWebUI.queryUserOrderList)"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.userId = UserId() # tag 0
|
self.userId = UserId() # tag 0
|
||||||
self.offset: int = 0 # tag 1
|
self.offset: int = 0 # tag 1
|
||||||
self.orderType: int = 1 # tag 2
|
self.orderType: int = 1 # tag 2
|
||||||
self.pageSize: int = 10 # tag 3
|
self.pageSize: int = 10 # tag 3
|
||||||
self.status: int = 0 # tag 4
|
self.status: int = 0 # tag 4
|
||||||
|
|
||||||
def write_to(self, os: TafOutputStream):
|
def write_to(self, os: TafOutputStream):
|
||||||
os.write_struct(0, self.userId)
|
os.write_struct(0, self.userId)
|
||||||
@@ -587,6 +606,7 @@ class QueryUserOrderListReq(TafStruct):
|
|||||||
|
|
||||||
class QueryUserOrderListRsp(TafStruct):
|
class QueryUserOrderListRsp(TafStruct):
|
||||||
"""购买历史订单响应"""
|
"""购买历史订单响应"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.code: int = 0
|
self.code: int = 0
|
||||||
self.message: str = ""
|
self.message: str = ""
|
||||||
@@ -624,17 +644,18 @@ class QueryUserOrderListRsp(TafStruct):
|
|||||||
# 下单
|
# 下单
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
class CreateOrderExtraParam(TafStruct):
|
class CreateOrderExtraParam(TafStruct):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.freight: int = 0 # tag 0
|
self.freight: int = 0 # tag 0
|
||||||
self.channelStockType: str = "" # tag 1
|
self.channelStockType: str = "" # tag 1
|
||||||
self.channelStockCode: str = "" # tag 2
|
self.channelStockCode: str = "" # tag 2
|
||||||
self.relatedBizId: str = "" # tag 3
|
self.relatedBizId: str = "" # tag 3
|
||||||
self.bizParams: str = "" # tag 4
|
self.bizParams: str = "" # tag 4
|
||||||
self.popupTraceId: str = "" # tag 5
|
self.popupTraceId: str = "" # tag 5
|
||||||
self.supplierUid: int = 0 # tag 6
|
self.supplierUid: int = 0 # tag 6
|
||||||
self.categoryId: str = "" # tag 7
|
self.categoryId: str = "" # tag 7
|
||||||
self.ext: str = "" # tag 8
|
self.ext: str = "" # tag 8
|
||||||
|
|
||||||
def write_to(self, os: TafOutputStream):
|
def write_to(self, os: TafOutputStream):
|
||||||
# HAR 实证:下单 extraParam 会强制写默认 0/空串字段
|
# HAR 实证:下单 extraParam 会强制写默认 0/空串字段
|
||||||
@@ -655,9 +676,9 @@ class CreateOrderExtraParam(TafStruct):
|
|||||||
|
|
||||||
class CreateOrderPromotionParam(TafStruct):
|
class CreateOrderPromotionParam(TafStruct):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.yxjDeductPrice: int = 0 # tag 0
|
self.yxjDeductPrice: int = 0 # tag 0
|
||||||
self.userModifyPriceId: int = 0 # tag 1
|
self.userModifyPriceId: int = 0 # tag 1
|
||||||
self.enablePromotion: int = 1 # tag 2
|
self.enablePromotion: int = 1 # tag 2
|
||||||
|
|
||||||
def write_to(self, os: TafOutputStream):
|
def write_to(self, os: TafOutputStream):
|
||||||
# HAR 实证:tag0/tag1 为 0,tag2 为 1
|
# HAR 实证:tag0/tag1 为 0,tag2 为 1
|
||||||
@@ -671,11 +692,11 @@ class CreateOrderPromotionParam(TafStruct):
|
|||||||
|
|
||||||
class CreateOrderAccountParam(TafStruct):
|
class CreateOrderAccountParam(TafStruct):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.payoutTypeList: List[int] = [] # tag 0 Vector<INT32>
|
self.payoutTypeList: List[int] = [] # tag 0 Vector<INT32>
|
||||||
self.payoutChargeAmount: int = 0 # tag 1
|
self.payoutChargeAmount: int = 0 # tag 1
|
||||||
self.cancelPayoutTypeList: List[int] = [] # tag 2
|
self.cancelPayoutTypeList: List[int] = [] # tag 2
|
||||||
self.recycleSupplierId: int = 0 # tag 3
|
self.recycleSupplierId: int = 0 # tag 3
|
||||||
self.claimPrice: int = 0 # tag 4
|
self.claimPrice: int = 0 # tag 4
|
||||||
|
|
||||||
def write_to(self, os: TafOutputStream):
|
def write_to(self, os: TafOutputStream):
|
||||||
# HAR 实证:空 list/0 值也会写出
|
# HAR 实证:空 list/0 值也会写出
|
||||||
@@ -691,8 +712,8 @@ class CreateOrderAccountParam(TafStruct):
|
|||||||
|
|
||||||
class PromotionItem(TafStruct):
|
class PromotionItem(TafStruct):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.promotionId: int = 0 # tag 0
|
self.promotionId: int = 0 # tag 0
|
||||||
self.promotionType: int = 0 # tag 1
|
self.promotionType: int = 0 # tag 1
|
||||||
|
|
||||||
def write_to(self, os: TafOutputStream):
|
def write_to(self, os: TafOutputStream):
|
||||||
_opt_int(os, 0, self.promotionId)
|
_opt_int(os, 0, self.promotionId)
|
||||||
@@ -705,40 +726,41 @@ class PromotionItem(TafStruct):
|
|||||||
|
|
||||||
class CreateOrderReqV5(TafStruct):
|
class CreateOrderReqV5(TafStruct):
|
||||||
"""下单请求 (shopMiddleUI.createOrderV5)"""
|
"""下单请求 (shopMiddleUI.createOrderV5)"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.userId = UserId() # tag 0
|
self.userId = UserId() # tag 0
|
||||||
self.shopAppInfo = ShopAppInfo() # tag 1
|
self.shopAppInfo = ShopAppInfo() # tag 1
|
||||||
self.receiveId: int = 0 # tag 2
|
self.receiveId: int = 0 # tag 2
|
||||||
self.pid: int = 0 # tag 3
|
self.pid: int = 0 # tag 3
|
||||||
self.skuId: int = 0 # tag 4
|
self.skuId: int = 0 # tag 4
|
||||||
self.itemCount: int = 1 # tag 5
|
self.itemCount: int = 1 # tag 5
|
||||||
self.remark: str = "" # tag 6
|
self.remark: str = "" # tag 6
|
||||||
self.spuId: str = "" # tag 7
|
self.spuId: str = "" # tag 7
|
||||||
self.gameId: str = "" # tag 8
|
self.gameId: str = "" # tag 8
|
||||||
self.orderId: int = 0 # tag 9
|
self.orderId: int = 0 # tag 9
|
||||||
self.src: int = 0 # tag 10
|
self.src: int = 0 # tag 10
|
||||||
self.couponUserIds: List[int] = [] # tag 11 Vector<INT64>
|
self.couponUserIds: List[int] = [] # tag 11 Vector<INT64>
|
||||||
self.orderType: int = 0 # tag 12
|
self.orderType: int = 0 # tag 12
|
||||||
self.extraParam: Optional[CreateOrderExtraParam] = None # tag 13
|
self.extraParam: Optional[CreateOrderExtraParam] = None # tag 13
|
||||||
self.scene: int = 0 # tag 14
|
self.scene: int = 0 # tag 14
|
||||||
self.promotionItems: List = [] # tag 15 Vector<PromotionItem>
|
self.promotionItems: List = [] # tag 15 Vector<PromotionItem>
|
||||||
self.sourceId: str = "" # tag 16
|
self.sourceId: str = "" # tag 16
|
||||||
self.env: Dict[str, str] = {} # tag 17 Map<STRING,STRING>
|
self.env: Dict[str, str] = {} # tag 17 Map<STRING,STRING>
|
||||||
self.orderScene: int = 0 # tag 18
|
self.orderScene: int = 0 # tag 18
|
||||||
self.watchWord: str = "" # tag 19
|
self.watchWord: str = "" # tag 19
|
||||||
self.marketingChannel: str = "" # tag 20
|
self.marketingChannel: str = "" # tag 20
|
||||||
self.promotionParam: Optional[CreateOrderPromotionParam] = None # tag 21
|
self.promotionParam: Optional[CreateOrderPromotionParam] = None # tag 21
|
||||||
self.externalTraceKey: str = "" # tag 22
|
self.externalTraceKey: str = "" # tag 22
|
||||||
self.kefuUid: int = 0 # tag 23
|
self.kefuUid: int = 0 # tag 23
|
||||||
self.accountParam: Optional[CreateOrderAccountParam] = None # tag 24
|
self.accountParam: Optional[CreateOrderAccountParam] = None # tag 24
|
||||||
self.parentOrderId: int = 0 # tag 25
|
self.parentOrderId: int = 0 # tag 25
|
||||||
self.vendorAccountType: str = "" # tag 26
|
self.vendorAccountType: str = "" # tag 26
|
||||||
self.vendorAccountVal: str = "" # tag 27
|
self.vendorAccountVal: str = "" # tag 27
|
||||||
self.vendorSubAccountVal: str = "" # tag 28
|
self.vendorSubAccountVal: str = "" # tag 28
|
||||||
self.vendorSubAccountType: str = "" # tag 29
|
self.vendorSubAccountType: str = "" # tag 29
|
||||||
self.bizType: int = 0 # tag 30
|
self.bizType: int = 0 # tag 30
|
||||||
self.gameCategoryId: int = 0 # tag 31
|
self.gameCategoryId: int = 0 # tag 31
|
||||||
self.ext: str = "" # tag 32
|
self.ext: str = "" # tag 32
|
||||||
|
|
||||||
def write_to(self, os: TafOutputStream):
|
def write_to(self, os: TafOutputStream):
|
||||||
# HAR 实证:createOrderV5 会写出完整字段,即使值为 0/空串/空 list
|
# HAR 实证:createOrderV5 会写出完整字段,即使值为 0/空串/空 list
|
||||||
@@ -782,13 +804,14 @@ class CreateOrderReqV5(TafStruct):
|
|||||||
|
|
||||||
class CreateOrderRsp(TafStruct):
|
class CreateOrderRsp(TafStruct):
|
||||||
"""下单响应 (shopMiddleUI.createOrderV5)"""
|
"""下单响应 (shopMiddleUI.createOrderV5)"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.code: int = 0 # tag 0
|
self.code: int = 0 # tag 0
|
||||||
self.message: str = "" # tag 1
|
self.message: str = "" # tag 1
|
||||||
self.orderId: int = 0 # tag 2 虎牙订单号
|
self.orderId: int = 0 # tag 2 虎牙订单号
|
||||||
self.subOrderId: int = 0 # tag 4
|
self.subOrderId: int = 0 # tag 4
|
||||||
self.orderStatus: int = 0 # tag 5
|
self.orderStatus: int = 0 # tag 5
|
||||||
self.riskUrl: str = "" # tag 8 风控跳转URL(code!=200时可能有)
|
self.riskUrl: str = "" # tag 8 风控跳转URL(code!=200时可能有)
|
||||||
|
|
||||||
def read_from(self, ins: TafInputStream):
|
def read_from(self, ins: TafInputStream):
|
||||||
self.code = ins.read_int32(0, default=self.code)
|
self.code = ins.read_int32(0, default=self.code)
|
||||||
@@ -806,19 +829,21 @@ class CreateOrderRsp(TafStruct):
|
|||||||
# 支付(payOrderSubmitV5)
|
# 支付(payOrderSubmitV5)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
class PayOrderRes(TafStruct):
|
class PayOrderRes(TafStruct):
|
||||||
"""
|
"""
|
||||||
发起支付响应 (shopMiddleUI.payOrderSubmitV5)
|
发起支付响应 (shopMiddleUI.payOrderSubmitV5)
|
||||||
结构从 state_shop_ts.js 的 payOrderRes 推断,tag 顺序按出现顺序
|
结构从 state_shop_ts.js 的 payOrderRes 推断,tag 顺序按出现顺序
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.code: int = 0 # tag 0
|
self.code: int = 0 # tag 0
|
||||||
self.message: str = "" # tag 1
|
self.message: str = "" # tag 1
|
||||||
self.orderId: int = 0 # tag 2 虎牙订单号
|
self.orderId: int = 0 # tag 2 虎牙订单号
|
||||||
self.appOrderId: str = "" # tag 3
|
self.appOrderId: str = "" # tag 3
|
||||||
self.payOrderId: str = "" # tag 4 支付宝 payOrderId
|
self.payOrderId: str = "" # tag 4 支付宝 payOrderId
|
||||||
self.payUrl: str = "" # tag 5 支付宝下单URL(含sign)
|
self.payUrl: str = "" # tag 5 支付宝下单URL(含sign)
|
||||||
self.amount: int = 0 # tag 6
|
self.amount: int = 0 # tag 6
|
||||||
|
|
||||||
def read_from(self, ins: TafInputStream):
|
def read_from(self, ins: TafInputStream):
|
||||||
self.code = ins.read_int32(0, default=self.code)
|
self.code = ins.read_int32(0, default=self.code)
|
||||||
|
|||||||
+69
-52
@@ -8,6 +8,7 @@
|
|||||||
0x08 MAP 0x09 LIST 0x0a STRUCT_BEGIN 0x0b STRUCT_END
|
0x08 MAP 0x09 LIST 0x0a STRUCT_BEGIN 0x0b STRUCT_END
|
||||||
0x0c ZERO 0x0d SIMPLE_LIST
|
0x0c ZERO 0x0d SIMPLE_LIST
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import struct
|
import struct
|
||||||
import io
|
import io
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
@@ -24,16 +25,17 @@ class TafType:
|
|||||||
STRING4 = 0x07
|
STRING4 = 0x07
|
||||||
MAP = 0x08
|
MAP = 0x08
|
||||||
LIST = 0x09
|
LIST = 0x09
|
||||||
STRUCT_BEGIN = 0x0a
|
STRUCT_BEGIN = 0x0A
|
||||||
STRUCT_END = 0x0b
|
STRUCT_END = 0x0B
|
||||||
ZERO = 0x0c
|
ZERO = 0x0C
|
||||||
SIMPLE_LIST = 0x0d
|
SIMPLE_LIST = 0x0D
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# 输出流(编码)
|
# 输出流(编码)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
class TafOutputStream:
|
class TafOutputStream:
|
||||||
"""TAF 编码输出流"""
|
"""TAF 编码输出流"""
|
||||||
|
|
||||||
@@ -46,9 +48,9 @@ class TafOutputStream:
|
|||||||
# ---- head ----
|
# ---- head ----
|
||||||
def write_head(self, tag: int, data_type: int):
|
def write_head(self, tag: int, data_type: int):
|
||||||
if tag < 15:
|
if tag < 15:
|
||||||
self.buf.write(struct.pack('B', (tag << 4) | data_type))
|
self.buf.write(struct.pack("B", (tag << 4) | data_type))
|
||||||
else:
|
else:
|
||||||
self.buf.write(struct.pack('BB', 0xF0 | data_type, tag))
|
self.buf.write(struct.pack("BB", 0xF0 | data_type, tag))
|
||||||
|
|
||||||
# ---- 整数(带自动优化) ----
|
# ---- 整数(带自动优化) ----
|
||||||
def write_int8(self, tag: int, value: int):
|
def write_int8(self, tag: int, value: int):
|
||||||
@@ -56,28 +58,28 @@ class TafOutputStream:
|
|||||||
self.write_head(tag, TafType.ZERO)
|
self.write_head(tag, TafType.ZERO)
|
||||||
else:
|
else:
|
||||||
self.write_head(tag, TafType.INT8)
|
self.write_head(tag, TafType.INT8)
|
||||||
self.buf.write(struct.pack('b', value))
|
self.buf.write(struct.pack("b", value))
|
||||||
|
|
||||||
def write_int16(self, tag: int, value: int):
|
def write_int16(self, tag: int, value: int):
|
||||||
if -128 <= value <= 127:
|
if -128 <= value <= 127:
|
||||||
self.write_int8(tag, value)
|
self.write_int8(tag, value)
|
||||||
else:
|
else:
|
||||||
self.write_head(tag, TafType.INT16)
|
self.write_head(tag, TafType.INT16)
|
||||||
self.buf.write(struct.pack('>h', value))
|
self.buf.write(struct.pack(">h", value))
|
||||||
|
|
||||||
def write_int32(self, tag: int, value: int):
|
def write_int32(self, tag: int, value: int):
|
||||||
if -32768 <= value <= 32767:
|
if -32768 <= value <= 32767:
|
||||||
self.write_int16(tag, value)
|
self.write_int16(tag, value)
|
||||||
else:
|
else:
|
||||||
self.write_head(tag, TafType.INT32)
|
self.write_head(tag, TafType.INT32)
|
||||||
self.buf.write(struct.pack('>i', value))
|
self.buf.write(struct.pack(">i", value))
|
||||||
|
|
||||||
def write_int64(self, tag: int, value: int):
|
def write_int64(self, tag: int, value: int):
|
||||||
if -2147483648 <= value <= 2147483647:
|
if -2147483648 <= value <= 2147483647:
|
||||||
self.write_int32(tag, value)
|
self.write_int32(tag, value)
|
||||||
else:
|
else:
|
||||||
self.write_head(tag, TafType.INT64)
|
self.write_head(tag, TafType.INT64)
|
||||||
self.buf.write(struct.pack('>q', value))
|
self.buf.write(struct.pack(">q", value))
|
||||||
|
|
||||||
def write_uint64(self, tag: int, value: int):
|
def write_uint64(self, tag: int, value: int):
|
||||||
"""uint64:超过 int32 范围用 INT64"""
|
"""uint64:超过 int32 范围用 INT64"""
|
||||||
@@ -85,27 +87,27 @@ class TafOutputStream:
|
|||||||
self.write_int32(tag, value)
|
self.write_int32(tag, value)
|
||||||
else:
|
else:
|
||||||
self.write_head(tag, TafType.INT64)
|
self.write_head(tag, TafType.INT64)
|
||||||
self.buf.write(struct.pack('>Q', value))
|
self.buf.write(struct.pack(">Q", value))
|
||||||
|
|
||||||
# ---- 浮点 ----
|
# ---- 浮点 ----
|
||||||
def write_float(self, tag: int, value: float):
|
def write_float(self, tag: int, value: float):
|
||||||
self.write_head(tag, TafType.FLOAT)
|
self.write_head(tag, TafType.FLOAT)
|
||||||
self.buf.write(struct.pack('>f', value))
|
self.buf.write(struct.pack(">f", value))
|
||||||
|
|
||||||
def write_double(self, tag: int, value: float):
|
def write_double(self, tag: int, value: float):
|
||||||
self.write_head(tag, TafType.DOUBLE)
|
self.write_head(tag, TafType.DOUBLE)
|
||||||
self.buf.write(struct.pack('>d', value))
|
self.buf.write(struct.pack(">d", value))
|
||||||
|
|
||||||
# ---- 字符串 ----
|
# ---- 字符串 ----
|
||||||
def write_string(self, tag: int, value: str):
|
def write_string(self, tag: int, value: str):
|
||||||
encoded = value.encode('utf-8')
|
encoded = value.encode("utf-8")
|
||||||
length = len(encoded)
|
length = len(encoded)
|
||||||
if length > 255:
|
if length > 255:
|
||||||
self.write_head(tag, TafType.STRING4)
|
self.write_head(tag, TafType.STRING4)
|
||||||
self.buf.write(struct.pack('>I', length))
|
self.buf.write(struct.pack(">I", length))
|
||||||
else:
|
else:
|
||||||
self.write_head(tag, TafType.STRING1)
|
self.write_head(tag, TafType.STRING1)
|
||||||
self.buf.write(struct.pack('B', length))
|
self.buf.write(struct.pack("B", length))
|
||||||
self.buf.write(encoded)
|
self.buf.write(encoded)
|
||||||
|
|
||||||
# ---- 字节数组 ----
|
# ---- 字节数组 ----
|
||||||
@@ -133,8 +135,9 @@ class TafOutputStream:
|
|||||||
self.write_struct_end()
|
self.write_struct_end()
|
||||||
|
|
||||||
# ---- Map ----
|
# ---- Map ----
|
||||||
def write_map(self, tag: int, value: Dict[Any, Any],
|
def write_map(
|
||||||
key_writer=None, val_writer=None):
|
self, tag: int, value: Dict[Any, Any], key_writer=None, val_writer=None
|
||||||
|
):
|
||||||
self.write_head(tag, TafType.MAP)
|
self.write_head(tag, TafType.MAP)
|
||||||
self.write_int32(0, len(value))
|
self.write_int32(0, len(value))
|
||||||
for k, v in value.items():
|
for k, v in value.items():
|
||||||
@@ -172,7 +175,7 @@ class TafOutputStream:
|
|||||||
self.write_map(tag, value)
|
self.write_map(tag, value)
|
||||||
elif isinstance(value, (list, tuple)):
|
elif isinstance(value, (list, tuple)):
|
||||||
self.write_list(tag, list(value))
|
self.write_list(tag, list(value))
|
||||||
elif hasattr(value, 'write_to'):
|
elif hasattr(value, "write_to"):
|
||||||
self.write_struct(tag, value)
|
self.write_struct(tag, value)
|
||||||
else:
|
else:
|
||||||
raise TypeError(f"不支持的类型: {type(value)}")
|
raise TypeError(f"不支持的类型: {type(value)}")
|
||||||
@@ -182,6 +185,7 @@ class TafOutputStream:
|
|||||||
# 输入流(解码)—— 完整实现,支持所有类型
|
# 输入流(解码)—— 完整实现,支持所有类型
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
class TafInputStream:
|
class TafInputStream:
|
||||||
"""TAF 解码输入流"""
|
"""TAF 解码输入流"""
|
||||||
|
|
||||||
@@ -201,14 +205,14 @@ class TafInputStream:
|
|||||||
data = self.buf.read(1)
|
data = self.buf.read(1)
|
||||||
if not data:
|
if not data:
|
||||||
raise EOFError("读取到文件末尾")
|
raise EOFError("读取到文件末尾")
|
||||||
b = struct.unpack('B', data)[0]
|
b = struct.unpack("B", data)[0]
|
||||||
tag = (b >> 4) & 0x0F
|
tag = (b >> 4) & 0x0F
|
||||||
data_type = b & 0x0F
|
data_type = b & 0x0F
|
||||||
if tag == 15:
|
if tag == 15:
|
||||||
data = self.buf.read(1)
|
data = self.buf.read(1)
|
||||||
if not data:
|
if not data:
|
||||||
raise EOFError("读取 tag 扩展字节失败")
|
raise EOFError("读取 tag 扩展字节失败")
|
||||||
tag = struct.unpack('B', data)[0]
|
tag = struct.unpack("B", data)[0]
|
||||||
return tag, data_type
|
return tag, data_type
|
||||||
|
|
||||||
# ---- 跳过 ----
|
# ---- 跳过 ----
|
||||||
@@ -228,10 +232,10 @@ class TafInputStream:
|
|||||||
elif data_type == TafType.DOUBLE:
|
elif data_type == TafType.DOUBLE:
|
||||||
self.buf.read(8)
|
self.buf.read(8)
|
||||||
elif data_type == TafType.STRING1:
|
elif data_type == TafType.STRING1:
|
||||||
length = struct.unpack('B', self.buf.read(1))[0]
|
length = struct.unpack("B", self.buf.read(1))[0]
|
||||||
self.buf.read(length)
|
self.buf.read(length)
|
||||||
elif data_type == TafType.STRING4:
|
elif data_type == TafType.STRING4:
|
||||||
length = struct.unpack('>I', self.buf.read(4))[0]
|
length = struct.unpack(">I", self.buf.read(4))[0]
|
||||||
self.buf.read(length)
|
self.buf.read(length)
|
||||||
elif data_type == TafType.MAP:
|
elif data_type == TafType.MAP:
|
||||||
self._skip_map()
|
self._skip_map()
|
||||||
@@ -258,13 +262,13 @@ class TafInputStream:
|
|||||||
if dtype == TafType.ZERO:
|
if dtype == TafType.ZERO:
|
||||||
return 0
|
return 0
|
||||||
if dtype == TafType.INT8:
|
if dtype == TafType.INT8:
|
||||||
return struct.unpack('b', self.buf.read(1))[0]
|
return struct.unpack("b", self.buf.read(1))[0]
|
||||||
if dtype == TafType.INT16:
|
if dtype == TafType.INT16:
|
||||||
return struct.unpack('>h', self.buf.read(2))[0]
|
return struct.unpack(">h", self.buf.read(2))[0]
|
||||||
if dtype == TafType.INT32:
|
if dtype == TafType.INT32:
|
||||||
return struct.unpack('>i', self.buf.read(4))[0]
|
return struct.unpack(">i", self.buf.read(4))[0]
|
||||||
if dtype == TafType.INT64:
|
if dtype == TafType.INT64:
|
||||||
return struct.unpack('>q', self.buf.read(8))[0]
|
return struct.unpack(">q", self.buf.read(8))[0]
|
||||||
raise ValueError(f"期望整数, 实际 0x{dtype:02x}")
|
raise ValueError(f"期望整数, 实际 0x{dtype:02x}")
|
||||||
|
|
||||||
def _skip_struct(self):
|
def _skip_struct(self):
|
||||||
@@ -335,19 +339,23 @@ class TafInputStream:
|
|||||||
if dtype == TafType.ZERO:
|
if dtype == TafType.ZERO:
|
||||||
return 0
|
return 0
|
||||||
if dtype == TafType.INT8:
|
if dtype == TafType.INT8:
|
||||||
return struct.unpack('B', self.buf.read(1))[0]
|
return struct.unpack("B", self.buf.read(1))[0]
|
||||||
if dtype == TafType.INT16:
|
if dtype == TafType.INT16:
|
||||||
return struct.unpack('>H', self.buf.read(2))[0]
|
return struct.unpack(">H", self.buf.read(2))[0]
|
||||||
if dtype == TafType.INT32:
|
if dtype == TafType.INT32:
|
||||||
return struct.unpack('>I', self.buf.read(4))[0]
|
return struct.unpack(">I", self.buf.read(4))[0]
|
||||||
if dtype == TafType.INT64:
|
if dtype == TafType.INT64:
|
||||||
return struct.unpack('>Q', self.buf.read(8))[0]
|
return struct.unpack(">Q", self.buf.read(8))[0]
|
||||||
raise ValueError(f"期望 uint, 实际 0x{dtype:02x}")
|
raise ValueError(f"期望 uint, 实际 0x{dtype:02x}")
|
||||||
|
|
||||||
def read_boolean(self, tag: int, required: bool = False, default: bool = False) -> bool:
|
def read_boolean(
|
||||||
|
self, tag: int, required: bool = False, default: bool = False
|
||||||
|
) -> bool:
|
||||||
return bool(self.read_int8(tag, required, 1 if default else 0))
|
return bool(self.read_int8(tag, required, 1 if default else 0))
|
||||||
|
|
||||||
def read_float(self, tag: int, required: bool = False, default: float = 0.0) -> float:
|
def read_float(
|
||||||
|
self, tag: int, required: bool = False, default: float = 0.0
|
||||||
|
) -> float:
|
||||||
found = self._find_tag(tag, required)
|
found = self._find_tag(tag, required)
|
||||||
if not found:
|
if not found:
|
||||||
return default
|
return default
|
||||||
@@ -355,12 +363,14 @@ class TafInputStream:
|
|||||||
if dtype == TafType.ZERO:
|
if dtype == TafType.ZERO:
|
||||||
return 0.0
|
return 0.0
|
||||||
if dtype == TafType.FLOAT:
|
if dtype == TafType.FLOAT:
|
||||||
return struct.unpack('>f', self.buf.read(4))[0]
|
return struct.unpack(">f", self.buf.read(4))[0]
|
||||||
if dtype == TafType.DOUBLE:
|
if dtype == TafType.DOUBLE:
|
||||||
return struct.unpack('>d', self.buf.read(8))[0]
|
return struct.unpack(">d", self.buf.read(8))[0]
|
||||||
return float(self._read_int_value(dtype))
|
return float(self._read_int_value(dtype))
|
||||||
|
|
||||||
def read_double(self, tag: int, required: bool = False, default: float = 0.0) -> float:
|
def read_double(
|
||||||
|
self, tag: int, required: bool = False, default: float = 0.0
|
||||||
|
) -> float:
|
||||||
return self.read_float(tag, required, default)
|
return self.read_float(tag, required, default)
|
||||||
|
|
||||||
def read_string(self, tag: int, required: bool = False, default: str = "") -> str:
|
def read_string(self, tag: int, required: bool = False, default: str = "") -> str:
|
||||||
@@ -369,14 +379,16 @@ class TafInputStream:
|
|||||||
return default
|
return default
|
||||||
dtype = found[1]
|
dtype = found[1]
|
||||||
if dtype == TafType.STRING1:
|
if dtype == TafType.STRING1:
|
||||||
length = struct.unpack('B', self.buf.read(1))[0]
|
length = struct.unpack("B", self.buf.read(1))[0]
|
||||||
elif dtype == TafType.STRING4:
|
elif dtype == TafType.STRING4:
|
||||||
length = struct.unpack('>I', self.buf.read(4))[0]
|
length = struct.unpack(">I", self.buf.read(4))[0]
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"期望 string, 实际 0x{dtype:02x}")
|
raise ValueError(f"期望 string, 实际 0x{dtype:02x}")
|
||||||
return self.buf.read(length).decode('utf-8', errors='replace')
|
return self.buf.read(length).decode("utf-8", errors="replace")
|
||||||
|
|
||||||
def read_bytes(self, tag: int, required: bool = False, default: bytes = b'') -> bytes:
|
def read_bytes(
|
||||||
|
self, tag: int, required: bool = False, default: bytes = b""
|
||||||
|
) -> bytes:
|
||||||
found = self._find_tag(tag, required)
|
found = self._find_tag(tag, required)
|
||||||
if not found:
|
if not found:
|
||||||
return default
|
return default
|
||||||
@@ -388,8 +400,9 @@ class TafInputStream:
|
|||||||
return self.buf.read(length)
|
return self.buf.read(length)
|
||||||
|
|
||||||
# ---- 复合类型 ----
|
# ---- 复合类型 ----
|
||||||
def read_map(self, tag: int, required: bool = False,
|
def read_map(
|
||||||
key_reader=None, val_reader=None) -> Dict:
|
self, tag: int, required: bool = False, key_reader=None, val_reader=None
|
||||||
|
) -> Dict:
|
||||||
found = self._find_tag(tag, required)
|
found = self._find_tag(tag, required)
|
||||||
if not found:
|
if not found:
|
||||||
return {}
|
return {}
|
||||||
@@ -405,8 +418,7 @@ class TafInputStream:
|
|||||||
result[k] = v
|
result[k] = v
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def read_list(self, tag: int, required: bool = False,
|
def read_list(self, tag: int, required: bool = False, item_reader=None) -> List:
|
||||||
item_reader=None) -> List:
|
|
||||||
found = self._find_tag(tag, required)
|
found = self._find_tag(tag, required)
|
||||||
if not found:
|
if not found:
|
||||||
return []
|
return []
|
||||||
@@ -439,13 +451,18 @@ class TafInputStream:
|
|||||||
return reader(self, 0)
|
return reader(self, 0)
|
||||||
# 自动推断
|
# 自动推断
|
||||||
if dtype == TafType.STRING1:
|
if dtype == TafType.STRING1:
|
||||||
length = struct.unpack('B', self.buf.read(1))[0]
|
length = struct.unpack("B", self.buf.read(1))[0]
|
||||||
return self.buf.read(length).decode('utf-8', errors='replace')
|
return self.buf.read(length).decode("utf-8", errors="replace")
|
||||||
if dtype == TafType.STRING4:
|
if dtype == TafType.STRING4:
|
||||||
length = struct.unpack('>I', self.buf.read(4))[0]
|
length = struct.unpack(">I", self.buf.read(4))[0]
|
||||||
return self.buf.read(length).decode('utf-8', errors='replace')
|
return self.buf.read(length).decode("utf-8", errors="replace")
|
||||||
if dtype in (TafType.ZERO, TafType.INT8, TafType.INT16,
|
if dtype in (
|
||||||
TafType.INT32, TafType.INT64):
|
TafType.ZERO,
|
||||||
|
TafType.INT8,
|
||||||
|
TafType.INT16,
|
||||||
|
TafType.INT32,
|
||||||
|
TafType.INT64,
|
||||||
|
):
|
||||||
return self._read_int_value(dtype)
|
return self._read_int_value(dtype)
|
||||||
if dtype == TafType.STRUCT_BEGIN:
|
if dtype == TafType.STRUCT_BEGIN:
|
||||||
# 未知 struct,跳过
|
# 未知 struct,跳过
|
||||||
@@ -459,6 +476,7 @@ class TafInputStream:
|
|||||||
# 结构体基类
|
# 结构体基类
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
class TafStruct:
|
class TafStruct:
|
||||||
"""TAF 结构体基类:子类实现 write_to / read_from"""
|
"""TAF 结构体基类:子类实现 write_to / read_from"""
|
||||||
|
|
||||||
@@ -470,8 +488,7 @@ class TafStruct:
|
|||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
"""调试用:转字典"""
|
"""调试用:转字典"""
|
||||||
return {k: v for k, v in self.__dict__.items()
|
return {k: v for k, v in self.__dict__.items() if not k.startswith("_")}
|
||||||
if not k.startswith('_')}
|
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"{self.__class__.__name__}({self.to_dict()})"
|
return f"{self.__class__.__name__}({self.to_dict()})"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
基于 AES-128-ECB 与 0 填充。
|
基于 AES-128-ECB 与 0 填充。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from Crypto.Cipher import AES
|
from Crypto.Cipher import AES
|
||||||
|
|||||||
+37
-13
@@ -134,12 +134,18 @@ def make_user_action(now_ms: int | None = None) -> str:
|
|||||||
"longitude": "-1.0",
|
"longitude": "-1.0",
|
||||||
"ssid": "",
|
"ssid": "",
|
||||||
"user_action": [
|
"user_action": [
|
||||||
{"id": "24", "time": str(t1),
|
{
|
||||||
"x": str(random.randint(150, 900)),
|
"id": "24",
|
||||||
"y": str(random.randint(800, 1600))},
|
"time": str(t1),
|
||||||
{"id": "11", "time": str(t2),
|
"x": str(random.randint(150, 900)),
|
||||||
"x": str(random.randint(150, 900)),
|
"y": str(random.randint(800, 1600)),
|
||||||
"y": str(random.randint(800, 1600))},
|
},
|
||||||
|
{
|
||||||
|
"id": "11",
|
||||||
|
"time": str(t2),
|
||||||
|
"x": str(random.randint(150, 900)),
|
||||||
|
"y": str(random.randint(800, 1600)),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
@@ -152,10 +158,17 @@ def make_trace_id(pid: int = 0) -> str:
|
|||||||
return f"{random.getrandbits(64):016x}-{pid}-{int(_time.time() * 1000)}"
|
return f"{random.getrandbits(64):016x}-{pid}-{int(_time.time() * 1000)}"
|
||||||
|
|
||||||
|
|
||||||
def _build_wup_data(w: _Writer, uid_str: str, sha1_password: str,
|
def _build_wup_data(
|
||||||
safedeviceid: str, hdid: str, session: int,
|
w: _Writer,
|
||||||
trace_id: str, user_action_json: str,
|
uid_str: str,
|
||||||
device_info: Dict[str, str]) -> None:
|
sha1_password: str,
|
||||||
|
safedeviceid: str,
|
||||||
|
hdid: str,
|
||||||
|
session: int,
|
||||||
|
trace_id: str,
|
||||||
|
user_action_json: str,
|
||||||
|
device_info: Dict[str, str],
|
||||||
|
) -> None:
|
||||||
"""编码 _wup_data struct。"""
|
"""编码 _wup_data struct。"""
|
||||||
meta_json = _build_meta_json(session, trace_id)
|
meta_json = _build_meta_json(session, trace_id)
|
||||||
name = _make_name(uid_str)
|
name = _make_name(uid_str)
|
||||||
@@ -179,7 +192,9 @@ def _build_wup_data(w: _Writer, uid_str: str, sha1_password: str,
|
|||||||
# -- t1: 设备信息 struct --
|
# -- t1: 设备信息 struct --
|
||||||
di = device_info
|
di = device_info
|
||||||
w.struct_begin(1)
|
w.struct_begin(1)
|
||||||
w.string(0, hdid) # t1.t0 = HDID32 (登录帧32hex设备证书, libhydeviceid.so 硬锚, 换→APP_SIGN_NOT_MATCH)
|
w.string(
|
||||||
|
0, hdid
|
||||||
|
) # t1.t0 = HDID32 (登录帧32hex设备证书, libhydeviceid.so 硬锚, 换→APP_SIGN_NOT_MATCH)
|
||||||
w.string(1, di.get("app_version", "13.4.22"))
|
w.string(1, di.get("app_version", "13.4.22"))
|
||||||
w.string(2, di.get("sdk_version", "1.0.80138"))
|
w.string(2, di.get("sdk_version", "1.0.80138"))
|
||||||
w.string(3, "")
|
w.string(3, "")
|
||||||
@@ -224,8 +239,17 @@ def build_password_login_wup(
|
|||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""构造密码登录的 WUP TAF 请求体。"""
|
"""构造密码登录的 WUP TAF 请求体。"""
|
||||||
wd = _Writer()
|
wd = _Writer()
|
||||||
_build_wup_data(wd, uid_str, sha1_password, safedeviceid, hdid,
|
_build_wup_data(
|
||||||
session, trace_id, user_action_json, device_info)
|
wd,
|
||||||
|
uid_str,
|
||||||
|
sha1_password,
|
||||||
|
safedeviceid,
|
||||||
|
hdid,
|
||||||
|
session,
|
||||||
|
trace_id,
|
||||||
|
user_action_json,
|
||||||
|
device_info,
|
||||||
|
)
|
||||||
wup_data = wd.get()
|
wup_data = wd.get()
|
||||||
|
|
||||||
req = _Writer()
|
req = _Writer()
|
||||||
|
|||||||
+26
-20
@@ -9,6 +9,7 @@ Wup 包结构:
|
|||||||
tag7:sBuffer(bytes), tag8:iTimeout, tag9:context(map), tag10:status(map)
|
tag7:sBuffer(bytes), tag8:iTimeout, tag9:context(map), tag10:status(map)
|
||||||
sBuffer = Map<"tReq", 编码后的请求结构体>
|
sBuffer = Map<"tReq", 编码后的请求结构体>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import struct
|
import struct
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
from .taf_protocol import TafOutputStream, TafInputStream, TafType, TafStruct
|
from .taf_protocol import TafOutputStream, TafInputStream, TafType, TafStruct
|
||||||
@@ -18,16 +19,16 @@ class WupRequest:
|
|||||||
"""Wup 请求对象"""
|
"""Wup 请求对象"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.iVersion: int = 3 # tag 1
|
self.iVersion: int = 3 # tag 1
|
||||||
self.cPacketType: int = 0 # tag 2
|
self.cPacketType: int = 0 # tag 2
|
||||||
self.iMessageType: int = 0 # tag 3
|
self.iMessageType: int = 0 # tag 3
|
||||||
self.iRequestId: int = 0 # tag 4
|
self.iRequestId: int = 0 # tag 4
|
||||||
self.sServantName: str = "" # tag 5
|
self.sServantName: str = "" # tag 5
|
||||||
self.sFuncName: str = "" # tag 6
|
self.sFuncName: str = "" # tag 6
|
||||||
self.sBuffer: bytes = b'' # tag 7
|
self.sBuffer: bytes = b"" # tag 7
|
||||||
self.iTimeout: int = 3000 # tag 8
|
self.iTimeout: int = 3000 # tag 8
|
||||||
self.context: Dict[str, str] = {} # tag 9
|
self.context: Dict[str, str] = {} # tag 9
|
||||||
self.status: Dict[str, str] = {} # tag 10
|
self.status: Dict[str, str] = {} # tag 10
|
||||||
self.newdata: Dict[str, bytes] = {}
|
self.newdata: Dict[str, bytes] = {}
|
||||||
|
|
||||||
def setServant(self, name: str):
|
def setServant(self, name: str):
|
||||||
@@ -49,7 +50,7 @@ class WupRequest:
|
|||||||
"""
|
"""
|
||||||
os = TafOutputStream()
|
os = TafOutputStream()
|
||||||
|
|
||||||
if isinstance(struct_data, TafStruct) or hasattr(struct_data, 'write_to'):
|
if isinstance(struct_data, TafStruct) or hasattr(struct_data, "write_to"):
|
||||||
# 结构体对象:STRUCT_BEGIN + 内容 + STRUCT_END
|
# 结构体对象:STRUCT_BEGIN + 内容 + STRUCT_END
|
||||||
os.write_struct(0, struct_data)
|
os.write_struct(0, struct_data)
|
||||||
elif isinstance(struct_data, dict):
|
elif isinstance(struct_data, dict):
|
||||||
@@ -83,7 +84,7 @@ class WupRequest:
|
|||||||
os.write_map(tag, value)
|
os.write_map(tag, value)
|
||||||
elif isinstance(value, (list, tuple)):
|
elif isinstance(value, (list, tuple)):
|
||||||
os.write_list(tag, list(value))
|
os.write_list(tag, list(value))
|
||||||
elif hasattr(value, 'write_to'):
|
elif hasattr(value, "write_to"):
|
||||||
os.write_struct(tag, value)
|
os.write_struct(tag, value)
|
||||||
else:
|
else:
|
||||||
raise TypeError(f"不支持的字段类型: {type(value)}")
|
raise TypeError(f"不支持的字段类型: {type(value)}")
|
||||||
@@ -115,14 +116,14 @@ class WupRequest:
|
|||||||
|
|
||||||
# 3. 长度前缀
|
# 3. 长度前缀
|
||||||
length = 4 + len(wup_body)
|
length = 4 + len(wup_body)
|
||||||
return struct.pack('>I', length) + wup_body
|
return struct.pack(">I", length) + wup_body
|
||||||
|
|
||||||
|
|
||||||
def normalize_wup_payload(data: bytes) -> bytes:
|
def normalize_wup_payload(data: bytes) -> bytes:
|
||||||
"""去掉可选的 4 字节 WUP 长度前缀,返回裸 WUP body"""
|
"""去掉可选的 4 字节 WUP 长度前缀,返回裸 WUP body"""
|
||||||
if len(data) < 4:
|
if len(data) < 4:
|
||||||
return data
|
return data
|
||||||
declared_len = struct.unpack('>I', data[0:4])[0]
|
declared_len = struct.unpack(">I", data[0:4])[0]
|
||||||
if declared_len == len(data) or declared_len + 4 == len(data):
|
if declared_len == len(data) or declared_len + 4 == len(data):
|
||||||
return data[4:]
|
return data[4:]
|
||||||
return data
|
return data
|
||||||
@@ -138,7 +139,7 @@ class WupResponse:
|
|||||||
self.iRequestId: int = 0
|
self.iRequestId: int = 0
|
||||||
self.sServantName: str = ""
|
self.sServantName: str = ""
|
||||||
self.sFuncName: str = ""
|
self.sFuncName: str = ""
|
||||||
self.sBuffer: bytes = b''
|
self.sBuffer: bytes = b""
|
||||||
self.iTimeout: int = 0
|
self.iTimeout: int = 0
|
||||||
self.context: Dict[str, str] = {}
|
self.context: Dict[str, str] = {}
|
||||||
self.status: Dict[str, str] = {}
|
self.status: Dict[str, str] = {}
|
||||||
@@ -249,19 +250,20 @@ class WupResponse:
|
|||||||
# 辅助:按已知 dtype 读取值
|
# 辅助:按已知 dtype 读取值
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
def _read_string_value(ins: TafInputStream, dtype: int) -> str:
|
def _read_string_value(ins: TafInputStream, dtype: int) -> str:
|
||||||
if dtype == TafType.STRING1:
|
if dtype == TafType.STRING1:
|
||||||
length = struct.unpack('B', ins.buf.read(1))[0]
|
length = struct.unpack("B", ins.buf.read(1))[0]
|
||||||
elif dtype == TafType.STRING4:
|
elif dtype == TafType.STRING4:
|
||||||
length = struct.unpack('>I', ins.buf.read(4))[0]
|
length = struct.unpack(">I", ins.buf.read(4))[0]
|
||||||
else:
|
else:
|
||||||
return ""
|
return ""
|
||||||
return ins.buf.read(length).decode('utf-8', errors='replace')
|
return ins.buf.read(length).decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
def _read_bytes_value(ins: TafInputStream, dtype: int) -> bytes:
|
def _read_bytes_value(ins: TafInputStream, dtype: int) -> bytes:
|
||||||
if dtype != TafType.SIMPLE_LIST:
|
if dtype != TafType.SIMPLE_LIST:
|
||||||
return b''
|
return b""
|
||||||
ins.read_head() # 元素类型 INT8
|
ins.read_head() # 元素类型 INT8
|
||||||
length = ins._read_int_len()
|
length = ins._read_int_len()
|
||||||
return ins.buf.read(length)
|
return ins.buf.read(length)
|
||||||
@@ -276,6 +278,10 @@ def _read_map_value(ins: TafInputStream, dtype: int) -> Dict:
|
|||||||
_, kt = ins.read_head()
|
_, kt = ins.read_head()
|
||||||
k = _read_string_value(ins, kt)
|
k = _read_string_value(ins, kt)
|
||||||
_, vt = ins.read_head()
|
_, vt = ins.read_head()
|
||||||
v = _read_string_value(ins, vt) if vt in (TafType.STRING1, TafType.STRING4) else ""
|
v = (
|
||||||
|
_read_string_value(ins, vt)
|
||||||
|
if vt in (TafType.STRING1, TafType.STRING4)
|
||||||
|
else ""
|
||||||
|
)
|
||||||
result[k] = v
|
result[k] = v
|
||||||
return result
|
return result
|
||||||
|
|||||||
+35
-15
@@ -58,12 +58,14 @@ def parse_sms_lines(text: str) -> list[SmsLine]:
|
|||||||
continue
|
continue
|
||||||
if not phone or not url:
|
if not phone or not url:
|
||||||
continue
|
continue
|
||||||
rows.append(SmsLine(
|
rows.append(
|
||||||
phone=phone,
|
SmsLine(
|
||||||
url=url,
|
phone=phone,
|
||||||
provider=detect_provider(url),
|
url=url,
|
||||||
raw=line,
|
provider=detect_provider(url),
|
||||||
))
|
raw=line,
|
||||||
|
)
|
||||||
|
)
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
@@ -133,32 +135,50 @@ class SmsProviderClient:
|
|||||||
if low.startswith("yes|"):
|
if low.startswith("yes|"):
|
||||||
code = extract_sms_code(body)
|
code = extract_sms_code(body)
|
||||||
if code:
|
if code:
|
||||||
return SmsPollResult(status="code", code=code, message="收到验证码", raw=body)
|
return SmsPollResult(
|
||||||
return SmsPollResult(status="error", message=f"已收到短信但未识别验证码: {body}", raw=body)
|
status="code", code=code, message="收到验证码", raw=body
|
||||||
|
)
|
||||||
|
return SmsPollResult(
|
||||||
|
status="error", message=f"已收到短信但未识别验证码: {body}", raw=body
|
||||||
|
)
|
||||||
code = extract_sms_code(body)
|
code = extract_sms_code(body)
|
||||||
if code:
|
if code:
|
||||||
return SmsPollResult(status="code", code=code, message="收到验证码", raw=body)
|
return SmsPollResult(
|
||||||
|
status="code", code=code, message="收到验证码", raw=body
|
||||||
|
)
|
||||||
return SmsPollResult(status="waiting", message=body, raw=body)
|
return SmsPollResult(status="waiting", message=body, raw=body)
|
||||||
|
|
||||||
def _parse_sms8(self, body: str, after_time: datetime | None = None) -> SmsPollResult:
|
def _parse_sms8(
|
||||||
|
self, body: str, after_time: datetime | None = None
|
||||||
|
) -> SmsPollResult:
|
||||||
try:
|
try:
|
||||||
payload = json.loads(body)
|
payload = json.loads(body)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
code = extract_sms_code(body)
|
code = extract_sms_code(body)
|
||||||
if code:
|
if code:
|
||||||
return SmsPollResult(status="code", code=code, message="收到验证码", raw=body)
|
return SmsPollResult(
|
||||||
return SmsPollResult(status="error", message=f"短信平台返回非 JSON: {body[:120]}", raw=body)
|
status="code", code=code, message="收到验证码", raw=body
|
||||||
|
)
|
||||||
|
return SmsPollResult(
|
||||||
|
status="error", message=f"短信平台返回非 JSON: {body[:120]}", raw=body
|
||||||
|
)
|
||||||
|
|
||||||
data = payload.get("data") if isinstance(payload.get("data"), dict) else {}
|
data = payload.get("data") if isinstance(payload.get("data"), dict) else {}
|
||||||
msg = str(payload.get("msg") or "")
|
msg = str(payload.get("msg") or "")
|
||||||
code_text = str(data.get("code") or "")
|
code_text = str(data.get("code") or "")
|
||||||
code_time = _parse_sms8_time(str(data.get("code_time") or ""))
|
code_time = _parse_sms8_time(str(data.get("code_time") or ""))
|
||||||
if after_time and code_time and code_time < after_time.replace(tzinfo=None):
|
if after_time and code_time and code_time < after_time.replace(tzinfo=None):
|
||||||
return SmsPollResult(status="waiting", message="验证码时间早于本次发码", raw=body)
|
return SmsPollResult(
|
||||||
|
status="waiting", message="验证码时间早于本次发码", raw=body
|
||||||
|
)
|
||||||
|
|
||||||
code = extract_sms_code(code_text)
|
code = extract_sms_code(code_text)
|
||||||
if int(payload.get("code") or 0) == 1 and code:
|
if int(payload.get("code") or 0) == 1 and code:
|
||||||
return SmsPollResult(status="code", code=code, message="收到验证码", raw=body)
|
return SmsPollResult(
|
||||||
|
status="code", code=code, message="收到验证码", raw=body
|
||||||
|
)
|
||||||
if code:
|
if code:
|
||||||
return SmsPollResult(status="code", code=code, message="收到验证码", raw=body)
|
return SmsPollResult(
|
||||||
|
status="code", code=code, message="收到验证码", raw=body
|
||||||
|
)
|
||||||
return SmsPollResult(status="waiting", message=msg or "暂无验证码", raw=body)
|
return SmsPollResult(status="waiting", message=msg or "暂无验证码", raw=body)
|
||||||
|
|||||||
+6
-3
@@ -12,11 +12,14 @@
|
|||||||
```python
|
```python
|
||||||
import frida, time
|
import frida, time
|
||||||
|
|
||||||
d = frida.get_device_manager().add_remote_device("127.0.0.1:31878") # frida-server -l 0.0.0.0:31878
|
d = frida.get_device_manager().add_remote_device(
|
||||||
|
"127.0.0.1:31878"
|
||||||
|
) # frida-server -l 0.0.0.0:31878
|
||||||
pid = d.spawn(["com.duowan.kiwi"])
|
pid = d.spawn(["com.duowan.kiwi"])
|
||||||
s = d.attach(pid)
|
s = d.attach(pid)
|
||||||
from bypass_loader import load_bypass # scripts/ 在 sys.path (同目录运行即可)
|
from bypass_loader import load_bypass # scripts/ 在 sys.path (同目录运行即可)
|
||||||
load_bypass(s) # ← 唯一的绕过步骤, 可传参
|
|
||||||
|
load_bypass(s) # ← 唯一的绕过步骤, 可传参
|
||||||
d.resume(pid)
|
d.resume(pid)
|
||||||
|
|
||||||
# 之后挂业务钩子 (挂业务 JS 的时机不再敏感 — patch_guard 已内置延迟)
|
# 之后挂业务钩子 (挂业务 JS 的时机不再敏感 — patch_guard 已内置延迟)
|
||||||
|
|||||||
@@ -73,10 +73,14 @@ def _normalize_value(value: object, target_column) -> object:
|
|||||||
try:
|
try:
|
||||||
return json.loads(value)
|
return json.loads(value)
|
||||||
except json.JSONDecodeError as exc:
|
except json.JSONDecodeError as exc:
|
||||||
raise ValueError(f"列 {target_column.name} 存在非法 JSON:{value[:120]!r}") from exc
|
raise ValueError(
|
||||||
|
f"列 {target_column.name} 存在非法 JSON:{value[:120]!r}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
def _source_rows(source_connection: Connection, source_table: Table, target_table: Table) -> Iterable[dict]:
|
def _source_rows(
|
||||||
|
source_connection: Connection, source_table: Table, target_table: Table
|
||||||
|
) -> Iterable[dict]:
|
||||||
columns = [column.name for column in target_table.columns]
|
columns = [column.name for column in target_table.columns]
|
||||||
missing_columns = [column for column in columns if column not in source_table.c]
|
missing_columns = [column for column in columns if column not in source_table.c]
|
||||||
if missing_columns:
|
if missing_columns:
|
||||||
@@ -88,7 +92,9 @@ def _source_rows(source_connection: Connection, source_table: Table, target_tabl
|
|||||||
primary_key_columns = list(source_table.primary_key.columns)
|
primary_key_columns = list(source_table.primary_key.columns)
|
||||||
statement = select(*(source_table.c[name] for name in columns))
|
statement = select(*(source_table.c[name] for name in columns))
|
||||||
if primary_key_columns:
|
if primary_key_columns:
|
||||||
statement = statement.order_by(*(column.asc() for column in primary_key_columns))
|
statement = statement.order_by(
|
||||||
|
*(column.asc() for column in primary_key_columns)
|
||||||
|
)
|
||||||
|
|
||||||
for row in source_connection.execute(statement).mappings():
|
for row in source_connection.execute(statement).mappings():
|
||||||
yield {
|
yield {
|
||||||
@@ -101,12 +107,12 @@ def _ensure_target_is_empty(target_engine: Engine, target_tables: list[Table]) -
|
|||||||
"""拒绝向已有业务数据的 MySQL 写入,防止误覆盖。"""
|
"""拒绝向已有业务数据的 MySQL 写入,防止误覆盖。"""
|
||||||
with target_engine.connect() as connection:
|
with target_engine.connect() as connection:
|
||||||
occupied = [
|
occupied = [
|
||||||
table.name
|
table.name for table in target_tables if _count_rows(connection, table) > 0
|
||||||
for table in target_tables
|
|
||||||
if _count_rows(connection, table) > 0
|
|
||||||
]
|
]
|
||||||
if occupied:
|
if occupied:
|
||||||
raise RuntimeError(f"目标 MySQL 已存在业务数据,拒绝迁移:{', '.join(occupied)}")
|
raise RuntimeError(
|
||||||
|
f"目标 MySQL 已存在业务数据,拒绝迁移:{', '.join(occupied)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _upgrade_source_sqlite(source_path: Path) -> None:
|
def _upgrade_source_sqlite(source_path: Path) -> None:
|
||||||
@@ -115,7 +121,11 @@ def _upgrade_source_sqlite(source_path: Path) -> None:
|
|||||||
source_env = os.environ.copy()
|
source_env = os.environ.copy()
|
||||||
source_env["DATABASE_URL"] = f"sqlite:///{source_path}"
|
source_env["DATABASE_URL"] = f"sqlite:///{source_path}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
[sys.executable, "-c", "from web.backend.database import run_migrations; run_migrations()"],
|
[
|
||||||
|
sys.executable,
|
||||||
|
"-c",
|
||||||
|
"from web.backend.database import run_migrations; run_migrations()",
|
||||||
|
],
|
||||||
cwd=PROJECT_ROOT,
|
cwd=PROJECT_ROOT,
|
||||||
env=source_env,
|
env=source_env,
|
||||||
check=True,
|
check=True,
|
||||||
@@ -141,7 +151,9 @@ def main() -> int:
|
|||||||
|
|
||||||
target_url = target_url or DATABASE_URL
|
target_url = target_url or DATABASE_URL
|
||||||
if not target_url.startswith("mysql+"):
|
if not target_url.startswith("mysql+"):
|
||||||
raise ValueError("目标库必须是 MySQL;可传 --target-url,或设置 DB_HOST/DB_* 环境变量")
|
raise ValueError(
|
||||||
|
"目标库必须是 MySQL;可传 --target-url,或设置 DB_HOST/DB_* 环境变量"
|
||||||
|
)
|
||||||
|
|
||||||
print("正在初始化目标 MySQL 表结构...")
|
print("正在初始化目标 MySQL 表结构...")
|
||||||
run_migrations()
|
run_migrations()
|
||||||
@@ -153,7 +165,9 @@ def main() -> int:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
source_table_names = set(inspect(source_engine).get_table_names())
|
source_table_names = set(inspect(source_engine).get_table_names())
|
||||||
unexpected_tables = source_table_names - target_table_names - IGNORED_SOURCE_TABLES
|
unexpected_tables = (
|
||||||
|
source_table_names - target_table_names - IGNORED_SOURCE_TABLES
|
||||||
|
)
|
||||||
if unexpected_tables:
|
if unexpected_tables:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"源 SQLite 存在当前程序无法识别的表:"
|
"源 SQLite 存在当前程序无法识别的表:"
|
||||||
@@ -171,7 +185,10 @@ def main() -> int:
|
|||||||
|
|
||||||
_ensure_target_is_empty(target_engine, tables_to_copy)
|
_ensure_target_is_empty(target_engine, tables_to_copy)
|
||||||
|
|
||||||
with source_engine.connect() as source_connection, target_engine.begin() as target_connection:
|
with (
|
||||||
|
source_engine.connect() as source_connection,
|
||||||
|
target_engine.begin() as target_connection,
|
||||||
|
):
|
||||||
for target_table in tables_to_copy:
|
for target_table in tables_to_copy:
|
||||||
source_table = source_metadata.tables[target_table.name]
|
source_table = source_metadata.tables[target_table.name]
|
||||||
source_count = _count_rows(source_connection, source_table)
|
source_count = _count_rows(source_connection, source_table)
|
||||||
@@ -184,7 +201,10 @@ def main() -> int:
|
|||||||
for batch in _chunks(rows, args.batch_size):
|
for batch in _chunks(rows, args.batch_size):
|
||||||
target_connection.execute(target_table.insert(), batch)
|
target_connection.execute(target_table.insert(), batch)
|
||||||
|
|
||||||
with source_engine.connect() as source_connection, target_engine.connect() as target_connection:
|
with (
|
||||||
|
source_engine.connect() as source_connection,
|
||||||
|
target_engine.connect() as target_connection,
|
||||||
|
):
|
||||||
for target_table in tables_to_copy:
|
for target_table in tables_to_copy:
|
||||||
source_table = source_metadata.tables[target_table.name]
|
source_table = source_metadata.tables[target_table.name]
|
||||||
source_count = _count_rows(source_connection, source_table)
|
source_count = _count_rows(source_connection, source_table)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
python3 main.py sample-session # 用迁移的 live 捕获生成示例会话态
|
python3 main.py sample-session # 用迁移的 live 捕获生成示例会话态
|
||||||
python3 main.py sample-order # 用迁移的 order7 生成示例订单
|
python3 main.py sample-order # 用迁移的 order7 生成示例订单
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -50,14 +51,31 @@ from pyvm.protocol import ( # noqa: E402
|
|||||||
REPLAY = ROOT / "replay"
|
REPLAY = ROOT / "replay"
|
||||||
DEFAULT_APPID = PAY_APPID
|
DEFAULT_APPID = PAY_APPID
|
||||||
DEFAULT_SAVE_URL = f"https://api.unipay.qq.com/v1/r/{DEFAULT_APPID}/web_save"
|
DEFAULT_SAVE_URL = f"https://api.unipay.qq.com/v1/r/{DEFAULT_APPID}/web_save"
|
||||||
ORDER_FIELDS = ["token_id", "openid", "openkey", "session_id", "session_type", "zoneid",
|
ORDER_FIELDS = [
|
||||||
"pay_method", "buy_quantity", "mb_pwd", "pay_id", "auth_key",
|
"token_id",
|
||||||
"card_value", "accounttype", "provide_uin", "extend", "ts",
|
"openid",
|
||||||
"from_h5", "webversion"]
|
"openkey",
|
||||||
|
"session_id",
|
||||||
|
"session_type",
|
||||||
|
"zoneid",
|
||||||
|
"pay_method",
|
||||||
|
"buy_quantity",
|
||||||
|
"mb_pwd",
|
||||||
|
"pay_id",
|
||||||
|
"auth_key",
|
||||||
|
"card_value",
|
||||||
|
"accounttype",
|
||||||
|
"provide_uin",
|
||||||
|
"extend",
|
||||||
|
"ts",
|
||||||
|
"from_h5",
|
||||||
|
"webversion",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- 工具
|
# ---------------------------------------------------------------- 工具
|
||||||
|
|
||||||
|
|
||||||
def _write_private_text(path: Path, content: str) -> None:
|
def _write_private_text(path: Path, content: str) -> None:
|
||||||
"""写入任务协议证据并限制为当前用户可读。"""
|
"""写入任务协议证据并限制为当前用户可读。"""
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -65,6 +83,7 @@ def _write_private_text(path: Path, content: str) -> None:
|
|||||||
path.write_text(content, encoding="utf-8")
|
path.write_text(content, encoding="utf-8")
|
||||||
os.chmod(path, 0o600)
|
os.chmod(path, 0o600)
|
||||||
|
|
||||||
|
|
||||||
def _load_cap(path: Path) -> dict:
|
def _load_cap(path: Path) -> dict:
|
||||||
"""加载 deepCap 捕获 JSON,兼容三种存档格式,返回顶层 dict(含 C 键)。
|
"""加载 deepCap 捕获 JSON,兼容三种存档格式,返回顶层 dict(含 C 键)。
|
||||||
|
|
||||||
@@ -113,32 +132,47 @@ def parse_plaintext(path: str | Path) -> dict:
|
|||||||
|
|
||||||
# ---------------------------------------------------------------- 命令
|
# ---------------------------------------------------------------- 命令
|
||||||
|
|
||||||
|
|
||||||
def cmd_verify(args=None) -> int:
|
def cmd_verify(args=None) -> int:
|
||||||
"""校验当前受版本控制的 goods 协议材料,不依赖已删除的历史抓包。"""
|
"""校验当前受版本控制的 goods 协议材料,不依赖已删除的历史抓包。"""
|
||||||
args_path = REPLAY / "live" / "default" / "args-template.json"
|
args_path = REPLAY / "live" / "default" / "args-template.json"
|
||||||
args_template = json.loads(args_path.read_text(encoding="utf-8"))
|
args_template = json.loads(args_path.read_text(encoding="utf-8"))
|
||||||
validate_goods_materials(args_template)
|
validate_goods_materials(args_template)
|
||||||
required = ("bytecode.json", "constants.json", "xmidasops.json", "e2e/multi-1.json",
|
required = (
|
||||||
"e2e/ws-args.json", "deepcaps2/cap-69667.json")
|
"bytecode.json",
|
||||||
|
"constants.json",
|
||||||
|
"xmidasops.json",
|
||||||
|
"e2e/multi-1.json",
|
||||||
|
"e2e/ws-args.json",
|
||||||
|
"deepcaps2/cap-69667.json",
|
||||||
|
)
|
||||||
missing = [name for name in required if not (REPLAY / name).exists()]
|
missing = [name for name in required if not (REPLAY / name).exists()]
|
||||||
if missing:
|
if missing:
|
||||||
print(f"❌ goods 协议材料缺失: {', '.join(missing)}")
|
print(f"❌ goods 协议材料缺失: {', '.join(missing)}")
|
||||||
return 1
|
return 1
|
||||||
print("✅ goods 协议材料校验通过(18 槽模板、Te/S-box、VM 文件均完整)")
|
print("✅ goods 协议材料校验通过(18 槽模板、Te/S-box、VM 文件均完整)")
|
||||||
print(" 历史逐字节黄金向量未随仓库保留;如需恢复,可放入 replay/golden 后由 CI 自动执行。")
|
print(
|
||||||
|
" 历史逐字节黄金向量未随仓库保留;如需恢复,可放入 replay/golden 后由 CI 自动执行。"
|
||||||
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _gen_with_session(st: SessionState, order: dict) -> str:
|
def _gen_with_session(st: SessionState, order: dict) -> str:
|
||||||
"""用会话态 + 订单参数生成 encrypt_msg(args_template 会话绑定,必须用捕获值)。"""
|
"""用会话态 + 订单参数生成 encrypt_msg(args_template 会话绑定,必须用捕获值)。"""
|
||||||
from pyvm.algorithm import decode_d
|
from pyvm.algorithm import decode_d
|
||||||
|
|
||||||
params = {k: order.get(k, "") for k in ORDER_FIELDS}
|
params = {k: order.get(k, "") for k in ORDER_FIELDS}
|
||||||
args_tpl = decode_d(st.args_template_d)
|
args_tpl = decode_d(st.args_template_d)
|
||||||
return generate_encrypt_msg_offline(
|
return generate_encrypt_msg_offline(
|
||||||
params, order.get("fk_extend", ""), order.get("ts", ""), order.get("_rand", ""),
|
params,
|
||||||
xmidas=st.xmidas_ops, xmidas_token=st.xmidas_token,
|
order.get("fk_extend", ""),
|
||||||
|
order.get("ts", ""),
|
||||||
|
order.get("_rand", ""),
|
||||||
|
xmidas=st.xmidas_ops,
|
||||||
|
xmidas_token=st.xmidas_token,
|
||||||
args_template=args_tpl,
|
args_template=args_tpl,
|
||||||
key16=st.key16, key1=st.key1,
|
key16=st.key16,
|
||||||
|
key1=st.key1,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -186,7 +220,8 @@ def cmd_submit(args) -> int:
|
|||||||
url = args.url or DEFAULT_SAVE_URL
|
url = args.url or DEFAULT_SAVE_URL
|
||||||
cookie_str = "; ".join(f"{k}={v}" for k, v in st.cookies.items())
|
cookie_str = "; ".join(f"{k}={v}" for k, v in st.cookies.items())
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
url, data=body.encode("utf-8"),
|
url,
|
||||||
|
data=body.encode("utf-8"),
|
||||||
headers={
|
headers={
|
||||||
"User-Agent": GOODS_USER_AGENT,
|
"User-Agent": GOODS_USER_AGENT,
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
@@ -221,6 +256,7 @@ def cmd_submit(args) -> int:
|
|||||||
def cmd_sample_session(args) -> int:
|
def cmd_sample_session(args) -> int:
|
||||||
"""用迁移的 live 捕获生成示例会话态(key16/key1/xmidas_ops)。"""
|
"""用迁移的 live 捕获生成示例会话态(key16/key1/xmidas_ops)。"""
|
||||||
from pyvm.algorithm import decode_d
|
from pyvm.algorithm import decode_d
|
||||||
|
|
||||||
web_args = decode_d(_load_cap(REPLAY / "live/caps7/cap-85091.json")["C"][2])
|
web_args = decode_d(_load_cap(REPLAY / "live/caps7/cap-85091.json")["C"][2])
|
||||||
cap7 = _load_cap(REPLAY / "live/caps7/cap-85091.json")
|
cap7 = _load_cap(REPLAY / "live/caps7/cap-85091.json")
|
||||||
st = SessionState(
|
st = SessionState(
|
||||||
@@ -228,7 +264,9 @@ def cmd_sample_session(args) -> int:
|
|||||||
key16=list(web_args[6][0]),
|
key16=list(web_args[6][0]),
|
||||||
key1=list(web_args[0][0]),
|
key1=list(web_args[0][0]),
|
||||||
xmidas_token="DE46DBA4754D42A6B66ADD4319FF80C144D9ED22ED73384C271793D9A9AA2FFBD6C104BE8D4A7F4ED2A8688FCB6F7540",
|
xmidas_token="DE46DBA4754D42A6B66ADD4319FF80C144D9ED22ED73384C271793D9A9AA2FFBD6C104BE8D4A7F4ED2A8688FCB6F7540",
|
||||||
args_template_d=cap7["C"][2] if isinstance(cap7["C"][2], str) else json.dumps(cap7["C"][2], ensure_ascii=False),
|
args_template_d=cap7["C"][2]
|
||||||
|
if isinstance(cap7["C"][2], str)
|
||||||
|
else json.dumps(cap7["C"][2], ensure_ascii=False),
|
||||||
cookies={},
|
cookies={},
|
||||||
source="live/order7 (归档示例,cookies 为空)",
|
source="live/order7 (归档示例,cookies 为空)",
|
||||||
)
|
)
|
||||||
@@ -252,6 +290,7 @@ def cmd_sample_order(args) -> int:
|
|||||||
|
|
||||||
# ---------------------------------------------------------------- mall 命令
|
# ---------------------------------------------------------------- mall 命令
|
||||||
|
|
||||||
|
|
||||||
def cmd_mall_verify(args=None) -> int:
|
def cmd_mall_verify(args=None) -> int:
|
||||||
"""校验当前受版本控制的 mall VM 和固定槽模板。"""
|
"""校验当前受版本控制的 mall VM 和固定槽模板。"""
|
||||||
fixed_path = REPLAY / "mall" / "transform-fixed.json"
|
fixed_path = REPLAY / "mall" / "transform-fixed.json"
|
||||||
@@ -282,7 +321,9 @@ def cmd_mall_gen(args) -> int:
|
|||||||
|
|
||||||
def cmd_mall_sample(args) -> int:
|
def cmd_mall_sample(args) -> int:
|
||||||
"""用归档同会话黄金对生成示例 mall 会话态(config/mall-session.json)。"""
|
"""用归档同会话黄金对生成示例 mall 会话态(config/mall-session.json)。"""
|
||||||
g = json.loads((ROOT / "config/golden/golden-final.json").read_text(encoding="utf-8"))
|
g = json.loads(
|
||||||
|
(ROOT / "config/golden/golden-final.json").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
session = MallSession(g["transform_input"], g["xmidas_ops"])
|
session = MallSession(g["transform_input"], g["xmidas_ops"])
|
||||||
out = Path(args.output) if args.output else ROOT / "config" / "mall-session.json"
|
out = Path(args.output) if args.output else ROOT / "config" / "mall-session.json"
|
||||||
out.parent.mkdir(parents=True, exist_ok=True)
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -325,7 +366,8 @@ def cmd_mall_submit(args) -> int:
|
|||||||
if "midas_openkey" not in cookies and "accesstoken" in cookies:
|
if "midas_openkey" not in cookies and "accesstoken" in cookies:
|
||||||
cookie_str += "; midas_openkey=" + cookies["accesstoken"]
|
cookie_str += "; midas_openkey=" + cookies["accesstoken"]
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
url, data=new_body.encode("utf-8"),
|
url,
|
||||||
|
data=new_body.encode("utf-8"),
|
||||||
headers={
|
headers={
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Accept": "application/json, text/plain, */*",
|
"Accept": "application/json, text/plain, */*",
|
||||||
@@ -346,7 +388,11 @@ def cmd_mall_submit(args) -> int:
|
|||||||
print(f"[submit] 网络错误: {e!r}")
|
print(f"[submit] 网络错误: {e!r}")
|
||||||
return 3
|
return 3
|
||||||
print(f"[submit] 响应: {raw[:800]}")
|
print(f"[submit] 响应: {raw[:800]}")
|
||||||
out = Path(args.output) if args.output else ROOT / "config" / "mall-order-response.json"
|
out = (
|
||||||
|
Path(args.output)
|
||||||
|
if args.output
|
||||||
|
else ROOT / "config" / "mall-order-response.json"
|
||||||
|
)
|
||||||
js = None
|
js = None
|
||||||
ret = None
|
ret = None
|
||||||
try:
|
try:
|
||||||
@@ -370,7 +416,9 @@ def cmd_mall_submit(args) -> int:
|
|||||||
print(f" 失败响应 → {fail_out}(保留上次成功响应)")
|
print(f" 失败响应 → {fail_out}(保留上次成功响应)")
|
||||||
if ret in ("1018", 1018):
|
if ret in ("1018", 1018):
|
||||||
print(" 原因: mall 登录态失效——请重新在浏览器登录并采集 mall 会话态")
|
print(" 原因: mall 登录态失效——请重新在浏览器登录并采集 mall 会话态")
|
||||||
print(" (node scripts/capture-mall-data.mjs,或手动刷新 mall-session.json 的 cookies)")
|
print(
|
||||||
|
" (node scripts/capture-mall-data.mjs,或手动刷新 mall-session.json 的 cookies)"
|
||||||
|
)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
||||||
@@ -383,11 +431,15 @@ def cmd_mall_capture(args) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
ap = argparse.ArgumentParser(description="YYB 加密参数生成框架(goods web_save + mall PlaceOrder)")
|
ap = argparse.ArgumentParser(
|
||||||
|
description="YYB 加密参数生成框架(goods web_save + mall PlaceOrder)"
|
||||||
|
)
|
||||||
sub = ap.add_subparsers(dest="module", required=True)
|
sub = ap.add_subparsers(dest="module", required=True)
|
||||||
|
|
||||||
# ---- goods 组 ----
|
# ---- goods 组 ----
|
||||||
pg = sub.add_parser("goods", help="goods 侧(web_save / web_new_encrypt,CHAOS VM 116 opcode)")
|
pg = sub.add_parser(
|
||||||
|
"goods", help="goods 侧(web_save / web_new_encrypt,CHAOS VM 116 opcode)"
|
||||||
|
)
|
||||||
gsub = pg.add_subparsers(dest="cmd", required=True)
|
gsub = pg.add_subparsers(dest="cmd", required=True)
|
||||||
gsub.add_parser("verify", help="校验当前 goods 协议材料")
|
gsub.add_parser("verify", help="校验当前 goods 协议材料")
|
||||||
p = gsub.add_parser("gen", help="仅生成 encrypt_msg(不联网)")
|
p = gsub.add_parser("gen", help="仅生成 encrypt_msg(不联网)")
|
||||||
@@ -416,30 +468,64 @@ def main() -> int:
|
|||||||
p.add_argument("--frames", default=None, help="最新捕获的 frames.jsonl(优先)")
|
p.add_argument("--frames", default=None, help="最新捕获的 frames.jsonl(优先)")
|
||||||
p.add_argument("--output", default=None)
|
p.add_argument("--output", default=None)
|
||||||
p = msub.add_parser("submit", help="生成 encrypt_msg + 提交 PlaceOrder(纯 Python)")
|
p = msub.add_parser("submit", help="生成 encrypt_msg + 提交 PlaceOrder(纯 Python)")
|
||||||
p.add_argument("--session", required=True, help="mall-session.json(含 transform_input/xmidas/cookies)")
|
p.add_argument(
|
||||||
p.add_argument("--order-template", default=str(ROOT / "config" / "mall-order-template.json"))
|
"--session",
|
||||||
|
required=True,
|
||||||
|
help="mall-session.json(含 transform_input/xmidas/cookies)",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--order-template", default=str(ROOT / "config" / "mall-order-template.json")
|
||||||
|
)
|
||||||
p.add_argument("--output", default=None)
|
p.add_argument("--output", default=None)
|
||||||
p = msub.add_parser("auto", help="仅凭 CK 全自动下单(纯HTTP GetPayToken + 纯Python encrypt_msg,无浏览器采集)")
|
p = msub.add_parser(
|
||||||
|
"auto",
|
||||||
|
help="仅凭 CK 全自动下单(纯HTTP GetPayToken + 纯Python encrypt_msg,无浏览器采集)",
|
||||||
|
)
|
||||||
p.add_argument("--session", default=str(ROOT / "config" / "mall-session.json"))
|
p.add_argument("--session", default=str(ROOT / "config" / "mall-session.json"))
|
||||||
p.add_argument("--order-template", default=str(ROOT / "config" / "mall-order-template.json"))
|
p.add_argument(
|
||||||
p.add_argument("--product-id", default=None, help="和平精英点券商品 ID;默认使用模板当前商品")
|
"--order-template", default=str(ROOT / "config" / "mall-order-template.json")
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--product-id", default=None, help="和平精英点券商品 ID;默认使用模板当前商品"
|
||||||
|
)
|
||||||
p.add_argument("--offer-id", default=None, help="当前商品服务端 offer ID")
|
p.add_argument("--offer-id", default=None, help="当前商品服务端 offer ID")
|
||||||
p.add_argument("--quantity", type=int, default=None, help="当前点券商品购买份数(正整数)")
|
p.add_argument(
|
||||||
|
"--quantity", type=int, default=None, help="当前点券商品购买份数(正整数)"
|
||||||
|
)
|
||||||
p.add_argument("--role-id", default=None, help="游戏角色 ID;默认使用模板当前角色")
|
p.add_argument("--role-id", default=None, help="游戏角色 ID;默认使用模板当前角色")
|
||||||
p.add_argument("--role-name", default=None, help="游戏角色名称;默认使用模板当前角色")
|
p.add_argument(
|
||||||
|
"--role-name", default=None, help="游戏角色名称;默认使用模板当前角色"
|
||||||
|
)
|
||||||
p.add_argument("--zone-id", default=None, help="游戏区服 ID;默认使用模板当前区服")
|
p.add_argument("--zone-id", default=None, help="游戏区服 ID;默认使用模板当前区服")
|
||||||
p.add_argument("--zone-name", default=None, help="游戏区服名称")
|
p.add_argument("--zone-name", default=None, help="游戏区服名称")
|
||||||
p.add_argument("--area", default=None, help="游戏大区 ID(QQ 平台与 zoneid 不同,来自角色查询的 partition_info)")
|
p.add_argument(
|
||||||
|
"--area",
|
||||||
|
default=None,
|
||||||
|
help="游戏大区 ID(QQ 平台与 zoneid 不同,来自角色查询的 partition_info)",
|
||||||
|
)
|
||||||
p.add_argument("--partition", default=None, help="游戏分区 ID(QQ 平台可为空)")
|
p.add_argument("--partition", default=None, help="游戏分区 ID(QQ 平台可为空)")
|
||||||
p.add_argument("--pf", default=None, help="支付平台标识;由角色选择器按 Android/iOS 写入")
|
p.add_argument(
|
||||||
|
"--pf", default=None, help="支付平台标识;由角色选择器按 Android/iOS 写入"
|
||||||
|
)
|
||||||
p.add_argument("--output", default=None)
|
p.add_argument("--output", default=None)
|
||||||
p = msub.add_parser("pay", help="mall 下单 -> goods web_save -> 微信支付二维码(纯 Python)")
|
p = msub.add_parser(
|
||||||
p.add_argument("--mall-response", default=str(ROOT / "config" / "mall-order-response.json"),
|
"pay", help="mall 下单 -> goods web_save -> 微信支付二维码(纯 Python)"
|
||||||
help="PlaceOrder 响应(含 url_params/token)")
|
)
|
||||||
p.add_argument("--goods-dir", default=str(ROOT / "replay" / "live" / "order10"),
|
p.add_argument(
|
||||||
help="goods 会话态目录(plaintext/body/xmidasops/keys/cap/web-token)")
|
"--mall-response",
|
||||||
p.add_argument("--session", default=str(ROOT / "config" / "mall-session.json"),
|
default=str(ROOT / "config" / "mall-order-response.json"),
|
||||||
help="mall 登录态(含 cookies)")
|
help="PlaceOrder 响应(含 url_params/token)",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--goods-dir",
|
||||||
|
default=str(ROOT / "replay" / "live" / "order10"),
|
||||||
|
help="goods 会话态目录(plaintext/body/xmidasops/keys/cap/web-token)",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--session",
|
||||||
|
default=str(ROOT / "config" / "mall-session.json"),
|
||||||
|
help="mall 登录态(含 cookies)",
|
||||||
|
)
|
||||||
p.add_argument("--appid", default=DEFAULT_APPID)
|
p.add_argument("--appid", default=DEFAULT_APPID)
|
||||||
p.add_argument("--output", default=None, help="二维码 PNG 输出路径")
|
p.add_argument("--output", default=None, help="二维码 PNG 输出路径")
|
||||||
msub.add_parser("capture", help="用浏览器捕获 mall 会话态(Node 脚本)")
|
msub.add_parser("capture", help="用浏览器捕获 mall 会话态(Node 脚本)")
|
||||||
@@ -447,20 +533,29 @@ def main() -> int:
|
|||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
try:
|
try:
|
||||||
if args.module == "goods":
|
if args.module == "goods":
|
||||||
return {"verify": cmd_verify, "gen": cmd_gen, "submit": cmd_submit,
|
return {
|
||||||
"sample-session": cmd_sample_session, "sample-order": cmd_sample_order}[args.cmd](args)
|
"verify": cmd_verify,
|
||||||
|
"gen": cmd_gen,
|
||||||
|
"submit": cmd_submit,
|
||||||
|
"sample-session": cmd_sample_session,
|
||||||
|
"sample-order": cmd_sample_order,
|
||||||
|
}[args.cmd](args)
|
||||||
elif args.module == "mall":
|
elif args.module == "mall":
|
||||||
return {"verify": cmd_mall_verify, "gen": cmd_mall_gen, "submit": cmd_mall_submit,
|
return {
|
||||||
"sample-session": cmd_mall_sample, "pay": cmd_mall_pay, "auto": cmd_mall_auto,
|
"verify": cmd_mall_verify,
|
||||||
"capture": cmd_mall_capture}[args.cmd](args)
|
"gen": cmd_mall_gen,
|
||||||
|
"submit": cmd_mall_submit,
|
||||||
|
"sample-session": cmd_mall_sample,
|
||||||
|
"pay": cmd_mall_pay,
|
||||||
|
"auto": cmd_mall_auto,
|
||||||
|
"capture": cmd_mall_capture,
|
||||||
|
}[args.cmd](args)
|
||||||
return 2
|
return 2
|
||||||
except (FileNotFoundError, ValueError) as e:
|
except (FileNotFoundError, ValueError) as e:
|
||||||
print(f"错误: {e}")
|
print(f"错误: {e}")
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def build_mall_transform(fixed: dict) -> list:
|
def build_mall_transform(fixed: dict) -> list:
|
||||||
"""仅CK全自动:固定槽模板 + 随机 key16/槽6/明文缓冲 构造 transform_input。
|
"""仅CK全自动:固定槽模板 + 随机 key16/槽6/明文缓冲 构造 transform_input。
|
||||||
|
|
||||||
@@ -487,6 +582,7 @@ def mall_getpaytoken(cookies: dict) -> tuple[list, str]:
|
|||||||
证据: evidence/getpaytoken-pure-http.json
|
证据: evidence/getpaytoken-pure-http.json
|
||||||
"""
|
"""
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
login = midas_login_params(cookies)
|
login = midas_login_params(cookies)
|
||||||
login["offer_id"] = "800001492"
|
login["offer_id"] = "800001492"
|
||||||
body = {
|
body = {
|
||||||
@@ -494,8 +590,12 @@ def mall_getpaytoken(cookies: dict) -> tuple[list, str]:
|
|||||||
"call_param": {
|
"call_param": {
|
||||||
"call_func": "GetPayToken",
|
"call_func": "GetPayToken",
|
||||||
"call_param_json": json.dumps(
|
"call_param_json": json.dumps(
|
||||||
{"version": "pagedoo-v2.0.0", "app_id": "202406061128117473047424",
|
{
|
||||||
"content_id": "ct1755160919_GEOCGTMN"}),
|
"version": "pagedoo-v2.0.0",
|
||||||
|
"app_id": "202406061128117473047424",
|
||||||
|
"content_id": "ct1755160919_GEOCGTMN",
|
||||||
|
}
|
||||||
|
),
|
||||||
"call_type": "security_service",
|
"call_type": "security_service",
|
||||||
"login_check_param_json": json.dumps(login),
|
"login_check_param_json": json.dumps(login),
|
||||||
},
|
},
|
||||||
@@ -505,12 +605,19 @@ def mall_getpaytoken(cookies: dict) -> tuple[list, str]:
|
|||||||
for k, v in [("midas_openid", "openid"), ("midas_openkey", "accesstoken")]:
|
for k, v in [("midas_openid", "openid"), ("midas_openkey", "accesstoken")]:
|
||||||
if k not in cookies and v in cookies:
|
if k not in cookies and v in cookies:
|
||||||
cookie_str += f"; {k}=" + cookies[v]
|
cookie_str += f"; {k}=" + cookies[v]
|
||||||
req = urllib.request.Request(url, data=json.dumps(body).encode("utf-8"), headers={
|
req = urllib.request.Request(
|
||||||
"Content-Type": "application/json", "Accept": "application/json, text/plain, */*",
|
url,
|
||||||
"Origin": "https://z.iwan.yyb.qq.com", "Referer": "https://z.iwan.yyb.qq.com/",
|
data=json.dumps(body).encode("utf-8"),
|
||||||
"Cookie": cookie_str,
|
headers={
|
||||||
"User-Agent": MALL_USER_AGENT,
|
"Content-Type": "application/json",
|
||||||
}, method="POST")
|
"Accept": "application/json, text/plain, */*",
|
||||||
|
"Origin": "https://z.iwan.yyb.qq.com",
|
||||||
|
"Referer": "https://z.iwan.yyb.qq.com/",
|
||||||
|
"Cookie": cookie_str,
|
||||||
|
"User-Agent": MALL_USER_AGENT,
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=30) as r:
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
raw = r.read().decode("utf-8", "replace")
|
raw = r.read().decode("utf-8", "replace")
|
||||||
@@ -549,7 +656,11 @@ def _apply_card_selection(payload: dict, args) -> None:
|
|||||||
if quantity is not None and quantity <= 0:
|
if quantity is not None and quantity <= 0:
|
||||||
raise ValueError("--quantity 必须是正整数")
|
raise ValueError("--quantity 必须是正整数")
|
||||||
products = payload.get("product_list")
|
products = payload.get("product_list")
|
||||||
if not isinstance(products, list) or not products or not isinstance(products[0], dict):
|
if (
|
||||||
|
not isinstance(products, list)
|
||||||
|
or not products
|
||||||
|
or not isinstance(products[0], dict)
|
||||||
|
):
|
||||||
raise ValueError("订单模板缺少 product_list[0]")
|
raise ValueError("订单模板缺少 product_list[0]")
|
||||||
product = products[0]
|
product = products[0]
|
||||||
product_id = getattr(args, "product_id", None)
|
product_id = getattr(args, "product_id", None)
|
||||||
@@ -617,7 +728,9 @@ def cmd_mall_auto(args) -> int:
|
|||||||
return 1
|
return 1
|
||||||
print(f"[auto] arrays {len(arrays)} | pay_token {pay_token[:16]}...")
|
print(f"[auto] arrays {len(arrays)} | pay_token {pay_token[:16]}...")
|
||||||
print("[auto] ② 构造 transform_input(固定槽 + 随机 key/明文缓冲)...")
|
print("[auto] ② 构造 transform_input(固定槽 + 随机 key/明文缓冲)...")
|
||||||
fixed = json.loads((ROOT / "replay/mall/transform-fixed.json").read_text(encoding="utf-8"))
|
fixed = json.loads(
|
||||||
|
(ROOT / "replay/mall/transform-fixed.json").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
validate_mall_materials(fixed)
|
validate_mall_materials(fixed)
|
||||||
ti = build_mall_transform(fixed)
|
ti = build_mall_transform(fixed)
|
||||||
print("[auto] ③ 纯 Python 生成 encrypt_msg...")
|
print("[auto] ③ 纯 Python 生成 encrypt_msg...")
|
||||||
@@ -639,18 +752,29 @@ def cmd_mall_auto(args) -> int:
|
|||||||
if k not in cookies and v in cookies:
|
if k not in cookies and v in cookies:
|
||||||
cookie_str += f"; {k}=" + cookies[v]
|
cookie_str += f"; {k}=" + cookies[v]
|
||||||
url = MALL_API_URL + "?t=" + str(int(time.time() * 1000))
|
url = MALL_API_URL + "?t=" + str(int(time.time() * 1000))
|
||||||
req = urllib.request.Request(url, data=new_body.encode("utf-8"), headers={
|
req = urllib.request.Request(
|
||||||
"Content-Type": "application/json", "Accept": "application/json, text/plain, */*",
|
url,
|
||||||
"Origin": "https://z.iwan.yyb.qq.com", "Referer": "https://z.iwan.yyb.qq.com/",
|
data=new_body.encode("utf-8"),
|
||||||
"Cookie": cookie_str,
|
headers={
|
||||||
"User-Agent": MALL_USER_AGENT,
|
"Content-Type": "application/json",
|
||||||
}, method="POST")
|
"Accept": "application/json, text/plain, */*",
|
||||||
|
"Origin": "https://z.iwan.yyb.qq.com",
|
||||||
|
"Referer": "https://z.iwan.yyb.qq.com/",
|
||||||
|
"Cookie": cookie_str,
|
||||||
|
"User-Agent": MALL_USER_AGENT,
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=30) as r:
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
raw = r.read().decode("utf-8", "replace")
|
raw = r.read().decode("utf-8", "replace")
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
raw = e.read().decode("utf-8", "replace")
|
raw = e.read().decode("utf-8", "replace")
|
||||||
out = Path(args.output) if args.output else ROOT / "config" / "mall-order-response.json"
|
out = (
|
||||||
|
Path(args.output)
|
||||||
|
if args.output
|
||||||
|
else ROOT / "config" / "mall-order-response.json"
|
||||||
|
)
|
||||||
_write_private_text(out, raw)
|
_write_private_text(out, raw)
|
||||||
js = json.loads(raw)
|
js = json.loads(raw)
|
||||||
ret = js.get("result_code")
|
ret = js.get("result_code")
|
||||||
@@ -669,7 +793,9 @@ def cmd_mall_auto(args) -> int:
|
|||||||
return 0
|
return 0
|
||||||
detail = js.get("result_info", "") or call_reply.get("result_info", "") or ""
|
detail = js.get("result_info", "") or call_reply.get("result_info", "") or ""
|
||||||
print(f"❌ 下单失败 ret={ret} inner={inner_ret} ({detail})")
|
print(f"❌ 下单失败 ret={ret} inner={inner_ret} ({detail})")
|
||||||
print(f" {describe_payment_failure(str(cookies.get('logintype', '')), 'order', f'{ret} {inner_ret} {detail}')}")
|
print(
|
||||||
|
f" {describe_payment_failure(str(cookies.get('logintype', '')), 'order', f'{ret} {inner_ret} {detail}')}"
|
||||||
|
)
|
||||||
print(f" 响应已保存 → {out}")
|
print(f" 响应已保存 → {out}")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
@@ -714,10 +840,13 @@ def cmd_mall_pay(args) -> int:
|
|||||||
cr = json.loads(resp["data"]["call_reply"])
|
cr = json.loads(resp["data"]["call_reply"])
|
||||||
up = cr["data"]["url_params"]
|
up = cr["data"]["url_params"]
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
q = parse_qs(urlparse(up).query)
|
q = parse_qs(urlparse(up).query)
|
||||||
mall_tid = q.get("token_id", [""])[0]
|
mall_tid = q.get("token_id", [""])[0]
|
||||||
if mall_tid and mall_tid != token_id:
|
if mall_tid and mall_tid != token_id:
|
||||||
print(f"⚠️ mall 响应订单({mall_tid[:16]}...)与捕获会话订单({token_id[:16]}...)不一致")
|
print(
|
||||||
|
f"⚠️ mall 响应订单({mall_tid[:16]}...)与捕获会话订单({token_id[:16]}...)不一致"
|
||||||
|
)
|
||||||
print(" goods web_token 绑定捕获页面订单,以捕获 body 订单为准继续")
|
print(" goods web_token 绑定捕获页面订单,以捕获 body 订单为准继续")
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
print(f"⚠️ mall 响应读取失败(忽略): {e!r}")
|
print(f"⚠️ mall 响应读取失败(忽略): {e!r}")
|
||||||
@@ -727,6 +856,7 @@ def cmd_mall_pay(args) -> int:
|
|||||||
keys = json.loads((gd / "keys.json").read_text(encoding="utf-8"))
|
keys = json.loads((gd / "keys.json").read_text(encoding="utf-8"))
|
||||||
xmidas = json.loads((gd / "xmidasops.json").read_text(encoding="utf-8"))
|
xmidas = json.loads((gd / "xmidasops.json").read_text(encoding="utf-8"))
|
||||||
from pyvm.algorithm import decode_d, recover_plaintext_from_buffer
|
from pyvm.algorithm import decode_d, recover_plaintext_from_buffer
|
||||||
|
|
||||||
at_file = gd / "args-template.json"
|
at_file = gd / "args-template.json"
|
||||||
if at_file.exists():
|
if at_file.exists():
|
||||||
# 新版采集:decoded 18参直接读取(capture-goods-session.mjs J 转储)
|
# 新版采集:decoded 18参直接读取(capture-goods-session.mjs J 转储)
|
||||||
@@ -740,8 +870,10 @@ def cmd_mall_pay(args) -> int:
|
|||||||
if not web_token:
|
if not web_token:
|
||||||
m = re.search(r"web_token=([0-9A-F]+)", body_tpl)
|
m = re.search(r"web_token=([0-9A-F]+)", body_tpl)
|
||||||
web_token = m.group(1) if m else ""
|
web_token = m.group(1) if m else ""
|
||||||
print(f"[pay] goods 会话态: xmidasops={len(xmidas)} key16={'有' if keys.get('key16') else '无'} "
|
print(
|
||||||
f"web_token={web_token[:12]}...")
|
f"[pay] goods 会话态: xmidasops={len(xmidas)} key16={'有' if keys.get('key16') else '无'} "
|
||||||
|
f"web_token={web_token[:12]}..."
|
||||||
|
)
|
||||||
|
|
||||||
# 3. 恢复页面真实明文(ts/fk_extend/_rand),刷新 ts 为当前一致值
|
# 3. 恢复页面真实明文(ts/fk_extend/_rand),刷新 ts 为当前一致值
|
||||||
try:
|
try:
|
||||||
@@ -761,22 +893,37 @@ def cmd_mall_pay(args) -> int:
|
|||||||
now_ms = int(time.time() * 1000)
|
now_ms = int(time.time() * 1000)
|
||||||
params["ts"] = str(now_ms // 1000)
|
params["ts"] = str(now_ms // 1000)
|
||||||
if rec_ts:
|
if rec_ts:
|
||||||
print(f"[pay] 页面真实明文: ts={rec_ts}(捕获) -> 使用当前 ts={params['ts']} "
|
print(
|
||||||
f"(E3:ts 与 body t 一致即可,不必等于页面值)")
|
f"[pay] 页面真实明文: ts={rec_ts}(捕获) -> 使用当前 ts={params['ts']} "
|
||||||
|
f"(E3:ts 与 body t 一致即可,不必等于页面值)"
|
||||||
|
)
|
||||||
print("[pay] 生成 goods encrypt_msg(纯 Python)...")
|
print("[pay] 生成 goods encrypt_msg(纯 Python)...")
|
||||||
hex_msg = generate_encrypt_msg_offline(
|
hex_msg = generate_encrypt_msg_offline(
|
||||||
params, fk_extend, params["ts"], rand_val,
|
params,
|
||||||
xmidas=xmidas, args_template=web_args,
|
fk_extend,
|
||||||
key16=keys.get("key16"), key1=keys.get("key1"),
|
params["ts"],
|
||||||
|
rand_val,
|
||||||
|
xmidas=xmidas,
|
||||||
|
args_template=web_args,
|
||||||
|
key16=keys.get("key16"),
|
||||||
|
key1=keys.get("key1"),
|
||||||
)
|
)
|
||||||
print(f"[pay] encrypt_msg ({len(hex_msg)} hex): {hex_msg[:32]}...")
|
print(f"[pay] encrypt_msg ({len(hex_msg)} hex): {hex_msg[:32]}...")
|
||||||
|
|
||||||
# 4. 构造 web_save body(会话订单 token + 当前动态值 + 纯 py encrypt_msg)
|
# 4. 构造 web_save body(会话订单 token + 当前动态值 + 纯 py encrypt_msg)
|
||||||
body = body_tpl
|
body = body_tpl
|
||||||
for k, v in [("token_id", token_id), ("transaction_id", transaction_id),
|
for k, v in [
|
||||||
("out_trade_no", out_trade_no), ("offer_type", offer_type)]:
|
("token_id", token_id),
|
||||||
|
("transaction_id", transaction_id),
|
||||||
|
("out_trade_no", out_trade_no),
|
||||||
|
("offer_type", offer_type),
|
||||||
|
]:
|
||||||
body = re.sub(rf"{k}=[^&]*", f"{k}=" + v, body, count=1)
|
body = re.sub(rf"{k}=[^&]*", f"{k}=" + v, body, count=1)
|
||||||
body = re.sub(r"pc_st=[^&]+", "pc_st=" + str(uuid.uuid4()).upper() + str(int(time.time() * 1000)), body)
|
body = re.sub(
|
||||||
|
r"pc_st=[^&]+",
|
||||||
|
"pc_st=" + str(uuid.uuid4()).upper() + str(int(time.time() * 1000)),
|
||||||
|
body,
|
||||||
|
)
|
||||||
body = re.sub(r"r=[0-9.]+", "r=" + str(secrets.SystemRandom().random()), body)
|
body = re.sub(r"r=[0-9.]+", "r=" + str(secrets.SystemRandom().random()), body)
|
||||||
body = re.sub(r"&t=[0-9]+", "&t=" + str(int(time.time() * 1000)), body)
|
body = re.sub(r"&t=[0-9]+", "&t=" + str(int(time.time() * 1000)), body)
|
||||||
body = re.sub(r"encrypt_msg=[0-9a-f]+", "encrypt_msg=" + hex_msg, body)
|
body = re.sub(r"encrypt_msg=[0-9a-f]+", "encrypt_msg=" + hex_msg, body)
|
||||||
@@ -798,7 +945,8 @@ def cmd_mall_pay(args) -> int:
|
|||||||
# 6. POST web_save
|
# 6. POST web_save
|
||||||
url = f"https://api.unipay.qq.com/v1/r/{args.appid}/web_save"
|
url = f"https://api.unipay.qq.com/v1/r/{args.appid}/web_save"
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
url, data=body.encode("utf-8"),
|
url,
|
||||||
|
data=body.encode("utf-8"),
|
||||||
headers={
|
headers={
|
||||||
"User-Agent": MALL_USER_AGENT,
|
"User-Agent": MALL_USER_AGENT,
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
@@ -855,5 +1003,6 @@ def cmd_mall_pay(args) -> int:
|
|||||||
pass
|
pass
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
sys.exit(main())
|
sys.exit(main())
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
- mall.py: mall 侧高层 API(MallSession + generate_encrypt_msg)
|
- mall.py: mall 侧高层 API(MallSession + generate_encrypt_msg)
|
||||||
- session.py: 会话态模型
|
- session.py: 会话态模型
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .algorithm import (
|
from .algorithm import (
|
||||||
build_plaintext,
|
build_plaintext,
|
||||||
decode_d,
|
decode_d,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@
|
|||||||
→ webSave(33 块变换)
|
→ webSave(33 块变换)
|
||||||
→ encrypt_msg(1056 hex)
|
→ encrypt_msg(1056 hex)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -16,17 +17,39 @@ from typing import Any
|
|||||||
from .algorithm import generate_encrypt_msg_offline
|
from .algorithm import generate_encrypt_msg_offline
|
||||||
|
|
||||||
REPLAY = Path(__file__).resolve().parent.parent / "replay"
|
REPLAY = Path(__file__).resolve().parent.parent / "replay"
|
||||||
ORDER_FIELDS = ["token_id", "openid", "openkey", "session_id", "session_type", "zoneid",
|
ORDER_FIELDS = [
|
||||||
"pay_method", "buy_quantity", "mb_pwd", "pay_id", "auth_key",
|
"token_id",
|
||||||
"card_value", "accounttype", "provide_uin", "extend", "ts",
|
"openid",
|
||||||
"from_h5", "webversion"]
|
"openkey",
|
||||||
|
"session_id",
|
||||||
|
"session_type",
|
||||||
|
"zoneid",
|
||||||
|
"pay_method",
|
||||||
|
"buy_quantity",
|
||||||
|
"mb_pwd",
|
||||||
|
"pay_id",
|
||||||
|
"auth_key",
|
||||||
|
"card_value",
|
||||||
|
"accounttype",
|
||||||
|
"provide_uin",
|
||||||
|
"extend",
|
||||||
|
"ts",
|
||||||
|
"from_h5",
|
||||||
|
"webversion",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class GoodsSession:
|
class GoodsSession:
|
||||||
"""goods 会话态:xMidasOps(59640,服务端生成)+ key16/key1 + args_template。"""
|
"""goods 会话态:xMidasOps(59640,服务端生成)+ key16/key1 + args_template。"""
|
||||||
|
|
||||||
def __init__(self, xmidas_ops: list, key16: list, key1: list,
|
def __init__(
|
||||||
args_template_d: str = "", xmidas_token: str = ""):
|
self,
|
||||||
|
xmidas_ops: list,
|
||||||
|
key16: list,
|
||||||
|
key1: list,
|
||||||
|
args_template_d: str = "",
|
||||||
|
xmidas_token: str = "",
|
||||||
|
):
|
||||||
self.xmidas_ops = xmidas_ops
|
self.xmidas_ops = xmidas_ops
|
||||||
self.key16 = key16
|
self.key16 = key16
|
||||||
self.key1 = key1
|
self.key1 = key1
|
||||||
@@ -44,8 +67,13 @@ class GoodsSession:
|
|||||||
def from_session_state(cls, path: str | Path) -> "GoodsSession":
|
def from_session_state(cls, path: str | Path) -> "GoodsSession":
|
||||||
"""从 scripts/capture-session.mjs 生成的 session-state.json 加载。"""
|
"""从 scripts/capture-session.mjs 生成的 session-state.json 加载。"""
|
||||||
d = json.loads(Path(path).read_text(encoding="utf-8"))
|
d = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||||
return cls(d["xmidas_ops"], d["key16"], d["key1"],
|
return cls(
|
||||||
d.get("args_template_d", ""), d.get("xmidas_token", ""))
|
d["xmidas_ops"],
|
||||||
|
d["key16"],
|
||||||
|
d["key1"],
|
||||||
|
d.get("args_template_d", ""),
|
||||||
|
d.get("xmidas_token", ""),
|
||||||
|
)
|
||||||
|
|
||||||
def to_json(self) -> dict:
|
def to_json(self) -> dict:
|
||||||
return {
|
return {
|
||||||
@@ -58,17 +86,29 @@ class GoodsSession:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_json(cls, d: dict) -> "GoodsSession":
|
def from_json(cls, d: dict) -> "GoodsSession":
|
||||||
return cls(d["xmidas_ops"], d["key16"], d["key1"],
|
return cls(
|
||||||
d.get("args_template_d", ""), d.get("xmidas_token", ""))
|
d["xmidas_ops"],
|
||||||
|
d["key16"],
|
||||||
|
d["key1"],
|
||||||
|
d.get("args_template_d", ""),
|
||||||
|
d.get("xmidas_token", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def generate_encrypt_msg(session: GoodsSession, order: dict) -> str:
|
def generate_encrypt_msg(session: GoodsSession, order: dict) -> str:
|
||||||
"""用会话态 + 订单参数生成 goods encrypt_msg(1056 hex)。"""
|
"""用会话态 + 订单参数生成 goods encrypt_msg(1056 hex)。"""
|
||||||
from .algorithm import decode_d
|
from .algorithm import decode_d
|
||||||
|
|
||||||
params = {k: order.get(k, "") for k in ORDER_FIELDS}
|
params = {k: order.get(k, "") for k in ORDER_FIELDS}
|
||||||
args_tpl = decode_d(session.args_template_d) if session.args_template_d else None
|
args_tpl = decode_d(session.args_template_d) if session.args_template_d else None
|
||||||
return generate_encrypt_msg_offline(
|
return generate_encrypt_msg_offline(
|
||||||
params, order.get("fk_extend", ""), order.get("ts", ""), order.get("_rand", ""),
|
params,
|
||||||
xmidas=session.xmidas_ops, xmidas_token=session.xmidas_token,
|
order.get("fk_extend", ""),
|
||||||
args_template=args_tpl, key16=session.key16, key1=session.key1,
|
order.get("ts", ""),
|
||||||
|
order.get("_rand", ""),
|
||||||
|
xmidas=session.xmidas_ops,
|
||||||
|
xmidas_token=session.xmidas_token,
|
||||||
|
args_template=args_tpl,
|
||||||
|
key16=session.key16,
|
||||||
|
key1=session.key1,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""YYB mall APIs require different session fields for WeChat and QQ OAuth."""
|
"""YYB mall APIs require different session fields for WeChat and QQ OAuth."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
WECHAT_APPID = "wxd44977328b36e647"
|
WECHAT_APPID = "wxd44977328b36e647"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ mall 加密链路(E3 已验证,U-2022 闭环):
|
|||||||
注意:xMidasOps 是 mall 详情页页面级数据表(59620 长度,服务端生成),
|
注意:xMidasOps 是 mall 详情页页面级数据表(59620 长度,服务端生成),
|
||||||
必须从浏览器捕获(与 goods 的 59640 不同)。
|
必须从浏览器捕获(与 goods 的 59640 不同)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
@@ -24,9 +25,28 @@ from .algorithm import UNDEF, JSObject, Window
|
|||||||
from .pagedoo_vm import PagedooVM
|
from .pagedoo_vm import PagedooVM
|
||||||
|
|
||||||
REPLAY = Path(__file__).resolve().parent.parent / "replay" / "mall"
|
REPLAY = Path(__file__).resolve().parent.parent / "replay" / "mall"
|
||||||
GLOBALS = [UNDEF, None, True, False, 4294967295, 3995986053, 2103143698, 1622111212,
|
GLOBALS = [
|
||||||
4263108271, 3162892160, 1960464030, 2867129963, 3224029870, 3514649446,
|
UNDEF,
|
||||||
1382846327, 1898428403, 1268470028, 1457769175, 1595352606, 1100935262]
|
None,
|
||||||
|
True,
|
||||||
|
False,
|
||||||
|
4294967295,
|
||||||
|
3995986053,
|
||||||
|
2103143698,
|
||||||
|
1622111212,
|
||||||
|
4263108271,
|
||||||
|
3162892160,
|
||||||
|
1960464030,
|
||||||
|
2867129963,
|
||||||
|
3224029870,
|
||||||
|
3514649446,
|
||||||
|
1382846327,
|
||||||
|
1898428403,
|
||||||
|
1268470028,
|
||||||
|
1457769175,
|
||||||
|
1595352606,
|
||||||
|
1100935262,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class MallSession:
|
class MallSession:
|
||||||
@@ -45,9 +65,13 @@ class MallSession:
|
|||||||
|
|
||||||
def validate(self) -> None:
|
def validate(self) -> None:
|
||||||
if len(self.transform_input) != 18:
|
if len(self.transform_input) != 18:
|
||||||
raise ValueError(f"transform_input 应为 18 槽,实际 {len(self.transform_input)}")
|
raise ValueError(
|
||||||
|
f"transform_input 应为 18 槽,实际 {len(self.transform_input)}"
|
||||||
|
)
|
||||||
if len(self.xmidas_ops) != 59620:
|
if len(self.xmidas_ops) != 59620:
|
||||||
raise ValueError(f"mall xMidasOps 应为 59620(非 goods 59640),实际 {len(self.xmidas_ops)}")
|
raise ValueError(
|
||||||
|
f"mall xMidasOps 应为 59620(非 goods 59640),实际 {len(self.xmidas_ops)}"
|
||||||
|
)
|
||||||
mid = self.transform_input[10]
|
mid = self.transform_input[10]
|
||||||
if isinstance(mid, list) and mid and isinstance(mid[0], list):
|
if isinstance(mid, list) and mid and isinstance(mid[0], list):
|
||||||
if len(mid[0]) != 624:
|
if len(mid[0]) != 624:
|
||||||
@@ -61,7 +85,11 @@ class MallSession:
|
|||||||
- J|...|e377650|{JSON} e377650 创建参数(transform_input)
|
- J|...|e377650|{JSON} e377650 创建参数(transform_input)
|
||||||
- XMIDAS_OPS|url|59620数组 mall 详情页 xMidasOps
|
- XMIDAS_OPS|url|59620数组 mall 详情页 xMidasOps
|
||||||
"""
|
"""
|
||||||
lines = Path(frames_jsonl).read_text(encoding="utf-8", errors="replace").splitlines()
|
lines = (
|
||||||
|
Path(frames_jsonl)
|
||||||
|
.read_text(encoding="utf-8", errors="replace")
|
||||||
|
.splitlines()
|
||||||
|
)
|
||||||
transform_input = None
|
transform_input = None
|
||||||
xmidas = None
|
xmidas = None
|
||||||
# xMidasOps:取 mall 详情页(z.iwan / pagedoo)那条
|
# xMidasOps:取 mall 详情页(z.iwan / pagedoo)那条
|
||||||
@@ -85,7 +113,9 @@ class MallSession:
|
|||||||
transform_input = json.loads(l.split("|e377650|", 1)[1])
|
transform_input = json.loads(l.split("|e377650|", 1)[1])
|
||||||
break
|
break
|
||||||
if transform_input is None:
|
if transform_input is None:
|
||||||
raise ValueError("frames.jsonl 中未找到 e377650 J 转储(需 mall 详情页购买触发加密)")
|
raise ValueError(
|
||||||
|
"frames.jsonl 中未找到 e377650 J 转储(需 mall 详情页购买触发加密)"
|
||||||
|
)
|
||||||
if xmidas is None:
|
if xmidas is None:
|
||||||
raise ValueError("frames.jsonl 中未找到 59620 长度 xMidasOps")
|
raise ValueError("frames.jsonl 中未找到 59620 长度 xMidasOps")
|
||||||
return cls(transform_input, xmidas)
|
return cls(transform_input, xmidas)
|
||||||
@@ -112,8 +142,18 @@ def _mk_window(xmidas: list) -> Window:
|
|||||||
w.set("sessionStorage", JSObject())
|
w.set("sessionStorage", JSObject())
|
||||||
w.set("screen", JSObject())
|
w.set("screen", JSObject())
|
||||||
w.set("history", JSObject())
|
w.set("history", JSObject())
|
||||||
w.set("XMLHttpRequest", type("XHR", (), {
|
w.set(
|
||||||
"open": lambda *a: None, "send": lambda *a: None, "setRequestHeader": lambda *a: None}))
|
"XMLHttpRequest",
|
||||||
|
type(
|
||||||
|
"XHR",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"open": lambda *a: None,
|
||||||
|
"send": lambda *a: None,
|
||||||
|
"setRequestHeader": lambda *a: None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
w.set("fetch", lambda *a: None)
|
w.set("fetch", lambda *a: None)
|
||||||
w.set("xMidasOps", xmidas)
|
w.set("xMidasOps", xmidas)
|
||||||
return w
|
return w
|
||||||
@@ -143,5 +183,7 @@ def generate_encrypt_msg(session: MallSession, random_seed: int = 1) -> str:
|
|||||||
|
|
||||||
h9 = h[9][0] if isinstance(h[9], list) and h[9] else h[9]
|
h9 = h[9][0] if isinstance(h[9], list) and h[9] else h[9]
|
||||||
if not isinstance(h9, list) or len(h9) != 624:
|
if not isinstance(h9, list) or len(h9) != 624:
|
||||||
raise RuntimeError(f"e377650 输出异常: {type(h9).__name__} len={len(h9) if isinstance(h9, list) else '?'}")
|
raise RuntimeError(
|
||||||
|
f"e377650 输出异常: {type(h9).__name__} len={len(h9) if isinstance(h9, list) else '?'}"
|
||||||
|
)
|
||||||
return "".join(f"{x & 255:02x}" for x in h9)
|
return "".join(f"{x & 255:02x}" for x in h9)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Read YYB's official order list after a payment is completed."""
|
"""Read YYB's official order list after a payment is completed."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -70,7 +71,9 @@ def order_completion_states(document: dict[str, Any]) -> dict[str, bool]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def find_completed_order(document: dict[str, Any], previous_states: dict[str, bool]) -> dict[str, Any] | None:
|
def find_completed_order(
|
||||||
|
document: dict[str, Any], previous_states: dict[str, bool]
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
"""Find an order that appeared or transitioned to completed after the QR display."""
|
"""Find an order that appeared or transitioned to completed after the QR display."""
|
||||||
for item in document.get("list", []):
|
for item in document.get("list", []):
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
|||||||
"""YYB 下单和付款失败响应的统一归类。"""
|
"""YYB 下单和付款失败响应的统一归类。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
这里仅收敛已由成功请求验证的固定值,不负责推断或修改协议字段。
|
这里仅收敛已由成功请求验证的固定值,不负责推断或修改协议字段。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -47,21 +48,38 @@ def file_fingerprint(path: Path) -> str:
|
|||||||
return _sha256_prefix_bytes(path.read_bytes())
|
return _sha256_prefix_bytes(path.read_bytes())
|
||||||
|
|
||||||
|
|
||||||
def validate_goods_materials(args_template: list, xmidas: list[int] | None = None) -> None:
|
def validate_goods_materials(
|
||||||
|
args_template: list, xmidas: list[int] | None = None
|
||||||
|
) -> None:
|
||||||
"""校验 goods webSave VM 所需静态表结构,发现升级时尽早失败。"""
|
"""校验 goods webSave VM 所需静态表结构,发现升级时尽早失败。"""
|
||||||
if not isinstance(args_template, list) or len(args_template) != 18:
|
if not isinstance(args_template, list) or len(args_template) != 18:
|
||||||
raise ValueError("goods args-template 必须是 18 槽数组")
|
raise ValueError("goods args-template 必须是 18 槽数组")
|
||||||
for index in (0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 12, 13, 14, 15, 16):
|
for index in (0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 12, 13, 14, 15, 16):
|
||||||
if not isinstance(args_template[index], list) or not args_template[index]:
|
if not isinstance(args_template[index], list) or not args_template[index]:
|
||||||
raise ValueError(f"goods args-template 槽 {index} 缺失")
|
raise ValueError(f"goods args-template 槽 {index} 缺失")
|
||||||
expected_lengths = {0: 16, 1: 256, 2: 256, 3: 256, 4: 256, 5: 256,
|
expected_lengths = {
|
||||||
6: 16, 10: 528, 11: 1024, 12: 1024, 13: 256,
|
0: 16,
|
||||||
14: 256, 15: 256, 16: 256}
|
1: 256,
|
||||||
|
2: 256,
|
||||||
|
3: 256,
|
||||||
|
4: 256,
|
||||||
|
5: 256,
|
||||||
|
6: 16,
|
||||||
|
10: 528,
|
||||||
|
11: 1024,
|
||||||
|
12: 1024,
|
||||||
|
13: 256,
|
||||||
|
14: 256,
|
||||||
|
15: 256,
|
||||||
|
16: 256,
|
||||||
|
}
|
||||||
for index, expected in expected_lengths.items():
|
for index, expected in expected_lengths.items():
|
||||||
value = args_template[index][0]
|
value = args_template[index][0]
|
||||||
if not isinstance(value, list) or len(value) != expected:
|
if not isinstance(value, list) or len(value) != expected:
|
||||||
actual = len(value) if isinstance(value, list) else "非数组"
|
actual = len(value) if isinstance(value, list) else "非数组"
|
||||||
raise ValueError(f"goods args-template 槽 {index} 长度异常: {actual} != {expected}")
|
raise ValueError(
|
||||||
|
f"goods args-template 槽 {index} 长度异常: {actual} != {expected}"
|
||||||
|
)
|
||||||
if xmidas is not None and len(xmidas) != 59640:
|
if xmidas is not None and len(xmidas) != 59640:
|
||||||
raise ValueError(f"goods xMidasOps 长度异常: {len(xmidas)} != 59640")
|
raise ValueError(f"goods xMidasOps 长度异常: {len(xmidas)} != 59640")
|
||||||
|
|
||||||
@@ -70,7 +88,9 @@ def validate_mall_materials(transform_fixed: dict[str, Any]) -> None:
|
|||||||
"""校验 mall 固定槽模板;动态槽仍由当前会话的 GetPayToken 填充。"""
|
"""校验 mall 固定槽模板;动态槽仍由当前会话的 GetPayToken 填充。"""
|
||||||
if not isinstance(transform_fixed, dict):
|
if not isinstance(transform_fixed, dict):
|
||||||
raise ValueError("mall transform-fixed 必须是对象")
|
raise ValueError("mall transform-fixed 必须是对象")
|
||||||
required = {str(index) for index in (1, 2, 3, 4, 5, 7, 8, 9, 11, 12, 13, 14, 15, 16, 17)}
|
required = {
|
||||||
|
str(index) for index in (1, 2, 3, 4, 5, 7, 8, 9, 11, 12, 13, 14, 15, 16, 17)
|
||||||
|
}
|
||||||
missing = sorted(required - set(transform_fixed))
|
missing = sorted(required - set(transform_fixed))
|
||||||
if missing:
|
if missing:
|
||||||
raise ValueError(f"mall transform-fixed 缺少槽: {', '.join(missing)}")
|
raise ValueError(f"mall transform-fixed 缺少槽: {', '.join(missing)}")
|
||||||
@@ -79,7 +99,9 @@ def validate_mall_materials(transform_fixed: dict[str, Any]) -> None:
|
|||||||
raise ValueError(f"mall transform-fixed 槽 {index} 不是数组")
|
raise ValueError(f"mall transform-fixed 槽 {index} 不是数组")
|
||||||
|
|
||||||
|
|
||||||
def goods_material_diagnostics(root: Path, args_template: list, xmidas: list[int]) -> dict[str, Any]:
|
def goods_material_diagnostics(
|
||||||
|
root: Path, args_template: list, xmidas: list[int]
|
||||||
|
) -> dict[str, Any]:
|
||||||
"""构造脱敏协议指纹,便于区分页面升级和服务端业务拒绝。"""
|
"""构造脱敏协议指纹,便于区分页面升级和服务端业务拒绝。"""
|
||||||
validate_goods_materials(args_template, xmidas)
|
validate_goods_materials(args_template, xmidas)
|
||||||
replay = root / "replay"
|
replay = root / "replay"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ F-2049/F-2051(E3):encrypt_msg 与当前会话绑定——
|
|||||||
- key1: 诱饵密钥(点击级,捕获即可)
|
- key1: 诱饵密钥(点击级,捕获即可)
|
||||||
服务端能验证 key 派生状态(随机 key 变体 ret:1099),因此新订单必须先捕获会话态。
|
服务端能验证 key 派生状态(随机 key 变体 ret:1099),因此新订单必须先捕获会话态。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -31,7 +32,9 @@ class SessionState:
|
|||||||
key16: list[int] = field(default_factory=list)
|
key16: list[int] = field(default_factory=list)
|
||||||
key1: list[int] = field(default_factory=list)
|
key1: list[int] = field(default_factory=list)
|
||||||
xmidas_token: str = DEFAULT_XMIDAS_TOKEN
|
xmidas_token: str = DEFAULT_XMIDAS_TOKEN
|
||||||
args_template_d: str = "" # webSave 18 参深拷贝(deepcap PC 85091 的 C[2] 原始 D 编码,会话绑定)
|
args_template_d: str = (
|
||||||
|
"" # webSave 18 参深拷贝(deepcap PC 85091 的 C[2] 原始 D 编码,会话绑定)
|
||||||
|
)
|
||||||
cookies: dict[str, str] = field(default_factory=dict)
|
cookies: dict[str, str] = field(default_factory=dict)
|
||||||
openid: str = ""
|
openid: str = ""
|
||||||
openkey: str = ""
|
openkey: str = ""
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
付款码只在服务端 ``web_save`` 返回 ``ret=0`` 后渲染。付款后通过商城官方订单
|
付款码只在服务端 ``web_save`` 返回 ``ret=0`` 后渲染。付款后通过商城官方订单
|
||||||
列表确认本次新出现的完成订单;该检查不触发付款或确认操作。
|
列表确认本次新出现的完成订单;该检查不触发付款或确认操作。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -26,7 +27,11 @@ from pathlib import Path
|
|||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
sys.path.insert(0, str(ROOT))
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
from pyvm.algorithm import build_plaintext, derive_key1_from_key16, generate_encrypt_msg_offline # noqa: E402
|
from pyvm.algorithm import (
|
||||||
|
build_plaintext,
|
||||||
|
derive_key1_from_key16,
|
||||||
|
generate_encrypt_msg_offline,
|
||||||
|
) # noqa: E402
|
||||||
from pyvm.login_profile import midas_login_params # noqa: E402
|
from pyvm.login_profile import midas_login_params # noqa: E402
|
||||||
from pyvm.payment_errors import describe_payment_failure # noqa: E402
|
from pyvm.payment_errors import describe_payment_failure # noqa: E402
|
||||||
from pyvm.protocol import ( # noqa: E402
|
from pyvm.protocol import ( # noqa: E402
|
||||||
@@ -64,6 +69,8 @@ def write_private_text(path: Path, content: str) -> None:
|
|||||||
|
|
||||||
def write_private_json(path: Path, value: dict) -> None:
|
def write_private_json(path: Path, value: dict) -> None:
|
||||||
write_private_text(path, json.dumps(value, ensure_ascii=False, indent=2) + "\n")
|
write_private_text(path, json.dumps(value, ensure_ascii=False, indent=2) + "\n")
|
||||||
|
|
||||||
|
|
||||||
def load_json(path: Path) -> dict:
|
def load_json(path: Path) -> dict:
|
||||||
return json.loads(path.read_text(encoding="utf-8"))
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
@@ -74,8 +81,12 @@ def parse_url_params(mall_response: dict) -> dict[str, str]:
|
|||||||
url_params = call_reply["data"]["url_params"]
|
url_params = call_reply["data"]["url_params"]
|
||||||
except (KeyError, TypeError, json.JSONDecodeError) as exc:
|
except (KeyError, TypeError, json.JSONDecodeError) as exc:
|
||||||
raise ValueError("mall 响应缺少 data.call_reply.data.url_params") from exc
|
raise ValueError("mall 响应缺少 data.call_reply.data.url_params") from exc
|
||||||
return {key: values[-1] for key, values in urllib.parse.parse_qs(
|
return {
|
||||||
urllib.parse.urlparse(url_params).query, keep_blank_values=True).items()}
|
key: values[-1]
|
||||||
|
for key, values in urllib.parse.parse_qs(
|
||||||
|
urllib.parse.urlparse(url_params).query, keep_blank_values=True
|
||||||
|
).items()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def cookie_header(cookies: dict[str, str]) -> str:
|
def cookie_header(cookies: dict[str, str]) -> str:
|
||||||
@@ -96,8 +107,9 @@ def request_bytes(url: str, cookies: dict[str, str], body: bytes | None = None)
|
|||||||
if body is not None:
|
if body is not None:
|
||||||
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
||||||
headers["Origin"] = "https://pay.qq.com"
|
headers["Origin"] = "https://pay.qq.com"
|
||||||
request = urllib.request.Request(url, data=body, headers=headers,
|
request = urllib.request.Request(
|
||||||
method="POST" if body is not None else "GET")
|
url, data=body, headers=headers, method="POST" if body is not None else "GET"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(request, timeout=30) as response:
|
with urllib.request.urlopen(request, timeout=30) as response:
|
||||||
return response.read().decode("utf-8", "replace")
|
return response.read().decode("utf-8", "replace")
|
||||||
@@ -105,7 +117,9 @@ def request_bytes(url: str, cookies: dict[str, str], body: bytes | None = None)
|
|||||||
return exc.read().decode("utf-8", "replace")
|
return exc.read().decode("utf-8", "replace")
|
||||||
|
|
||||||
|
|
||||||
def goods_page_url(cookies: dict[str, str], order: dict[str, str], zone_id: str = "1", pf: str = "") -> str:
|
def goods_page_url(
|
||||||
|
cookies: dict[str, str], order: dict[str, str], zone_id: str = "1", pf: str = ""
|
||||||
|
) -> str:
|
||||||
openid = cookies.get("openid") or cookies.get("midas_openid")
|
openid = cookies.get("openid") or cookies.get("midas_openid")
|
||||||
openkey = cookies.get("accesstoken") or cookies.get("midas_openkey")
|
openkey = cookies.get("accesstoken") or cookies.get("midas_openkey")
|
||||||
if not openid or not openkey:
|
if not openid or not openkey:
|
||||||
@@ -135,7 +149,9 @@ def extract_goods_state(html: str) -> tuple[list[int], str, str]:
|
|||||||
token = re.search(r'id="xMidasToken"\s+value="([0-9A-Fa-f]+)"', html)
|
token = re.search(r'id="xMidasToken"\s+value="([0-9A-Fa-f]+)"', html)
|
||||||
anti = re.search(r'id="antiAutoScriptToken"\s+value="([0-9A-Fa-f]+)"', html)
|
anti = re.search(r'id="antiAutoScriptToken"\s+value="([0-9A-Fa-f]+)"', html)
|
||||||
if not ops or not token or not anti:
|
if not ops or not token or not anti:
|
||||||
raise ValueError("goods 页面缺少 xMidasOps/xMidasToken/antiAutoScriptToken,登录态或订单已失效")
|
raise ValueError(
|
||||||
|
"goods 页面缺少 xMidasOps/xMidasToken/antiAutoScriptToken,登录态或订单已失效"
|
||||||
|
)
|
||||||
xmidas = [int(value) for value in ops.group(1).split(",") if value]
|
xmidas = [int(value) for value in ops.group(1).split(",") if value]
|
||||||
if len(xmidas) != 59640:
|
if len(xmidas) != 59640:
|
||||||
raise ValueError(f"goods xMidasOps 长度异常: {len(xmidas)}")
|
raise ValueError(f"goods xMidasOps 长度异常: {len(xmidas)}")
|
||||||
@@ -147,16 +163,25 @@ def load_template_args() -> list:
|
|||||||
runtime_args = runtime_template / "args-template.json"
|
runtime_args = runtime_template / "args-template.json"
|
||||||
if runtime_args.exists():
|
if runtime_args.exists():
|
||||||
return load_json(runtime_args)
|
return load_json(runtime_args)
|
||||||
candidates = sorted((ROOT / "replay/live").glob("*/args-template.json"), reverse=True)
|
candidates = sorted(
|
||||||
|
(ROOT / "replay/live").glob("*/args-template.json"), reverse=True
|
||||||
|
)
|
||||||
for path in candidates:
|
for path in candidates:
|
||||||
if path.exists():
|
if path.exists():
|
||||||
return load_json(path)
|
return load_json(path)
|
||||||
raise FileNotFoundError("缺少归档 goods args-template.json")
|
raise FileNotFoundError("缺少归档 goods args-template.json")
|
||||||
|
|
||||||
|
|
||||||
def build_save_fields(order: dict[str, str], cookies: dict[str, str], web_token: str,
|
def build_save_fields(
|
||||||
anti_token: str, encrypt_msg: str, zone_id: str, pf: str,
|
order: dict[str, str],
|
||||||
amount_fen: int) -> dict[str, str]:
|
cookies: dict[str, str],
|
||||||
|
web_token: str,
|
||||||
|
anti_token: str,
|
||||||
|
encrypt_msg: str,
|
||||||
|
zone_id: str,
|
||||||
|
pf: str,
|
||||||
|
amount_fen: int,
|
||||||
|
) -> dict[str, str]:
|
||||||
"""按当前订单构造完整 web_save 表单,不能使用脱敏空模板。"""
|
"""按当前订单构造完整 web_save 表单,不能使用脱敏空模板。"""
|
||||||
if amount_fen <= 0:
|
if amount_fen <= 0:
|
||||||
raise ValueError("充值金额必须为正数")
|
raise ValueError("充值金额必须为正数")
|
||||||
@@ -176,7 +201,9 @@ def build_save_fields(order: dict[str, str], cookies: dict[str, str], web_token:
|
|||||||
"openkey": cookies.get("accesstoken", ""),
|
"openkey": cookies.get("accesstoken", ""),
|
||||||
"session_id": login["session_id"],
|
"session_id": login["session_id"],
|
||||||
"session_type": login["session_type"],
|
"session_type": login["session_type"],
|
||||||
"sck": hashlib.md5((APPID + cookies.get("accesstoken", "")).encode()).hexdigest().upper(),
|
"sck": hashlib.md5((APPID + cookies.get("accesstoken", "")).encode())
|
||||||
|
.hexdigest()
|
||||||
|
.upper(),
|
||||||
"anti_auto_script_token_id": anti_token,
|
"anti_auto_script_token_id": anti_token,
|
||||||
"zoneid": zone_id,
|
"zoneid": zone_id,
|
||||||
"buy_quantity": "1",
|
"buy_quantity": "1",
|
||||||
@@ -207,17 +234,38 @@ def build_save_fields(order: dict[str, str], cookies: dict[str, str], web_token:
|
|||||||
return fields
|
return fields
|
||||||
|
|
||||||
|
|
||||||
def build_save_body(order: dict[str, str], cookies: dict[str, str], web_token: str,
|
def build_save_body(
|
||||||
anti_token: str, encrypt_msg: str, zone_id: str, pf: str,
|
order: dict[str, str],
|
||||||
amount_fen: int) -> str:
|
cookies: dict[str, str],
|
||||||
|
web_token: str,
|
||||||
|
anti_token: str,
|
||||||
|
encrypt_msg: str,
|
||||||
|
zone_id: str,
|
||||||
|
pf: str,
|
||||||
|
amount_fen: int,
|
||||||
|
) -> str:
|
||||||
"""编码完整支付表单,确保加密明文和实际请求使用同一上下文。"""
|
"""编码完整支付表单,确保加密明文和实际请求使用同一上下文。"""
|
||||||
return urllib.parse.urlencode(build_save_fields(
|
return urllib.parse.urlencode(
|
||||||
order, cookies, web_token, anti_token, encrypt_msg, zone_id, pf, amount_fen,
|
build_save_fields(
|
||||||
))
|
order,
|
||||||
|
cookies,
|
||||||
|
web_token,
|
||||||
|
anti_token,
|
||||||
|
encrypt_msg,
|
||||||
|
zone_id,
|
||||||
|
pf,
|
||||||
|
amount_fen,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_page_info_body(order: dict[str, str], cookies: dict[str, str], anti_token: str,
|
def build_page_info_body(
|
||||||
zone_id: str, pf: str) -> str:
|
order: dict[str, str],
|
||||||
|
cookies: dict[str, str],
|
||||||
|
anti_token: str,
|
||||||
|
zone_id: str,
|
||||||
|
pf: str,
|
||||||
|
) -> str:
|
||||||
"""构造网页端 fp-behv 后的 web_page_info 表单。"""
|
"""构造网页端 fp-behv 后的 web_page_info 表单。"""
|
||||||
login = midas_login_params(cookies)
|
login = midas_login_params(cookies)
|
||||||
fields = {
|
fields = {
|
||||||
@@ -235,7 +283,9 @@ def build_page_info_body(order: dict[str, str], cookies: dict[str, str], anti_to
|
|||||||
"openkey": cookies.get("accesstoken", ""),
|
"openkey": cookies.get("accesstoken", ""),
|
||||||
"session_id": login["session_id"],
|
"session_id": login["session_id"],
|
||||||
"session_type": login["session_type"],
|
"session_type": login["session_type"],
|
||||||
"sck": hashlib.md5((APPID + cookies.get("accesstoken", "")).encode()).hexdigest().upper(),
|
"sck": hashlib.md5((APPID + cookies.get("accesstoken", "")).encode())
|
||||||
|
.hexdigest()
|
||||||
|
.upper(),
|
||||||
"anti_auto_script_token_id": anti_token,
|
"anti_auto_script_token_id": anti_token,
|
||||||
"isusempaymode": "1",
|
"isusempaymode": "1",
|
||||||
"zoneid": zone_id,
|
"zoneid": zone_id,
|
||||||
@@ -249,10 +299,13 @@ def build_page_info_body(order: dict[str, str], cookies: dict[str, str], anti_to
|
|||||||
return urllib.parse.urlencode(fields)
|
return urllib.parse.urlencode(fields)
|
||||||
|
|
||||||
|
|
||||||
def make_encrypt_rand(params: dict[str, str], fk_extend: str, ts: str,
|
def make_encrypt_rand(
|
||||||
is_qq_login: bool) -> str:
|
params: dict[str, str], fk_extend: str, ts: str, is_qq_login: bool
|
||||||
|
) -> str:
|
||||||
"""按登录渠道生成页面已验证形态的 _rand,不能仅按长度替换控制字节。"""
|
"""按登录渠道生成页面已验证形态的 _rand,不能仅按长度替换控制字节。"""
|
||||||
prefix = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(8))
|
prefix = "".join(
|
||||||
|
secrets.choice(string.ascii_letters + string.digits) for _ in range(8)
|
||||||
|
)
|
||||||
# 微信历史成功请求固定使用 8 个随机字符加 \x01。_rand 位于加密明文中,
|
# 微信历史成功请求固定使用 8 个随机字符加 \x01。_rand 位于加密明文中,
|
||||||
# 末控制字节是协议内容,不能为了对齐擅自替换为 QQ 使用的 \x03。
|
# 末控制字节是协议内容,不能为了对齐擅自替换为 QQ 使用的 \x03。
|
||||||
if not is_qq_login:
|
if not is_qq_login:
|
||||||
@@ -264,15 +317,27 @@ def make_encrypt_rand(params: dict[str, str], fk_extend: str, ts: str,
|
|||||||
return prefix + ("\x03" * padding_length)
|
return prefix + ("\x03" * padding_length)
|
||||||
|
|
||||||
|
|
||||||
def save_web_save_request_meta(out_dir: Path, fields: dict[str, str], device_fp_length: int,
|
def save_web_save_request_meta(
|
||||||
plaintext_length: int) -> None:
|
out_dir: Path, fields: dict[str, str], device_fp_length: int, plaintext_length: int
|
||||||
|
) -> None:
|
||||||
"""保存可比对的脱敏请求形态,避免在证据目录落盘支付凭据。"""
|
"""保存可比对的脱敏请求形态,避免在证据目录落盘支付凭据。"""
|
||||||
sensitive = ("token_id", "transaction_id", "out_trade_no", "openid", "openkey",
|
sensitive = (
|
||||||
"sck", "anti_auto_script_token_id", "web_token", "encrypt_msg")
|
"token_id",
|
||||||
|
"transaction_id",
|
||||||
|
"out_trade_no",
|
||||||
|
"openid",
|
||||||
|
"openkey",
|
||||||
|
"sck",
|
||||||
|
"anti_auto_script_token_id",
|
||||||
|
"web_token",
|
||||||
|
"encrypt_msg",
|
||||||
|
)
|
||||||
fingerprints = {
|
fingerprints = {
|
||||||
name: {
|
name: {
|
||||||
"length": len(fields.get(name, "")),
|
"length": len(fields.get(name, "")),
|
||||||
"sha256_prefix": hashlib.sha256(fields.get(name, "").encode()).hexdigest()[:12],
|
"sha256_prefix": hashlib.sha256(fields.get(name, "").encode()).hexdigest()[
|
||||||
|
:12
|
||||||
|
],
|
||||||
}
|
}
|
||||||
for name in sensitive
|
for name in sensitive
|
||||||
}
|
}
|
||||||
@@ -290,7 +355,9 @@ def make_qr(sign: str, output: Path) -> None:
|
|||||||
try:
|
try:
|
||||||
import segno
|
import segno
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
raise RuntimeError("缺少 segno;请安装后重新执行: python3 -m pip install segno") from exc
|
raise RuntimeError(
|
||||||
|
"缺少 segno;请安装后重新执行: python3 -m pip install segno"
|
||||||
|
) from exc
|
||||||
make_private_directory(output.parent)
|
make_private_directory(output.parent)
|
||||||
segno.make(sign).save(str(output), scale=6, border=2)
|
segno.make(sign).save(str(output), scale=6, border=2)
|
||||||
os.chmod(output, 0o600)
|
os.chmod(output, 0o600)
|
||||||
@@ -303,7 +370,9 @@ def node_environment() -> dict[str, str]:
|
|||||||
return environment
|
return environment
|
||||||
|
|
||||||
|
|
||||||
def save_payment_status(path: Path, document: dict, baseline: dict[str, bool], matched: dict | None = None) -> None:
|
def save_payment_status(
|
||||||
|
path: Path, document: dict, baseline: dict[str, bool], matched: dict | None = None
|
||||||
|
) -> None:
|
||||||
"""Persist a small, non-payment-side-effect status record for this run."""
|
"""Persist a small, non-payment-side-effect status record for this run."""
|
||||||
from pyvm.order_status import completion_summary, order_ids
|
from pyvm.order_status import completion_summary, order_ids
|
||||||
|
|
||||||
@@ -316,8 +385,13 @@ def save_payment_status(path: Path, document: dict, baseline: dict[str, bool], m
|
|||||||
write_private_json(path, record)
|
write_private_json(path, record)
|
||||||
|
|
||||||
|
|
||||||
def save_payment_meta(path: Path, order: dict[str, str], baseline: dict[str, bool],
|
def save_payment_meta(
|
||||||
document: dict, portal_serial_no: str = "") -> None:
|
path: Path,
|
||||||
|
order: dict[str, str],
|
||||||
|
baseline: dict[str, bool],
|
||||||
|
document: dict,
|
||||||
|
portal_serial_no: str = "",
|
||||||
|
) -> None:
|
||||||
"""Persist the order identifiers and pre-payment baseline needed by a later check.
|
"""Persist the order identifiers and pre-payment baseline needed by a later check.
|
||||||
|
|
||||||
``check`` only re-reads this file and never touches web_save, so re-running a
|
``check`` only re-reads this file and never touches web_save, so re-running a
|
||||||
@@ -343,7 +417,9 @@ def save_payment_meta(path: Path, order: dict[str, str], baseline: dict[str, boo
|
|||||||
write_private_json(path, record)
|
write_private_json(path, record)
|
||||||
|
|
||||||
|
|
||||||
def _match_finished_by_identifiers(listed: list[dict], identifiers: dict[str, str]) -> dict | None:
|
def _match_finished_by_identifiers(
|
||||||
|
listed: list[dict], identifiers: dict[str, str]
|
||||||
|
) -> dict | None:
|
||||||
"""Match a finished order by the identifiers recorded at QR creation time.
|
"""Match a finished order by the identifiers recorded at QR creation time.
|
||||||
|
|
||||||
The official order list may not carry token_id/out_trade_no, so no fallback
|
The official order list may not carry token_id/out_trade_no, so no fallback
|
||||||
@@ -397,23 +473,52 @@ def cmd_check_only(session_path: Path, out_dir: Path) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(description="YYB: jsdom DeviceFP + web_save -> 微信付款码")
|
parser = argparse.ArgumentParser(
|
||||||
|
description="YYB: jsdom DeviceFP + web_save -> 微信付款码"
|
||||||
|
)
|
||||||
parser.add_argument("--session", default=str(ROOT / "config/mall-session.json"))
|
parser.add_argument("--session", default=str(ROOT / "config/mall-session.json"))
|
||||||
parser.add_argument("--mall-response", default=str(ROOT / "config/mall-order-response.json"))
|
parser.add_argument(
|
||||||
parser.add_argument("--out-dir", default=None, help="运行证据目录;默认 config/jsdom-order-<timestamp>")
|
"--mall-response", default=str(ROOT / "config/mall-order-response.json")
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--out-dir",
|
||||||
|
default=None,
|
||||||
|
help="运行证据目录;默认 config/jsdom-order-<timestamp>",
|
||||||
|
)
|
||||||
parser.add_argument("--qr", default=None, help="付款二维码 PNG 路径")
|
parser.add_argument("--qr", default=None, help="付款二维码 PNG 路径")
|
||||||
parser.add_argument("--wait", type=int, default=5, help="jsdom 等待 DeviceFP 的秒数")
|
parser.add_argument(
|
||||||
|
"--wait", type=int, default=5, help="jsdom 等待 DeviceFP 的秒数"
|
||||||
|
)
|
||||||
parser.add_argument("--zone-id", default="1", help="所选游戏区服 ID")
|
parser.add_argument("--zone-id", default="1", help="所选游戏区服 ID")
|
||||||
parser.add_argument("--pf", default="", help="所选 Android/iOS 支付平台标识")
|
parser.add_argument("--pf", default="", help="所选 Android/iOS 支付平台标识")
|
||||||
parser.add_argument("--amount-fen", type=int, default=0, help="所选点券的价格,单位分(check-only 模式不需要)")
|
parser.add_argument(
|
||||||
parser.add_argument("--dry-run", action="store_true", help="仅拉取页面并生成 DeviceFP,不上报或创建付款码")
|
"--amount-fen",
|
||||||
parser.add_argument("--payment-timeout", type=float, default=300,
|
type=int,
|
||||||
help="付款码生成后等待订单完成的最长秒数")
|
default=0,
|
||||||
parser.add_argument("--payment-interval", type=float, default=3,
|
help="所选点券的价格,单位分(check-only 模式不需要)",
|
||||||
help="订单完成状态检查间隔秒数")
|
)
|
||||||
parser.add_argument("--skip-payment-check", action="store_true", help="仅生成付款码,不等待订单完成")
|
parser.add_argument(
|
||||||
parser.add_argument("--check-only", action="store_true",
|
"--dry-run",
|
||||||
help="只读检测到账(依赖 --out-dir 下已保存的 payment-meta.json),不创建订单")
|
action="store_true",
|
||||||
|
help="仅拉取页面并生成 DeviceFP,不上报或创建付款码",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--payment-timeout",
|
||||||
|
type=float,
|
||||||
|
default=300,
|
||||||
|
help="付款码生成后等待订单完成的最长秒数",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--payment-interval", type=float, default=3, help="订单完成状态检查间隔秒数"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--skip-payment-check", action="store_true", help="仅生成付款码,不等待订单完成"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--check-only",
|
||||||
|
action="store_true",
|
||||||
|
help="只读检测到账(依赖 --out-dir 下已保存的 payment-meta.json),不创建订单",
|
||||||
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
if args.payment_timeout <= 0 or args.payment_interval <= 0:
|
if args.payment_timeout <= 0 or args.payment_interval <= 0:
|
||||||
raise ValueError("--payment-timeout 和 --payment-interval 必须为正数")
|
raise ValueError("--payment-timeout 和 --payment-interval 必须为正数")
|
||||||
@@ -421,7 +526,9 @@ def main() -> int:
|
|||||||
session_path = Path(args.session).resolve()
|
session_path = Path(args.session).resolve()
|
||||||
response_path = Path(args.mall_response).resolve()
|
response_path = Path(args.mall_response).resolve()
|
||||||
stamp = time.strftime("%Y%m%d-%H%M%S")
|
stamp = time.strftime("%Y%m%d-%H%M%S")
|
||||||
out_dir = Path(args.out_dir) if args.out_dir else ROOT / "config" / f"jsdom-order-{stamp}"
|
out_dir = (
|
||||||
|
Path(args.out_dir) if args.out_dir else ROOT / "config" / f"jsdom-order-{stamp}"
|
||||||
|
)
|
||||||
if args.check_only:
|
if args.check_only:
|
||||||
make_private_directory(out_dir)
|
make_private_directory(out_dir)
|
||||||
return cmd_check_only(session_path, out_dir)
|
return cmd_check_only(session_path, out_dir)
|
||||||
@@ -444,7 +551,9 @@ def main() -> int:
|
|||||||
raise ValueError("mall 响应无法解析 url_params") from exc
|
raise ValueError("mall 响应无法解析 url_params") from exc
|
||||||
|
|
||||||
stamp = time.strftime("%Y%m%d-%H%M%S")
|
stamp = time.strftime("%Y%m%d-%H%M%S")
|
||||||
out_dir = Path(args.out_dir) if args.out_dir else ROOT / "config" / f"jsdom-order-{stamp}"
|
out_dir = (
|
||||||
|
Path(args.out_dir) if args.out_dir else ROOT / "config" / f"jsdom-order-{stamp}"
|
||||||
|
)
|
||||||
make_private_directory(out_dir)
|
make_private_directory(out_dir)
|
||||||
url = goods_page_url(cookies, order, args.zone_id, args.pf)
|
url = goods_page_url(cookies, order, args.zone_id, args.pf)
|
||||||
print(f"[jsdom-pay] 订单: {order['token_id'][:20]}...")
|
print(f"[jsdom-pay] 订单: {order['token_id'][:20]}...")
|
||||||
@@ -457,9 +566,18 @@ def main() -> int:
|
|||||||
|
|
||||||
fp_path = out_dir / "device-fp.json"
|
fp_path = out_dir / "device-fp.json"
|
||||||
command = [
|
command = [
|
||||||
"node", str(ROOT / "scripts/generate-devicefp-jsdom.mjs"),
|
"node",
|
||||||
"--html", str(html_path), "--goods-url", url, "--cookies", str(session_path),
|
str(ROOT / "scripts/generate-devicefp-jsdom.mjs"),
|
||||||
"--output", str(fp_path), "--wait", str(args.wait * 1000),
|
"--html",
|
||||||
|
str(html_path),
|
||||||
|
"--goods-url",
|
||||||
|
url,
|
||||||
|
"--cookies",
|
||||||
|
str(session_path),
|
||||||
|
"--output",
|
||||||
|
str(fp_path),
|
||||||
|
"--wait",
|
||||||
|
str(args.wait * 1000),
|
||||||
]
|
]
|
||||||
print("[jsdom-pay] 运行 jsdom DeviceFP...")
|
print("[jsdom-pay] 运行 jsdom DeviceFP...")
|
||||||
subprocess.run(command, check=True, cwd=ROOT, env=node_environment())
|
subprocess.run(command, check=True, cwd=ROOT, env=node_environment())
|
||||||
@@ -469,36 +587,53 @@ def main() -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
print("[jsdom-pay] 上报 fp-behv...")
|
print("[jsdom-pay] 上报 fp-behv...")
|
||||||
fp_response = request_bytes(fp.get("fp_url", FP_URL), cookies, fp["fp_body"].encode())
|
fp_response = request_bytes(
|
||||||
|
fp.get("fp_url", FP_URL), cookies, fp["fp_body"].encode()
|
||||||
|
)
|
||||||
write_private_text(out_dir / "fp-response.json", fp_response)
|
write_private_text(out_dir / "fp-response.json", fp_response)
|
||||||
try:
|
try:
|
||||||
fp_json = json.loads(fp_response)
|
fp_json = json.loads(fp_response)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
fp_json = {}
|
fp_json = {}
|
||||||
if fp_json.get("ret") != 0:
|
if fp_json.get("ret") != 0:
|
||||||
print(f"[jsdom-pay] fp-behv 失败: ret={fp_json.get('ret')} {fp_json.get('msg', '')}")
|
print(
|
||||||
|
f"[jsdom-pay] fp-behv 失败: ret={fp_json.get('ret')} {fp_json.get('msg', '')}"
|
||||||
|
)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
# QQ 成功 HAR 包含该前置;微信历史成功链路没有,不能将 QQ 状态机混入微信请求。
|
# QQ 成功 HAR 包含该前置;微信历史成功链路没有,不能将 QQ 状态机混入微信请求。
|
||||||
if midas_login_params(cookies)["qq_appid"]:
|
if midas_login_params(cookies)["qq_appid"]:
|
||||||
print("[jsdom-pay] 拉取 QQ web_page_info...")
|
print("[jsdom-pay] 拉取 QQ web_page_info...")
|
||||||
page_info = request_bytes(PAGE_INFO_URL, cookies, build_page_info_body(
|
page_info = request_bytes(
|
||||||
order, cookies, anti_token, args.zone_id, payment_pf,
|
PAGE_INFO_URL,
|
||||||
).encode())
|
cookies,
|
||||||
|
build_page_info_body(
|
||||||
|
order,
|
||||||
|
cookies,
|
||||||
|
anti_token,
|
||||||
|
args.zone_id,
|
||||||
|
payment_pf,
|
||||||
|
).encode(),
|
||||||
|
)
|
||||||
write_private_text(out_dir / "web-page-info-response.json", page_info)
|
write_private_text(out_dir / "web-page-info-response.json", page_info)
|
||||||
try:
|
try:
|
||||||
page_info_json = json.loads(page_info)
|
page_info_json = json.loads(page_info)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
page_info_json = {}
|
page_info_json = {}
|
||||||
if page_info_json.get("ret") != 0:
|
if page_info_json.get("ret") != 0:
|
||||||
print(f"[jsdom-pay] QQ web_page_info 失败: ret={page_info_json.get('ret')} "
|
print(
|
||||||
f"{page_info_json.get('msg', '')}")
|
f"[jsdom-pay] QQ web_page_info 失败: ret={page_info_json.get('ret')} "
|
||||||
|
f"{page_info_json.get('msg', '')}"
|
||||||
|
)
|
||||||
print(f"[jsdom-pay] 证据目录: {out_dir}")
|
print(f"[jsdom-pay] 证据目录: {out_dir}")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
web_args = load_template_args()
|
web_args = load_template_args()
|
||||||
validate_goods_materials(web_args, xmidas)
|
validate_goods_materials(web_args, xmidas)
|
||||||
write_private_json(out_dir / "protocol-materials.json", goods_material_diagnostics(ROOT, web_args, xmidas))
|
write_private_json(
|
||||||
|
out_dir / "protocol-materials.json",
|
||||||
|
goods_material_diagnostics(ROOT, web_args, xmidas),
|
||||||
|
)
|
||||||
print("[jsdom-pay] 协议材料校验通过")
|
print("[jsdom-pay] 协议材料校验通过")
|
||||||
# key 派生已破解(F-2052,2026-08-12):key16 可随机生成,key1 由反解器
|
# key 派生已破解(F-2052,2026-08-12):key16 可随机生成,key1 由反解器
|
||||||
# derive_key1_from_key16 求解(key16=Sbox[Te 链(key1)]),满足服务端派生校验,
|
# derive_key1_from_key16 求解(key16=Sbox[Te 链(key1)]),满足服务端派生校验,
|
||||||
@@ -529,18 +664,38 @@ def main() -> int:
|
|||||||
now_seconds = str(int(time.time()))
|
now_seconds = str(int(time.time()))
|
||||||
fk_extend = "tdrc_session%3D" + fp["session_id"]
|
fk_extend = "tdrc_session%3D" + fp["session_id"]
|
||||||
random_suffix = make_encrypt_rand(
|
random_suffix = make_encrypt_rand(
|
||||||
params, fk_extend, now_seconds, bool(midas_login_params(cookies)["qq_appid"]),
|
params,
|
||||||
|
fk_extend,
|
||||||
|
now_seconds,
|
||||||
|
bool(midas_login_params(cookies)["qq_appid"]),
|
||||||
|
)
|
||||||
|
plaintext_length = len(
|
||||||
|
build_plaintext(params, fk_extend, now_seconds, random_suffix).encode("latin-1")
|
||||||
)
|
)
|
||||||
plaintext_length = len(build_plaintext(params, fk_extend, now_seconds, random_suffix).encode("latin-1"))
|
|
||||||
encrypt_msg = generate_encrypt_msg_offline(
|
encrypt_msg = generate_encrypt_msg_offline(
|
||||||
params, fk_extend, now_seconds, random_suffix,
|
params,
|
||||||
key16=key16, key1=key1, args_template=web_args, xmidas=xmidas, xmidas_token=web_token,
|
fk_extend,
|
||||||
|
now_seconds,
|
||||||
|
random_suffix,
|
||||||
|
key16=key16,
|
||||||
|
key1=key1,
|
||||||
|
args_template=web_args,
|
||||||
|
xmidas=xmidas,
|
||||||
|
xmidas_token=web_token,
|
||||||
)
|
)
|
||||||
fields = build_save_fields(
|
fields = build_save_fields(
|
||||||
order, cookies, web_token, anti_token, encrypt_msg,
|
order,
|
||||||
args.zone_id, payment_pf, args.amount_fen,
|
cookies,
|
||||||
|
web_token,
|
||||||
|
anti_token,
|
||||||
|
encrypt_msg,
|
||||||
|
args.zone_id,
|
||||||
|
payment_pf,
|
||||||
|
args.amount_fen,
|
||||||
|
)
|
||||||
|
save_web_save_request_meta(
|
||||||
|
out_dir, fields, int(fp.get("device_fp_length", 0)), plaintext_length
|
||||||
)
|
)
|
||||||
save_web_save_request_meta(out_dir, fields, int(fp.get("device_fp_length", 0)), plaintext_length)
|
|
||||||
body = urllib.parse.urlencode(fields)
|
body = urllib.parse.urlencode(fields)
|
||||||
from pyvm.order_status import order_completion_states, get_official_orders
|
from pyvm.order_status import order_completion_states, get_official_orders
|
||||||
|
|
||||||
@@ -561,10 +716,14 @@ def main() -> int:
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
response_json = {}
|
response_json = {}
|
||||||
if response_json.get("ret") != 0:
|
if response_json.get("ret") != 0:
|
||||||
detail = f"ret={response_json.get('ret')} err_code={response_json.get('err_code', '')} " \
|
detail = (
|
||||||
f"{response_json.get('msg', '')}"
|
f"ret={response_json.get('ret')} err_code={response_json.get('err_code', '')} "
|
||||||
|
f"{response_json.get('msg', '')}"
|
||||||
|
)
|
||||||
print(f"[jsdom-pay] web_save 失败: {detail}")
|
print(f"[jsdom-pay] web_save 失败: {detail}")
|
||||||
print(f"[jsdom-pay] {describe_payment_failure(cookies.get('logintype', ''), 'payment', detail)}")
|
print(
|
||||||
|
f"[jsdom-pay] {describe_payment_failure(cookies.get('logintype', ''), 'payment', detail)}"
|
||||||
|
)
|
||||||
print(f"[jsdom-pay] 证据目录: {out_dir}")
|
print(f"[jsdom-pay] 证据目录: {out_dir}")
|
||||||
return 1
|
return 1
|
||||||
sign = response_json.get("info", {}).get("channel_info", {}).get("sign", "")
|
sign = response_json.get("info", {}).get("channel_info", {}).get("sign", "")
|
||||||
@@ -588,7 +747,13 @@ def main() -> int:
|
|||||||
baseline_document = {"list": []}
|
baseline_document = {"list": []}
|
||||||
baseline_states = {}
|
baseline_states = {}
|
||||||
print("[jsdom-pay] 记录付款前订单基线与本次订单标识...")
|
print("[jsdom-pay] 记录付款前订单基线与本次订单标识...")
|
||||||
save_payment_meta(out_dir / "payment-meta.json", order, baseline_states, baseline_document, portal_serial_no)
|
save_payment_meta(
|
||||||
|
out_dir / "payment-meta.json",
|
||||||
|
order,
|
||||||
|
baseline_states,
|
||||||
|
baseline_document,
|
||||||
|
portal_serial_no,
|
||||||
|
)
|
||||||
if args.skip_payment_check:
|
if args.skip_payment_check:
|
||||||
print("[jsdom-pay] 已保存基线;后台将单独执行只读到账检测")
|
print("[jsdom-pay] 已保存基线;后台将单独执行只读到账检测")
|
||||||
return 0
|
return 0
|
||||||
@@ -596,17 +761,23 @@ def main() -> int:
|
|||||||
print("[jsdom-pay] 记录付款前订单列表,等待微信付款完成...")
|
print("[jsdom-pay] 记录付款前订单列表,等待微信付款完成...")
|
||||||
status_path = out_dir / "payment-status.json"
|
status_path = out_dir / "payment-status.json"
|
||||||
save_payment_status(status_path, baseline_document, baseline_states)
|
save_payment_status(status_path, baseline_document, baseline_states)
|
||||||
identifiers = {key: value for key, value in {
|
identifiers = {
|
||||||
"token_id": order.get("token_id", ""),
|
key: value
|
||||||
"transaction_id": order.get("transaction_id", ""),
|
for key, value in {
|
||||||
"out_trade_no": order.get("out_trade_no", ""),
|
"token_id": order.get("token_id", ""),
|
||||||
}.items() if value}
|
"transaction_id": order.get("transaction_id", ""),
|
||||||
|
"out_trade_no": order.get("out_trade_no", ""),
|
||||||
|
}.items()
|
||||||
|
if value
|
||||||
|
}
|
||||||
deadline = time.monotonic() + args.payment_timeout
|
deadline = time.monotonic() + args.payment_timeout
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
time.sleep(args.payment_interval)
|
time.sleep(args.payment_interval)
|
||||||
document = get_official_orders(cookies)
|
document = get_official_orders(cookies)
|
||||||
completed = _match_finished_by_identifiers(
|
completed = _match_finished_by_identifiers(
|
||||||
[item for item in document.get("list", []) if isinstance(item, dict)], identifiers)
|
[item for item in document.get("list", []) if isinstance(item, dict)],
|
||||||
|
identifiers,
|
||||||
|
)
|
||||||
save_payment_status(status_path, document, baseline_states, completed)
|
save_payment_status(status_path, document, baseline_states, completed)
|
||||||
if completed:
|
if completed:
|
||||||
print("[jsdom-pay] 微信付款成功,商城订单已完成。")
|
print("[jsdom-pay] 微信付款成功,商城订单已完成。")
|
||||||
@@ -620,6 +791,11 @@ def main() -> int:
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
try:
|
try:
|
||||||
raise SystemExit(main())
|
raise SystemExit(main())
|
||||||
except (FileNotFoundError, ValueError, RuntimeError, subprocess.CalledProcessError) as exc:
|
except (
|
||||||
|
FileNotFoundError,
|
||||||
|
ValueError,
|
||||||
|
RuntimeError,
|
||||||
|
subprocess.CalledProcessError,
|
||||||
|
) as exc:
|
||||||
print(f"错误: {exc}", file=sys.stderr)
|
print(f"错误: {exc}", file=sys.stderr)
|
||||||
raise SystemExit(2) from exc
|
raise SystemExit(2) from exc
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""应用宝 QQ 二维码登录(纯 Python,无浏览器自动化)。"""
|
"""应用宝 QQ 二维码登录(纯 Python,无浏览器自动化)。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -15,7 +16,12 @@ from http.cookiejar import CookieJar
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.error import HTTPError
|
from urllib.error import HTTPError
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
from urllib.request import HTTPRedirectHandler, HTTPCookieProcessor, Request, build_opener
|
from urllib.request import (
|
||||||
|
HTTPRedirectHandler,
|
||||||
|
HTTPCookieProcessor,
|
||||||
|
Request,
|
||||||
|
build_opener,
|
||||||
|
)
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
OPEN_APPID = "102033112"
|
OPEN_APPID = "102033112"
|
||||||
@@ -60,8 +66,15 @@ class Client:
|
|||||||
self.jar = CookieJar()
|
self.jar = CookieJar()
|
||||||
self.opener = build_opener(NoRedirect, HTTPCookieProcessor(self.jar))
|
self.opener = build_opener(NoRedirect, HTTPCookieProcessor(self.jar))
|
||||||
|
|
||||||
def request(self, url: str, *, method: str = "GET", body: bytes | None = None,
|
def request(
|
||||||
referer: str = "", headers: dict[str, str] | None = None) -> Response:
|
self,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
method: str = "GET",
|
||||||
|
body: bytes | None = None,
|
||||||
|
referer: str = "",
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
) -> Response:
|
||||||
request_headers = {"User-Agent": USER_AGENT, "Accept": "*/*"}
|
request_headers = {"User-Agent": USER_AGENT, "Accept": "*/*"}
|
||||||
if referer:
|
if referer:
|
||||||
request_headers["Referer"] = referer
|
request_headers["Referer"] = referer
|
||||||
@@ -155,7 +168,9 @@ def write_session(path: Path, cookies: dict[str, str]) -> None:
|
|||||||
document["login_type"] = cookies.get("logintype", "QC")
|
document["login_type"] = cookies.get("logintype", "QC")
|
||||||
document["login_updated_at"] = int(time.time())
|
document["login_updated_at"] = int(time.time())
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
fd, temporary = tempfile.mkstemp(prefix=".mall-session-", suffix=".tmp", dir=path.parent)
|
fd, temporary = tempfile.mkstemp(
|
||||||
|
prefix=".mall-session-", suffix=".tmp", dir=path.parent
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||||
json.dump(document, handle, ensure_ascii=False, indent=2)
|
json.dump(document, handle, ensure_ascii=False, indent=2)
|
||||||
@@ -172,7 +187,9 @@ def write_session(path: Path, cookies: dict[str, str]) -> None:
|
|||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(description="应用宝 QQ 扫码登录(纯 Python)")
|
parser = argparse.ArgumentParser(description="应用宝 QQ 扫码登录(纯 Python)")
|
||||||
parser.add_argument("--session", type=Path, default=ROOT / "config/mall-session.json")
|
parser.add_argument(
|
||||||
|
"--session", type=Path, default=ROOT / "config/mall-session.json"
|
||||||
|
)
|
||||||
parser.add_argument("--qr", type=Path, default=ROOT / "config/qq-login.jpg")
|
parser.add_argument("--qr", type=Path, default=ROOT / "config/qq-login.jpg")
|
||||||
parser.add_argument("--timeout", type=float, default=300, help="二维码等待秒数")
|
parser.add_argument("--timeout", type=float, default=300, help="二维码等待秒数")
|
||||||
parser.add_argument("--interval", type=float, default=3, help="轮询间隔秒数")
|
parser.add_argument("--interval", type=float, default=3, help="轮询间隔秒数")
|
||||||
@@ -183,8 +200,12 @@ def main() -> int:
|
|||||||
client = Client()
|
client = Client()
|
||||||
state = secrets.token_urlsafe(14)
|
state = secrets.token_urlsafe(14)
|
||||||
show_query = {
|
show_query = {
|
||||||
"which": "Login", "display": "pc", "response_type": "code", "client_id": OPEN_APPID,
|
"which": "Login",
|
||||||
"redirect_uri": CALLBACK, "state": state,
|
"display": "pc",
|
||||||
|
"response_type": "code",
|
||||||
|
"client_id": OPEN_APPID,
|
||||||
|
"redirect_uri": CALLBACK,
|
||||||
|
"state": state,
|
||||||
}
|
}
|
||||||
show_url = f"{GRAPH_SHOW}?{urlencode(show_query)}"
|
show_url = f"{GRAPH_SHOW}?{urlencode(show_query)}"
|
||||||
show = client.request(show_url, referer="https://m.yyb.qq.com/")
|
show = client.request(show_url, referer="https://m.yyb.qq.com/")
|
||||||
@@ -192,11 +213,18 @@ def main() -> int:
|
|||||||
raise RuntimeError(f"QQ OAuth 页面请求失败: HTTP {show.status}")
|
raise RuntimeError(f"QQ OAuth 页面请求失败: HTTP {show.status}")
|
||||||
|
|
||||||
xlogin_query = {
|
xlogin_query = {
|
||||||
"appid": PT_APPID, "daid": PT_DAID, "style": "33", "login_text": "登录",
|
"appid": PT_APPID,
|
||||||
"hide_title_bar": "1", "hide_border": "1", "target": "self", "s_url": LOGIN_JUMP,
|
"daid": PT_DAID,
|
||||||
|
"style": "33",
|
||||||
|
"login_text": "登录",
|
||||||
|
"hide_title_bar": "1",
|
||||||
|
"hide_border": "1",
|
||||||
|
"target": "self",
|
||||||
|
"s_url": LOGIN_JUMP,
|
||||||
"pt_3rd_aid": OPEN_APPID,
|
"pt_3rd_aid": OPEN_APPID,
|
||||||
"pt_feedback_link": f"https://support.qq.com/products/77942?customInfo=.appid{OPEN_APPID}",
|
"pt_feedback_link": f"https://support.qq.com/products/77942?customInfo=.appid{OPEN_APPID}",
|
||||||
"theme": "2", "verify_theme": "",
|
"theme": "2",
|
||||||
|
"verify_theme": "",
|
||||||
}
|
}
|
||||||
xlogin_url = f"{XLOGIN}?{urlencode(xlogin_query)}"
|
xlogin_url = f"{XLOGIN}?{urlencode(xlogin_query)}"
|
||||||
xlogin = client.request(xlogin_url, referer=show_url)
|
xlogin = client.request(xlogin_url, referer=show_url)
|
||||||
@@ -207,9 +235,16 @@ def main() -> int:
|
|||||||
raise RuntimeError("QQ 登录页未写入 pt_login_sig")
|
raise RuntimeError("QQ 登录页未写入 pt_login_sig")
|
||||||
|
|
||||||
qr_query = {
|
qr_query = {
|
||||||
"appid": PT_APPID, "e": "2", "l": "M", "s": "3", "d": "72", "v": "4",
|
"appid": PT_APPID,
|
||||||
"t": str(secrets.randbelow(1_000_000) / 1_000_000), "daid": PT_DAID,
|
"e": "2",
|
||||||
"pt_3rd_aid": OPEN_APPID, "u1": LOGIN_JUMP,
|
"l": "M",
|
||||||
|
"s": "3",
|
||||||
|
"d": "72",
|
||||||
|
"v": "4",
|
||||||
|
"t": str(secrets.randbelow(1_000_000) / 1_000_000),
|
||||||
|
"daid": PT_DAID,
|
||||||
|
"pt_3rd_aid": OPEN_APPID,
|
||||||
|
"u1": LOGIN_JUMP,
|
||||||
}
|
}
|
||||||
qr_url = f"{QR_SHOW}?{urlencode(qr_query)}"
|
qr_url = f"{QR_SHOW}?{urlencode(qr_query)}"
|
||||||
image = client.request(qr_url, referer=xlogin_url)
|
image = client.request(qr_url, referer=xlogin_url)
|
||||||
@@ -228,10 +263,23 @@ def main() -> int:
|
|||||||
o1v_id = secrets.token_hex(16)
|
o1v_id = secrets.token_hex(16)
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
poll_query = {
|
poll_query = {
|
||||||
"u1": LOGIN_JUMP, "ptqrtoken": str(ptqr_token(qrsig)), "ptredirect": "0", "h": "1", "t": "1",
|
"u1": LOGIN_JUMP,
|
||||||
"g": "1", "from_ui": "1", "ptlang": "2052", "action": f"0-0-{int(time.time() * 1000)}",
|
"ptqrtoken": str(ptqr_token(qrsig)),
|
||||||
"js_ver": "26071711", "js_type": "1", "login_sig": login_sig, "pt_uistyle": "40",
|
"ptredirect": "0",
|
||||||
"aid": PT_APPID, "daid": PT_DAID, "pt_3rd_aid": OPEN_APPID, "o1vId": o1v_id,
|
"h": "1",
|
||||||
|
"t": "1",
|
||||||
|
"g": "1",
|
||||||
|
"from_ui": "1",
|
||||||
|
"ptlang": "2052",
|
||||||
|
"action": f"0-0-{int(time.time() * 1000)}",
|
||||||
|
"js_ver": "26071711",
|
||||||
|
"js_type": "1",
|
||||||
|
"login_sig": login_sig,
|
||||||
|
"pt_uistyle": "40",
|
||||||
|
"aid": PT_APPID,
|
||||||
|
"daid": PT_DAID,
|
||||||
|
"pt_3rd_aid": OPEN_APPID,
|
||||||
|
"o1vId": o1v_id,
|
||||||
"pt_js_version": "c1987b96",
|
"pt_js_version": "c1987b96",
|
||||||
}
|
}
|
||||||
poll = client.request(f"{QR_POLL}?{urlencode(poll_query)}", referer=xlogin_url)
|
poll = client.request(f"{QR_POLL}?{urlencode(poll_query)}", referer=xlogin_url)
|
||||||
@@ -252,7 +300,9 @@ def main() -> int:
|
|||||||
|
|
||||||
check_sig = client.request(callback, referer=xlogin_url)
|
check_sig = client.request(callback, referer=xlogin_url)
|
||||||
login_jump = check_sig.location()
|
login_jump = check_sig.location()
|
||||||
if check_sig.status != 302 or not login_jump.startswith("https://graph.qq.com/oauth2.0/login_jump"):
|
if check_sig.status != 302 or not login_jump.startswith(
|
||||||
|
"https://graph.qq.com/oauth2.0/login_jump"
|
||||||
|
):
|
||||||
raise RuntimeError("QQ check_sig 未跳转到 OAuth login_jump")
|
raise RuntimeError("QQ check_sig 未跳转到 OAuth login_jump")
|
||||||
jump = client.request(login_jump, referer=callback)
|
jump = client.request(login_jump, referer=callback)
|
||||||
if jump.status != 200:
|
if jump.status != 200:
|
||||||
@@ -262,8 +312,10 @@ def main() -> int:
|
|||||||
if not p_skey:
|
if not p_skey:
|
||||||
raise RuntimeError("QQ check_sig 未写入 p_skey")
|
raise RuntimeError("QQ check_sig 未写入 p_skey")
|
||||||
authorize = client.request(
|
authorize = client.request(
|
||||||
"https://graph.qq.com/oauth2.0/authorize", method="POST",
|
"https://graph.qq.com/oauth2.0/authorize",
|
||||||
body=urlencode(authorize_params(state, p_skey)).encode("utf-8"), referer=login_jump,
|
method="POST",
|
||||||
|
body=urlencode(authorize_params(state, p_skey)).encode("utf-8"),
|
||||||
|
referer=login_jump,
|
||||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
)
|
)
|
||||||
oauth_callback = authorize.location()
|
oauth_callback = authorize.location()
|
||||||
@@ -276,12 +328,16 @@ def main() -> int:
|
|||||||
if not cookies.get("openid") or not cookies.get("accesstoken"):
|
if not cookies.get("openid") or not cookies.get("accesstoken"):
|
||||||
raise RuntimeError("YYB QQ OAuth 回调未写入 openid/accesstoken")
|
raise RuntimeError("YYB QQ OAuth 回调未写入 openid/accesstoken")
|
||||||
login_type = cookies.get("logintype", "QC")
|
login_type = cookies.get("logintype", "QC")
|
||||||
info = client.request(USER_INFO, headers={
|
info = client.request(
|
||||||
"Ual-Access-Login-Type": login_type_header(login_type),
|
USER_INFO,
|
||||||
"Ual-Access-Access-Token": cookies["accesstoken"],
|
headers={
|
||||||
"Ual-Access-Openid": cookies["openid"],
|
"Ual-Access-Login-Type": login_type_header(login_type),
|
||||||
"Origin": "https://m.yyb.qq.com", "Referer": "https://m.yyb.qq.com/",
|
"Ual-Access-Access-Token": cookies["accesstoken"],
|
||||||
})
|
"Ual-Access-Openid": cookies["openid"],
|
||||||
|
"Origin": "https://m.yyb.qq.com",
|
||||||
|
"Referer": "https://m.yyb.qq.com/",
|
||||||
|
},
|
||||||
|
)
|
||||||
if info.status != 200:
|
if info.status != 200:
|
||||||
raise RuntimeError(f"YYB QQ 登录态校验失败: HTTP {info.status}")
|
raise RuntimeError(f"YYB QQ 登录态校验失败: HTTP {info.status}")
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
二维码由微信 OAuth 生成,用户用微信扫码确认;本脚本轮询授权结果,完成
|
二维码由微信 OAuth 生成,用户用微信扫码确认;本脚本轮询授权结果,完成
|
||||||
YYB OAuth 回调后将动态 cookies 合并到 mall-session.json,供后续纯 CK 流程使用。
|
YYB OAuth 回调后将动态 cookies 合并到 mall-session.json,供后续纯 CK 流程使用。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -18,7 +19,12 @@ from http.cookiejar import CookieJar
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.error import HTTPError
|
from urllib.error import HTTPError
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
from urllib.request import HTTPRedirectHandler, HTTPCookieProcessor, Request, build_opener
|
from urllib.request import (
|
||||||
|
HTTPRedirectHandler,
|
||||||
|
HTTPCookieProcessor,
|
||||||
|
Request,
|
||||||
|
build_opener,
|
||||||
|
)
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
OPEN_APPID = "wxd44977328b36e647"
|
OPEN_APPID = "wxd44977328b36e647"
|
||||||
@@ -58,7 +64,9 @@ class Client:
|
|||||||
self.jar = CookieJar()
|
self.jar = CookieJar()
|
||||||
self.opener = build_opener(NoRedirect, HTTPCookieProcessor(self.jar))
|
self.opener = build_opener(NoRedirect, HTTPCookieProcessor(self.jar))
|
||||||
|
|
||||||
def request(self, url: str, *, referer: str = "", headers: dict[str, str] | None = None) -> Response:
|
def request(
|
||||||
|
self, url: str, *, referer: str = "", headers: dict[str, str] | None = None
|
||||||
|
) -> Response:
|
||||||
request_headers = {"User-Agent": USER_AGENT, "Accept": "*/*"}
|
request_headers = {"User-Agent": USER_AGENT, "Accept": "*/*"}
|
||||||
if referer:
|
if referer:
|
||||||
request_headers["Referer"] = referer
|
request_headers["Referer"] = referer
|
||||||
@@ -116,7 +124,9 @@ def write_session(path: Path, cookies: dict[str, str]) -> None:
|
|||||||
document["login_type"] = cookies.get("logintype", "WX")
|
document["login_type"] = cookies.get("logintype", "WX")
|
||||||
document["login_updated_at"] = int(time.time())
|
document["login_updated_at"] = int(time.time())
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
fd, temporary = tempfile.mkstemp(prefix=".mall-session-", suffix=".tmp", dir=path.parent)
|
fd, temporary = tempfile.mkstemp(
|
||||||
|
prefix=".mall-session-", suffix=".tmp", dir=path.parent
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||||
json.dump(document, handle, ensure_ascii=False, indent=2)
|
json.dump(document, handle, ensure_ascii=False, indent=2)
|
||||||
@@ -133,7 +143,9 @@ def write_session(path: Path, cookies: dict[str, str]) -> None:
|
|||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(description="应用宝微信扫码登录(纯 Python)")
|
parser = argparse.ArgumentParser(description="应用宝微信扫码登录(纯 Python)")
|
||||||
parser.add_argument("--session", type=Path, default=ROOT / "config/mall-session.json")
|
parser.add_argument(
|
||||||
|
"--session", type=Path, default=ROOT / "config/mall-session.json"
|
||||||
|
)
|
||||||
parser.add_argument("--qr", type=Path, default=ROOT / "config/wechat-login.jpg")
|
parser.add_argument("--qr", type=Path, default=ROOT / "config/wechat-login.jpg")
|
||||||
parser.add_argument("--timeout", type=float, default=300, help="二维码等待秒数")
|
parser.add_argument("--timeout", type=float, default=300, help="二维码等待秒数")
|
||||||
parser.add_argument("--interval", type=float, default=2, help="轮询间隔秒数")
|
parser.add_argument("--interval", type=float, default=2, help="轮询间隔秒数")
|
||||||
@@ -158,7 +170,9 @@ def main() -> int:
|
|||||||
if page.status != 200:
|
if page.status != 200:
|
||||||
raise RuntimeError(f"微信授权页请求失败: HTTP {page.status}")
|
raise RuntimeError(f"微信授权页请求失败: HTTP {page.status}")
|
||||||
uuid = extract_uuid(page.text)
|
uuid = extract_uuid(page.text)
|
||||||
image = client.request(f"https://open.weixin.qq.com/connect/qrcode/{uuid}", referer=authorization_url)
|
image = client.request(
|
||||||
|
f"https://open.weixin.qq.com/connect/qrcode/{uuid}", referer=authorization_url
|
||||||
|
)
|
||||||
if image.status != 200 or not image.body:
|
if image.status != 200 or not image.body:
|
||||||
raise RuntimeError(f"微信二维码请求失败: HTTP {image.status}")
|
raise RuntimeError(f"微信二维码请求失败: HTTP {image.status}")
|
||||||
args.qr.parent.mkdir(parents=True, exist_ok=True)
|
args.qr.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -173,7 +187,9 @@ def main() -> int:
|
|||||||
poll_query = {"uuid": uuid}
|
poll_query = {"uuid": uuid}
|
||||||
if last:
|
if last:
|
||||||
poll_query["last"] = last
|
poll_query["last"] = last
|
||||||
poll = client.request(f"{POLL_QR}?{urlencode(poll_query)}", referer=authorization_url)
|
poll = client.request(
|
||||||
|
f"{POLL_QR}?{urlencode(poll_query)}", referer=authorization_url
|
||||||
|
)
|
||||||
errcode, code = parse_poll(poll.text)
|
errcode, code = parse_poll(poll.text)
|
||||||
if errcode == 405 and code:
|
if errcode == 405 and code:
|
||||||
break
|
break
|
||||||
@@ -202,13 +218,16 @@ def main() -> int:
|
|||||||
login_type = cookies.get("logintype", "WX")
|
login_type = cookies.get("logintype", "WX")
|
||||||
if not openid or not access_token:
|
if not openid or not access_token:
|
||||||
raise RuntimeError("YYB OAuth 回调未写入 openid/accesstoken")
|
raise RuntimeError("YYB OAuth 回调未写入 openid/accesstoken")
|
||||||
info = client.request(USER_INFO, headers={
|
info = client.request(
|
||||||
"Ual-Access-Login-Type": login_type_header(login_type),
|
USER_INFO,
|
||||||
"Ual-Access-Access-Token": access_token,
|
headers={
|
||||||
"Ual-Access-Openid": openid,
|
"Ual-Access-Login-Type": login_type_header(login_type),
|
||||||
"Origin": "https://m.yyb.qq.com",
|
"Ual-Access-Access-Token": access_token,
|
||||||
"Referer": "https://m.yyb.qq.com/",
|
"Ual-Access-Openid": openid,
|
||||||
})
|
"Origin": "https://m.yyb.qq.com",
|
||||||
|
"Referer": "https://m.yyb.qq.com/",
|
||||||
|
},
|
||||||
|
)
|
||||||
if info.status != 200:
|
if info.status != 200:
|
||||||
raise RuntimeError(f"YYB 登录态校验失败: HTTP {info.status}")
|
raise RuntimeError(f"YYB 登录态校验失败: HTTP {info.status}")
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""用已登录的 YYB CK 选择和平精英点券档位、区服和角色。"""
|
"""用已登录的 YYB CK 选择和平精英点券档位、区服和角色。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -45,26 +46,43 @@ UA = (
|
|||||||
|
|
||||||
def load_cookies(path: Path) -> dict[str, str]:
|
def load_cookies(path: Path) -> dict[str, str]:
|
||||||
session = json.loads(path.read_text(encoding="utf-8"))
|
session = json.loads(path.read_text(encoding="utf-8"))
|
||||||
cookies = {str(key): str(value) for key, value in session.get("cookies", {}).items() if value}
|
cookies = {
|
||||||
|
str(key): str(value)
|
||||||
|
for key, value in session.get("cookies", {}).items()
|
||||||
|
if value
|
||||||
|
}
|
||||||
if not cookies.get("openid") or not cookies.get("accesstoken"):
|
if not cookies.get("openid") or not cookies.get("accesstoken"):
|
||||||
raise ValueError("会话缺少 openid/accesstoken,请先执行 login-wechat.py")
|
raise ValueError("会话缺少 openid/accesstoken,请先执行 login-wechat.py")
|
||||||
return cookies
|
return cookies
|
||||||
|
|
||||||
|
|
||||||
def product_options(cookies: dict[str, str], platform: str) -> list[dict[str, str | int]]:
|
def product_options(
|
||||||
|
cookies: dict[str, str], platform: str
|
||||||
|
) -> list[dict[str, str | int]]:
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
PRODUCTS_URL,
|
PRODUCTS_URL,
|
||||||
json={"platform": PLATFORMS[platform]["query_platform"], "source_id": SOURCE_ID,
|
json={
|
||||||
"yyb_app_id": YYB_APP_ID},
|
"platform": PLATFORMS[platform]["query_platform"],
|
||||||
headers={"Accept": "application/json, text/plain, */*", "Origin": "https://m.yyb.qq.com",
|
"source_id": SOURCE_ID,
|
||||||
"Referer": "https://m.yyb.qq.com/boc-mall/goods-mall/", "User-Agent": UA},
|
"yyb_app_id": YYB_APP_ID,
|
||||||
cookies=cookies, impersonate="chrome", timeout=30,
|
},
|
||||||
|
headers={
|
||||||
|
"Accept": "application/json, text/plain, */*",
|
||||||
|
"Origin": "https://m.yyb.qq.com",
|
||||||
|
"Referer": "https://m.yyb.qq.com/boc-mall/goods-mall/",
|
||||||
|
"User-Agent": UA,
|
||||||
|
},
|
||||||
|
cookies=cookies,
|
||||||
|
impersonate="chrome",
|
||||||
|
timeout=30,
|
||||||
)
|
)
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
raise RuntimeError(f"点券商品查询失败: HTTP {response.status_code}")
|
raise RuntimeError(f"点券商品查询失败: HTTP {response.status_code}")
|
||||||
document = response.json()
|
document = response.json()
|
||||||
if document.get("code") not in (None, 0):
|
if document.get("code") not in (None, 0):
|
||||||
raise RuntimeError(f"点券商品查询失败: {document.get('code')} {document.get('message', '')}")
|
raise RuntimeError(
|
||||||
|
f"点券商品查询失败: {document.get('code')} {document.get('message', '')}"
|
||||||
|
)
|
||||||
products = document.get("token_mod", {}).get("products", [])
|
products = document.get("token_mod", {}).get("products", [])
|
||||||
result: list[dict[str, str | int]] = []
|
result: list[dict[str, str | int]] = []
|
||||||
for item in products:
|
for item in products:
|
||||||
@@ -72,15 +90,19 @@ def product_options(cookies: dict[str, str], platform: str) -> list[dict[str, st
|
|||||||
match = re.fullmatch(r"(\d+)点券", str(product.get("product_name", "")))
|
match = re.fullmatch(r"(\d+)点券", str(product.get("product_name", "")))
|
||||||
if not match or str(product.get("status")) != "20":
|
if not match or str(product.get("status")) != "20":
|
||||||
continue
|
continue
|
||||||
result.append({
|
result.append(
|
||||||
"points": int(match.group(1)),
|
{
|
||||||
"product_id": str(product.get("product_id", "")),
|
"points": int(match.group(1)),
|
||||||
"price_fen": int(product.get("price", 0)),
|
"product_id": str(product.get("product_id", "")),
|
||||||
"offer_id": str(product.get("res_offer_id", "")),
|
"price_fen": int(product.get("price", 0)),
|
||||||
"name": str(product.get("product_name", "")),
|
"offer_id": str(product.get("res_offer_id", "")),
|
||||||
})
|
"name": str(product.get("product_name", "")),
|
||||||
|
}
|
||||||
|
)
|
||||||
result.sort(key=lambda item: int(item["points"]))
|
result.sort(key=lambda item: int(item["points"]))
|
||||||
if not result or any(not item["product_id"] or not item["offer_id"] for item in result):
|
if not result or any(
|
||||||
|
not item["product_id"] or not item["offer_id"] for item in result
|
||||||
|
):
|
||||||
raise RuntimeError("点券商品响应缺少 product_id 或 offer_id")
|
raise RuntimeError("点券商品响应缺少 product_id 或 offer_id")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -95,30 +117,48 @@ class Cmall:
|
|||||||
def query(self, cmd: str, **extra: str) -> dict:
|
def query(self, cmd: str, **extra: str) -> dict:
|
||||||
login = midas_login_params(self.cookies)
|
login = midas_login_params(self.cookies)
|
||||||
params = {
|
params = {
|
||||||
"from_h5": "1", "pf": PLATFORMS[self.platform]["cmall_pf"], "r": str(random.random()), "cmd": cmd,
|
"from_h5": "1",
|
||||||
|
"pf": PLATFORMS[self.platform]["cmall_pf"],
|
||||||
|
"r": str(random.random()),
|
||||||
|
"cmd": cmd,
|
||||||
"session_token": self.session_token,
|
"session_token": self.session_token,
|
||||||
"pfkey": "pfkey", "webversion": "", **extra,
|
"pfkey": "pfkey",
|
||||||
|
"webversion": "",
|
||||||
|
**extra,
|
||||||
**login,
|
**login,
|
||||||
}
|
}
|
||||||
# 当前商城页面将查询参数放在 URL 上,但请求方法为 POST 且没有 body。
|
# 当前商城页面将查询参数放在 URL 上,但请求方法为 POST 且没有 body。
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
CMALL_URL.format(offer_id=self.offer_id) + "?" + urlencode(params),
|
CMALL_URL.format(offer_id=self.offer_id) + "?" + urlencode(params),
|
||||||
headers={"Origin": "https://z.iwan.yyb.qq.com", "Referer": "https://z.iwan.yyb.qq.com/",
|
headers={
|
||||||
"User-Agent": UA},
|
"Origin": "https://z.iwan.yyb.qq.com",
|
||||||
cookies=self.cookies, impersonate="chrome", timeout=30,
|
"Referer": "https://z.iwan.yyb.qq.com/",
|
||||||
|
"User-Agent": UA,
|
||||||
|
},
|
||||||
|
cookies=self.cookies,
|
||||||
|
impersonate="chrome",
|
||||||
|
timeout=30,
|
||||||
)
|
)
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
raise RuntimeError(f"游戏数据查询失败: HTTP {response.status_code}")
|
raise RuntimeError(f"游戏数据查询失败: HTTP {response.status_code}")
|
||||||
document = response.json()
|
document = response.json()
|
||||||
if document.get("ret") not in (0, "0"):
|
if document.get("ret") not in (0, "0"):
|
||||||
raise RuntimeError(f"游戏数据查询失败: {document.get('ret')} {document.get('msg', '')}")
|
raise RuntimeError(
|
||||||
|
f"游戏数据查询失败: {document.get('ret')} {document.get('msg', '')}"
|
||||||
|
)
|
||||||
return document
|
return document
|
||||||
|
|
||||||
def zones(self) -> list[dict[str, str]]:
|
def zones(self) -> list[dict[str, str]]:
|
||||||
response = self.query("14", use_currency_offerid="1")
|
response = self.query("14", use_currency_offerid="1")
|
||||||
zones = response.get("zone_list", [])
|
zones = response.get("zone_list", [])
|
||||||
return [{"zone_id": str(item.get("zone_id", "")), "name": str(item.get("zone_name", ""))}
|
return [
|
||||||
for item in zones if isinstance(item, dict) and item.get("zone_id")]
|
{
|
||||||
|
"zone_id": str(item.get("zone_id", "")),
|
||||||
|
"name": str(item.get("zone_name", "")),
|
||||||
|
}
|
||||||
|
for item in zones
|
||||||
|
if isinstance(item, dict) and item.get("zone_id")
|
||||||
|
]
|
||||||
|
|
||||||
def roles(self, zone_id: str) -> list[dict[str, str]]:
|
def roles(self, zone_id: str) -> list[dict[str, str]]:
|
||||||
response = self.query("15", use_currency_offerid="1", zoneid=zone_id)
|
response = self.query("15", use_currency_offerid="1", zoneid=zone_id)
|
||||||
@@ -126,14 +166,18 @@ class Cmall:
|
|||||||
# QQ 平台和平精英的 area 与 zoneid 不同(如 area=2, zoneid=1);
|
# QQ 平台和平精英的 area 与 zoneid 不同(如 area=2, zoneid=1);
|
||||||
# PlaceOrder 校验角色时需要该分区信息,随角色一并返回。
|
# PlaceOrder 校验角色时需要该分区信息,随角色一并返回。
|
||||||
partition = response.get("partition_info") or {}
|
partition = response.get("partition_info") or {}
|
||||||
return [{
|
return [
|
||||||
"role_id": str(item.get("role_id", "")),
|
{
|
||||||
"name": unquote(str(item.get("role_name", ""))),
|
"role_id": str(item.get("role_id", "")),
|
||||||
"ban_status": str(item.get("ban_status", "")),
|
"name": unquote(str(item.get("role_name", ""))),
|
||||||
"area": str(partition.get("area", "")),
|
"ban_status": str(item.get("ban_status", "")),
|
||||||
"partition": str(partition.get("partition", "")),
|
"area": str(partition.get("area", "")),
|
||||||
"platid": str(partition.get("platid", "")),
|
"partition": str(partition.get("partition", "")),
|
||||||
} for item in roles if isinstance(item, dict) and item.get("role_id")]
|
"platid": str(partition.get("platid", "")),
|
||||||
|
}
|
||||||
|
for item in roles
|
||||||
|
if isinstance(item, dict) and item.get("role_id")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def choose(label: str, options: list[dict], display) -> dict:
|
def choose(label: str, options: list[dict], display) -> dict:
|
||||||
@@ -151,18 +195,31 @@ def choose(label: str, options: list[dict], display) -> dict:
|
|||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(description="和平精英点券和角色选择(纯 CK 查询)")
|
parser = argparse.ArgumentParser(description="和平精英点券和角色选择(纯 CK 查询)")
|
||||||
parser.add_argument("--session", type=Path, default=ROOT / "config/mall-session.json")
|
parser.add_argument(
|
||||||
parser.add_argument("--output", type=Path, default=ROOT / "config/peace-elite-selection.json")
|
"--session", type=Path, default=ROOT / "config/mall-session.json"
|
||||||
parser.add_argument("--platform", choices=tuple(PLATFORMS), default=None,
|
)
|
||||||
help="预选 Android/iOS;不传则显示平台菜单")
|
parser.add_argument(
|
||||||
parser.add_argument("--points", type=int, default=None, help="预选点券数;不传则显示菜单")
|
"--output", type=Path, default=ROOT / "config/peace-elite-selection.json"
|
||||||
parser.add_argument("--list-products", action="store_true", help="仅列出当前所有点券档位")
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--platform",
|
||||||
|
choices=tuple(PLATFORMS),
|
||||||
|
default=None,
|
||||||
|
help="预选 Android/iOS;不传则显示平台菜单",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--points", type=int, default=None, help="预选点券数;不传则显示菜单"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--list-products", action="store_true", help="仅列出当前所有点券档位"
|
||||||
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
cookies = load_cookies(args.session)
|
cookies = load_cookies(args.session)
|
||||||
if args.platform is None:
|
if args.platform is None:
|
||||||
selected_platform = choose(
|
selected_platform = choose(
|
||||||
"平台", [{"id": key, **value} for key, value in PLATFORMS.items()],
|
"平台",
|
||||||
|
[{"id": key, **value} for key, value in PLATFORMS.items()],
|
||||||
lambda item: item["label"],
|
lambda item: item["label"],
|
||||||
)
|
)
|
||||||
platform = str(selected_platform["id"])
|
platform = str(selected_platform["id"])
|
||||||
@@ -172,32 +229,59 @@ def main() -> int:
|
|||||||
products = product_options(cookies, platform)
|
products = product_options(cookies, platform)
|
||||||
if args.list_products:
|
if args.list_products:
|
||||||
for product in products:
|
for product in products:
|
||||||
print(f"{product['points']}点券\t{product['price_fen'] / 100:g}元\t{product['product_id']}")
|
print(
|
||||||
|
f"{product['points']}点券\t{product['price_fen'] / 100:g}元\t{product['product_id']}"
|
||||||
|
)
|
||||||
return 0
|
return 0
|
||||||
if args.points is None:
|
if args.points is None:
|
||||||
product = choose("点券档位", products, lambda item: f"{item['points']}点券({item['price_fen'] / 100:g}元)")
|
product = choose(
|
||||||
|
"点券档位",
|
||||||
|
products,
|
||||||
|
lambda item: f"{item['points']}点券({item['price_fen'] / 100:g}元)",
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
product = next((item for item in products if item["points"] == args.points), None)
|
product = next(
|
||||||
|
(item for item in products if item["points"] == args.points), None
|
||||||
|
)
|
||||||
if product is None:
|
if product is None:
|
||||||
available = ", ".join(str(item["points"]) for item in products)
|
available = ", ".join(str(item["points"]) for item in products)
|
||||||
raise ValueError(f"不支持 {args.points} 点券;当前可选: {available}")
|
raise ValueError(f"不支持 {args.points} 点券;当前可选: {available}")
|
||||||
print(f"已选择: {product['points']}点券({product['price_fen'] / 100:g}元)")
|
print(f"已选择: {product['points']}点券({product['price_fen'] / 100:g}元)")
|
||||||
|
|
||||||
cmall = Cmall(cookies, str(product["offer_id"]), platform)
|
cmall = Cmall(cookies, str(product["offer_id"]), platform)
|
||||||
zone = choose("区服", cmall.zones(), lambda item: f"{item['name']}(ID {item['zone_id']})")
|
zone = choose(
|
||||||
|
"区服", cmall.zones(), lambda item: f"{item['name']}(ID {item['zone_id']})"
|
||||||
|
)
|
||||||
roles = cmall.roles(zone["zone_id"])
|
roles = cmall.roles(zone["zone_id"])
|
||||||
role = choose("角色", roles, lambda item: f"{item['name']}({'禁用' if item['ban_status'] == '1' else '可用'})")
|
role = choose(
|
||||||
|
"角色",
|
||||||
|
roles,
|
||||||
|
lambda item: (
|
||||||
|
f"{item['name']}({'禁用' if item['ban_status'] == '1' else '可用'})"
|
||||||
|
),
|
||||||
|
)
|
||||||
if role["ban_status"] == "1":
|
if role["ban_status"] == "1":
|
||||||
raise RuntimeError("所选角色已被封禁,不能充值")
|
raise RuntimeError("所选角色已被封禁,不能充值")
|
||||||
|
|
||||||
selection = {"platform": platform, "order_pf": PLATFORMS[platform]["order_pf"],
|
selection = {
|
||||||
"points": product["points"], "product_id": product["product_id"],
|
"platform": platform,
|
||||||
"offer_id": product["offer_id"], "price_fen": product["price_fen"],
|
"order_pf": PLATFORMS[platform]["order_pf"],
|
||||||
"zone_id": zone["zone_id"], "zone_name": zone["name"],
|
"points": product["points"],
|
||||||
"role_id": role["role_id"], "role_name": role["name"]}
|
"product_id": product["product_id"],
|
||||||
|
"offer_id": product["offer_id"],
|
||||||
|
"price_fen": product["price_fen"],
|
||||||
|
"zone_id": zone["zone_id"],
|
||||||
|
"zone_name": zone["name"],
|
||||||
|
"role_id": role["role_id"],
|
||||||
|
"role_name": role["name"],
|
||||||
|
}
|
||||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
args.output.write_text(json.dumps(selection, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
args.output.write_text(
|
||||||
print(f"\n已选择 {selection['points']}点券 / {selection['zone_name']} / {selection['role_name']}")
|
json.dumps(selection, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"\n已选择 {selection['points']}点券 / {selection['zone_name']} / {selection['role_name']}"
|
||||||
|
)
|
||||||
print(f"选择已保存: {args.output}")
|
print(f"选择已保存: {args.output}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ The worker owns per-job sessions and invokes the already verified protocol
|
|||||||
scripts. It intentionally exposes QR images and state only; cookies and raw
|
scripts. It intentionally exposes QR images and state only; cookies and raw
|
||||||
payment links never leave the worker API.
|
payment links never leave the worker API.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -79,15 +80,23 @@ def _safe_log(job: dict, line: str) -> None:
|
|||||||
clean = re.sub(r"weixin://\S+", "[付款链接已隐藏]", line)
|
clean = re.sub(r"weixin://\S+", "[付款链接已隐藏]", line)
|
||||||
clean = re.sub(r"HOLD_[A-Za-z0-9_-]+", "[订单已隐藏]", clean)
|
clean = re.sub(r"HOLD_[A-Za-z0-9_-]+", "[订单已隐藏]", clean)
|
||||||
clean = re.sub(r"(订单\s*[::]\s*)\S+", r"\1[订单已隐藏]", clean)
|
clean = re.sub(r"(订单\s*[::]\s*)\S+", r"\1[订单已隐藏]", clean)
|
||||||
clean = re.sub(r"(?:token|openid|openkey|cookie)=\S+", "[敏感字段已隐藏]", clean, flags=re.I)
|
clean = re.sub(
|
||||||
clean = re.sub(r"(?:pay_token|web_token|anti_token|session_id|sessionid)\s*[:= ]\s*\S+",
|
r"(?:token|openid|openkey|cookie)=\S+", "[敏感字段已隐藏]", clean, flags=re.I
|
||||||
"[敏感字段已隐藏]", clean, flags=re.I)
|
)
|
||||||
|
clean = re.sub(
|
||||||
|
r"(?:pay_token|web_token|anti_token|session_id|sessionid)\s*[:= ]\s*\S+",
|
||||||
|
"[敏感字段已隐藏]",
|
||||||
|
clean,
|
||||||
|
flags=re.I,
|
||||||
|
)
|
||||||
timestamp = time.strftime("%H:%M:%S")
|
timestamp = time.strftime("%H:%M:%S")
|
||||||
with _lock:
|
with _lock:
|
||||||
job["logs"] = (job.get("logs", []) + [f"[{timestamp}] {clean.strip()}"])[-100:]
|
job["logs"] = (job.get("logs", []) + [f"[{timestamp}] {clean.strip()}"])[-100:]
|
||||||
|
|
||||||
|
|
||||||
def _run_process(job_id: str, command: list[str], phase: str, mark_success: bool = True) -> int:
|
def _run_process(
|
||||||
|
job_id: str, command: list[str], phase: str, mark_success: bool = True
|
||||||
|
) -> int:
|
||||||
"""Run one stage process and return its exit code.
|
"""Run one stage process and return its exit code.
|
||||||
|
|
||||||
When ``mark_success`` is False the caller owns the post-success state
|
When ``mark_success`` is False the caller owns the post-success state
|
||||||
@@ -104,16 +113,24 @@ def _run_process(job_id: str, command: list[str], phase: str, mark_success: bool
|
|||||||
stage_started = time.monotonic()
|
stage_started = time.monotonic()
|
||||||
_safe_log(job, f"[{phase}] 开始执行")
|
_safe_log(job, f"[{phase}] 开始执行")
|
||||||
try:
|
try:
|
||||||
process = subprocess.Popen(command, cwd=ROOT, stdout=subprocess.PIPE,
|
process = subprocess.Popen(
|
||||||
stderr=subprocess.STDOUT, text=True,
|
command,
|
||||||
bufsize=1, env=environment)
|
cwd=ROOT,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
bufsize=1,
|
||||||
|
env=environment,
|
||||||
|
)
|
||||||
with _lock:
|
with _lock:
|
||||||
job["process_pid"] = process.pid
|
job["process_pid"] = process.pid
|
||||||
assert process.stdout is not None
|
assert process.stdout is not None
|
||||||
for line in process.stdout:
|
for line in process.stdout:
|
||||||
_safe_log(job, line)
|
_safe_log(job, line)
|
||||||
code = process.wait()
|
code = process.wait()
|
||||||
_safe_log(job, f"[{phase}] 执行结束,耗时 {time.monotonic() - stage_started:.1f} 秒")
|
_safe_log(
|
||||||
|
job, f"[{phase}] 执行结束,耗时 {time.monotonic() - stage_started:.1f} 秒"
|
||||||
|
)
|
||||||
with _lock:
|
with _lock:
|
||||||
job["process_pid"] = None
|
job["process_pid"] = None
|
||||||
if code != 0:
|
if code != 0:
|
||||||
@@ -134,7 +151,9 @@ def _run_process(job_id: str, command: list[str], phase: str, mark_success: bool
|
|||||||
job["message"] = "付款流程已完成"
|
job["message"] = "付款流程已完成"
|
||||||
return code
|
return code
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
_safe_log(job, f"[{phase}] 执行异常,耗时 {time.monotonic() - stage_started:.1f} 秒")
|
_safe_log(
|
||||||
|
job, f"[{phase}] 执行异常,耗时 {time.monotonic() - stage_started:.1f} 秒"
|
||||||
|
)
|
||||||
with _lock:
|
with _lock:
|
||||||
job["status"] = "failed"
|
job["status"] = "failed"
|
||||||
job["message"] = str(exc)
|
job["message"] = str(exc)
|
||||||
@@ -146,42 +165,74 @@ def _start_login(job_id: str, provider: str, timeout: int) -> None:
|
|||||||
directory = _job_dir(job_id)
|
directory = _job_dir(job_id)
|
||||||
session = directory / "mall-session.json"
|
session = directory / "mall-session.json"
|
||||||
qr = directory / ("qq-login.jpg" if provider == "qq" else "wechat-login.jpg")
|
qr = directory / ("qq-login.jpg" if provider == "qq" else "wechat-login.jpg")
|
||||||
command = [sys.executable, f"scripts/login-{provider}.py", "--session", str(session),
|
command = [
|
||||||
"--qr", str(qr), "--timeout", str(timeout)]
|
sys.executable,
|
||||||
|
f"scripts/login-{provider}.py",
|
||||||
|
"--session",
|
||||||
|
str(session),
|
||||||
|
"--qr",
|
||||||
|
str(qr),
|
||||||
|
"--timeout",
|
||||||
|
str(timeout),
|
||||||
|
]
|
||||||
with _lock:
|
with _lock:
|
||||||
job["provider"] = provider
|
job["provider"] = provider
|
||||||
job["qr_path"] = str(qr)
|
job["qr_path"] = str(qr)
|
||||||
job["session_path"] = str(session)
|
job["session_path"] = str(session)
|
||||||
job["status"] = "waiting_login"
|
job["status"] = "waiting_login"
|
||||||
job["phase"] = "login"
|
job["phase"] = "login"
|
||||||
threading.Thread(target=_run_process, args=(job_id, command, "login"), daemon=True).start()
|
threading.Thread(
|
||||||
|
target=_run_process, args=(job_id, command, "login"), daemon=True
|
||||||
|
).start()
|
||||||
|
|
||||||
|
|
||||||
def _selection_options(job_id: str, platform: str, points: int | None, zone_id: str | None = None) -> dict:
|
def _selection_options(
|
||||||
|
job_id: str, platform: str, points: int | None, zone_id: str | None = None
|
||||||
|
) -> dict:
|
||||||
if job_id not in _jobs:
|
if job_id not in _jobs:
|
||||||
raise ValueError("任务不存在")
|
raise ValueError("任务不存在")
|
||||||
selector = _load_selector()
|
selector = _load_selector()
|
||||||
session = json.loads((_job_dir(job_id) / "mall-session.json").read_text(encoding="utf-8"))
|
session = json.loads(
|
||||||
|
(_job_dir(job_id) / "mall-session.json").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
cookies = session.get("cookies", {})
|
cookies = session.get("cookies", {})
|
||||||
products = selector.product_options(cookies, platform)
|
products = selector.product_options(cookies, platform)
|
||||||
if points is not None and not any(int(item["points"]) == points for item in products):
|
if points is not None and not any(
|
||||||
|
int(item["points"]) == points for item in products
|
||||||
|
):
|
||||||
raise ValueError("当前登录态不支持该点券档位")
|
raise ValueError("当前登录态不支持该点券档位")
|
||||||
product = next((item for item in products if int(item["points"]) == points), None) if points else None
|
product = (
|
||||||
|
next((item for item in products if int(item["points"]) == points), None)
|
||||||
|
if points
|
||||||
|
else None
|
||||||
|
)
|
||||||
if product is None:
|
if product is None:
|
||||||
product = products[0]
|
product = products[0]
|
||||||
cmall = selector.Cmall(cookies, str(product["offer_id"]), platform)
|
cmall = selector.Cmall(cookies, str(product["offer_id"]), platform)
|
||||||
zones = cmall.zones()
|
zones = cmall.zones()
|
||||||
selected_zone = next((zone for zone in zones if str(zone["zone_id"]) == str(zone_id)), None)
|
selected_zone = next(
|
||||||
|
(zone for zone in zones if str(zone["zone_id"]) == str(zone_id)), None
|
||||||
|
)
|
||||||
if zone_id and selected_zone is None:
|
if zone_id and selected_zone is None:
|
||||||
raise ValueError("区服不存在")
|
raise ValueError("区服不存在")
|
||||||
selected_zone = selected_zone or (zones[0] if zones else None)
|
selected_zone = selected_zone or (zones[0] if zones else None)
|
||||||
roles = cmall.roles(selected_zone["zone_id"]) if selected_zone else []
|
roles = cmall.roles(selected_zone["zone_id"]) if selected_zone else []
|
||||||
return {"products": products, "zones": zones, "roles": roles,
|
return {
|
||||||
"default_product": product, "default_zone": selected_zone}
|
"products": products,
|
||||||
|
"zones": zones,
|
||||||
|
"roles": roles,
|
||||||
|
"default_product": product,
|
||||||
|
"default_zone": selected_zone,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _payment_stage(job_id: str, command: list[str], phase: str, running_message: str,
|
def _payment_stage(
|
||||||
failed_message: str) -> bool:
|
job_id: str,
|
||||||
|
command: list[str],
|
||||||
|
phase: str,
|
||||||
|
running_message: str,
|
||||||
|
failed_message: str,
|
||||||
|
) -> bool:
|
||||||
"""Run one payment stage; return True on success without touching final state."""
|
"""Run one payment stage; return True on success without touching final state."""
|
||||||
job = _jobs[job_id]
|
job = _jobs[job_id]
|
||||||
with _lock:
|
with _lock:
|
||||||
@@ -210,18 +261,32 @@ def _check_payment_once(job_id: str) -> int:
|
|||||||
"""Read-only completion check; returns 0=confirmed, 1=not yet, 2=check failed."""
|
"""Read-only completion check; returns 0=confirmed, 1=not yet, 2=check failed."""
|
||||||
job = _jobs[job_id]
|
job = _jobs[job_id]
|
||||||
directory = _job_dir(job_id)
|
directory = _job_dir(job_id)
|
||||||
command = [sys.executable, "scripts/jsdom-pay.py", "--check-only",
|
command = [
|
||||||
"--session", str(directory / "mall-session.json"),
|
sys.executable,
|
||||||
"--out-dir", str(directory / "jsdom-order")]
|
"scripts/jsdom-pay.py",
|
||||||
|
"--check-only",
|
||||||
|
"--session",
|
||||||
|
str(directory / "mall-session.json"),
|
||||||
|
"--out-dir",
|
||||||
|
str(directory / "jsdom-order"),
|
||||||
|
]
|
||||||
environment = os.environ.copy()
|
environment = os.environ.copy()
|
||||||
environment.pop("NODE_OPTIONS", None)
|
environment.pop("NODE_OPTIONS", None)
|
||||||
check_started = time.monotonic()
|
check_started = time.monotonic()
|
||||||
_safe_log(job, "[到账检测] 开始执行")
|
_safe_log(job, "[到账检测] 开始执行")
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(command, cwd=ROOT, capture_output=True, text=True,
|
result = subprocess.run(
|
||||||
env=environment, timeout=90)
|
command,
|
||||||
|
cwd=ROOT,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=environment,
|
||||||
|
timeout=90,
|
||||||
|
)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
_safe_log(job, f"[到账检测] 执行超时,耗时 {time.monotonic() - check_started:.1f} 秒")
|
_safe_log(
|
||||||
|
job, f"[到账检测] 执行超时,耗时 {time.monotonic() - check_started:.1f} 秒"
|
||||||
|
)
|
||||||
with _lock:
|
with _lock:
|
||||||
job["payment_last_checked_at"] = int(time.time())
|
job["payment_last_checked_at"] = int(time.time())
|
||||||
return 2
|
return 2
|
||||||
@@ -229,7 +294,9 @@ def _check_payment_once(job_id: str) -> int:
|
|||||||
_safe_log(job, line)
|
_safe_log(job, line)
|
||||||
for line in (result.stderr or "").splitlines():
|
for line in (result.stderr or "").splitlines():
|
||||||
_safe_log(job, line)
|
_safe_log(job, line)
|
||||||
_safe_log(job, f"[到账检测] 执行结束,耗时 {time.monotonic() - check_started:.1f} 秒")
|
_safe_log(
|
||||||
|
job, f"[到账检测] 执行结束,耗时 {time.monotonic() - check_started:.1f} 秒"
|
||||||
|
)
|
||||||
with _lock:
|
with _lock:
|
||||||
job["payment_last_checked_at"] = int(time.time())
|
job["payment_last_checked_at"] = int(time.time())
|
||||||
return result.returncode
|
return result.returncode
|
||||||
@@ -275,28 +342,62 @@ def _payment_flow(job_id: str, selection: dict) -> None:
|
|||||||
job["status"] = "ordering"
|
job["status"] = "ordering"
|
||||||
job["phase"] = "payment"
|
job["phase"] = "payment"
|
||||||
job["message"] = "正在创建商城订单"
|
job["message"] = "正在创建商城订单"
|
||||||
order_cmd = [sys.executable, "main.py", "mall", "auto", "--session", str(session),
|
order_cmd = [
|
||||||
"--order-template", str(ROOT / "config" / "mall-order-template.json"),
|
sys.executable,
|
||||||
"--quantity", "1", "--product-id", str(selection["product_id"]),
|
"main.py",
|
||||||
"--offer-id", str(selection["offer_id"]),
|
"mall",
|
||||||
"--role-id", str(selection["role_id"]), "--role-name", str(selection["role_name"]),
|
"auto",
|
||||||
"--zone-id", str(selection["zone_id"]), "--zone-name", str(selection["zone_name"]),
|
"--session",
|
||||||
"--output", str(response)]
|
str(session),
|
||||||
|
"--order-template",
|
||||||
|
str(ROOT / "config" / "mall-order-template.json"),
|
||||||
|
"--quantity",
|
||||||
|
"1",
|
||||||
|
"--product-id",
|
||||||
|
str(selection["product_id"]),
|
||||||
|
"--offer-id",
|
||||||
|
str(selection["offer_id"]),
|
||||||
|
"--role-id",
|
||||||
|
str(selection["role_id"]),
|
||||||
|
"--role-name",
|
||||||
|
str(selection["role_name"]),
|
||||||
|
"--zone-id",
|
||||||
|
str(selection["zone_id"]),
|
||||||
|
"--zone-name",
|
||||||
|
str(selection["zone_name"]),
|
||||||
|
"--output",
|
||||||
|
str(response),
|
||||||
|
]
|
||||||
if selection.get("area"):
|
if selection.get("area"):
|
||||||
order_cmd.extend(["--area", str(selection["area"])])
|
order_cmd.extend(["--area", str(selection["area"])])
|
||||||
if selection.get("partition"):
|
if selection.get("partition"):
|
||||||
order_cmd.extend(["--partition", str(selection["partition"])])
|
order_cmd.extend(["--partition", str(selection["partition"])])
|
||||||
if selection.get("order_pf"):
|
if selection.get("order_pf"):
|
||||||
order_cmd.extend(["--pf", str(selection["order_pf"])])
|
order_cmd.extend(["--pf", str(selection["order_pf"])])
|
||||||
if not _payment_stage(job_id, order_cmd, "order", "正在创建商城订单", "创建商城订单失败"):
|
if not _payment_stage(
|
||||||
|
job_id, order_cmd, "order", "正在创建商城订单", "创建商城订单失败"
|
||||||
|
):
|
||||||
return
|
return
|
||||||
pay_cmd = [sys.executable, "scripts/jsdom-pay.py", "--session", str(session),
|
pay_cmd = [
|
||||||
"--mall-response", str(response), "--out-dir", str(output),
|
sys.executable,
|
||||||
"--zone-id", str(selection["zone_id"]),
|
"scripts/jsdom-pay.py",
|
||||||
"--pf", str(selection.get("order_pf", "")),
|
"--session",
|
||||||
"--amount-fen", str(selection["price_fen"]),
|
str(session),
|
||||||
"--skip-payment-check"]
|
"--mall-response",
|
||||||
if not _payment_stage(job_id, pay_cmd, "payment", "正在生成微信付款码", "生成付款码失败"):
|
str(response),
|
||||||
|
"--out-dir",
|
||||||
|
str(output),
|
||||||
|
"--zone-id",
|
||||||
|
str(selection["zone_id"]),
|
||||||
|
"--pf",
|
||||||
|
str(selection.get("order_pf", "")),
|
||||||
|
"--amount-fen",
|
||||||
|
str(selection["price_fen"]),
|
||||||
|
"--skip-payment-check",
|
||||||
|
]
|
||||||
|
if not _payment_stage(
|
||||||
|
job_id, pay_cmd, "payment", "正在生成微信付款码", "生成付款码失败"
|
||||||
|
):
|
||||||
return
|
return
|
||||||
meta_path = output / "payment-meta.json"
|
meta_path = output / "payment-meta.json"
|
||||||
try:
|
try:
|
||||||
@@ -318,7 +419,9 @@ def _start_payment(job_id: str, selection: dict) -> None:
|
|||||||
_jobs[job_id]["status"] = "ordering"
|
_jobs[job_id]["status"] = "ordering"
|
||||||
_jobs[job_id]["phase"] = "payment"
|
_jobs[job_id]["phase"] = "payment"
|
||||||
_jobs[job_id]["message"] = "正在创建商城订单"
|
_jobs[job_id]["message"] = "正在创建商城订单"
|
||||||
threading.Thread(target=_payment_flow, args=(job_id, selection), daemon=True).start()
|
threading.Thread(
|
||||||
|
target=_payment_flow, args=(job_id, selection), daemon=True
|
||||||
|
).start()
|
||||||
|
|
||||||
|
|
||||||
def _stop_job(job_id: str) -> None:
|
def _stop_job(job_id: str) -> None:
|
||||||
@@ -344,13 +447,18 @@ def _stop_job(job_id: str) -> None:
|
|||||||
|
|
||||||
def _public_job(job_id: str) -> dict:
|
def _public_job(job_id: str) -> dict:
|
||||||
job = _jobs[job_id]
|
job = _jobs[job_id]
|
||||||
result = {key: value for key, value in job.items()
|
result = {
|
||||||
if key not in {"directory", "session_path", "process_pid"}}
|
key: value
|
||||||
|
for key, value in job.items()
|
||||||
|
if key not in {"directory", "session_path", "process_pid"}
|
||||||
|
}
|
||||||
qr_path = job.get("qr_path", "")
|
qr_path = job.get("qr_path", "")
|
||||||
if qr_path and Path(qr_path).exists():
|
if qr_path and Path(qr_path).exists():
|
||||||
qr_bytes = Path(qr_path).read_bytes()
|
qr_bytes = Path(qr_path).read_bytes()
|
||||||
result["qr_data"] = base64.b64encode(qr_bytes).decode("ascii")
|
result["qr_data"] = base64.b64encode(qr_bytes).decode("ascii")
|
||||||
result["qr_mime_type"] = "image/jpeg" if qr_bytes.startswith(b"\xff\xd8\xff") else "image/png"
|
result["qr_mime_type"] = (
|
||||||
|
"image/jpeg" if qr_bytes.startswith(b"\xff\xd8\xff") else "image/png"
|
||||||
|
)
|
||||||
output = Path(job["directory"]) / "jsdom-order"
|
output = Path(job["directory"]) / "jsdom-order"
|
||||||
for name in ("wechat-pay.png", "payment-status.json"):
|
for name in ("wechat-pay.png", "payment-status.json"):
|
||||||
path = None
|
path = None
|
||||||
@@ -359,18 +467,32 @@ def _public_job(job_id: str) -> dict:
|
|||||||
path = direct if direct.exists() else next(output.glob(f"*/{name}"), None)
|
path = direct if direct.exists() else next(output.glob(f"*/{name}"), None)
|
||||||
if path and name.endswith(".png"):
|
if path and name.endswith(".png"):
|
||||||
payment_qr_bytes = path.read_bytes()
|
payment_qr_bytes = path.read_bytes()
|
||||||
result["payment_qr_data"] = base64.b64encode(payment_qr_bytes).decode("ascii")
|
result["payment_qr_data"] = base64.b64encode(payment_qr_bytes).decode(
|
||||||
result["payment_qr_mime_type"] = "image/jpeg" if payment_qr_bytes.startswith(b"\xff\xd8\xff") else "image/png"
|
"ascii"
|
||||||
|
)
|
||||||
|
result["payment_qr_mime_type"] = (
|
||||||
|
"image/jpeg"
|
||||||
|
if payment_qr_bytes.startswith(b"\xff\xd8\xff")
|
||||||
|
else "image/png"
|
||||||
|
)
|
||||||
elif path:
|
elif path:
|
||||||
try:
|
try:
|
||||||
status = json.loads(path.read_text(encoding="utf-8"))
|
status = json.loads(path.read_text(encoding="utf-8"))
|
||||||
matched = status.get("matched_completion") if isinstance(status, dict) else None
|
matched = (
|
||||||
|
status.get("matched_completion")
|
||||||
|
if isinstance(status, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
result["payment_status"] = {
|
result["payment_status"] = {
|
||||||
"checked_at": status.get("checked_at") if isinstance(status, dict) else None,
|
"checked_at": status.get("checked_at")
|
||||||
|
if isinstance(status, dict)
|
||||||
|
else None,
|
||||||
"matched_completion": {
|
"matched_completion": {
|
||||||
"is_finished": matched.get("is_finished"),
|
"is_finished": matched.get("is_finished"),
|
||||||
"status": matched.get("status"),
|
"status": matched.get("status"),
|
||||||
} if isinstance(matched, dict) else None,
|
}
|
||||||
|
if isinstance(matched, dict)
|
||||||
|
else None,
|
||||||
}
|
}
|
||||||
except (OSError, json.JSONDecodeError):
|
except (OSError, json.JSONDecodeError):
|
||||||
pass
|
pass
|
||||||
@@ -411,8 +533,14 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
directory = DEFAULT_DATA / job_id
|
directory = DEFAULT_DATA / job_id
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
os.chmod(directory, 0o700)
|
os.chmod(directory, 0o700)
|
||||||
_jobs[job_id] = {"job_id": job_id, "status": "created", "phase": "login",
|
_jobs[job_id] = {
|
||||||
"logs": [], "directory": str(directory), "created_at": int(time.time())}
|
"job_id": job_id,
|
||||||
|
"status": "created",
|
||||||
|
"phase": "login",
|
||||||
|
"logs": [],
|
||||||
|
"directory": str(directory),
|
||||||
|
"created_at": int(time.time()),
|
||||||
|
}
|
||||||
return self._json(201, _public_job(job_id))
|
return self._json(201, _public_job(job_id))
|
||||||
if len(path) == 4 and path[:2] == ["v1", "jobs"] and path[3] == "login":
|
if len(path) == 4 and path[:2] == ["v1", "jobs"] and path[3] == "login":
|
||||||
job_id = path[2]
|
job_id = path[2]
|
||||||
@@ -421,44 +549,87 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return self._json(400, {"detail": "无效任务或登录方式"})
|
return self._json(400, {"detail": "无效任务或登录方式"})
|
||||||
_start_login(job_id, body["provider"], int(body.get("timeout", 600)))
|
_start_login(job_id, body["provider"], int(body.get("timeout", 600)))
|
||||||
return self._json(202, _public_job(job_id))
|
return self._json(202, _public_job(job_id))
|
||||||
if len(path) == 4 and path[:2] == ["v1", "jobs"] and path[3] == "selection-options":
|
if (
|
||||||
|
len(path) == 4
|
||||||
|
and path[:2] == ["v1", "jobs"]
|
||||||
|
and path[3] == "selection-options"
|
||||||
|
):
|
||||||
job_id = path[2]
|
job_id = path[2]
|
||||||
body = self._body()
|
body = self._body()
|
||||||
options = _selection_options(job_id, str(body.get("platform", "android")), body.get("points"), body.get("zone_id"))
|
options = _selection_options(
|
||||||
|
job_id,
|
||||||
|
str(body.get("platform", "android")),
|
||||||
|
body.get("points"),
|
||||||
|
body.get("zone_id"),
|
||||||
|
)
|
||||||
_jobs[job_id]["selection_options"] = options
|
_jobs[job_id]["selection_options"] = options
|
||||||
return self._json(200, options)
|
return self._json(200, options)
|
||||||
if len(path) == 4 and path[:2] == ["v1", "jobs"] and path[3] == "selection":
|
if len(path) == 4 and path[:2] == ["v1", "jobs"] and path[3] == "selection":
|
||||||
job_id = path[2]
|
job_id = path[2]
|
||||||
body = self._body()
|
body = self._body()
|
||||||
required = ("platform", "points", "product_id", "role_id", "role_name", "zone_id")
|
required = (
|
||||||
|
"platform",
|
||||||
|
"points",
|
||||||
|
"product_id",
|
||||||
|
"role_id",
|
||||||
|
"role_name",
|
||||||
|
"zone_id",
|
||||||
|
)
|
||||||
if job_id not in _jobs or any(not body.get(key) for key in required):
|
if job_id not in _jobs or any(not body.get(key) for key in required):
|
||||||
return self._json(400, {"detail": "选择参数不完整"})
|
return self._json(400, {"detail": "选择参数不完整"})
|
||||||
selector = _load_selector()
|
selector = _load_selector()
|
||||||
if body["platform"] not in selector.PLATFORMS:
|
if body["platform"] not in selector.PLATFORMS:
|
||||||
return self._json(400, {"detail": "不支持的平台"})
|
return self._json(400, {"detail": "不支持的平台"})
|
||||||
session_path = _job_dir(job_id) / "mall-session.json"
|
session_path = _job_dir(job_id) / "mall-session.json"
|
||||||
cookies = json.loads(session_path.read_text(encoding="utf-8")).get("cookies", {})
|
cookies = json.loads(session_path.read_text(encoding="utf-8")).get(
|
||||||
product = next((item for item in selector.product_options(cookies, body["platform"])
|
"cookies", {}
|
||||||
if str(item["product_id"]) == str(body["product_id"])
|
)
|
||||||
and int(item["points"]) == int(body["points"])), None)
|
product = next(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for item in selector.product_options(cookies, body["platform"])
|
||||||
|
if str(item["product_id"]) == str(body["product_id"])
|
||||||
|
and int(item["points"]) == int(body["points"])
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
if product is None:
|
if product is None:
|
||||||
return self._json(400, {"detail": "商品已失效,请重新选择"})
|
return self._json(400, {"detail": "商品已失效,请重新选择"})
|
||||||
cmall = selector.Cmall(cookies, str(product["offer_id"]), body["platform"])
|
cmall = selector.Cmall(
|
||||||
zone = next((item for item in cmall.zones()
|
cookies, str(product["offer_id"]), body["platform"]
|
||||||
if str(item["zone_id"]) == str(body["zone_id"])), None)
|
)
|
||||||
|
zone = next(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for item in cmall.zones()
|
||||||
|
if str(item["zone_id"]) == str(body["zone_id"])
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
if zone is None:
|
if zone is None:
|
||||||
return self._json(400, {"detail": "区服已失效,请重新选择"})
|
return self._json(400, {"detail": "区服已失效,请重新选择"})
|
||||||
role = next((item for item in cmall.roles(zone["zone_id"])
|
role = next(
|
||||||
if str(item["role_id"]) == str(body["role_id"])), None)
|
(
|
||||||
|
item
|
||||||
|
for item in cmall.roles(zone["zone_id"])
|
||||||
|
if str(item["role_id"]) == str(body["role_id"])
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
if role is None or role.get("ban_status") == "1":
|
if role is None or role.get("ban_status") == "1":
|
||||||
return self._json(400, {"detail": "角色不可充值,请重新选择"})
|
return self._json(400, {"detail": "角色不可充值,请重新选择"})
|
||||||
_jobs[job_id]["selection"] = {
|
_jobs[job_id]["selection"] = {
|
||||||
"platform": body["platform"], "points": product["points"],
|
"platform": body["platform"],
|
||||||
|
"points": product["points"],
|
||||||
"price_fen": product["price_fen"],
|
"price_fen": product["price_fen"],
|
||||||
"product_id": product["product_id"], "offer_id": product["offer_id"],
|
"product_id": product["product_id"],
|
||||||
"zone_id": zone["zone_id"], "zone_name": zone["name"],
|
"offer_id": product["offer_id"],
|
||||||
"role_id": role["role_id"], "role_name": role["name"],
|
"zone_id": zone["zone_id"],
|
||||||
"area": role.get("area", ""), "partition": role.get("partition", ""),
|
"zone_name": zone["name"],
|
||||||
|
"role_id": role["role_id"],
|
||||||
|
"role_name": role["name"],
|
||||||
|
"area": role.get("area", ""),
|
||||||
|
"partition": role.get("partition", ""),
|
||||||
"order_pf": selector.PLATFORMS[body["platform"]]["order_pf"],
|
"order_pf": selector.PLATFORMS[body["platform"]]["order_pf"],
|
||||||
}
|
}
|
||||||
_jobs[job_id]["phase"] = "payment"
|
_jobs[job_id]["phase"] = "payment"
|
||||||
@@ -473,14 +644,23 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
with _lock:
|
with _lock:
|
||||||
if job.get("status") == "stopped":
|
if job.get("status") == "stopped":
|
||||||
return self._json(400, {"detail": "任务已停止,不能生成付款码"})
|
return self._json(400, {"detail": "任务已停止,不能生成付款码"})
|
||||||
if job.get("status") in {"ordering", "waiting_payment", "payment_timeout"} \
|
if job.get("status") in {
|
||||||
or job.get("process_pid"):
|
"ordering",
|
||||||
return self._json(400, {"detail": "已有进行中的支付流程,请勿重复操作"})
|
"waiting_payment",
|
||||||
|
"payment_timeout",
|
||||||
|
} or job.get("process_pid"):
|
||||||
|
return self._json(
|
||||||
|
400, {"detail": "已有进行中的支付流程,请勿重复操作"}
|
||||||
|
)
|
||||||
if not job.get("selection"):
|
if not job.get("selection"):
|
||||||
return self._json(400, {"detail": "请先完成角色选择"})
|
return self._json(400, {"detail": "请先完成角色选择"})
|
||||||
_start_payment(job_id, job["selection"])
|
_start_payment(job_id, job["selection"])
|
||||||
return self._json(202, _public_job(job_id))
|
return self._json(202, _public_job(job_id))
|
||||||
if len(path) == 5 and path[:2] == ["v1", "jobs"] and path[3:5] == ["payment", "check"]:
|
if (
|
||||||
|
len(path) == 5
|
||||||
|
and path[:2] == ["v1", "jobs"]
|
||||||
|
and path[3:5] == ["payment", "check"]
|
||||||
|
):
|
||||||
job_id = path[2]
|
job_id = path[2]
|
||||||
if job_id not in _jobs:
|
if job_id not in _jobs:
|
||||||
return self._json(400, {"detail": "任务不存在"})
|
return self._json(400, {"detail": "任务不存在"})
|
||||||
@@ -541,7 +721,9 @@ def _restore_jobs(data_dir: Path) -> int:
|
|||||||
continue
|
continue
|
||||||
if not meta_path.exists():
|
if not meta_path.exists():
|
||||||
_jobs[job_id] = {
|
_jobs[job_id] = {
|
||||||
"job_id": job_id, "status": "ready", "phase": "selection",
|
"job_id": job_id,
|
||||||
|
"status": "ready",
|
||||||
|
"phase": "selection",
|
||||||
"logs": ["服务重启,已从任务目录恢复登录会话"],
|
"logs": ["服务重启,已从任务目录恢复登录会话"],
|
||||||
"directory": str(directory),
|
"directory": str(directory),
|
||||||
"created_at": int(time.time()),
|
"created_at": int(time.time()),
|
||||||
@@ -569,7 +751,9 @@ def _restore_jobs(data_dir: Path) -> int:
|
|||||||
except (OSError, json.JSONDecodeError):
|
except (OSError, json.JSONDecodeError):
|
||||||
pass
|
pass
|
||||||
_jobs[job_id] = {
|
_jobs[job_id] = {
|
||||||
"job_id": job_id, "status": status, "phase": phase,
|
"job_id": job_id,
|
||||||
|
"status": status,
|
||||||
|
"phase": phase,
|
||||||
"logs": ["服务重启,已从任务目录恢复本任务"],
|
"logs": ["服务重启,已从任务目录恢复本任务"],
|
||||||
"directory": str(directory),
|
"directory": str(directory),
|
||||||
"created_at": int(time.time()),
|
"created_at": int(time.time()),
|
||||||
@@ -587,9 +771,15 @@ def main() -> int:
|
|||||||
parser.add_argument("--host", default="127.0.0.1")
|
parser.add_argument("--host", default="127.0.0.1")
|
||||||
parser.add_argument("--port", type=int, default=8810)
|
parser.add_argument("--port", type=int, default=8810)
|
||||||
parser.add_argument("--data-dir", type=Path, default=DEFAULT_DATA)
|
parser.add_argument("--data-dir", type=Path, default=DEFAULT_DATA)
|
||||||
parser.add_argument("--evidence-ttl-hours", type=int, default=DEFAULT_EVIDENCE_TTL_HOURS,
|
parser.add_argument(
|
||||||
help="任务原始证据保留时长;0 表示不自动清理")
|
"--evidence-ttl-hours",
|
||||||
parser.add_argument("--key", default=None, help="HTTP Bearer 鉴权密钥;默认读取 YYB_WORKER_KEY")
|
type=int,
|
||||||
|
default=DEFAULT_EVIDENCE_TTL_HOURS,
|
||||||
|
help="任务原始证据保留时长;0 表示不自动清理",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--key", default=None, help="HTTP Bearer 鉴权密钥;默认读取 YYB_WORKER_KEY"
|
||||||
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
# 强制绝对路径:子进程以 ROOT 为 cwd,相对路径会让任务文件写到错误位置。
|
# 强制绝对路径:子进程以 ROOT 为 cwd,相对路径会让任务文件写到错误位置。
|
||||||
DEFAULT_DATA = Path(args.data_dir).resolve()
|
DEFAULT_DATA = Path(args.data_dir).resolve()
|
||||||
@@ -603,7 +793,10 @@ def main() -> int:
|
|||||||
os.chmod(DEFAULT_DATA, 0o700)
|
os.chmod(DEFAULT_DATA, 0o700)
|
||||||
removed = _cleanup_expired_jobs(DEFAULT_DATA, args.evidence_ttl_hours)
|
removed = _cleanup_expired_jobs(DEFAULT_DATA, args.evidence_ttl_hours)
|
||||||
if removed:
|
if removed:
|
||||||
print(f"已清理 {removed} 个过期任务证据(保留期 {args.evidence_ttl_hours} 小时)", flush=True)
|
print(
|
||||||
|
f"已清理 {removed} 个过期任务证据(保留期 {args.evidence_ttl_hours} 小时)",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
restored = _restore_jobs(DEFAULT_DATA)
|
restored = _restore_jobs(DEFAULT_DATA)
|
||||||
if restored:
|
if restored:
|
||||||
print(f"已从任务目录恢复 {restored} 个支付任务", flush=True)
|
print(f"已从任务目录恢复 {restored} 个支付任务", flush=True)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""YYB 付款表单的离线回归测试。"""
|
"""YYB 付款表单的离线回归测试。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import importlib.util
|
import importlib.util
|
||||||
@@ -23,14 +24,24 @@ from pyvm.protocol import goods_material_diagnostics, validate_goods_materials
|
|||||||
class TestJsdomPay(unittest.TestCase):
|
class TestJsdomPay(unittest.TestCase):
|
||||||
def test_build_save_body_has_current_payment_context(self):
|
def test_build_save_body_has_current_payment_context(self):
|
||||||
body = _MODULE.build_save_body(
|
body = _MODULE.build_save_body(
|
||||||
{"token_id": "token", "transaction_id": "transaction", "out_trade_no": "trade"},
|
{
|
||||||
|
"token_id": "token",
|
||||||
|
"transaction_id": "transaction",
|
||||||
|
"out_trade_no": "trade",
|
||||||
|
},
|
||||||
{"openid": "openid", "accesstoken": "access-token", "logintype": "WX"},
|
{"openid": "openid", "accesstoken": "access-token", "logintype": "WX"},
|
||||||
"web-token", "anti-token", "cipher", "2",
|
"web-token",
|
||||||
"mds_myappjp-__mds_myappjp_PC_aW9zd2hpdGVsaX0-iap", 600,
|
"anti-token",
|
||||||
|
"cipher",
|
||||||
|
"2",
|
||||||
|
"mds_myappjp-__mds_myappjp_PC_aW9zd2hpdGVsaX0-iap",
|
||||||
|
600,
|
||||||
)
|
)
|
||||||
fields = parse_qs(body, keep_blank_values=True)
|
fields = parse_qs(body, keep_blank_values=True)
|
||||||
self.assertEqual(fields["type"], ["bg"])
|
self.assertEqual(fields["type"], ["bg"])
|
||||||
self.assertEqual(fields["pf"], ["mds_myappjp-__mds_myappjp_PC_aW9zd2hpdGVsaX0-iap"])
|
self.assertEqual(
|
||||||
|
fields["pf"], ["mds_myappjp-__mds_myappjp_PC_aW9zd2hpdGVsaX0-iap"]
|
||||||
|
)
|
||||||
self.assertEqual(fields["zoneid"], ["2"])
|
self.assertEqual(fields["zoneid"], ["2"])
|
||||||
self.assertEqual(fields["pay_method"], ["wechat"])
|
self.assertEqual(fields["pay_method"], ["wechat"])
|
||||||
self.assertEqual(fields["wcp"], ["type=CNY&amt=600"])
|
self.assertEqual(fields["wcp"], ["type=CNY&amt=600"])
|
||||||
@@ -39,9 +50,23 @@ class TestJsdomPay(unittest.TestCase):
|
|||||||
|
|
||||||
def test_qq_payment_uses_qq_oauth_session_fields(self):
|
def test_qq_payment_uses_qq_oauth_session_fields(self):
|
||||||
body = _MODULE.build_save_body(
|
body = _MODULE.build_save_body(
|
||||||
{"token_id": "token", "transaction_id": "transaction", "out_trade_no": "trade"},
|
{
|
||||||
{"openid": "openid", "accesstoken": "access-token", "logintype": "QC", "appid": "102033112"},
|
"token_id": "token",
|
||||||
"web-token", "anti-token", "cipher", "1", "pf", 100,
|
"transaction_id": "transaction",
|
||||||
|
"out_trade_no": "trade",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"openid": "openid",
|
||||||
|
"accesstoken": "access-token",
|
||||||
|
"logintype": "QC",
|
||||||
|
"appid": "102033112",
|
||||||
|
},
|
||||||
|
"web-token",
|
||||||
|
"anti-token",
|
||||||
|
"cipher",
|
||||||
|
"1",
|
||||||
|
"pf",
|
||||||
|
100,
|
||||||
)
|
)
|
||||||
fields = parse_qs(body, keep_blank_values=True)
|
fields = parse_qs(body, keep_blank_values=True)
|
||||||
self.assertEqual(fields["session_id"], ["openid"])
|
self.assertEqual(fields["session_id"], ["openid"])
|
||||||
@@ -52,18 +77,38 @@ class TestJsdomPay(unittest.TestCase):
|
|||||||
|
|
||||||
def test_wechat_payment_does_not_include_qq_offer_field(self):
|
def test_wechat_payment_does_not_include_qq_offer_field(self):
|
||||||
body = _MODULE.build_save_body(
|
body = _MODULE.build_save_body(
|
||||||
{"token_id": "token", "transaction_id": "transaction", "out_trade_no": "trade"},
|
{
|
||||||
|
"token_id": "token",
|
||||||
|
"transaction_id": "transaction",
|
||||||
|
"out_trade_no": "trade",
|
||||||
|
},
|
||||||
{"openid": "openid", "accesstoken": "access-token", "logintype": "WX"},
|
{"openid": "openid", "accesstoken": "access-token", "logintype": "WX"},
|
||||||
"web-token", "anti-token", "cipher", "1", "pf", 100,
|
"web-token",
|
||||||
|
"anti-token",
|
||||||
|
"cipher",
|
||||||
|
"1",
|
||||||
|
"pf",
|
||||||
|
100,
|
||||||
)
|
)
|
||||||
fields = parse_qs(body, keep_blank_values=True)
|
fields = parse_qs(body, keep_blank_values=True)
|
||||||
self.assertNotIn("offerid_for_qq_appid", fields)
|
self.assertNotIn("offerid_for_qq_appid", fields)
|
||||||
|
|
||||||
def test_qq_page_info_matches_the_web_flow(self):
|
def test_qq_page_info_matches_the_web_flow(self):
|
||||||
body = _MODULE.build_page_info_body(
|
body = _MODULE.build_page_info_body(
|
||||||
{"token_id": "token", "transaction_id": "transaction", "out_trade_no": "trade"},
|
{
|
||||||
{"openid": "openid", "accesstoken": "access-token", "logintype": "QC", "appid": "102033112"},
|
"token_id": "token",
|
||||||
"anti-token", "1", "pf",
|
"transaction_id": "transaction",
|
||||||
|
"out_trade_no": "trade",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"openid": "openid",
|
||||||
|
"accesstoken": "access-token",
|
||||||
|
"logintype": "QC",
|
||||||
|
"appid": "102033112",
|
||||||
|
},
|
||||||
|
"anti-token",
|
||||||
|
"1",
|
||||||
|
"pf",
|
||||||
)
|
)
|
||||||
fields = parse_qs(body, keep_blank_values=True)
|
fields = parse_qs(body, keep_blank_values=True)
|
||||||
self.assertEqual(fields["isusempaymode"], ["1"])
|
self.assertEqual(fields["isusempaymode"], ["1"])
|
||||||
@@ -71,13 +116,17 @@ class TestJsdomPay(unittest.TestCase):
|
|||||||
self.assertNotIn("pay_method", fields)
|
self.assertNotIn("pay_method", fields)
|
||||||
|
|
||||||
def test_qq_risk_rejection_is_not_reported_as_qr_error(self):
|
def test_qq_risk_rejection_is_not_reported_as_qr_error(self):
|
||||||
message = describe_payment_failure("QC", "payment", "ret=1099 err_code=1099-1007-0")
|
message = describe_payment_failure(
|
||||||
|
"QC", "payment", "ret=1099 err_code=1099-1007-0"
|
||||||
|
)
|
||||||
self.assertIn("风控/限流", message)
|
self.assertIn("风控/限流", message)
|
||||||
self.assertIn("未生成微信付款码", message)
|
self.assertIn("未生成微信付款码", message)
|
||||||
self.assertIn("停止连续重试", message)
|
self.assertIn("停止连续重试", message)
|
||||||
|
|
||||||
def test_wechat_rejection_is_not_labeled_as_risk_control(self):
|
def test_wechat_rejection_is_not_labeled_as_risk_control(self):
|
||||||
message = describe_payment_failure("WX", "payment", "ret=1099 err_code=1099-1007-0")
|
message = describe_payment_failure(
|
||||||
|
"WX", "payment", "ret=1099 err_code=1099-1007-0"
|
||||||
|
)
|
||||||
self.assertIn("服务端拒绝", message)
|
self.assertIn("服务端拒绝", message)
|
||||||
self.assertNotIn("风控", message)
|
self.assertNotIn("风控", message)
|
||||||
|
|
||||||
@@ -88,17 +137,30 @@ class TestJsdomPay(unittest.TestCase):
|
|||||||
|
|
||||||
def test_qq_encrypt_rand_aligns_plaintext_to_aes_block(self):
|
def test_qq_encrypt_rand_aligns_plaintext_to_aes_block(self):
|
||||||
params = {
|
params = {
|
||||||
"token_id": "t", "openid": "o", "openkey": "k", "session_id": "openid",
|
"token_id": "t",
|
||||||
"session_type": "kp_accesstoken", "zoneid": "1", "pay_method": "wechat",
|
"openid": "o",
|
||||||
"buy_quantity": "1", "from_h5": "1", "webversion": "minipayv2",
|
"openkey": "k",
|
||||||
|
"session_id": "openid",
|
||||||
|
"session_type": "kp_accesstoken",
|
||||||
|
"zoneid": "1",
|
||||||
|
"pay_method": "wechat",
|
||||||
|
"buy_quantity": "1",
|
||||||
|
"from_h5": "1",
|
||||||
|
"webversion": "minipayv2",
|
||||||
}
|
}
|
||||||
rand_value = _MODULE.make_encrypt_rand(params, "tdrc_session%3Dpay-test", "1775990000", True)
|
rand_value = _MODULE.make_encrypt_rand(
|
||||||
plaintext = _MODULE.build_plaintext(params, "tdrc_session%3Dpay-test", "1775990000", rand_value)
|
params, "tdrc_session%3Dpay-test", "1775990000", True
|
||||||
|
)
|
||||||
|
plaintext = _MODULE.build_plaintext(
|
||||||
|
params, "tdrc_session%3Dpay-test", "1775990000", rand_value
|
||||||
|
)
|
||||||
self.assertEqual(len(plaintext.encode("latin-1")) % 16, 0)
|
self.assertEqual(len(plaintext.encode("latin-1")) % 16, 0)
|
||||||
self.assertGreaterEqual(len(rand_value), 8)
|
self.assertGreaterEqual(len(rand_value), 8)
|
||||||
|
|
||||||
def test_wechat_encrypt_rand_preserves_historical_control_byte(self):
|
def test_wechat_encrypt_rand_preserves_historical_control_byte(self):
|
||||||
rand_value = _MODULE.make_encrypt_rand({}, "tdrc_session%3Dpay-test", "1775990000", False)
|
rand_value = _MODULE.make_encrypt_rand(
|
||||||
|
{}, "tdrc_session%3Dpay-test", "1775990000", False
|
||||||
|
)
|
||||||
self.assertEqual(len(rand_value), 9)
|
self.assertEqual(len(rand_value), 9)
|
||||||
self.assertEqual(rand_value[-1], "\x01")
|
self.assertEqual(rand_value[-1], "\x01")
|
||||||
|
|
||||||
@@ -124,12 +186,26 @@ class TestJsdomPay(unittest.TestCase):
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
template = _MODULE.load_template_args()
|
template = _MODULE.load_template_args()
|
||||||
xmidas = json.loads((_MODULE.ROOT / "replay" / "xmidasops.json").read_text(encoding="utf-8"))
|
xmidas = json.loads(
|
||||||
|
(_MODULE.ROOT / "replay" / "xmidasops.json").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
params = {
|
params = {
|
||||||
"token_id": "token", "openid": "openid", "openkey": "key", "session_id": "openid",
|
"token_id": "token",
|
||||||
"session_type": "kp_accesstoken", "zoneid": "1", "pay_method": "wechat",
|
"openid": "openid",
|
||||||
"buy_quantity": "1", "mb_pwd": "", "pay_id": "", "auth_key": "", "card_value": "",
|
"openkey": "key",
|
||||||
"accounttype": "", "provide_uin": "", "extend": "", "from_h5": "1",
|
"session_id": "openid",
|
||||||
|
"session_type": "kp_accesstoken",
|
||||||
|
"zoneid": "1",
|
||||||
|
"pay_method": "wechat",
|
||||||
|
"buy_quantity": "1",
|
||||||
|
"mb_pwd": "",
|
||||||
|
"pay_id": "",
|
||||||
|
"auth_key": "",
|
||||||
|
"card_value": "",
|
||||||
|
"accounttype": "",
|
||||||
|
"provide_uin": "",
|
||||||
|
"extend": "",
|
||||||
|
"from_h5": "1",
|
||||||
"webversion": "minipayv2",
|
"webversion": "minipayv2",
|
||||||
}
|
}
|
||||||
timestamp = "1775990000"
|
timestamp = "1775990000"
|
||||||
@@ -137,16 +213,27 @@ class TestJsdomPay(unittest.TestCase):
|
|||||||
for length in range(512):
|
for length in range(512):
|
||||||
params["extend"] = "x" * length
|
params["extend"] = "x" * length
|
||||||
rand_value = _MODULE.make_encrypt_rand(params, fk_extend, timestamp, True)
|
rand_value = _MODULE.make_encrypt_rand(params, fk_extend, timestamp, True)
|
||||||
plaintext = _MODULE.build_plaintext(params, fk_extend, timestamp, rand_value)
|
plaintext = _MODULE.build_plaintext(
|
||||||
|
params, fk_extend, timestamp, rand_value
|
||||||
|
)
|
||||||
if len(plaintext.encode("latin-1")) == 528:
|
if len(plaintext.encode("latin-1")) == 528:
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
self.fail("无法构造 528B goods 测试明文")
|
self.fail("无法构造 528B goods 测试明文")
|
||||||
key16 = list(range(16))
|
key16 = list(range(16))
|
||||||
key1 = derive_key1_from_key16(key16, [template[i][0] for i in (1, 2, 3, 4)], template[5][0])
|
key1 = derive_key1_from_key16(
|
||||||
|
key16, [template[i][0] for i in (1, 2, 3, 4)], template[5][0]
|
||||||
|
)
|
||||||
ciphertext = generate_encrypt_msg_offline(
|
ciphertext = generate_encrypt_msg_offline(
|
||||||
params, fk_extend, timestamp, rand_value, key16=key16, key1=key1,
|
params,
|
||||||
args_template=template, xmidas=xmidas, xmidas_token="A" * 96,
|
fk_extend,
|
||||||
|
timestamp,
|
||||||
|
rand_value,
|
||||||
|
key16=key16,
|
||||||
|
key1=key1,
|
||||||
|
args_template=template,
|
||||||
|
xmidas=xmidas,
|
||||||
|
xmidas_token="A" * 96,
|
||||||
)
|
)
|
||||||
self.assertEqual(len(ciphertext), 1056)
|
self.assertEqual(len(ciphertext), 1056)
|
||||||
|
|
||||||
|
|||||||
+24
-8
@@ -67,8 +67,12 @@ class _SensitiveDataFilter(logging.Filter):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def redact(cls, message: str) -> str:
|
def redact(cls, message: str) -> str:
|
||||||
message = cls._HEADER_RE.sub(lambda m: f"{m.group(1)}{m.group(2)} [REDACTED]", message)
|
message = cls._HEADER_RE.sub(
|
||||||
message = cls._KEY_VALUE_RE.sub(lambda m: f"{m.group('prefix')}[REDACTED]", message)
|
lambda m: f"{m.group(1)}{m.group(2)} [REDACTED]", message
|
||||||
|
)
|
||||||
|
message = cls._KEY_VALUE_RE.sub(
|
||||||
|
lambda m: f"{m.group('prefix')}[REDACTED]", message
|
||||||
|
)
|
||||||
message = cls._BEARER_RE.sub("Bearer [REDACTED]", message)
|
message = cls._BEARER_RE.sub("Bearer [REDACTED]", message)
|
||||||
return cls._JWT_RE.sub("[REDACTED_JWT]", message)
|
return cls._JWT_RE.sub("[REDACTED_JWT]", message)
|
||||||
|
|
||||||
@@ -105,7 +109,9 @@ class _SizeAndDayRotatingFileHandler(BaseRotatingHandler):
|
|||||||
self._filename_prefix = re.sub(r"-\d{4}-\d{2}-\d{2}$", "", path.stem)
|
self._filename_prefix = re.sub(r"-\d{4}-\d{2}-\d{2}$", "", path.stem)
|
||||||
|
|
||||||
def _path_for_day(self, day) -> Path:
|
def _path_for_day(self, day) -> Path:
|
||||||
return self._log_dir / f"{self._filename_prefix}-{day.isoformat()}{self._suffix}"
|
return (
|
||||||
|
self._log_dir / f"{self._filename_prefix}-{day.isoformat()}{self._suffix}"
|
||||||
|
)
|
||||||
|
|
||||||
def shouldRollover(self, record: logging.LogRecord) -> bool: # noqa: N802
|
def shouldRollover(self, record: logging.LogRecord) -> bool: # noqa: N802
|
||||||
if datetime.now().date() != self._active_day:
|
if datetime.now().date() != self._active_day:
|
||||||
@@ -133,7 +139,9 @@ class _SizeAndDayRotatingFileHandler(BaseRotatingHandler):
|
|||||||
archive = source.with_name(f"{source.name}.{stamp}.{os.getpid()}.gz")
|
archive = source.with_name(f"{source.name}.{stamp}.{os.getpid()}.gz")
|
||||||
sequence = 1
|
sequence = 1
|
||||||
while archive.exists():
|
while archive.exists():
|
||||||
archive = source.with_name(f"{source.name}.{stamp}.{os.getpid()}.{sequence}.gz")
|
archive = source.with_name(
|
||||||
|
f"{source.name}.{stamp}.{os.getpid()}.{sequence}.gz"
|
||||||
|
)
|
||||||
sequence += 1
|
sequence += 1
|
||||||
with source.open("rb") as raw, gzip.open(archive, "wb") as compressed:
|
with source.open("rb") as raw, gzip.open(archive, "wb") as compressed:
|
||||||
shutil.copyfileobj(raw, compressed)
|
shutil.copyfileobj(raw, compressed)
|
||||||
@@ -160,7 +168,9 @@ def _configure_standard_logging(level: str, log_path: Path) -> logging.Logger:
|
|||||||
"""将业务日志和关键框架日志收敛到单一安全轮转文件。"""
|
"""将业务日志和关键框架日志收敛到单一安全轮转文件。"""
|
||||||
log_level = getattr(logging, level.upper(), logging.INFO)
|
log_level = getattr(logging, level.upper(), logging.INFO)
|
||||||
framework_level = max(log_level, logging.INFO)
|
framework_level = max(log_level, logging.INFO)
|
||||||
formatter = logging.Formatter("%(asctime)s | %(levelname)-8s | %(name)s | %(message)s")
|
formatter = logging.Formatter(
|
||||||
|
"%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
|
||||||
|
)
|
||||||
redaction_filter = _SensitiveDataFilter()
|
redaction_filter = _SensitiveDataFilter()
|
||||||
|
|
||||||
console_handler = logging.StreamHandler(sys.stdout)
|
console_handler = logging.StreamHandler(sys.stdout)
|
||||||
@@ -168,9 +178,13 @@ def _configure_standard_logging(level: str, log_path: Path) -> logging.Logger:
|
|||||||
console_handler.setFormatter(formatter)
|
console_handler.setFormatter(formatter)
|
||||||
console_handler.addFilter(redaction_filter)
|
console_handler.addFilter(redaction_filter)
|
||||||
|
|
||||||
retention_days = _parse_positive_int(os.getenv("LOG_RETENTION_DAYS"), _DEFAULT_RETENTION_DAYS)
|
retention_days = _parse_positive_int(
|
||||||
|
os.getenv("LOG_RETENTION_DAYS"), _DEFAULT_RETENTION_DAYS
|
||||||
|
)
|
||||||
rotation_size = _parse_size(os.getenv("LOG_ROTATION_SIZE"))
|
rotation_size = _parse_size(os.getenv("LOG_ROTATION_SIZE"))
|
||||||
file_handler = _SizeAndDayRotatingFileHandler(log_path, rotation_size, retention_days)
|
file_handler = _SizeAndDayRotatingFileHandler(
|
||||||
|
log_path, rotation_size, retention_days
|
||||||
|
)
|
||||||
file_handler.setLevel(log_level)
|
file_handler.setLevel(log_level)
|
||||||
file_handler.setFormatter(formatter)
|
file_handler.setFormatter(formatter)
|
||||||
file_handler.addFilter(redaction_filter)
|
file_handler.addFilter(redaction_filter)
|
||||||
@@ -199,7 +213,9 @@ def _configure_standard_logging(level: str, log_path: Path) -> logging.Logger:
|
|||||||
return app_logger
|
return app_logger
|
||||||
|
|
||||||
|
|
||||||
def setup_logger(level: str = "INFO", log_dir: str | None = None, log_file: str | None = None) -> None:
|
def setup_logger(
|
||||||
|
level: str = "INFO", log_dir: str | None = None, log_file: str | None = None
|
||||||
|
) -> None:
|
||||||
"""配置控制台和文件日志。
|
"""配置控制台和文件日志。
|
||||||
|
|
||||||
``log_dir`` 默认写入 ``app-YYYY-MM-DD.log``;``log_file`` 可指定完整文件名。环境变量
|
``log_dir`` 默认写入 ``app-YYYY-MM-DD.log``;``log_file`` 可指定完整文件名。环境变量
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ def encrypt_value(value: str | None) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def _decrypt_with_candidate(value: str, candidate: _KeyCandidate) -> str:
|
def _decrypt_with_candidate(value: str, candidate: _KeyCandidate) -> str:
|
||||||
raw = _b64d(value[len(_PREFIX):])
|
raw = _b64d(value[len(_PREFIX) :])
|
||||||
if len(raw) < 28:
|
if len(raw) < 28:
|
||||||
raise ValueError("密文字段长度无效")
|
raise ValueError("密文字段长度无效")
|
||||||
nonce = raw[:12]
|
nonce = raw[:12]
|
||||||
@@ -112,7 +112,9 @@ def decrypt_value(value: str | None) -> str | None:
|
|||||||
return _decrypt_with_candidate(value, candidate)
|
return _decrypt_with_candidate(value, candidate)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
last_error = exc
|
last_error = exc
|
||||||
raise ValueError("敏感字段解密失败,请确认 APP_ENCRYPTION_KEY 是否正确") from last_error
|
raise ValueError(
|
||||||
|
"敏感字段解密失败,请确认 APP_ENCRYPTION_KEY 是否正确"
|
||||||
|
) from last_error
|
||||||
|
|
||||||
|
|
||||||
def decrypt_value_with_key_name(value: str) -> tuple[str, str]:
|
def decrypt_value_with_key_name(value: str) -> tuple[str, str]:
|
||||||
@@ -125,7 +127,9 @@ def decrypt_value_with_key_name(value: str) -> tuple[str, str]:
|
|||||||
return _decrypt_with_candidate(value, candidate), candidate.name
|
return _decrypt_with_candidate(value, candidate), candidate.name
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
last_error = exc
|
last_error = exc
|
||||||
raise ValueError("敏感字段解密失败,请确认 APP_ENCRYPTION_KEY 是否正确") from last_error
|
raise ValueError(
|
||||||
|
"敏感字段解密失败,请确认 APP_ENCRYPTION_KEY 是否正确"
|
||||||
|
) from last_error
|
||||||
|
|
||||||
|
|
||||||
class EncryptedText(TypeDecorator):
|
class EncryptedText(TypeDecorator):
|
||||||
@@ -184,9 +188,15 @@ def encrypt_existing_sensitive_data(engine: Engine) -> int:
|
|||||||
for table_name, column_name in _SENSITIVE_COLUMNS:
|
for table_name, column_name in _SENSITIVE_COLUMNS:
|
||||||
if not _table_exists(engine, table_name):
|
if not _table_exists(engine, table_name):
|
||||||
continue
|
continue
|
||||||
rows = conn.execute(
|
rows = (
|
||||||
text(f"SELECT id, {column_name} FROM {table_name} WHERE {column_name} IS NOT NULL")
|
conn.execute(
|
||||||
).mappings().all()
|
text(
|
||||||
|
f"SELECT id, {column_name} FROM {table_name} WHERE {column_name} IS NOT NULL"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
for row in rows:
|
for row in rows:
|
||||||
raw_value = row[column_name]
|
raw_value = row[column_name]
|
||||||
if raw_value is None or raw_value == "":
|
if raw_value is None or raw_value == "":
|
||||||
@@ -204,7 +214,9 @@ def encrypt_existing_sensitive_data(engine: Engine) -> int:
|
|||||||
continue
|
continue
|
||||||
encrypted = encrypt_value(plain_value)
|
encrypted = encrypt_value(plain_value)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
text(f"UPDATE {table_name} SET {column_name} = :value WHERE id = :id"),
|
text(
|
||||||
|
f"UPDATE {table_name} SET {column_name} = :value WHERE id = :id"
|
||||||
|
),
|
||||||
{"value": encrypted, "id": row["id"]},
|
{"value": encrypted, "id": row["id"]},
|
||||||
)
|
)
|
||||||
changed += 1
|
changed += 1
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ def _get_database_url() -> str:
|
|||||||
db_user = os.getenv("DB_USER", "douyu_login").strip()
|
db_user = os.getenv("DB_USER", "douyu_login").strip()
|
||||||
db_password = os.getenv("DB_PASSWORD", "")
|
db_password = os.getenv("DB_PASSWORD", "")
|
||||||
if not db_name or not db_user or not db_password:
|
if not db_name or not db_user or not db_password:
|
||||||
raise RuntimeError("使用 DB_HOST 时必须同时设置 DB_NAME、DB_USER 和 DB_PASSWORD")
|
raise RuntimeError(
|
||||||
|
"使用 DB_HOST 时必须同时设置 DB_NAME、DB_USER 和 DB_PASSWORD"
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
f"mysql+pymysql://{quote_plus(db_user)}:{quote_plus(db_password)}"
|
f"mysql+pymysql://{quote_plus(db_user)}:{quote_plus(db_password)}"
|
||||||
f"@{db_host}:{db_port}/{db_name}?charset=utf8mb4"
|
f"@{db_host}:{db_port}/{db_name}?charset=utf8mb4"
|
||||||
@@ -69,6 +71,7 @@ engine = create_engine(DATABASE_URL, **engine_options)
|
|||||||
|
|
||||||
|
|
||||||
if DATABASE_URL.startswith("sqlite"):
|
if DATABASE_URL.startswith("sqlite"):
|
||||||
|
|
||||||
@event.listens_for(engine, "connect")
|
@event.listens_for(engine, "connect")
|
||||||
def _set_sqlite_pragmas(dbapi_connection, connection_record):
|
def _set_sqlite_pragmas(dbapi_connection, connection_record):
|
||||||
"""提升 SQLite 并发写入稳定性。"""
|
"""提升 SQLite 并发写入稳定性。"""
|
||||||
@@ -78,6 +81,7 @@ if DATABASE_URL.startswith("sqlite"):
|
|||||||
cursor.execute("PRAGMA foreign_keys=ON")
|
cursor.execute("PRAGMA foreign_keys=ON")
|
||||||
cursor.close()
|
cursor.close()
|
||||||
|
|
||||||
|
|
||||||
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
|
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
@@ -104,7 +108,9 @@ def run_migrations():
|
|||||||
from alembic.config import Config
|
from alembic.config import Config
|
||||||
|
|
||||||
config = Config(str(PROJECT_ROOT / "alembic.ini"))
|
config = Config(str(PROJECT_ROOT / "alembic.ini"))
|
||||||
config.set_main_option("script_location", str(PROJECT_ROOT / "web" / "backend" / "migrations"))
|
config.set_main_option(
|
||||||
|
"script_location", str(PROJECT_ROOT / "web" / "backend" / "migrations")
|
||||||
|
)
|
||||||
config.set_main_option("sqlalchemy.url", DATABASE_URL)
|
config.set_main_option("sqlalchemy.url", DATABASE_URL)
|
||||||
# 标记为应用内嵌调用,env.py 据此跳过 fileConfig,避免覆盖 uvicorn 日志配置。
|
# 标记为应用内嵌调用,env.py 据此跳过 fileConfig,避免覆盖 uvicorn 日志配置。
|
||||||
os.environ["ALEMBIC_EMBEDDED"] = "1"
|
os.environ["ALEMBIC_EMBEDDED"] = "1"
|
||||||
|
|||||||
+23
-3
@@ -12,7 +12,20 @@ from fastapi.responses import FileResponse
|
|||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
from .database import init_db
|
from .database import init_db
|
||||||
from .routers import auth, users, accounts, account_check, dashboard, login, proxy, cookies, huya, douyu, yyb, audit
|
from .routers import (
|
||||||
|
auth,
|
||||||
|
users,
|
||||||
|
accounts,
|
||||||
|
account_check,
|
||||||
|
dashboard,
|
||||||
|
login,
|
||||||
|
proxy,
|
||||||
|
cookies,
|
||||||
|
huya,
|
||||||
|
douyu,
|
||||||
|
yyb,
|
||||||
|
audit,
|
||||||
|
)
|
||||||
from .schemas import AppInfo
|
from .schemas import AppInfo
|
||||||
from .version import get_app_version
|
from .version import get_app_version
|
||||||
from utils import setup_logger
|
from utils import setup_logger
|
||||||
@@ -22,7 +35,9 @@ from utils import setup_logger
|
|||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# 本地 dev.sh 和 Docker 均显式设置 LOG_DIR;直接运行时回退到项目 logs/。
|
# 本地 dev.sh 和 Docker 均显式设置 LOG_DIR;直接运行时回退到项目 logs/。
|
||||||
_log_level = os.getenv("LOG_LEVEL", "INFO")
|
_log_level = os.getenv("LOG_LEVEL", "INFO")
|
||||||
_log_dir = Path(os.getenv("LOG_DIR", str(Path(__file__).resolve().parents[2] / "logs")))
|
_log_dir = Path(
|
||||||
|
os.getenv("LOG_DIR", str(Path(__file__).resolve().parents[2] / "logs"))
|
||||||
|
)
|
||||||
setup_logger(level=_log_level, log_dir=str(_log_dir))
|
setup_logger(level=_log_level, log_dir=str(_log_dir))
|
||||||
|
|
||||||
init_db()
|
init_db()
|
||||||
@@ -46,6 +61,7 @@ async def lifespan(app: FastAPI):
|
|||||||
if cleaned_relogin:
|
if cleaned_relogin:
|
||||||
logger.info(f"启动清理 CK 重登残留任务: {cleaned_relogin} 条")
|
logger.info(f"启动清理 CK 重登残留任务: {cleaned_relogin} 条")
|
||||||
from .services.yyb_service import cleanup_orphan_yyb_tasks
|
from .services.yyb_service import cleanup_orphan_yyb_tasks
|
||||||
|
|
||||||
cleaned_yyb = cleanup_orphan_yyb_tasks(db, message="任务已中断(服务重启)")
|
cleaned_yyb = cleanup_orphan_yyb_tasks(db, message="任务已中断(服务重启)")
|
||||||
if cleaned_yyb:
|
if cleaned_yyb:
|
||||||
logger.info(f"启动清理应用宝残留任务: {cleaned_yyb} 条")
|
logger.info(f"启动清理应用宝残留任务: {cleaned_yyb} 条")
|
||||||
@@ -65,7 +81,11 @@ _cors_env = os.getenv("CORS_ORIGINS", "")
|
|||||||
if _cors_env:
|
if _cors_env:
|
||||||
_cors_origins = [o.strip() for o in _cors_env.split(",") if o.strip()]
|
_cors_origins = [o.strip() for o in _cors_env.split(",") if o.strip()]
|
||||||
else:
|
else:
|
||||||
_cors_origins = ["http://localhost:5174", "http://localhost:5173", "http://localhost:3000"]
|
_cors_origins = [
|
||||||
|
"http://localhost:5174",
|
||||||
|
"http://localhost:5173",
|
||||||
|
"http://localhost:3000",
|
||||||
|
]
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ def _add_column_if_missing(bind, table_name: str, column: sa.Column) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _create_index_if_missing(bind, name: str, table_name: str, columns: list[str], unique: bool = False) -> None:
|
def _create_index_if_missing(
|
||||||
|
bind, name: str, table_name: str, columns: list[str], unique: bool = False
|
||||||
|
) -> None:
|
||||||
if name not in _indexes(bind, table_name):
|
if name not in _indexes(bind, table_name):
|
||||||
op.create_index(name, table_name, columns, unique=unique)
|
op.create_index(name, table_name, columns, unique=unique)
|
||||||
|
|
||||||
@@ -62,8 +64,12 @@ def upgrade() -> None:
|
|||||||
sa.PrimaryKeyConstraint("id"),
|
sa.PrimaryKeyConstraint("id"),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
_add_column_if_missing(bind, "users", sa.Column("custom_permissions", sa.JSON(), nullable=True))
|
_add_column_if_missing(
|
||||||
_create_index_if_missing(bind, "ix_users_username", "users", ["username"], unique=True)
|
bind, "users", sa.Column("custom_permissions", sa.JSON(), nullable=True)
|
||||||
|
)
|
||||||
|
_create_index_if_missing(
|
||||||
|
bind, "ix_users_username", "users", ["username"], unique=True
|
||||||
|
)
|
||||||
|
|
||||||
if not _has_table(bind, "accounts"):
|
if not _has_table(bind, "accounts"):
|
||||||
op.create_table(
|
op.create_table(
|
||||||
@@ -84,8 +90,14 @@ def upgrade() -> None:
|
|||||||
sa.PrimaryKeyConstraint("id"),
|
sa.PrimaryKeyConstraint("id"),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
_add_column_if_missing(bind, "accounts", sa.Column("tag", sa.String(length=64), server_default=""))
|
_add_column_if_missing(
|
||||||
added_ssl = _add_column_if_missing(bind, "accounts", sa.Column("email_imap_ssl", sa.Boolean(), server_default=sa.text("1")))
|
bind, "accounts", sa.Column("tag", sa.String(length=64), server_default="")
|
||||||
|
)
|
||||||
|
added_ssl = _add_column_if_missing(
|
||||||
|
bind,
|
||||||
|
"accounts",
|
||||||
|
sa.Column("email_imap_ssl", sa.Boolean(), server_default=sa.text("1")),
|
||||||
|
)
|
||||||
if added_ssl:
|
if added_ssl:
|
||||||
op.execute(
|
op.execute(
|
||||||
"UPDATE accounts SET email_imap_port = 143, email_imap_ssl = 0 "
|
"UPDATE accounts SET email_imap_port = 143, email_imap_ssl = 0 "
|
||||||
@@ -95,7 +107,9 @@ def upgrade() -> None:
|
|||||||
"UPDATE accounts SET email_imap_port = 143, email_imap_ssl = 0 "
|
"UPDATE accounts SET email_imap_port = 143, email_imap_ssl = 0 "
|
||||||
"WHERE email_imap_server = 'mail.bdhg.xyz'"
|
"WHERE email_imap_server = 'mail.bdhg.xyz'"
|
||||||
)
|
)
|
||||||
_create_index_if_missing(bind, "ix_accounts_assigned_to", "accounts", ["assigned_to"])
|
_create_index_if_missing(
|
||||||
|
bind, "ix_accounts_assigned_to", "accounts", ["assigned_to"]
|
||||||
|
)
|
||||||
|
|
||||||
if not _has_table(bind, "proxy_config"):
|
if not _has_table(bind, "proxy_config"):
|
||||||
op.create_table(
|
op.create_table(
|
||||||
@@ -127,7 +141,9 @@ def upgrade() -> None:
|
|||||||
sa.ForeignKeyConstraint(["created_by"], ["users.id"]),
|
sa.ForeignKeyConstraint(["created_by"], ["users.id"]),
|
||||||
sa.PrimaryKeyConstraint("id"),
|
sa.PrimaryKeyConstraint("id"),
|
||||||
)
|
)
|
||||||
_create_index_if_missing(bind, "ix_login_tasks_batch_id", "login_tasks", ["batch_id"])
|
_create_index_if_missing(
|
||||||
|
bind, "ix_login_tasks_batch_id", "login_tasks", ["batch_id"]
|
||||||
|
)
|
||||||
|
|
||||||
if not _has_table(bind, "audit_logs"):
|
if not _has_table(bind, "audit_logs"):
|
||||||
op.create_table(
|
op.create_table(
|
||||||
|
|||||||
@@ -19,16 +19,30 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
with op.batch_alter_table("accounts") as batch:
|
with op.batch_alter_table("accounts") as batch:
|
||||||
batch.alter_column("password", existing_type=sa.String(length=256), type_=sa.Text())
|
batch.alter_column(
|
||||||
batch.alter_column("email", existing_type=sa.String(length=128), type_=sa.Text())
|
"password", existing_type=sa.String(length=256), type_=sa.Text()
|
||||||
batch.alter_column("email_password", existing_type=sa.String(length=256), type_=sa.Text())
|
)
|
||||||
|
batch.alter_column(
|
||||||
|
"email", existing_type=sa.String(length=128), type_=sa.Text()
|
||||||
|
)
|
||||||
|
batch.alter_column(
|
||||||
|
"email_password", existing_type=sa.String(length=256), type_=sa.Text()
|
||||||
|
)
|
||||||
|
|
||||||
with op.batch_alter_table("proxy_config") as batch:
|
with op.batch_alter_table("proxy_config") as batch:
|
||||||
batch.alter_column("api_url", existing_type=sa.String(length=512), type_=sa.Text())
|
batch.alter_column(
|
||||||
|
"api_url", existing_type=sa.String(length=512), type_=sa.Text()
|
||||||
|
)
|
||||||
batch.alter_column("http", existing_type=sa.String(length=256), type_=sa.Text())
|
batch.alter_column("http", existing_type=sa.String(length=256), type_=sa.Text())
|
||||||
batch.alter_column("https", existing_type=sa.String(length=256), type_=sa.Text())
|
batch.alter_column(
|
||||||
batch.alter_column("whitelist_uid", existing_type=sa.String(length=64), type_=sa.Text())
|
"https", existing_type=sa.String(length=256), type_=sa.Text()
|
||||||
batch.alter_column("whitelist_ukey", existing_type=sa.String(length=128), type_=sa.Text())
|
)
|
||||||
|
batch.alter_column(
|
||||||
|
"whitelist_uid", existing_type=sa.String(length=64), type_=sa.Text()
|
||||||
|
)
|
||||||
|
batch.alter_column(
|
||||||
|
"whitelist_ukey", existing_type=sa.String(length=128), type_=sa.Text()
|
||||||
|
)
|
||||||
|
|
||||||
with op.batch_alter_table("login_tasks") as batch:
|
with op.batch_alter_table("login_tasks") as batch:
|
||||||
batch.alter_column("cookie", existing_type=sa.Text(), type_=sa.Text())
|
batch.alter_column("cookie", existing_type=sa.Text(), type_=sa.Text())
|
||||||
@@ -39,13 +53,27 @@ def downgrade() -> None:
|
|||||||
batch.alter_column("cookie", existing_type=sa.Text(), type_=sa.Text())
|
batch.alter_column("cookie", existing_type=sa.Text(), type_=sa.Text())
|
||||||
|
|
||||||
with op.batch_alter_table("proxy_config") as batch:
|
with op.batch_alter_table("proxy_config") as batch:
|
||||||
batch.alter_column("whitelist_ukey", existing_type=sa.Text(), type_=sa.String(length=128))
|
batch.alter_column(
|
||||||
batch.alter_column("whitelist_uid", existing_type=sa.Text(), type_=sa.String(length=64))
|
"whitelist_ukey", existing_type=sa.Text(), type_=sa.String(length=128)
|
||||||
batch.alter_column("https", existing_type=sa.Text(), type_=sa.String(length=256))
|
)
|
||||||
|
batch.alter_column(
|
||||||
|
"whitelist_uid", existing_type=sa.Text(), type_=sa.String(length=64)
|
||||||
|
)
|
||||||
|
batch.alter_column(
|
||||||
|
"https", existing_type=sa.Text(), type_=sa.String(length=256)
|
||||||
|
)
|
||||||
batch.alter_column("http", existing_type=sa.Text(), type_=sa.String(length=256))
|
batch.alter_column("http", existing_type=sa.Text(), type_=sa.String(length=256))
|
||||||
batch.alter_column("api_url", existing_type=sa.Text(), type_=sa.String(length=512))
|
batch.alter_column(
|
||||||
|
"api_url", existing_type=sa.Text(), type_=sa.String(length=512)
|
||||||
|
)
|
||||||
|
|
||||||
with op.batch_alter_table("accounts") as batch:
|
with op.batch_alter_table("accounts") as batch:
|
||||||
batch.alter_column("email_password", existing_type=sa.Text(), type_=sa.String(length=256))
|
batch.alter_column(
|
||||||
batch.alter_column("email", existing_type=sa.Text(), type_=sa.String(length=128))
|
"email_password", existing_type=sa.Text(), type_=sa.String(length=256)
|
||||||
batch.alter_column("password", existing_type=sa.Text(), type_=sa.String(length=256))
|
)
|
||||||
|
batch.alter_column(
|
||||||
|
"email", existing_type=sa.Text(), type_=sa.String(length=128)
|
||||||
|
)
|
||||||
|
batch.alter_column(
|
||||||
|
"password", existing_type=sa.Text(), type_=sa.String(length=256)
|
||||||
|
)
|
||||||
|
|||||||
@@ -22,9 +22,7 @@ def upgrade() -> None:
|
|||||||
batch.add_column(
|
batch.add_column(
|
||||||
sa.Column("whitelist_platform", sa.String(32), server_default="xiequ")
|
sa.Column("whitelist_platform", sa.String(32), server_default="xiequ")
|
||||||
)
|
)
|
||||||
batch.add_column(
|
batch.add_column(sa.Column("whitelist_credentials", sa.JSON(), nullable=True))
|
||||||
sa.Column("whitelist_credentials", sa.JSON(), nullable=True)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ def _indexes(bind, table_name: str) -> set[str]:
|
|||||||
return {index["name"] for index in sa.inspect(bind).get_indexes(table_name)}
|
return {index["name"] for index in sa.inspect(bind).get_indexes(table_name)}
|
||||||
|
|
||||||
|
|
||||||
def _create_index_if_missing(bind, name: str, table_name: str, columns: list[str], unique: bool = False) -> None:
|
def _create_index_if_missing(
|
||||||
|
bind, name: str, table_name: str, columns: list[str], unique: bool = False
|
||||||
|
) -> None:
|
||||||
if name not in _indexes(bind, table_name):
|
if name not in _indexes(bind, table_name):
|
||||||
op.create_index(name, table_name, columns, unique=unique)
|
op.create_index(name, table_name, columns, unique=unique)
|
||||||
|
|
||||||
@@ -59,7 +61,9 @@ def upgrade() -> None:
|
|||||||
)
|
)
|
||||||
_create_index_if_missing(bind, "ix_huya_accounts_uid", "huya_accounts", ["uid"])
|
_create_index_if_missing(bind, "ix_huya_accounts_uid", "huya_accounts", ["uid"])
|
||||||
_create_index_if_missing(bind, "ix_huya_accounts_yyuid", "huya_accounts", ["yyuid"])
|
_create_index_if_missing(bind, "ix_huya_accounts_yyuid", "huya_accounts", ["yyuid"])
|
||||||
_create_index_if_missing(bind, "ix_huya_accounts_assigned_to", "huya_accounts", ["assigned_to"])
|
_create_index_if_missing(
|
||||||
|
bind, "ix_huya_accounts_assigned_to", "huya_accounts", ["assigned_to"]
|
||||||
|
)
|
||||||
|
|
||||||
if not _has_table(bind, "huya_tasks"):
|
if not _has_table(bind, "huya_tasks"):
|
||||||
op.create_table(
|
op.create_table(
|
||||||
@@ -79,7 +83,9 @@ def upgrade() -> None:
|
|||||||
sa.PrimaryKeyConstraint("id"),
|
sa.PrimaryKeyConstraint("id"),
|
||||||
)
|
)
|
||||||
_create_index_if_missing(bind, "ix_huya_tasks_batch_id", "huya_tasks", ["batch_id"])
|
_create_index_if_missing(bind, "ix_huya_tasks_batch_id", "huya_tasks", ["batch_id"])
|
||||||
_create_index_if_missing(bind, "ix_huya_tasks_task_type", "huya_tasks", ["task_type"])
|
_create_index_if_missing(
|
||||||
|
bind, "ix_huya_tasks_task_type", "huya_tasks", ["task_type"]
|
||||||
|
)
|
||||||
|
|
||||||
if not _has_table(bind, "huya_config"):
|
if not _has_table(bind, "huya_config"):
|
||||||
op.create_table(
|
op.create_table(
|
||||||
@@ -106,13 +112,17 @@ def upgrade() -> None:
|
|||||||
sa.Column("updated_at", sa.DateTime(), nullable=True),
|
sa.Column("updated_at", sa.DateTime(), nullable=True),
|
||||||
sa.PrimaryKeyConstraint("id"),
|
sa.PrimaryKeyConstraint("id"),
|
||||||
)
|
)
|
||||||
_create_index_if_missing(bind, "ix_huya_goods_snapshot_product_id", "huya_goods_snapshot", ["product_id"])
|
_create_index_if_missing(
|
||||||
|
bind, "ix_huya_goods_snapshot_product_id", "huya_goods_snapshot", ["product_id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
bind = op.get_bind()
|
bind = op.get_bind()
|
||||||
if _has_table(bind, "huya_goods_snapshot"):
|
if _has_table(bind, "huya_goods_snapshot"):
|
||||||
op.drop_index("ix_huya_goods_snapshot_product_id", table_name="huya_goods_snapshot")
|
op.drop_index(
|
||||||
|
"ix_huya_goods_snapshot_product_id", table_name="huya_goods_snapshot"
|
||||||
|
)
|
||||||
op.drop_table("huya_goods_snapshot")
|
op.drop_table("huya_goods_snapshot")
|
||||||
if _has_table(bind, "huya_config"):
|
if _has_table(bind, "huya_config"):
|
||||||
op.drop_table("huya_config")
|
op.drop_table("huya_config")
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ def _indexes(bind, table_name: str) -> set[str]:
|
|||||||
return {index["name"] for index in sa.inspect(bind).get_indexes(table_name)}
|
return {index["name"] for index in sa.inspect(bind).get_indexes(table_name)}
|
||||||
|
|
||||||
|
|
||||||
def _create_index_if_missing(bind, name: str, table_name: str, columns: list[str], unique: bool = False) -> None:
|
def _create_index_if_missing(
|
||||||
|
bind, name: str, table_name: str, columns: list[str], unique: bool = False
|
||||||
|
) -> None:
|
||||||
if name not in _indexes(bind, table_name):
|
if name not in _indexes(bind, table_name):
|
||||||
op.create_index(name, table_name, columns, unique=unique)
|
op.create_index(name, table_name, columns, unique=unique)
|
||||||
|
|
||||||
@@ -71,7 +73,13 @@ def downgrade() -> None:
|
|||||||
if _has_table(bind, "huya_recharge_goods_snapshot"):
|
if _has_table(bind, "huya_recharge_goods_snapshot"):
|
||||||
indexes = _indexes(bind, "huya_recharge_goods_snapshot")
|
indexes = _indexes(bind, "huya_recharge_goods_snapshot")
|
||||||
if "ix_huya_recharge_goods_snapshot_sku_id" in indexes:
|
if "ix_huya_recharge_goods_snapshot_sku_id" in indexes:
|
||||||
op.drop_index("ix_huya_recharge_goods_snapshot_sku_id", table_name="huya_recharge_goods_snapshot")
|
op.drop_index(
|
||||||
|
"ix_huya_recharge_goods_snapshot_sku_id",
|
||||||
|
table_name="huya_recharge_goods_snapshot",
|
||||||
|
)
|
||||||
if "ix_huya_recharge_goods_snapshot_spu_id" in indexes:
|
if "ix_huya_recharge_goods_snapshot_spu_id" in indexes:
|
||||||
op.drop_index("ix_huya_recharge_goods_snapshot_spu_id", table_name="huya_recharge_goods_snapshot")
|
op.drop_index(
|
||||||
|
"ix_huya_recharge_goods_snapshot_spu_id",
|
||||||
|
table_name="huya_recharge_goods_snapshot",
|
||||||
|
)
|
||||||
op.drop_table("huya_recharge_goods_snapshot")
|
op.drop_table("huya_recharge_goods_snapshot")
|
||||||
|
|||||||
@@ -59,9 +59,17 @@ def upgrade() -> None:
|
|||||||
sa.PrimaryKeyConstraint("id"),
|
sa.PrimaryKeyConstraint("id"),
|
||||||
sa.UniqueConstraint("batch_id"),
|
sa.UniqueConstraint("batch_id"),
|
||||||
)
|
)
|
||||||
op.create_index("ix_huya_register_batches_batch_id", "huya_register_batches", ["batch_id"])
|
op.create_index(
|
||||||
op.create_index("ix_huya_register_batches_created_by", "huya_register_batches", ["created_by"])
|
"ix_huya_register_batches_batch_id", "huya_register_batches", ["batch_id"]
|
||||||
op.create_index("ix_huya_register_batches_status", "huya_register_batches", ["status"])
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_huya_register_batches_created_by",
|
||||||
|
"huya_register_batches",
|
||||||
|
["created_by"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_huya_register_batches_status", "huya_register_batches", ["status"]
|
||||||
|
)
|
||||||
|
|
||||||
if not _has_table(bind, "huya_register_items"):
|
if not _has_table(bind, "huya_register_items"):
|
||||||
op.create_table(
|
op.create_table(
|
||||||
@@ -91,10 +99,18 @@ def upgrade() -> None:
|
|||||||
sa.ForeignKeyConstraint(["batch_db_id"], ["huya_register_batches.id"]),
|
sa.ForeignKeyConstraint(["batch_db_id"], ["huya_register_batches.id"]),
|
||||||
sa.PrimaryKeyConstraint("id"),
|
sa.PrimaryKeyConstraint("id"),
|
||||||
)
|
)
|
||||||
op.create_index("ix_huya_register_items_batch_db_id", "huya_register_items", ["batch_db_id"])
|
op.create_index(
|
||||||
op.create_index("ix_huya_register_items_batch_id", "huya_register_items", ["batch_id"])
|
"ix_huya_register_items_batch_db_id", "huya_register_items", ["batch_db_id"]
|
||||||
op.create_index("ix_huya_register_items_phone", "huya_register_items", ["phone"])
|
)
|
||||||
op.create_index("ix_huya_register_items_status", "huya_register_items", ["status"])
|
op.create_index(
|
||||||
|
"ix_huya_register_items_batch_id", "huya_register_items", ["batch_id"]
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_huya_register_items_phone", "huya_register_items", ["phone"]
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_huya_register_items_status", "huya_register_items", ["status"]
|
||||||
|
)
|
||||||
|
|
||||||
if not _has_table(bind, "huya_register_success_logs"):
|
if not _has_table(bind, "huya_register_success_logs"):
|
||||||
op.create_table(
|
op.create_table(
|
||||||
@@ -116,10 +132,24 @@ def upgrade() -> None:
|
|||||||
sa.ForeignKeyConstraint(["created_by"], ["users.id"]),
|
sa.ForeignKeyConstraint(["created_by"], ["users.id"]),
|
||||||
sa.PrimaryKeyConstraint("id"),
|
sa.PrimaryKeyConstraint("id"),
|
||||||
)
|
)
|
||||||
op.create_index("ix_huya_register_success_logs_batch_id", "huya_register_success_logs", ["batch_id"])
|
op.create_index(
|
||||||
op.create_index("ix_huya_register_success_logs_phone", "huya_register_success_logs", ["phone"])
|
"ix_huya_register_success_logs_batch_id",
|
||||||
op.create_index("ix_huya_register_success_logs_tag", "huya_register_success_logs", ["tag"])
|
"huya_register_success_logs",
|
||||||
op.create_index("ix_huya_register_success_logs_created_at", "huya_register_success_logs", ["created_at"])
|
["batch_id"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_huya_register_success_logs_phone",
|
||||||
|
"huya_register_success_logs",
|
||||||
|
["phone"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_huya_register_success_logs_tag", "huya_register_success_logs", ["tag"]
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_huya_register_success_logs_created_at",
|
||||||
|
"huya_register_success_logs",
|
||||||
|
["created_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ def _add_column_if_missing(bind, table_name: str, column: sa.Column) -> None:
|
|||||||
op.add_column(table_name, column)
|
op.add_column(table_name, column)
|
||||||
|
|
||||||
|
|
||||||
def _create_index_if_missing(bind, name: str, table_name: str, columns: list[str], unique: bool = False) -> None:
|
def _create_index_if_missing(
|
||||||
|
bind, name: str, table_name: str, columns: list[str], unique: bool = False
|
||||||
|
) -> None:
|
||||||
if name not in _indexes(bind, table_name):
|
if name not in _indexes(bind, table_name):
|
||||||
op.create_index(name, table_name, columns, unique=unique)
|
op.create_index(name, table_name, columns, unique=unique)
|
||||||
|
|
||||||
@@ -46,16 +48,40 @@ def _create_index_if_missing(bind, name: str, table_name: str, columns: list[str
|
|||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
bind = op.get_bind()
|
bind = op.get_bind()
|
||||||
|
|
||||||
_add_column_if_missing(bind, "accounts", sa.Column("uid", sa.String(length=32), nullable=True))
|
_add_column_if_missing(
|
||||||
_add_column_if_missing(bind, "accounts", sa.Column("nickname", sa.String(length=128), nullable=True))
|
bind, "accounts", sa.Column("uid", sa.String(length=32), nullable=True)
|
||||||
_add_column_if_missing(bind, "accounts", sa.Column("points", sa.Integer(), nullable=True))
|
)
|
||||||
_add_column_if_missing(bind, "accounts", sa.Column("game_name", sa.String(length=128), nullable=True))
|
_add_column_if_missing(
|
||||||
_add_column_if_missing(bind, "accounts", sa.Column("game_channel", sa.String(length=128), nullable=True))
|
bind, "accounts", sa.Column("nickname", sa.String(length=128), nullable=True)
|
||||||
_add_column_if_missing(bind, "accounts", sa.Column("gold_balance", sa.Integer(), nullable=True))
|
)
|
||||||
_add_column_if_missing(bind, "accounts", sa.Column("exchange_balance", sa.Integer(), nullable=True))
|
_add_column_if_missing(
|
||||||
_add_column_if_missing(bind, "accounts", sa.Column("bind_status", sa.String(length=32), nullable=True))
|
bind, "accounts", sa.Column("points", sa.Integer(), nullable=True)
|
||||||
_add_column_if_missing(bind, "accounts", sa.Column("change_role_wait_time", sa.Integer(), nullable=True))
|
)
|
||||||
_add_column_if_missing(bind, "accounts", sa.Column("updated_at", sa.DateTime(), nullable=True))
|
_add_column_if_missing(
|
||||||
|
bind, "accounts", sa.Column("game_name", sa.String(length=128), nullable=True)
|
||||||
|
)
|
||||||
|
_add_column_if_missing(
|
||||||
|
bind,
|
||||||
|
"accounts",
|
||||||
|
sa.Column("game_channel", sa.String(length=128), nullable=True),
|
||||||
|
)
|
||||||
|
_add_column_if_missing(
|
||||||
|
bind, "accounts", sa.Column("gold_balance", sa.Integer(), nullable=True)
|
||||||
|
)
|
||||||
|
_add_column_if_missing(
|
||||||
|
bind, "accounts", sa.Column("exchange_balance", sa.Integer(), nullable=True)
|
||||||
|
)
|
||||||
|
_add_column_if_missing(
|
||||||
|
bind, "accounts", sa.Column("bind_status", sa.String(length=32), nullable=True)
|
||||||
|
)
|
||||||
|
_add_column_if_missing(
|
||||||
|
bind,
|
||||||
|
"accounts",
|
||||||
|
sa.Column("change_role_wait_time", sa.Integer(), nullable=True),
|
||||||
|
)
|
||||||
|
_add_column_if_missing(
|
||||||
|
bind, "accounts", sa.Column("updated_at", sa.DateTime(), nullable=True)
|
||||||
|
)
|
||||||
_create_index_if_missing(bind, "ix_accounts_uid", "accounts", ["uid"])
|
_create_index_if_missing(bind, "ix_accounts_uid", "accounts", ["uid"])
|
||||||
|
|
||||||
if not _has_table(bind, "douyu_tasks"):
|
if not _has_table(bind, "douyu_tasks"):
|
||||||
@@ -75,8 +101,12 @@ def upgrade() -> None:
|
|||||||
sa.ForeignKeyConstraint(["created_by"], ["users.id"]),
|
sa.ForeignKeyConstraint(["created_by"], ["users.id"]),
|
||||||
sa.PrimaryKeyConstraint("id"),
|
sa.PrimaryKeyConstraint("id"),
|
||||||
)
|
)
|
||||||
_create_index_if_missing(bind, "ix_douyu_tasks_batch_id", "douyu_tasks", ["batch_id"])
|
_create_index_if_missing(
|
||||||
_create_index_if_missing(bind, "ix_douyu_tasks_task_type", "douyu_tasks", ["task_type"])
|
bind, "ix_douyu_tasks_batch_id", "douyu_tasks", ["batch_id"]
|
||||||
|
)
|
||||||
|
_create_index_if_missing(
|
||||||
|
bind, "ix_douyu_tasks_task_type", "douyu_tasks", ["task_type"]
|
||||||
|
)
|
||||||
|
|
||||||
if not _has_table(bind, "douyu_config"):
|
if not _has_table(bind, "douyu_config"):
|
||||||
op.create_table(
|
op.create_table(
|
||||||
@@ -121,7 +151,10 @@ def downgrade() -> None:
|
|||||||
if _has_table(bind, "douyu_goods_snapshot"):
|
if _has_table(bind, "douyu_goods_snapshot"):
|
||||||
indexes = _indexes(bind, "douyu_goods_snapshot")
|
indexes = _indexes(bind, "douyu_goods_snapshot")
|
||||||
if "ix_douyu_goods_snapshot_commodity_id" in indexes:
|
if "ix_douyu_goods_snapshot_commodity_id" in indexes:
|
||||||
op.drop_index("ix_douyu_goods_snapshot_commodity_id", table_name="douyu_goods_snapshot")
|
op.drop_index(
|
||||||
|
"ix_douyu_goods_snapshot_commodity_id",
|
||||||
|
table_name="douyu_goods_snapshot",
|
||||||
|
)
|
||||||
op.drop_table("douyu_goods_snapshot")
|
op.drop_table("douyu_goods_snapshot")
|
||||||
if _has_table(bind, "douyu_config"):
|
if _has_table(bind, "douyu_config"):
|
||||||
op.drop_table("douyu_config")
|
op.drop_table("douyu_config")
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ def _indexes(bind, table_name: str) -> set[str]:
|
|||||||
return {index["name"] for index in sa.inspect(bind).get_indexes(table_name)}
|
return {index["name"] for index in sa.inspect(bind).get_indexes(table_name)}
|
||||||
|
|
||||||
|
|
||||||
def _create_index_if_missing(bind, name: str, table_name: str, columns: list[str]) -> None:
|
def _create_index_if_missing(
|
||||||
|
bind, name: str, table_name: str, columns: list[str]
|
||||||
|
) -> None:
|
||||||
if name not in _indexes(bind, table_name):
|
if name not in _indexes(bind, table_name):
|
||||||
op.create_index(name, table_name, columns)
|
op.create_index(name, table_name, columns)
|
||||||
|
|
||||||
@@ -50,8 +52,16 @@ INDEXES = [
|
|||||||
("ix_huya_tasks_account_id", "huya_tasks", ["account_id", "id"]),
|
("ix_huya_tasks_account_id", "huya_tasks", ["account_id", "id"]),
|
||||||
("ix_huya_tasks_created_by_id", "huya_tasks", ["created_by", "id"]),
|
("ix_huya_tasks_created_by_id", "huya_tasks", ["created_by", "id"]),
|
||||||
("ix_huya_tasks_batch_status_id", "huya_tasks", ["batch_id", "status", "id"]),
|
("ix_huya_tasks_batch_status_id", "huya_tasks", ["batch_id", "status", "id"]),
|
||||||
("ix_huya_register_items_batch_db_line", "huya_register_items", ["batch_db_id", "line"]),
|
(
|
||||||
("ix_huya_register_success_logs_batch_id_id", "huya_register_success_logs", ["batch_id", "id"]),
|
"ix_huya_register_items_batch_db_line",
|
||||||
|
"huya_register_items",
|
||||||
|
["batch_db_id", "line"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ix_huya_register_success_logs_batch_id_id",
|
||||||
|
"huya_register_success_logs",
|
||||||
|
["batch_id", "id"],
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ def upgrade() -> None:
|
|||||||
bind = op.get_bind()
|
bind = op.get_bind()
|
||||||
columns = {column["name"] for column in sa.inspect(bind).get_columns("accounts")}
|
columns = {column["name"] for column in sa.inspect(bind).get_columns("accounts")}
|
||||||
if "esports_can_change_time" not in columns:
|
if "esports_can_change_time" not in columns:
|
||||||
op.add_column("accounts", sa.Column("esports_can_change_time", sa.Integer(), nullable=True))
|
op.add_column(
|
||||||
|
"accounts",
|
||||||
|
sa.Column("esports_can_change_time", sa.Integer(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ def upgrade() -> None:
|
|||||||
return
|
return
|
||||||
columns = {column["name"] for column in sa.inspect(bind).get_columns("accounts")}
|
columns = {column["name"] for column in sa.inspect(bind).get_columns("accounts")}
|
||||||
if "xpd_fragments" not in columns:
|
if "xpd_fragments" not in columns:
|
||||||
op.add_column("accounts", sa.Column("xpd_fragments", sa.Integer(), nullable=True))
|
op.add_column(
|
||||||
|
"accounts", sa.Column("xpd_fragments", sa.Integer(), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -56,10 +56,8 @@ def upgrade() -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
for index in range(0, len(delete_ids), 500):
|
for index in range(0, len(delete_ids), 500):
|
||||||
chunk = delete_ids[index:index + 500]
|
chunk = delete_ids[index : index + 500]
|
||||||
bind.execute(
|
bind.execute(task_table.delete().where(task_table.c.id.in_(chunk)))
|
||||||
task_table.delete().where(task_table.c.id.in_(chunk))
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ def upgrade() -> None:
|
|||||||
"yyb_recharge_tasks",
|
"yyb_recharge_tasks",
|
||||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
sa.Column("task_id", sa.String(64), nullable=False),
|
sa.Column("task_id", sa.String(64), nullable=False),
|
||||||
sa.Column("created_by", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
|
sa.Column(
|
||||||
|
"created_by", sa.Integer(), sa.ForeignKey("users.id"), nullable=False
|
||||||
|
),
|
||||||
sa.Column("worker_job_id", sa.String(64), nullable=False),
|
sa.Column("worker_job_id", sa.String(64), nullable=False),
|
||||||
sa.Column("provider", sa.String(16), nullable=False, server_default=""),
|
sa.Column("provider", sa.String(16), nullable=False, server_default=""),
|
||||||
sa.Column("platform", sa.String(16), nullable=False, server_default="android"),
|
sa.Column("platform", sa.String(16), nullable=False, server_default="android"),
|
||||||
@@ -37,9 +39,15 @@ def upgrade() -> None:
|
|||||||
sa.Column("created_at", sa.DateTime(), nullable=True),
|
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||||
sa.Column("finished_at", sa.DateTime(), nullable=True),
|
sa.Column("finished_at", sa.DateTime(), nullable=True),
|
||||||
)
|
)
|
||||||
op.create_index("uq_yyb_recharge_tasks_task_id", "yyb_recharge_tasks", ["task_id"], unique=True)
|
op.create_index(
|
||||||
op.create_index("ix_yyb_recharge_tasks_created_by", "yyb_recharge_tasks", ["created_by"])
|
"uq_yyb_recharge_tasks_task_id", "yyb_recharge_tasks", ["task_id"], unique=True
|
||||||
op.create_index("ix_yyb_recharge_tasks_worker_job_id", "yyb_recharge_tasks", ["worker_job_id"])
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_yyb_recharge_tasks_created_by", "yyb_recharge_tasks", ["created_by"]
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_yyb_recharge_tasks_worker_job_id", "yyb_recharge_tasks", ["worker_job_id"]
|
||||||
|
)
|
||||||
op.create_index("ix_yyb_recharge_tasks_status", "yyb_recharge_tasks", ["status"])
|
op.create_index("ix_yyb_recharge_tasks_status", "yyb_recharge_tasks", ["status"])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,12 @@ def _is_mysql() -> bool:
|
|||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
if _is_mysql():
|
if _is_mysql():
|
||||||
op.execute("ALTER TABLE yyb_recharge_tasks MODIFY login_qr_data MEDIUMTEXT NULL")
|
op.execute(
|
||||||
op.execute("ALTER TABLE yyb_recharge_tasks MODIFY payment_qr_data MEDIUMTEXT NULL")
|
"ALTER TABLE yyb_recharge_tasks MODIFY login_qr_data MEDIUMTEXT NULL"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE yyb_recharge_tasks MODIFY payment_qr_data MEDIUMTEXT NULL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -13,10 +13,21 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
op.add_column("yyb_recharge_tasks", sa.Column("price_fen", sa.Integer(), nullable=True))
|
op.add_column(
|
||||||
op.add_column("yyb_recharge_tasks", sa.Column("payment_started_at", sa.DateTime(), nullable=True))
|
"yyb_recharge_tasks", sa.Column("price_fen", sa.Integer(), nullable=True)
|
||||||
op.add_column("yyb_recharge_tasks", sa.Column("payment_qr_created_at", sa.DateTime(), nullable=True))
|
)
|
||||||
op.add_column("yyb_recharge_tasks", sa.Column("payment_last_checked_at", sa.DateTime(), nullable=True))
|
op.add_column(
|
||||||
|
"yyb_recharge_tasks",
|
||||||
|
sa.Column("payment_started_at", sa.DateTime(), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"yyb_recharge_tasks",
|
||||||
|
sa.Column("payment_qr_created_at", sa.DateTime(), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"yyb_recharge_tasks",
|
||||||
|
sa.Column("payment_last_checked_at", sa.DateTime(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ def upgrade() -> None:
|
|||||||
op.add_column("users", sa.Column("deleted_at", sa.DateTime(), nullable=True))
|
op.add_column("users", sa.Column("deleted_at", sa.DateTime(), nullable=True))
|
||||||
op.create_index("ix_users_deleted_at", "users", ["deleted_at"])
|
op.create_index("ix_users_deleted_at", "users", ["deleted_at"])
|
||||||
if "deleted_username" not in columns:
|
if "deleted_username" not in columns:
|
||||||
op.add_column("users", sa.Column("deleted_username", sa.String(length=64), nullable=True))
|
op.add_column(
|
||||||
|
"users", sa.Column("deleted_username", sa.String(length=64), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -26,35 +26,71 @@ def upgrade() -> None:
|
|||||||
if "handbook_scope" not in _columns(bind, "douyu_tasks"):
|
if "handbook_scope" not in _columns(bind, "douyu_tasks"):
|
||||||
op.add_column(
|
op.add_column(
|
||||||
"douyu_tasks",
|
"douyu_tasks",
|
||||||
sa.Column("handbook_scope", sa.String(length=16), nullable=False, server_default="legacy"),
|
sa.Column(
|
||||||
|
"handbook_scope",
|
||||||
|
sa.String(length=16),
|
||||||
|
nullable=False,
|
||||||
|
server_default="legacy",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_douyu_tasks_handbook_scope", "douyu_tasks", ["handbook_scope"]
|
||||||
)
|
)
|
||||||
op.create_index("ix_douyu_tasks_handbook_scope", "douyu_tasks", ["handbook_scope"])
|
|
||||||
|
|
||||||
if not sa.inspect(bind).has_table("douyu_workbench_accounts"):
|
if not sa.inspect(bind).has_table("douyu_workbench_accounts"):
|
||||||
op.create_table(
|
op.create_table(
|
||||||
"douyu_workbench_accounts",
|
"douyu_workbench_accounts",
|
||||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
|
sa.Column(
|
||||||
|
"user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False
|
||||||
|
),
|
||||||
sa.Column("handbook_scope", sa.String(length=16), nullable=False),
|
sa.Column("handbook_scope", sa.String(length=16), nullable=False),
|
||||||
sa.Column("account_id", sa.Integer(), sa.ForeignKey("accounts.id"), nullable=False),
|
sa.Column(
|
||||||
|
"account_id", sa.Integer(), sa.ForeignKey("accounts.id"), nullable=False
|
||||||
|
),
|
||||||
sa.Column("created_at", sa.DateTime(), nullable=True),
|
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||||
sa.UniqueConstraint("user_id", "handbook_scope", "account_id", name="uq_douyu_workbench_account"),
|
sa.UniqueConstraint(
|
||||||
|
"user_id",
|
||||||
|
"handbook_scope",
|
||||||
|
"account_id",
|
||||||
|
name="uq_douyu_workbench_account",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_douyu_workbench_accounts_user_id",
|
||||||
|
"douyu_workbench_accounts",
|
||||||
|
["user_id"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_douyu_workbench_accounts_handbook_scope",
|
||||||
|
"douyu_workbench_accounts",
|
||||||
|
["handbook_scope"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_douyu_workbench_accounts_account_id",
|
||||||
|
"douyu_workbench_accounts",
|
||||||
|
["account_id"],
|
||||||
)
|
)
|
||||||
op.create_index("ix_douyu_workbench_accounts_user_id", "douyu_workbench_accounts", ["user_id"])
|
|
||||||
op.create_index("ix_douyu_workbench_accounts_handbook_scope", "douyu_workbench_accounts", ["handbook_scope"])
|
|
||||||
op.create_index("ix_douyu_workbench_accounts_account_id", "douyu_workbench_accounts", ["account_id"])
|
|
||||||
|
|
||||||
if not sa.inspect(bind).has_table("douyu_workbenches"):
|
if not sa.inspect(bind).has_table("douyu_workbenches"):
|
||||||
op.create_table(
|
op.create_table(
|
||||||
"douyu_workbenches",
|
"douyu_workbenches",
|
||||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
|
sa.Column(
|
||||||
|
"user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False
|
||||||
|
),
|
||||||
sa.Column("handbook_scope", sa.String(length=16), nullable=False),
|
sa.Column("handbook_scope", sa.String(length=16), nullable=False),
|
||||||
sa.Column("updated_at", sa.DateTime(), nullable=True),
|
sa.Column("updated_at", sa.DateTime(), nullable=True),
|
||||||
sa.UniqueConstraint("user_id", "handbook_scope", name="uq_douyu_workbench"),
|
sa.UniqueConstraint("user_id", "handbook_scope", name="uq_douyu_workbench"),
|
||||||
)
|
)
|
||||||
op.create_index("ix_douyu_workbenches_user_id", "douyu_workbenches", ["user_id"])
|
op.create_index(
|
||||||
op.create_index("ix_douyu_workbenches_handbook_scope", "douyu_workbenches", ["handbook_scope"])
|
"ix_douyu_workbenches_user_id", "douyu_workbenches", ["user_id"]
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_douyu_workbenches_handbook_scope",
|
||||||
|
"douyu_workbenches",
|
||||||
|
["handbook_scope"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ def upgrade() -> None:
|
|||||||
sa.UniqueConstraint("user_id", "handbook_scope", name="uq_douyu_workbench"),
|
sa.UniqueConstraint("user_id", "handbook_scope", name="uq_douyu_workbench"),
|
||||||
)
|
)
|
||||||
op.create_index("ix_douyu_workbenches_user_id", "douyu_workbenches", ["user_id"])
|
op.create_index("ix_douyu_workbenches_user_id", "douyu_workbenches", ["user_id"])
|
||||||
op.create_index("ix_douyu_workbenches_handbook_scope", "douyu_workbenches", ["handbook_scope"])
|
op.create_index(
|
||||||
|
"ix_douyu_workbenches_handbook_scope", "douyu_workbenches", ["handbook_scope"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -27,9 +27,33 @@ def upgrade() -> None:
|
|||||||
return
|
return
|
||||||
columns = _columns(bind)
|
columns = _columns(bind)
|
||||||
additions = (
|
additions = (
|
||||||
("gold_recharge_channel", sa.Column("gold_recharge_channel", sa.String(length=16), nullable=True, server_default="wechat_qr")),
|
(
|
||||||
("gold_api_product_id", sa.Column("gold_api_product_id", sa.String(length=128), nullable=True, server_default="111570")),
|
"gold_recharge_channel",
|
||||||
("gold_api_account_template_name", sa.Column("gold_api_account_template_name", sa.String(length=64), nullable=True, server_default="斗鱼UID")),
|
sa.Column(
|
||||||
|
"gold_recharge_channel",
|
||||||
|
sa.String(length=16),
|
||||||
|
nullable=True,
|
||||||
|
server_default="wechat_qr",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"gold_api_product_id",
|
||||||
|
sa.Column(
|
||||||
|
"gold_api_product_id",
|
||||||
|
sa.String(length=128),
|
||||||
|
nullable=True,
|
||||||
|
server_default="111570",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"gold_api_account_template_name",
|
||||||
|
sa.Column(
|
||||||
|
"gold_api_account_template_name",
|
||||||
|
sa.String(length=64),
|
||||||
|
nullable=True,
|
||||||
|
server_default="斗鱼UID",
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
for name, column in additions:
|
for name, column in additions:
|
||||||
if name not in columns:
|
if name not in columns:
|
||||||
@@ -41,6 +65,10 @@ def downgrade() -> None:
|
|||||||
if not sa.inspect(bind).has_table("douyu_config"):
|
if not sa.inspect(bind).has_table("douyu_config"):
|
||||||
return
|
return
|
||||||
columns = _columns(bind)
|
columns = _columns(bind)
|
||||||
for name in ("gold_api_account_template_name", "gold_api_product_id", "gold_recharge_channel"):
|
for name in (
|
||||||
|
"gold_api_account_template_name",
|
||||||
|
"gold_api_product_id",
|
||||||
|
"gold_recharge_channel",
|
||||||
|
):
|
||||||
if name in columns:
|
if name in columns:
|
||||||
op.drop_column("douyu_config", name)
|
op.drop_column("douyu_config", name)
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ def upgrade() -> None:
|
|||||||
return
|
return
|
||||||
columns = {column["name"] for column in inspector.get_columns("douyu_tasks")}
|
columns = {column["name"] for column in inspector.get_columns("douyu_tasks")}
|
||||||
if "supplier_out_order_id" not in columns:
|
if "supplier_out_order_id" not in columns:
|
||||||
op.add_column("douyu_tasks", sa.Column("supplier_out_order_id", sa.String(length=64), nullable=True))
|
op.add_column(
|
||||||
|
"douyu_tasks",
|
||||||
|
sa.Column("supplier_out_order_id", sa.String(length=64), nullable=True),
|
||||||
|
)
|
||||||
op.create_index(
|
op.create_index(
|
||||||
"ix_douyu_tasks_supplier_out_order_id",
|
"ix_douyu_tasks_supplier_out_order_id",
|
||||||
"douyu_tasks",
|
"douyu_tasks",
|
||||||
|
|||||||
@@ -25,10 +25,17 @@ def upgrade() -> None:
|
|||||||
return
|
return
|
||||||
columns = {column["name"] for column in inspector.get_columns("huya_accounts")}
|
columns = {column["name"] for column in inspector.get_columns("huya_accounts")}
|
||||||
if "login_channel" not in columns:
|
if "login_channel" not in columns:
|
||||||
op.add_column("huya_accounts", sa.Column("login_channel", sa.String(16), nullable=False, server_default=""))
|
op.add_column(
|
||||||
|
"huya_accounts",
|
||||||
|
sa.Column(
|
||||||
|
"login_channel", sa.String(16), nullable=False, server_default=""
|
||||||
|
),
|
||||||
|
)
|
||||||
indexes = {index["name"] for index in inspector.get_indexes("huya_accounts")}
|
indexes = {index["name"] for index in inspector.get_indexes("huya_accounts")}
|
||||||
if "ix_huya_accounts_login_channel" not in indexes:
|
if "ix_huya_accounts_login_channel" not in indexes:
|
||||||
op.create_index("ix_huya_accounts_login_channel", "huya_accounts", ["login_channel"])
|
op.create_index(
|
||||||
|
"ix_huya_accounts_login_channel", "huya_accounts", ["login_channel"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -27,8 +27,16 @@ INDEXES = (
|
|||||||
["handbook_scope", "task_type", "id"],
|
["handbook_scope", "task_type", "id"],
|
||||||
),
|
),
|
||||||
# Supports terminal-task retention and finished-time ordered Cookie views.
|
# Supports terminal-task retention and finished-time ordered Cookie views.
|
||||||
("ix_douyu_tasks_status_finished_at_id", "douyu_tasks", ["status", "finished_at", "id"]),
|
(
|
||||||
("ix_login_tasks_status_finished_at_id", "login_tasks", ["status", "finished_at", "id"]),
|
"ix_douyu_tasks_status_finished_at_id",
|
||||||
|
"douyu_tasks",
|
||||||
|
["status", "finished_at", "id"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ix_login_tasks_status_finished_at_id",
|
||||||
|
"login_tasks",
|
||||||
|
["status", "finished_at", "id"],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,10 @@ def _audit_log_out(db: Session, row: AuditLog) -> dict:
|
|||||||
accounts = (
|
accounts = (
|
||||||
db.query(DouyuTask, Account)
|
db.query(DouyuTask, Account)
|
||||||
.join(Account, Account.id == DouyuTask.account_id)
|
.join(Account, Account.id == DouyuTask.account_id)
|
||||||
.filter(DouyuTask.batch_id == batch_id, DouyuTask.task_type == "create_gold_qr")
|
.filter(
|
||||||
|
DouyuTask.batch_id == batch_id,
|
||||||
|
DouyuTask.task_type == "create_gold_qr",
|
||||||
|
)
|
||||||
.order_by(DouyuTask.id.asc())
|
.order_by(DouyuTask.id.asc())
|
||||||
.limit(100)
|
.limit(100)
|
||||||
.all()
|
.all()
|
||||||
@@ -56,14 +59,19 @@ def _audit_log_out(db: Session, row: AuditLog) -> dict:
|
|||||||
batch_id = row.target.removeprefix("douyu_batch:")
|
batch_id = row.target.removeprefix("douyu_batch:")
|
||||||
task = (
|
task = (
|
||||||
db.query(DouyuTask)
|
db.query(DouyuTask)
|
||||||
.filter(DouyuTask.batch_id == batch_id, DouyuTask.task_type == "create_gold_qr")
|
.filter(
|
||||||
|
DouyuTask.batch_id == batch_id,
|
||||||
|
DouyuTask.task_type == "create_gold_qr",
|
||||||
|
)
|
||||||
.order_by(DouyuTask.id.asc())
|
.order_by(DouyuTask.id.asc())
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
task_result = task.result if task and isinstance(task.result, dict) else {}
|
task_result = task.result if task and isinstance(task.result, dict) else {}
|
||||||
recharge_channel = str(task_result.get("recharge_channel") or "wechat_qr")
|
recharge_channel = str(task_result.get("recharge_channel") or "wechat_qr")
|
||||||
detail["recharge_channel"] = recharge_channel
|
detail["recharge_channel"] = recharge_channel
|
||||||
detail["payment_method"] = "API 直充支付" if recharge_channel == "supplier_api" else "微信扫码支付"
|
detail["payment_method"] = (
|
||||||
|
"API 直充支付" if recharge_channel == "supplier_api" else "微信扫码支付"
|
||||||
|
)
|
||||||
if isinstance(detail, dict):
|
if isinstance(detail, dict):
|
||||||
detail_text = json.dumps(detail, ensure_ascii=False, separators=(",", ":"))
|
detail_text = json.dumps(detail, ensure_ascii=False, separators=(",", ":"))
|
||||||
return {
|
return {
|
||||||
@@ -99,7 +107,9 @@ def list_audit_logs(
|
|||||||
query = query.filter(AuditLog.action == action.strip())
|
query = query.filter(AuditLog.action == action.strip())
|
||||||
if keyword:
|
if keyword:
|
||||||
pattern = f"%{keyword.strip()}%"
|
pattern = f"%{keyword.strip()}%"
|
||||||
query = query.filter((AuditLog.target.ilike(pattern)) | (AuditLog.detail.ilike(pattern)))
|
query = query.filter(
|
||||||
|
(AuditLog.target.ilike(pattern)) | (AuditLog.detail.ilike(pattern))
|
||||||
|
)
|
||||||
if success is not None:
|
if success is not None:
|
||||||
query = query.filter(AuditLog.success == success)
|
query = query.filter(AuditLog.success == success)
|
||||||
if start_time:
|
if start_time:
|
||||||
@@ -107,7 +117,12 @@ def list_audit_logs(
|
|||||||
if end_time:
|
if end_time:
|
||||||
query = query.filter(AuditLog.created_at <= end_time)
|
query = query.filter(AuditLog.created_at <= end_time)
|
||||||
total = query.count()
|
total = query.count()
|
||||||
rows = query.order_by(AuditLog.id.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
rows = (
|
||||||
|
query.order_by(AuditLog.id.desc())
|
||||||
|
.offset((page - 1) * page_size)
|
||||||
|
.limit(page_size)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"items": [_audit_log_out(db, row) for row in rows],
|
"items": [_audit_log_out(db, row) for row in rows],
|
||||||
"total": total,
|
"total": total,
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ def login(req: LoginRequest, response: Response, db: Session = Depends(get_db)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 审计
|
# 审计
|
||||||
db.add(AuditLog(user_id=user.id, username=user.username, action="login", target="auth"))
|
db.add(
|
||||||
|
AuditLog(user_id=user.id, username=user.username, action="login", target="auth")
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return TokenResponse(
|
return TokenResponse(
|
||||||
@@ -66,8 +68,19 @@ def me(current_user: User = Depends(get_current_user)):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/logout")
|
@router.post("/logout")
|
||||||
def logout(response: Response, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
def logout(
|
||||||
|
response: Response,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
response.delete_cookie(key="access_token", path="/")
|
response.delete_cookie(key="access_token", path="/")
|
||||||
db.add(AuditLog(user_id=current_user.id, username=current_user.username, action="logout", target="auth"))
|
db.add(
|
||||||
|
AuditLog(
|
||||||
|
user_id=current_user.id,
|
||||||
|
username=current_user.username,
|
||||||
|
action="logout",
|
||||||
|
target="auth",
|
||||||
|
)
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "已登出"}
|
return {"message": "已登出"}
|
||||||
|
|||||||
@@ -44,7 +44,9 @@ def _empty_task_summary() -> dict:
|
|||||||
|
|
||||||
def _can_view_huya_all(user: User) -> bool:
|
def _can_view_huya_all(user: User) -> bool:
|
||||||
"""兼容旧 huya:account 全量权限。"""
|
"""兼容旧 huya:account 全量权限。"""
|
||||||
return user_has_permission(user, "huya:view_all") or user_has_permission(user, "huya:account")
|
return user_has_permission(user, "huya:view_all") or user_has_permission(
|
||||||
|
user, "huya:account"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/summary")
|
@router.get("/summary")
|
||||||
@@ -65,12 +67,17 @@ def dashboard_summary(
|
|||||||
)
|
)
|
||||||
|
|
||||||
login_tasks = _empty_task_summary()
|
login_tasks = _empty_task_summary()
|
||||||
if any(user_has_permission(current, permission) for permission in (
|
if any(
|
||||||
"login:batch",
|
user_has_permission(current, permission)
|
||||||
"login:view_all",
|
for permission in (
|
||||||
"login:view_assigned",
|
"login:batch",
|
||||||
)):
|
"login:view_all",
|
||||||
login_query = db.query(LoginTask).join(Account, LoginTask.account_id == Account.id)
|
"login:view_assigned",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
login_query = db.query(LoginTask).join(
|
||||||
|
Account, LoginTask.account_id == Account.id
|
||||||
|
)
|
||||||
if not user_has_permission(current, "login:view_all"):
|
if not user_has_permission(current, "login:view_all"):
|
||||||
login_query = login_query.filter(Account.assigned_to == current.id)
|
login_query = login_query.filter(Account.assigned_to == current.id)
|
||||||
login_tasks = _task_summary(login_query, LoginTask)
|
login_tasks = _task_summary(login_query, LoginTask)
|
||||||
@@ -87,27 +94,40 @@ def dashboard_summary(
|
|||||||
cookies = cookie_query.with_entities(func.count(LoginTask.id)).scalar() or 0
|
cookies = cookie_query.with_entities(func.count(LoginTask.id)).scalar() or 0
|
||||||
|
|
||||||
huya_accounts = 0
|
huya_accounts = 0
|
||||||
can_view_huya_accounts = any(user_has_permission(current, permission) for permission in (
|
can_view_huya_accounts = any(
|
||||||
"huya:account",
|
user_has_permission(current, permission)
|
||||||
"huya:view_all",
|
for permission in (
|
||||||
"huya:view_assigned",
|
"huya:account",
|
||||||
))
|
"huya:view_all",
|
||||||
|
"huya:view_assigned",
|
||||||
|
)
|
||||||
|
)
|
||||||
if can_view_huya_accounts:
|
if can_view_huya_accounts:
|
||||||
huya_account_query = db.query(HuyaAccount)
|
huya_account_query = db.query(HuyaAccount)
|
||||||
if not _can_view_huya_all(current):
|
if not _can_view_huya_all(current):
|
||||||
huya_account_query = huya_account_query.filter(HuyaAccount.assigned_to == current.id)
|
huya_account_query = huya_account_query.filter(
|
||||||
huya_accounts = huya_account_query.with_entities(func.count(HuyaAccount.id)).scalar() or 0
|
HuyaAccount.assigned_to == current.id
|
||||||
|
)
|
||||||
|
huya_accounts = (
|
||||||
|
huya_account_query.with_entities(func.count(HuyaAccount.id)).scalar() or 0
|
||||||
|
)
|
||||||
|
|
||||||
huya_tasks = _empty_task_summary()
|
huya_tasks = _empty_task_summary()
|
||||||
huya_goods = 0
|
huya_goods = 0
|
||||||
huya_recharge_goods = 0
|
huya_recharge_goods = 0
|
||||||
if user_has_permission(current, "huya:task"):
|
if user_has_permission(current, "huya:task"):
|
||||||
huya_task_query = db.query(HuyaTask).join(HuyaAccount, HuyaTask.account_id == HuyaAccount.id)
|
huya_task_query = db.query(HuyaTask).join(
|
||||||
|
HuyaAccount, HuyaTask.account_id == HuyaAccount.id
|
||||||
|
)
|
||||||
if not _can_view_huya_all(current):
|
if not _can_view_huya_all(current):
|
||||||
huya_task_query = huya_task_query.filter(HuyaAccount.assigned_to == current.id)
|
huya_task_query = huya_task_query.filter(
|
||||||
|
HuyaAccount.assigned_to == current.id
|
||||||
|
)
|
||||||
huya_tasks = _task_summary(huya_task_query, HuyaTask)
|
huya_tasks = _task_summary(huya_task_query, HuyaTask)
|
||||||
huya_goods = db.query(func.count(HuyaGoodsSnapshot.id)).scalar() or 0
|
huya_goods = db.query(func.count(HuyaGoodsSnapshot.id)).scalar() or 0
|
||||||
huya_recharge_goods = db.query(func.count(HuyaRechargeGoodsSnapshot.id)).scalar() or 0
|
huya_recharge_goods = (
|
||||||
|
db.query(func.count(HuyaRechargeGoodsSnapshot.id)).scalar() or 0
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"douyu": {
|
"douyu": {
|
||||||
|
|||||||
@@ -106,7 +106,11 @@ def list_tasks(
|
|||||||
current: User = Depends(get_current_user),
|
current: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""查看登录任务列表。"""
|
"""查看登录任务列表。"""
|
||||||
query = db.query(LoginTask).options(defer(LoginTask.cookie)).join(Account, LoginTask.account_id == Account.id)
|
query = (
|
||||||
|
db.query(LoginTask)
|
||||||
|
.options(defer(LoginTask.cookie))
|
||||||
|
.join(Account, LoginTask.account_id == Account.id)
|
||||||
|
)
|
||||||
|
|
||||||
# 客服只能看自己账号的任务
|
# 客服只能看自己账号的任务
|
||||||
if not user_has_permission(current, "login:view_all"):
|
if not user_has_permission(current, "login:view_all"):
|
||||||
@@ -130,12 +134,20 @@ def list_tasks(
|
|||||||
|
|
||||||
result = []
|
result = []
|
||||||
for t in rows:
|
for t in rows:
|
||||||
result.append(LoginTaskOut(
|
result.append(
|
||||||
id=t.id, batch_id=t.batch_id, account_id=t.account_id,
|
LoginTaskOut(
|
||||||
account_username=accounts_map.get(t.account_id, ""),
|
id=t.id,
|
||||||
status=t.status, cookie="", message=t.message or "",
|
batch_id=t.batch_id,
|
||||||
created_by=t.created_by, created_at=t.created_at, finished_at=t.finished_at,
|
account_id=t.account_id,
|
||||||
))
|
account_username=accounts_map.get(t.account_id, ""),
|
||||||
|
status=t.status,
|
||||||
|
cookie="",
|
||||||
|
message=t.message or "",
|
||||||
|
created_by=t.created_by,
|
||||||
|
created_at=t.created_at,
|
||||||
|
finished_at=t.finished_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -178,7 +190,11 @@ def delete_tasks(
|
|||||||
ids = [int(x) for x in task_ids.split(",") if x.strip().isdigit()]
|
ids = [int(x) for x in task_ids.split(",") if x.strip().isdigit()]
|
||||||
if not ids:
|
if not ids:
|
||||||
raise HTTPException(status_code=400, detail="无效的任务ID")
|
raise HTTPException(status_code=400, detail="无效的任务ID")
|
||||||
deleted = db.query(LoginTask).filter(LoginTask.id.in_(ids)).delete(synchronize_session=False)
|
deleted = (
|
||||||
|
db.query(LoginTask)
|
||||||
|
.filter(LoginTask.id.in_(ids))
|
||||||
|
.delete(synchronize_session=False)
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": f"已删除 {deleted} 个任务", "deleted": deleted, "success": True}
|
return {"message": f"已删除 {deleted} 个任务", "deleted": deleted, "success": True}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ from ..schemas import ProxyConfigOut, ProxyConfigUpdate, PlatformInfo, PlatformF
|
|||||||
from ..deps import require_permission, authenticate_websocket
|
from ..deps import require_permission, authenticate_websocket
|
||||||
from ..services.proxy_service import proxy_service
|
from ..services.proxy_service import proxy_service
|
||||||
from core.douyu.proxy_platforms import (
|
from core.douyu.proxy_platforms import (
|
||||||
get_platform_names, get_platform_labels, get_credential_fields,
|
get_platform_names,
|
||||||
|
get_platform_labels,
|
||||||
|
get_credential_fields,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/proxy", tags=["代理与白名单"])
|
router = APIRouter(prefix="/api/proxy", tags=["代理与白名单"])
|
||||||
@@ -37,7 +39,9 @@ def update_proxy_config(
|
|||||||
api_url=req.api_url if req.api_url is not None else "",
|
api_url=req.api_url if req.api_url is not None else "",
|
||||||
http=req.http if req.http is not None else "",
|
http=req.http if req.http is not None else "",
|
||||||
https=req.https if req.https is not None else "",
|
https=req.https if req.https is not None else "",
|
||||||
whitelist_enabled=req.whitelist_enabled if req.whitelist_enabled is not None else False,
|
whitelist_enabled=req.whitelist_enabled
|
||||||
|
if req.whitelist_enabled is not None
|
||||||
|
else False,
|
||||||
whitelist_platform=req.whitelist_platform or "xiequ",
|
whitelist_platform=req.whitelist_platform or "xiequ",
|
||||||
whitelist_credentials=req.whitelist_credentials,
|
whitelist_credentials=req.whitelist_credentials,
|
||||||
whitelist_uid=req.whitelist_uid,
|
whitelist_uid=req.whitelist_uid,
|
||||||
@@ -53,16 +57,19 @@ def list_platforms():
|
|||||||
result = []
|
result = []
|
||||||
for name in get_platform_names():
|
for name in get_platform_names():
|
||||||
fields = get_credential_fields(name)
|
fields = get_credential_fields(name)
|
||||||
result.append(PlatformInfo(
|
result.append(
|
||||||
name=name,
|
PlatformInfo(
|
||||||
label=labels.get(name, name),
|
name=name,
|
||||||
credential_fields=[PlatformFieldDef(**f) for f in fields],
|
label=labels.get(name, name),
|
||||||
))
|
credential_fields=[PlatformFieldDef(**f) for f in fields],
|
||||||
|
)
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
# ---- WebSocket 日志推送 ----
|
# ---- WebSocket 日志推送 ----
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/ws/test/{test_id}")
|
@router.websocket("/ws/test/{test_id}")
|
||||||
async def ws_test_logs(websocket: WebSocket, test_id: str):
|
async def ws_test_logs(websocket: WebSocket, test_id: str):
|
||||||
"""WebSocket 推送代理/白名单测试实时日志(需认证)。"""
|
"""WebSocket 推送代理/白名单测试实时日志(需认证)。"""
|
||||||
@@ -100,6 +107,7 @@ async def ws_test_logs(websocket: WebSocket, test_id: str):
|
|||||||
|
|
||||||
# ---- 异步测试 API 端点 ----
|
# ---- 异步测试 API 端点 ----
|
||||||
|
|
||||||
|
|
||||||
@router.post("/test")
|
@router.post("/test")
|
||||||
async def test_proxy(
|
async def test_proxy(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
|||||||
@@ -25,16 +25,18 @@ def list_users(
|
|||||||
users = db.query(User).filter(User.deleted_at.is_(None)).order_by(User.id).all()
|
users = db.query(User).filter(User.deleted_at.is_(None)).order_by(User.id).all()
|
||||||
result = []
|
result = []
|
||||||
for u in users:
|
for u in users:
|
||||||
result.append(UserInfo(
|
result.append(
|
||||||
id=u.id,
|
UserInfo(
|
||||||
username=u.username,
|
id=u.id,
|
||||||
role=u.role,
|
username=u.username,
|
||||||
is_active=u.is_active,
|
role=u.role,
|
||||||
remark=u.remark or "",
|
is_active=u.is_active,
|
||||||
created_at=u.created_at,
|
remark=u.remark or "",
|
||||||
permissions=get_user_permissions(u),
|
created_at=u.created_at,
|
||||||
custom_permissions=u.custom_permissions,
|
permissions=get_user_permissions(u),
|
||||||
))
|
custom_permissions=u.custom_permissions,
|
||||||
|
)
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -58,13 +60,22 @@ def create_user(
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(user)
|
db.refresh(user)
|
||||||
|
|
||||||
db.add(AuditLog(user_id=current.id, username=current.username,
|
db.add(
|
||||||
action="user:create", target=user.username))
|
AuditLog(
|
||||||
|
user_id=current.id,
|
||||||
|
username=current.username,
|
||||||
|
action="user:create",
|
||||||
|
target=user.username,
|
||||||
|
)
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return UserInfo(
|
return UserInfo(
|
||||||
id=user.id, username=user.username, role=user.role,
|
id=user.id,
|
||||||
is_active=user.is_active, remark=user.remark or "",
|
username=user.username,
|
||||||
|
role=user.role,
|
||||||
|
is_active=user.is_active,
|
||||||
|
remark=user.remark or "",
|
||||||
permissions=get_user_permissions(user),
|
permissions=get_user_permissions(user),
|
||||||
custom_permissions=user.custom_permissions,
|
custom_permissions=user.custom_permissions,
|
||||||
)
|
)
|
||||||
@@ -97,13 +108,22 @@ def update_user(
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(user)
|
db.refresh(user)
|
||||||
|
|
||||||
db.add(AuditLog(user_id=current.id, username=current.username,
|
db.add(
|
||||||
action="user:edit", target=user.username))
|
AuditLog(
|
||||||
|
user_id=current.id,
|
||||||
|
username=current.username,
|
||||||
|
action="user:edit",
|
||||||
|
target=user.username,
|
||||||
|
)
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return UserInfo(
|
return UserInfo(
|
||||||
id=user.id, username=user.username, role=user.role,
|
id=user.id,
|
||||||
is_active=user.is_active, remark=user.remark or "",
|
username=user.username,
|
||||||
|
role=user.role,
|
||||||
|
is_active=user.is_active,
|
||||||
|
remark=user.remark or "",
|
||||||
permissions=get_user_permissions(user),
|
permissions=get_user_permissions(user),
|
||||||
custom_permissions=user.custom_permissions,
|
custom_permissions=user.custom_permissions,
|
||||||
)
|
)
|
||||||
@@ -126,21 +146,27 @@ def rename_user(
|
|||||||
raise HTTPException(status_code=400, detail="用户名至少 2 个字符")
|
raise HTTPException(status_code=400, detail="用户名至少 2 个字符")
|
||||||
if username == user.username:
|
if username == user.username:
|
||||||
return UserInfo(
|
return UserInfo(
|
||||||
id=user.id, username=user.username, role=user.role,
|
id=user.id,
|
||||||
is_active=user.is_active, remark=user.remark or "",
|
username=user.username,
|
||||||
permissions=get_user_permissions(user), custom_permissions=user.custom_permissions,
|
role=user.role,
|
||||||
|
is_active=user.is_active,
|
||||||
|
remark=user.remark or "",
|
||||||
|
permissions=get_user_permissions(user),
|
||||||
|
custom_permissions=user.custom_permissions,
|
||||||
)
|
)
|
||||||
if db.query(User.id).filter(User.username == username).first():
|
if db.query(User.id).filter(User.username == username).first():
|
||||||
raise HTTPException(status_code=400, detail="用户名已存在")
|
raise HTTPException(status_code=400, detail="用户名已存在")
|
||||||
|
|
||||||
old_username = user.username
|
old_username = user.username
|
||||||
user.username = username
|
user.username = username
|
||||||
db.add(AuditLog(
|
db.add(
|
||||||
user_id=current.id,
|
AuditLog(
|
||||||
username=current.username,
|
user_id=current.id,
|
||||||
action="user:rename",
|
username=current.username,
|
||||||
target=f"{old_username} -> {username}",
|
action="user:rename",
|
||||||
))
|
target=f"{old_username} -> {username}",
|
||||||
|
)
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
db.commit()
|
db.commit()
|
||||||
except IntegrityError as exc:
|
except IntegrityError as exc:
|
||||||
@@ -148,9 +174,13 @@ def rename_user(
|
|||||||
raise HTTPException(status_code=400, detail="用户名已存在") from exc
|
raise HTTPException(status_code=400, detail="用户名已存在") from exc
|
||||||
db.refresh(user)
|
db.refresh(user)
|
||||||
return UserInfo(
|
return UserInfo(
|
||||||
id=user.id, username=user.username, role=user.role,
|
id=user.id,
|
||||||
is_active=user.is_active, remark=user.remark or "",
|
username=user.username,
|
||||||
permissions=get_user_permissions(user), custom_permissions=user.custom_permissions,
|
role=user.role,
|
||||||
|
is_active=user.is_active,
|
||||||
|
remark=user.remark or "",
|
||||||
|
permissions=get_user_permissions(user),
|
||||||
|
custom_permissions=user.custom_permissions,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -182,8 +212,14 @@ def delete_user(
|
|||||||
.update({HuyaAccount.assigned_to: None}, synchronize_session=False)
|
.update({HuyaAccount.assigned_to: None}, synchronize_session=False)
|
||||||
)
|
)
|
||||||
deleted_username = user.username
|
deleted_username = user.username
|
||||||
db.add(AuditLog(user_id=current.id, username=current.username,
|
db.add(
|
||||||
action="user:delete", target=deleted_username))
|
AuditLog(
|
||||||
|
user_id=current.id,
|
||||||
|
username=current.username,
|
||||||
|
action="user:delete",
|
||||||
|
target=deleted_username,
|
||||||
|
)
|
||||||
|
)
|
||||||
user.is_active = False
|
user.is_active = False
|
||||||
user.deleted_at = datetime.now(timezone.utc)
|
user.deleted_at = datetime.now(timezone.utc)
|
||||||
user.deleted_username = deleted_username
|
user.deleted_username = deleted_username
|
||||||
@@ -202,6 +238,7 @@ def delete_user(
|
|||||||
def list_permissions(_: User = Depends(require_permission("user:assign_permissions"))):
|
def list_permissions(_: User = Depends(require_permission("user:assign_permissions"))):
|
||||||
"""返回所有可用权限点及角色默认权限映射。"""
|
"""返回所有可用权限点及角色默认权限映射。"""
|
||||||
from ..permissions import ROLE_PERMISSIONS
|
from ..permissions import ROLE_PERMISSIONS
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"permissions": PERMISSIONS,
|
"permissions": PERMISSIONS,
|
||||||
"role_permissions": ROLE_PERMISSIONS,
|
"role_permissions": ROLE_PERMISSIONS,
|
||||||
|
|||||||
+172
-45
@@ -20,7 +20,9 @@ from ..services.audit_service import record_audit
|
|||||||
router = APIRouter(prefix="/api/yyb", tags=["应用宝充值"])
|
router = APIRouter(prefix="/api/yyb", tags=["应用宝充值"])
|
||||||
|
|
||||||
|
|
||||||
def _get_task(db: Session, task_id: int, current: User, write: bool = False) -> YybRechargeTask:
|
def _get_task(
|
||||||
|
db: Session, task_id: int, current: User, write: bool = False
|
||||||
|
) -> YybRechargeTask:
|
||||||
"""读取任务。
|
"""读取任务。
|
||||||
|
|
||||||
查看:本人或 yyb:history;写操作:本人或 yyb:manage。
|
查看:本人或 yyb:history;写操作:本人或 yyb:manage。
|
||||||
@@ -53,14 +55,26 @@ def _creator_username(db: Session, task: YybRechargeTask) -> str:
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/tasks")
|
@router.post("/tasks")
|
||||||
def create_task(payload: YybTaskCreateRequest, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))):
|
def create_task(
|
||||||
|
payload: YybTaskCreateRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current: User = Depends(require_permission("yyb:session")),
|
||||||
|
):
|
||||||
data = _worker_call(YybWorkerClient().create_job)
|
data = _worker_call(YybWorkerClient().create_job)
|
||||||
task = YybRechargeTask(task_id=uuid.uuid4().hex[:16], created_by=current.id,
|
task = YybRechargeTask(
|
||||||
worker_job_id=str(data["job_id"]), status=str(data.get("status", "created")),
|
task_id=uuid.uuid4().hex[:16],
|
||||||
phase="login", message="请选择登录方式")
|
created_by=current.id,
|
||||||
|
worker_job_id=str(data["job_id"]),
|
||||||
|
status=str(data.get("status", "created")),
|
||||||
|
phase="login",
|
||||||
|
message="请选择登录方式",
|
||||||
|
)
|
||||||
db.add(task)
|
db.add(task)
|
||||||
record_audit(
|
record_audit(
|
||||||
db, current, action="recharge:yyb:create", target=f"yyb_task:{task.task_id}",
|
db,
|
||||||
|
current,
|
||||||
|
action="recharge:yyb:create",
|
||||||
|
target=f"yyb_task:{task.task_id}",
|
||||||
detail={"task_id": task.task_id, "status": task.status},
|
detail={"task_id": task.task_id, "status": task.status},
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -69,9 +83,18 @@ def create_task(payload: YybTaskCreateRequest, db: Session = Depends(get_db), cu
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/tasks/{task_id}/login")
|
@router.post("/tasks/{task_id}/login")
|
||||||
def login(task_id: int, payload: YybLoginRequest, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))):
|
def login(
|
||||||
|
task_id: int,
|
||||||
|
payload: YybLoginRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current: User = Depends(require_permission("yyb:session")),
|
||||||
|
):
|
||||||
task = _get_task(db, task_id, current, write=True)
|
task = _get_task(db, task_id, current, write=True)
|
||||||
data = _worker_call(lambda: YybWorkerClient().login(task.worker_job_id, payload.provider, payload.timeout))
|
data = _worker_call(
|
||||||
|
lambda: YybWorkerClient().login(
|
||||||
|
task.worker_job_id, payload.provider, payload.timeout
|
||||||
|
)
|
||||||
|
)
|
||||||
task.provider = payload.provider
|
task.provider = payload.provider
|
||||||
task.status = str(data.get("status", "waiting_login"))
|
task.status = str(data.get("status", "waiting_login"))
|
||||||
task.phase = "login"
|
task.phase = "login"
|
||||||
@@ -79,31 +102,48 @@ def login(task_id: int, payload: YybLoginRequest, db: Session = Depends(get_db),
|
|||||||
if data.get("qr_data"):
|
if data.get("qr_data"):
|
||||||
task.login_qr_data = data["qr_data"]
|
task.login_qr_data = data["qr_data"]
|
||||||
record_audit(
|
record_audit(
|
||||||
db, current, action="recharge:yyb:login", target=f"yyb_task:{task.task_id}",
|
db,
|
||||||
detail={"task_id": task.task_id, "provider": payload.provider, "status": task.status},
|
current,
|
||||||
|
action="recharge:yyb:login",
|
||||||
|
target=f"yyb_task:{task.task_id}",
|
||||||
|
detail={
|
||||||
|
"task_id": task.task_id,
|
||||||
|
"provider": payload.provider,
|
||||||
|
"status": task.status,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return public_task(task, creator_username=_creator_username(db, task))
|
return public_task(task, creator_username=_creator_username(db, task))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tasks/{task_id}")
|
@router.get("/tasks/{task_id}")
|
||||||
def get_task(task_id: int, db: Session = Depends(get_db), current: User = Depends(get_current_user)):
|
def get_task(
|
||||||
|
task_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
task = _get_task(db, task_id, current)
|
task = _get_task(db, task_id, current)
|
||||||
try:
|
try:
|
||||||
sync_task(db, task, YybWorkerClient())
|
sync_task(db, task, YybWorkerClient())
|
||||||
except YybWorkerError:
|
except YybWorkerError:
|
||||||
# Worker 暂时重启时仍返回最近一次持久化状态。
|
# Worker 暂时重启时仍返回最近一次持久化状态。
|
||||||
pass
|
pass
|
||||||
return public_task(task, include_qr=user_has_permission(current, "yyb:session"),
|
return public_task(
|
||||||
include_payment_qr=user_has_permission(current, "yyb:recharge"),
|
task,
|
||||||
creator_username=_creator_username(db, task))
|
include_qr=user_has_permission(current, "yyb:session"),
|
||||||
|
include_payment_qr=user_has_permission(current, "yyb:recharge"),
|
||||||
|
creator_username=_creator_username(db, task),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tasks")
|
@router.get("/tasks")
|
||||||
def list_tasks(scope: str = Query("mine", pattern="^(mine|all)$"),
|
def list_tasks(
|
||||||
status: str | None = Query(None, max_length=32),
|
scope: str = Query("mine", pattern="^(mine|all)$"),
|
||||||
limit: int = Query(50, ge=1, le=200),
|
status: str | None = Query(None, max_length=32),
|
||||||
db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))):
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current: User = Depends(require_permission("yyb:session")),
|
||||||
|
):
|
||||||
if scope == "all" and not user_has_permission(current, "yyb:history"):
|
if scope == "all" and not user_has_permission(current, "yyb:history"):
|
||||||
raise HTTPException(403, "无权查看全部充值任务")
|
raise HTTPException(403, "无权查看全部充值任务")
|
||||||
query = db.query(YybRechargeTask)
|
query = db.query(YybRechargeTask)
|
||||||
@@ -114,36 +154,80 @@ def list_tasks(scope: str = Query("mine", pattern="^(mine|all)$"),
|
|||||||
rows = query.order_by(YybRechargeTask.id.desc()).limit(limit).all()
|
rows = query.order_by(YybRechargeTask.id.desc()).limit(limit).all()
|
||||||
usernames = {
|
usernames = {
|
||||||
user.id: user.username
|
user.id: user.username
|
||||||
for user in db.query(User).filter(User.id.in_({row.created_by for row in rows})).all()
|
for user in db.query(User)
|
||||||
|
.filter(User.id.in_({row.created_by for row in rows}))
|
||||||
|
.all()
|
||||||
}
|
}
|
||||||
return [public_task(task, include_qr=False, creator_username=usernames.get(task.created_by, ""))
|
return [
|
||||||
for task in rows]
|
public_task(
|
||||||
|
task, include_qr=False, creator_username=usernames.get(task.created_by, "")
|
||||||
|
)
|
||||||
|
for task in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tasks/{task_id}/selection-options")
|
@router.get("/tasks/{task_id}/selection-options")
|
||||||
def selection_options(task_id: int, platform: str = Query("android"), points: int | None = Query(None), zone_id: str | None = Query(None), db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))):
|
def selection_options(
|
||||||
|
task_id: int,
|
||||||
|
platform: str = Query("android"),
|
||||||
|
points: int | None = Query(None),
|
||||||
|
zone_id: str | None = Query(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current: User = Depends(require_permission("yyb:session")),
|
||||||
|
):
|
||||||
task = _get_task(db, task_id, current, write=True)
|
task = _get_task(db, task_id, current, write=True)
|
||||||
data = _worker_call(lambda: YybWorkerClient().selection_options(task.worker_job_id, platform, points, zone_id))
|
data = _worker_call(
|
||||||
|
lambda: YybWorkerClient().selection_options(
|
||||||
|
task.worker_job_id, platform, points, zone_id
|
||||||
|
)
|
||||||
|
)
|
||||||
task.platform = platform
|
task.platform = platform
|
||||||
db.commit()
|
db.commit()
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@router.post("/tasks/{task_id}/selection")
|
@router.post("/tasks/{task_id}/selection")
|
||||||
def selection(task_id: int, payload: YybSelectionRequest, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))):
|
def selection(
|
||||||
|
task_id: int,
|
||||||
|
payload: YybSelectionRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current: User = Depends(require_permission("yyb:session")),
|
||||||
|
):
|
||||||
task = _get_task(db, task_id, current, write=True)
|
task = _get_task(db, task_id, current, write=True)
|
||||||
data = _worker_call(lambda: YybWorkerClient().selection(task.worker_job_id, payload.model_dump()))
|
data = _worker_call(
|
||||||
|
lambda: YybWorkerClient().selection(task.worker_job_id, payload.model_dump())
|
||||||
|
)
|
||||||
selected = data.get("selection", payload.model_dump())
|
selected = data.get("selection", payload.model_dump())
|
||||||
for field in ("platform", "points", "product_id", "zone_id", "zone_name", "role_id", "role_name"):
|
for field in (
|
||||||
|
"platform",
|
||||||
|
"points",
|
||||||
|
"product_id",
|
||||||
|
"zone_id",
|
||||||
|
"zone_name",
|
||||||
|
"role_id",
|
||||||
|
"role_name",
|
||||||
|
):
|
||||||
setattr(task, field, selected[field])
|
setattr(task, field, selected[field])
|
||||||
task.price_fen = int(selected.get("price_fen") or 0)
|
task.price_fen = int(selected.get("price_fen") or 0)
|
||||||
task.phase, task.status, task.message = "payment", "ready", "选择已保存,可以生成付款码"
|
task.phase, task.status, task.message = (
|
||||||
|
"payment",
|
||||||
|
"ready",
|
||||||
|
"选择已保存,可以生成付款码",
|
||||||
|
)
|
||||||
record_audit(
|
record_audit(
|
||||||
db, current, action="recharge:yyb:selection", target=f"yyb_task:{task.task_id}",
|
db,
|
||||||
|
current,
|
||||||
|
action="recharge:yyb:selection",
|
||||||
|
target=f"yyb_task:{task.task_id}",
|
||||||
detail={
|
detail={
|
||||||
"task_id": task.task_id, "product_id": task.product_id, "points": task.points,
|
"task_id": task.task_id,
|
||||||
"zone_id": task.zone_id, "zone_name": task.zone_name, "role_id": task.role_id,
|
"product_id": task.product_id,
|
||||||
"role_name": task.role_name, "price_fen": task.price_fen,
|
"points": task.points,
|
||||||
|
"zone_id": task.zone_id,
|
||||||
|
"zone_name": task.zone_name,
|
||||||
|
"role_id": task.role_id,
|
||||||
|
"role_name": task.role_name,
|
||||||
|
"price_fen": task.price_fen,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -151,10 +235,19 @@ def selection(task_id: int, payload: YybSelectionRequest, db: Session = Depends(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/tasks/{task_id}/payment")
|
@router.post("/tasks/{task_id}/payment")
|
||||||
def payment(task_id: int, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:recharge"))):
|
def payment(
|
||||||
|
task_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current: User = Depends(require_permission("yyb:recharge")),
|
||||||
|
):
|
||||||
# 接手他人任务需 yyb:manage;锁行做原子状态迁移,防止并发双击重复下单。
|
# 接手他人任务需 yyb:manage;锁行做原子状态迁移,防止并发双击重复下单。
|
||||||
_get_task(db, task_id, current, write=True)
|
_get_task(db, task_id, current, write=True)
|
||||||
task = db.query(YybRechargeTask).filter(YybRechargeTask.id == task_id).with_for_update().first()
|
task = (
|
||||||
|
db.query(YybRechargeTask)
|
||||||
|
.filter(YybRechargeTask.id == task_id)
|
||||||
|
.with_for_update()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
if not task:
|
if not task:
|
||||||
raise HTTPException(404, "充值任务不存在")
|
raise HTTPException(404, "充值任务不存在")
|
||||||
if task.status != "ready" or task.phase != "payment":
|
if task.status != "ready" or task.phase != "payment":
|
||||||
@@ -174,8 +267,12 @@ def payment(task_id: int, db: Session = Depends(get_db), current: User = Depends
|
|||||||
task.message = "生成付款码失败,请稍后重试"
|
task.message = "生成付款码失败,请稍后重试"
|
||||||
task.payment_started_at = None
|
task.payment_started_at = None
|
||||||
record_audit(
|
record_audit(
|
||||||
db, current, action="recharge:yyb:payment", target=f"yyb_task:{task.task_id}",
|
db,
|
||||||
detail="生成付款码失败", success=False,
|
current,
|
||||||
|
action="recharge:yyb:payment",
|
||||||
|
target=f"yyb_task:{task.task_id}",
|
||||||
|
detail="生成付款码失败",
|
||||||
|
success=False,
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
raise
|
raise
|
||||||
@@ -183,16 +280,30 @@ def payment(task_id: int, db: Session = Depends(get_db), current: User = Depends
|
|||||||
task.phase = str(data.get("phase", "payment"))
|
task.phase = str(data.get("phase", "payment"))
|
||||||
task.message = str(data.get("message", task.message))
|
task.message = str(data.get("message", task.message))
|
||||||
record_audit(
|
record_audit(
|
||||||
db, current, action="recharge:yyb:payment", target=f"yyb_task:{task.task_id}",
|
db,
|
||||||
detail={"task_id": task.task_id, "product_id": task.product_id, "points": task.points,
|
current,
|
||||||
"price_fen": task.price_fen, "status": task.status},
|
action="recharge:yyb:payment",
|
||||||
|
target=f"yyb_task:{task.task_id}",
|
||||||
|
detail={
|
||||||
|
"task_id": task.task_id,
|
||||||
|
"product_id": task.product_id,
|
||||||
|
"points": task.points,
|
||||||
|
"price_fen": task.price_fen,
|
||||||
|
"status": task.status,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return public_task(task, include_qr=False, creator_username=_creator_username(db, task))
|
return public_task(
|
||||||
|
task, include_qr=False, creator_username=_creator_username(db, task)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/tasks/{task_id}/payment/check")
|
@router.post("/tasks/{task_id}/payment/check")
|
||||||
def payment_check(task_id: int, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:recharge"))):
|
def payment_check(
|
||||||
|
task_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current: User = Depends(require_permission("yyb:recharge")),
|
||||||
|
):
|
||||||
task = _get_task(db, task_id, current, write=True)
|
task = _get_task(db, task_id, current, write=True)
|
||||||
if task.status not in {"waiting_payment", "payment_timeout"}:
|
if task.status not in {"waiting_payment", "payment_timeout"}:
|
||||||
raise HTTPException(409, "当前任务状态不支持检测到账")
|
raise HTTPException(409, "当前任务状态不支持检测到账")
|
||||||
@@ -204,16 +315,29 @@ def payment_check(task_id: int, db: Session = Depends(get_db), current: User = D
|
|||||||
if task.status == "success":
|
if task.status == "success":
|
||||||
task.phase = "completed"
|
task.phase = "completed"
|
||||||
record_audit(
|
record_audit(
|
||||||
db, current, action="recharge:yyb:payment_check", target=f"yyb_task:{task.task_id}",
|
db,
|
||||||
detail={"task_id": task.task_id, "status": task.status, "message": task.message},
|
current,
|
||||||
|
action="recharge:yyb:payment_check",
|
||||||
|
target=f"yyb_task:{task.task_id}",
|
||||||
|
detail={
|
||||||
|
"task_id": task.task_id,
|
||||||
|
"status": task.status,
|
||||||
|
"message": task.message,
|
||||||
|
},
|
||||||
success=task.status != "failed",
|
success=task.status != "failed",
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return public_task(task, include_qr=False, creator_username=_creator_username(db, task))
|
return public_task(
|
||||||
|
task, include_qr=False, creator_username=_creator_username(db, task)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/tasks/{task_id}/stop")
|
@router.post("/tasks/{task_id}/stop")
|
||||||
def stop(task_id: int, db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))):
|
def stop(
|
||||||
|
task_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current: User = Depends(require_permission("yyb:session")),
|
||||||
|
):
|
||||||
task = _get_task(db, task_id, current, write=True)
|
task = _get_task(db, task_id, current, write=True)
|
||||||
data = _worker_call(lambda: YybWorkerClient().stop(task.worker_job_id))
|
data = _worker_call(lambda: YybWorkerClient().stop(task.worker_job_id))
|
||||||
task.status = "stopped"
|
task.status = "stopped"
|
||||||
@@ -221,7 +345,10 @@ def stop(task_id: int, db: Session = Depends(get_db), current: User = Depends(re
|
|||||||
task.message = str(data.get("message", "任务已停止"))
|
task.message = str(data.get("message", "任务已停止"))
|
||||||
task.finished_at = _utcnow()
|
task.finished_at = _utcnow()
|
||||||
record_audit(
|
record_audit(
|
||||||
db, current, action="recharge:yyb:stop", target=f"yyb_task:{task.task_id}",
|
db,
|
||||||
|
current,
|
||||||
|
action="recharge:yyb:stop",
|
||||||
|
target=f"yyb_task:{task.task_id}",
|
||||||
detail={"task_id": task.task_id, "status": task.status},
|
detail={"task_id": task.task_id, "status": task.status},
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -8,27 +8,31 @@ from sqlalchemy.orm import Session
|
|||||||
from ..models import Account, LoginTask
|
from ..models import Account, LoginTask
|
||||||
|
|
||||||
|
|
||||||
EMAIL_PATTERN = re.compile(r'^[^\s@|]+@[^\s@|]+\.[^\s@|]+$')
|
EMAIL_PATTERN = re.compile(r"^[^\s@|]+@[^\s@|]+\.[^\s@|]+$")
|
||||||
|
|
||||||
|
|
||||||
def split_account_line(line: str) -> list[str]:
|
def split_account_line(line: str) -> list[str]:
|
||||||
"""拆分一行账号文本,支持 |、tab、逗号、空格分隔。"""
|
"""拆分一行账号文本,支持 |、tab、逗号、空格分隔。"""
|
||||||
if '|' in line:
|
if "|" in line:
|
||||||
return line.split('|')
|
return line.split("|")
|
||||||
if '\t' in line:
|
if "\t" in line:
|
||||||
return line.split('\t')
|
return line.split("\t")
|
||||||
if ',' in line:
|
if "," in line:
|
||||||
return next(csv.reader([line]))
|
return next(csv.reader([line]))
|
||||||
return line.split()
|
return line.split()
|
||||||
|
|
||||||
|
|
||||||
def cookie_account_ids_query(db: Session):
|
def cookie_account_ids_query(db: Session):
|
||||||
"""返回有成功登录记录(cookie非空)的账号ID子查询。"""
|
"""返回有成功登录记录(cookie非空)的账号ID子查询。"""
|
||||||
return db.query(LoginTask.account_id).filter(
|
return (
|
||||||
LoginTask.status == 'success',
|
db.query(LoginTask.account_id)
|
||||||
LoginTask.cookie != '',
|
.filter(
|
||||||
LoginTask.cookie.isnot(None),
|
LoginTask.status == "success",
|
||||||
).distinct()
|
LoginTask.cookie != "",
|
||||||
|
LoginTask.cookie.isnot(None),
|
||||||
|
)
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def parse_and_build_accounts(
|
def parse_and_build_accounts(
|
||||||
@@ -61,9 +65,9 @@ def parse_and_build_accounts(
|
|||||||
skipped = 0
|
skipped = 0
|
||||||
duplicated = 0
|
duplicated = 0
|
||||||
seen_in_batch: set[str] = set()
|
seen_in_batch: set[str] = set()
|
||||||
for line in text.strip().split('\n'):
|
for line in text.strip().split("\n"):
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if not line or line.startswith('#'):
|
if not line or line.startswith("#"):
|
||||||
continue
|
continue
|
||||||
parts = split_account_line(line)
|
parts = split_account_line(line)
|
||||||
if len(parts) < 4:
|
if len(parts) < 4:
|
||||||
@@ -88,14 +92,16 @@ def parse_and_build_accounts(
|
|||||||
seen_in_batch.add(username_key)
|
seen_in_batch.add(username_key)
|
||||||
|
|
||||||
email_cfg = get_email_config_for_account(email)
|
email_cfg = get_email_config_for_account(email)
|
||||||
accounts.append(Account(
|
accounts.append(
|
||||||
username=username,
|
Account(
|
||||||
password=password,
|
username=username,
|
||||||
email=email,
|
password=password,
|
||||||
email_password=email_password,
|
email=email,
|
||||||
email_imap_server=email_cfg['server'],
|
email_password=email_password,
|
||||||
email_imap_port=email_cfg.get('port', 993),
|
email_imap_server=email_cfg["server"],
|
||||||
email_imap_ssl=email_cfg.get('ssl', True),
|
email_imap_port=email_cfg.get("port", 993),
|
||||||
tag=tag,
|
email_imap_ssl=email_cfg.get("ssl", True),
|
||||||
))
|
tag=tag,
|
||||||
|
)
|
||||||
|
)
|
||||||
return accounts, skipped, duplicated
|
return accounts, skipped, duplicated
|
||||||
|
|||||||
@@ -12,8 +12,17 @@ from ..models import AuditLog, User
|
|||||||
|
|
||||||
|
|
||||||
_SENSITIVE_KEY_PARTS = (
|
_SENSITIVE_KEY_PARTS = (
|
||||||
"cookie", "token", "password", "passwd", "secret", "signature", "sign",
|
"cookie",
|
||||||
"qr_data", "authorization", "credential", "private_key",
|
"token",
|
||||||
|
"password",
|
||||||
|
"passwd",
|
||||||
|
"secret",
|
||||||
|
"signature",
|
||||||
|
"sign",
|
||||||
|
"qr_data",
|
||||||
|
"authorization",
|
||||||
|
"credential",
|
||||||
|
"private_key",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -42,7 +51,9 @@ def record_audit(
|
|||||||
) -> AuditLog:
|
) -> AuditLog:
|
||||||
"""加入一条审计记录;调用方负责与业务变更一起提交事务。"""
|
"""加入一条审计记录;调用方负责与业务变更一起提交事务。"""
|
||||||
if isinstance(detail, Mapping):
|
if isinstance(detail, Mapping):
|
||||||
detail_text = json.dumps(_safe_value(detail), ensure_ascii=False, separators=(",", ":"))
|
detail_text = json.dumps(
|
||||||
|
_safe_value(detail), ensure_ascii=False, separators=(",", ":")
|
||||||
|
)
|
||||||
elif detail is None:
|
elif detail is None:
|
||||||
detail_text = ""
|
detail_text = ""
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -200,7 +200,9 @@ def snapshot_from_batch(batch: HuyaRegisterBatch) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def load_batch_snapshot(batch_id: str, *, recover_interrupted: bool = True) -> dict | None:
|
def load_batch_snapshot(
|
||||||
|
batch_id: str, *, recover_interrupted: bool = True
|
||||||
|
) -> dict | None:
|
||||||
"""从数据库加载批次详情;若服务中断则标记为 interrupted。"""
|
"""从数据库加载批次详情;若服务中断则标记为 interrupted。"""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@@ -246,7 +248,9 @@ def load_batch_snapshot(batch_id: str, *, recover_interrupted: bool = True) -> d
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def list_batch_summaries(limit: int = 50, live_batch_ids: set[str] | None = None) -> list[dict]:
|
def list_batch_summaries(
|
||||||
|
limit: int = 50, live_batch_ids: set[str] | None = None
|
||||||
|
) -> list[dict]:
|
||||||
"""列出最近的注册批次摘要。live_batch_ids 中的 running 保持运行中。"""
|
"""列出最近的注册批次摘要。live_batch_ids 中的 running 保持运行中。"""
|
||||||
live = live_batch_ids or set()
|
live = live_batch_ids or set()
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
@@ -269,42 +273,51 @@ def list_batch_summaries(limit: int = 50, live_batch_ids: set[str] | None = None
|
|||||||
if status == "running":
|
if status == "running":
|
||||||
running_count = max(
|
running_count = max(
|
||||||
0,
|
0,
|
||||||
int(row.total or 0) - int(row.success_count or 0) - int(row.failed_count or 0) - int(row.stopped_count or 0),
|
int(row.total or 0)
|
||||||
|
- int(row.success_count or 0)
|
||||||
|
- int(row.failed_count or 0)
|
||||||
|
- int(row.stopped_count or 0),
|
||||||
)
|
)
|
||||||
result.append({
|
result.append(
|
||||||
"batch_id": row.batch_id,
|
{
|
||||||
"status": status,
|
"batch_id": row.batch_id,
|
||||||
"message": message,
|
"status": status,
|
||||||
"tag": row.tag or "",
|
"message": message,
|
||||||
"created_by": row.created_by,
|
"tag": row.tag or "",
|
||||||
"concurrency": int(row.concurrency or 1),
|
"created_by": row.created_by,
|
||||||
"wait_seconds": float(row.wait_seconds or 180),
|
"concurrency": int(row.concurrency or 1),
|
||||||
"poll_interval": float(row.poll_interval or 5),
|
"wait_seconds": float(row.wait_seconds or 180),
|
||||||
"password_prefix": row.password_prefix or "hy",
|
"poll_interval": float(row.poll_interval or 5),
|
||||||
"use_proxy": bool(row.use_proxy),
|
"password_prefix": row.password_prefix or "hy",
|
||||||
"total": int(row.total or 0),
|
"use_proxy": bool(row.use_proxy),
|
||||||
"success_count": int(row.success_count or 0),
|
"total": int(row.total or 0),
|
||||||
"failed_count": int(row.failed_count or 0),
|
"success_count": int(row.success_count or 0),
|
||||||
"stopped_count": int(row.stopped_count or 0),
|
"failed_count": int(row.failed_count or 0),
|
||||||
"running_count": running_count,
|
"stopped_count": int(row.stopped_count or 0),
|
||||||
"created_at": row.created_at,
|
"running_count": running_count,
|
||||||
"started_at": row.started_at,
|
"created_at": row.created_at,
|
||||||
"finished_at": row.finished_at,
|
"started_at": row.started_at,
|
||||||
"items": [],
|
"finished_at": row.finished_at,
|
||||||
})
|
"items": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def _refresh_batch_counts(batch_row: HuyaRegisterBatchModel, items: list[HuyaRegisterItemModel]):
|
def _refresh_batch_counts(
|
||||||
|
batch_row: HuyaRegisterBatchModel, items: list[HuyaRegisterItemModel]
|
||||||
|
):
|
||||||
batch_row.total = len(items)
|
batch_row.total = len(items)
|
||||||
batch_row.success_count = sum(1 for item in items if item.status == "success")
|
batch_row.success_count = sum(1 for item in items if item.status == "success")
|
||||||
batch_row.failed_count = sum(1 for item in items if item.status == "error")
|
batch_row.failed_count = sum(1 for item in items if item.status == "error")
|
||||||
batch_row.stopped_count = sum(1 for item in items if item.status == "stopped")
|
batch_row.stopped_count = sum(1 for item in items if item.status == "stopped")
|
||||||
|
|
||||||
|
|
||||||
def format_success_export_line(username: str, uid: str, password: str, phone: str, sms_url: str) -> str:
|
def format_success_export_line(
|
||||||
|
username: str, uid: str, password: str, phone: str, sms_url: str
|
||||||
|
) -> str:
|
||||||
"""统一成功导出格式。"""
|
"""统一成功导出格式。"""
|
||||||
account = (username or uid or "").strip()
|
account = (username or uid or "").strip()
|
||||||
return f"{account}----{password or ''}----{phone or ''}----{sms_url or ''}"
|
return f"{account}----{password or ''}----{phone or ''}----{sms_url or ''}"
|
||||||
@@ -319,7 +332,9 @@ def export_success_logs_text(
|
|||||||
"""从成功流水表导出 txt。"""
|
"""从成功流水表导出 txt。"""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
query = db.query(HuyaRegisterSuccessLog).order_by(HuyaRegisterSuccessLog.id.asc())
|
query = db.query(HuyaRegisterSuccessLog).order_by(
|
||||||
|
HuyaRegisterSuccessLog.id.asc()
|
||||||
|
)
|
||||||
if batch_id:
|
if batch_id:
|
||||||
query = query.filter(HuyaRegisterSuccessLog.batch_id == batch_id)
|
query = query.filter(HuyaRegisterSuccessLog.batch_id == batch_id)
|
||||||
if tag:
|
if tag:
|
||||||
@@ -350,7 +365,9 @@ def list_success_logs(
|
|||||||
"""列出成功流水(含密码,供管理端展示/导出)。"""
|
"""列出成功流水(含密码,供管理端展示/导出)。"""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
query = db.query(HuyaRegisterSuccessLog).order_by(HuyaRegisterSuccessLog.id.desc())
|
query = db.query(HuyaRegisterSuccessLog).order_by(
|
||||||
|
HuyaRegisterSuccessLog.id.desc()
|
||||||
|
)
|
||||||
if batch_id:
|
if batch_id:
|
||||||
query = query.filter(HuyaRegisterSuccessLog.batch_id == batch_id)
|
query = query.filter(HuyaRegisterSuccessLog.batch_id == batch_id)
|
||||||
if tag:
|
if tag:
|
||||||
@@ -406,7 +423,11 @@ class HuyaRegisterRunner:
|
|||||||
|
|
||||||
def _create_proxy_fetcher(self) -> ProxyFetcher | None:
|
def _create_proxy_fetcher(self) -> ProxyFetcher | None:
|
||||||
"""按需创建 API 代理获取器。"""
|
"""按需创建 API 代理获取器。"""
|
||||||
if not self.batch.use_proxy or not self.proxy_config or not self.proxy_config.enabled:
|
if (
|
||||||
|
not self.batch.use_proxy
|
||||||
|
or not self.proxy_config
|
||||||
|
or not self.proxy_config.enabled
|
||||||
|
):
|
||||||
return None
|
return None
|
||||||
if not self.proxy_config.api_url:
|
if not self.proxy_config.api_url:
|
||||||
return None
|
return None
|
||||||
@@ -414,9 +435,15 @@ class HuyaRegisterRunner:
|
|||||||
wl_platform = "xiequ"
|
wl_platform = "xiequ"
|
||||||
wl_credentials = None
|
wl_credentials = None
|
||||||
if self.proxy_config.whitelist_enabled:
|
if self.proxy_config.whitelist_enabled:
|
||||||
wl_platform = getattr(self.proxy_config, "whitelist_platform", None) or "xiequ"
|
wl_platform = (
|
||||||
|
getattr(self.proxy_config, "whitelist_platform", None) or "xiequ"
|
||||||
|
)
|
||||||
wl_credentials = getattr(self.proxy_config, "whitelist_credentials", None)
|
wl_credentials = getattr(self.proxy_config, "whitelist_credentials", None)
|
||||||
if not wl_credentials and self.proxy_config.whitelist_uid and self.proxy_config.whitelist_ukey:
|
if (
|
||||||
|
not wl_credentials
|
||||||
|
and self.proxy_config.whitelist_uid
|
||||||
|
and self.proxy_config.whitelist_ukey
|
||||||
|
):
|
||||||
wl_credentials = {
|
wl_credentials = {
|
||||||
"uid": self.proxy_config.whitelist_uid,
|
"uid": self.proxy_config.whitelist_uid,
|
||||||
"ukey": self.proxy_config.whitelist_ukey,
|
"ukey": self.proxy_config.whitelist_ukey,
|
||||||
@@ -446,7 +473,11 @@ class HuyaRegisterRunner:
|
|||||||
return
|
return
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
row = db.query(HuyaRegisterBatchModel).filter(HuyaRegisterBatchModel.id == self.batch.db_id).first()
|
row = (
|
||||||
|
db.query(HuyaRegisterBatchModel)
|
||||||
|
.filter(HuyaRegisterBatchModel.id == self.batch.db_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
if not row:
|
if not row:
|
||||||
return
|
return
|
||||||
row.status = self.batch.status
|
row.status = self.batch.status
|
||||||
@@ -485,7 +516,11 @@ class HuyaRegisterRunner:
|
|||||||
return
|
return
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
row = db.query(HuyaRegisterItemModel).filter(HuyaRegisterItemModel.id == item.db_id).first()
|
row = (
|
||||||
|
db.query(HuyaRegisterItemModel)
|
||||||
|
.filter(HuyaRegisterItemModel.id == item.db_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
if not row:
|
if not row:
|
||||||
return
|
return
|
||||||
row.status = item.status
|
row.status = item.status
|
||||||
@@ -518,12 +553,16 @@ class HuyaRegisterRunner:
|
|||||||
setattr(item, key, value)
|
setattr(item, key, value)
|
||||||
self._persist_item(index)
|
self._persist_item(index)
|
||||||
|
|
||||||
def _save_success(self, index: int, result: HuyaAutoRegisterResult) -> tuple[int | None, str, str]:
|
def _save_success(
|
||||||
|
self, index: int, result: HuyaAutoRegisterResult
|
||||||
|
) -> tuple[int | None, str, str]:
|
||||||
"""成功时:写账号 + 成功流水(成功一个写一条,立即可导出)。"""
|
"""成功时:写账号 + 成功流水(成功一个写一条,立即可导出)。"""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
# upsert_huya_cookie 内部会 commit 一次
|
# upsert_huya_cookie 内部会 commit 一次
|
||||||
account = upsert_huya_cookie(db, result.cookie, tag=self.batch.tag, username_hint="")
|
account = upsert_huya_cookie(
|
||||||
|
db, result.cookie, tag=self.batch.tag, username_hint=""
|
||||||
|
)
|
||||||
account.game_phone = result.phone or account.game_phone or ""
|
account.game_phone = result.phone or account.game_phone or ""
|
||||||
if result.username:
|
if result.username:
|
||||||
account.username = result.username
|
account.username = result.username
|
||||||
@@ -537,23 +576,29 @@ class HuyaRegisterRunner:
|
|||||||
account.updated_at = _now()
|
account.updated_at = _now()
|
||||||
|
|
||||||
item = self.batch.items[index]
|
item = self.batch.items[index]
|
||||||
db.add(HuyaRegisterSuccessLog(
|
db.add(
|
||||||
batch_id=self.batch.batch_id,
|
HuyaRegisterSuccessLog(
|
||||||
item_id=item.db_id,
|
batch_id=self.batch.batch_id,
|
||||||
account_id=account.id,
|
item_id=item.db_id,
|
||||||
phone=result.phone or item.phone,
|
account_id=account.id,
|
||||||
username=result.username or account.username or "",
|
phone=result.phone or item.phone,
|
||||||
uid=result.uid or account.uid or account.yyuid or "",
|
username=result.username or account.username or "",
|
||||||
password=result.password or "",
|
uid=result.uid or account.uid or account.yyuid or "",
|
||||||
sms_url=sms_url,
|
password=result.password or "",
|
||||||
tag=self.batch.tag,
|
sms_url=sms_url,
|
||||||
provider=result.provider or item.provider,
|
tag=self.batch.tag,
|
||||||
created_by=self.batch.created_by,
|
provider=result.provider or item.provider,
|
||||||
created_at=_now(),
|
created_by=self.batch.created_by,
|
||||||
))
|
created_at=_now(),
|
||||||
|
)
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(account)
|
db.refresh(account)
|
||||||
return account.id, account.username or "", account.uid or account.yyuid or ""
|
return (
|
||||||
|
account.id,
|
||||||
|
account.username or "",
|
||||||
|
account.uid or account.yyuid or "",
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@@ -578,12 +623,16 @@ class HuyaRegisterRunner:
|
|||||||
|
|
||||||
def _run_one(self, index: int, item: SmsLine):
|
def _run_one(self, index: int, item: SmsLine):
|
||||||
if self._stop.is_set():
|
if self._stop.is_set():
|
||||||
self._set_item(index, status="stopped", message="已停止", finished_at=_now())
|
self._set_item(
|
||||||
|
index, status="stopped", message="已停止", finished_at=_now()
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
proxies, proxy_error = self._resolve_proxy()
|
proxies, proxy_error = self._resolve_proxy()
|
||||||
if proxy_error:
|
if proxy_error:
|
||||||
self._set_item(index, status="error", message=proxy_error, finished_at=_now())
|
self._set_item(
|
||||||
|
index, status="error", message=proxy_error, finished_at=_now()
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
self._set_item(
|
self._set_item(
|
||||||
@@ -714,7 +763,12 @@ class HuyaRegisterRunner:
|
|||||||
futures = []
|
futures = []
|
||||||
for index in indices:
|
for index in indices:
|
||||||
if self._stop.is_set():
|
if self._stop.is_set():
|
||||||
self._set_item(index, status="stopped", message="已停止", finished_at=_now())
|
self._set_item(
|
||||||
|
index,
|
||||||
|
status="stopped",
|
||||||
|
message="已停止",
|
||||||
|
finished_at=_now(),
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
item = self.sms_lines[index]
|
item = self.sms_lines[index]
|
||||||
futures.append(executor.submit(self._run_one, index, item))
|
futures.append(executor.submit(self._run_one, index, item))
|
||||||
@@ -841,7 +895,11 @@ class HuyaRegisterRegistry:
|
|||||||
# DB 同步为 running,避免返回 pending 导致前端误判
|
# DB 同步为 running,避免返回 pending 导致前端误判
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
row = db.query(HuyaRegisterBatchModel).filter(HuyaRegisterBatchModel.id == db_id).first()
|
row = (
|
||||||
|
db.query(HuyaRegisterBatchModel)
|
||||||
|
.filter(HuyaRegisterBatchModel.id == db_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
if row:
|
if row:
|
||||||
row.status = "running"
|
row.status = "running"
|
||||||
row.message = "批次运行中"
|
row.message = "批次运行中"
|
||||||
@@ -850,7 +908,9 @@ class HuyaRegisterRegistry:
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
runner = HuyaRegisterRunner(batch=batch, sms_lines=sms_lines, proxy_config=proxy_config)
|
runner = HuyaRegisterRunner(
|
||||||
|
batch=batch, sms_lines=sms_lines, proxy_config=proxy_config
|
||||||
|
)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._runners[batch_id] = runner
|
self._runners[batch_id] = runner
|
||||||
return runner
|
return runner
|
||||||
@@ -930,7 +990,9 @@ class HuyaRegisterRegistry:
|
|||||||
if poll_interval is not None:
|
if poll_interval is not None:
|
||||||
batch_row.poll_interval = int(max(1.0, float(poll_interval)))
|
batch_row.poll_interval = int(max(1.0, float(poll_interval)))
|
||||||
if password_prefix is not None:
|
if password_prefix is not None:
|
||||||
batch_row.password_prefix = (password_prefix or "hy").strip()[:8] or "hy"
|
batch_row.password_prefix = (password_prefix or "hy").strip()[
|
||||||
|
:8
|
||||||
|
] or "hy"
|
||||||
if fixed_password is not None:
|
if fixed_password is not None:
|
||||||
batch_row.fixed_password = (fixed_password or "").strip()
|
batch_row.fixed_password = (fixed_password or "").strip()
|
||||||
if use_proxy is not None:
|
if use_proxy is not None:
|
||||||
@@ -965,7 +1027,12 @@ class HuyaRegisterRegistry:
|
|||||||
|
|
||||||
batch = _batch_from_db(batch_row, item_rows)
|
batch = _batch_from_db(batch_row, item_rows)
|
||||||
sms_lines = [
|
sms_lines = [
|
||||||
SmsLine(phone=item.phone, url=item.sms_url, provider=item.provider, raw=f"{item.phone}----{item.sms_url}")
|
SmsLine(
|
||||||
|
phone=item.phone,
|
||||||
|
url=item.sms_url,
|
||||||
|
provider=item.provider,
|
||||||
|
raw=f"{item.phone}----{item.sms_url}",
|
||||||
|
)
|
||||||
for item in batch.items
|
for item in batch.items
|
||||||
]
|
]
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -19,15 +19,24 @@ from .huya_runner_recharge import RechargeMixin
|
|||||||
|
|
||||||
|
|
||||||
class HuyaBatchRunner(
|
class HuyaBatchRunner(
|
||||||
HuyaBatchRunnerCore, BindMixin, GoodsMixin, RechargeMixin,
|
HuyaBatchRunnerCore,
|
||||||
|
BindMixin,
|
||||||
|
GoodsMixin,
|
||||||
|
RechargeMixin,
|
||||||
):
|
):
|
||||||
"""批量执行虎牙任务(功能域 Mixin 聚合 + 批次调度)。"""
|
"""批量执行虎牙任务(功能域 Mixin 聚合 + 批次调度)。"""
|
||||||
|
|
||||||
def _execute_one(self, task_id: int, account_info: dict, config_info: dict, total: int):
|
def _execute_one(
|
||||||
|
self, task_id: int, account_info: dict, config_info: dict, total: int
|
||||||
|
):
|
||||||
worker_db = SessionLocal()
|
worker_db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
task = worker_db.query(HuyaTask).filter(HuyaTask.id == task_id).first()
|
task = worker_db.query(HuyaTask).filter(HuyaTask.id == task_id).first()
|
||||||
account = worker_db.query(HuyaAccount).filter(HuyaAccount.id == account_info["account_id"]).first()
|
account = (
|
||||||
|
worker_db.query(HuyaAccount)
|
||||||
|
.filter(HuyaAccount.id == account_info["account_id"])
|
||||||
|
.first()
|
||||||
|
)
|
||||||
if not task or not account:
|
if not task or not account:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -59,28 +68,48 @@ class HuyaBatchRunner(
|
|||||||
"create_recharge_order",
|
"create_recharge_order",
|
||||||
}:
|
}:
|
||||||
self._mark_task(worker_db, task, "failed", "该虎牙任务执行器暂未实现")
|
self._mark_task(worker_db, task, "failed", "该虎牙任务执行器暂未实现")
|
||||||
self._push_log("warning", f"[{current}] {name} 暂未实现: {self.task_type}")
|
self._push_log(
|
||||||
|
"warning", f"[{current}] {name} 暂未实现: {self.task_type}"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if self.task_type == "query_points":
|
if self.task_type == "query_points":
|
||||||
self._execute_query_points(worker_db, task, account, account_info, config_info)
|
self._execute_query_points(
|
||||||
|
worker_db, task, account, account_info, config_info
|
||||||
|
)
|
||||||
elif self.task_type == "refresh_goods":
|
elif self.task_type == "refresh_goods":
|
||||||
self._execute_refresh_goods(worker_db, task, account, account_info, config_info)
|
self._execute_refresh_goods(
|
||||||
|
worker_db, task, account, account_info, config_info
|
||||||
|
)
|
||||||
elif self.task_type == "refresh_recharge_goods":
|
elif self.task_type == "refresh_recharge_goods":
|
||||||
self._execute_refresh_recharge_goods(worker_db, task, account, account_info, config_info)
|
self._execute_refresh_recharge_goods(
|
||||||
|
worker_db, task, account, account_info, config_info
|
||||||
|
)
|
||||||
elif self.task_type == "exchange_goods":
|
elif self.task_type == "exchange_goods":
|
||||||
self._execute_exchange_goods(worker_db, task, account, account_info, config_info)
|
self._execute_exchange_goods(
|
||||||
|
worker_db, task, account, account_info, config_info
|
||||||
|
)
|
||||||
elif self.task_type == "create_recharge_order":
|
elif self.task_type == "create_recharge_order":
|
||||||
self._execute_create_recharge_order(worker_db, task, account, account_info, config_info)
|
self._execute_create_recharge_order(
|
||||||
|
worker_db, task, account, account_info, config_info
|
||||||
|
)
|
||||||
elif self.task_type == "get_bind_qr":
|
elif self.task_type == "get_bind_qr":
|
||||||
self._execute_get_bind_qr(worker_db, task, account, account_info, config_info)
|
self._execute_get_bind_qr(
|
||||||
|
worker_db, task, account, account_info, config_info
|
||||||
|
)
|
||||||
elif self.task_type == "confirm_bind":
|
elif self.task_type == "confirm_bind":
|
||||||
self._execute_confirm_bind(worker_db, task, account, account_info, config_info)
|
self._execute_confirm_bind(
|
||||||
|
worker_db, task, account, account_info, config_info
|
||||||
|
)
|
||||||
elif self.task_type == "query_game_name":
|
elif self.task_type == "query_game_name":
|
||||||
self._execute_query_game_name(worker_db, task, account, account_info, config_info)
|
self._execute_query_game_name(
|
||||||
|
worker_db, task, account, account_info, config_info
|
||||||
|
)
|
||||||
elif self.task_type == "query_exchange_records":
|
elif self.task_type == "query_exchange_records":
|
||||||
self._execute_query_exchange_records(worker_db, task, account, account_info, config_info)
|
self._execute_query_exchange_records(
|
||||||
|
worker_db, task, account, account_info, config_info
|
||||||
|
)
|
||||||
worker_db.refresh(task)
|
worker_db.refresh(task)
|
||||||
if task.status == "success":
|
if task.status == "success":
|
||||||
self._push_log("success", f"[{current}] {name} {task.message}")
|
self._push_log("success", f"[{current}] {name} {task.message}")
|
||||||
@@ -124,17 +153,19 @@ class HuyaBatchRunner(
|
|||||||
task.status = "pending"
|
task.status = "pending"
|
||||||
task.message = "等待执行"
|
task.message = "等待执行"
|
||||||
task.finished_at = None
|
task.finished_at = None
|
||||||
task_infos.append({
|
task_infos.append(
|
||||||
"task_id": task.id,
|
{
|
||||||
"account_info": {
|
"task_id": task.id,
|
||||||
"account_id": account.id,
|
"account_info": {
|
||||||
"uid": account.uid or "",
|
"account_id": account.id,
|
||||||
"yyuid": account.yyuid or "",
|
"uid": account.uid or "",
|
||||||
"username": account.username or "",
|
"yyuid": account.yyuid or "",
|
||||||
"nickname": account.nickname or "",
|
"username": account.username or "",
|
||||||
"cookie": normalize_huya_cookie(account.cookie or ""),
|
"nickname": account.nickname or "",
|
||||||
},
|
"cookie": normalize_huya_cookie(account.cookie or ""),
|
||||||
})
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|
||||||
total = len(task_infos)
|
total = len(task_infos)
|
||||||
@@ -149,13 +180,15 @@ class HuyaBatchRunner(
|
|||||||
if self._stop.is_set():
|
if self._stop.is_set():
|
||||||
self._push_log("warning", "任务已停止,跳过剩余账号")
|
self._push_log("warning", "任务已停止,跳过剩余账号")
|
||||||
break
|
break
|
||||||
futures.append(executor.submit(
|
futures.append(
|
||||||
self._execute_one,
|
executor.submit(
|
||||||
item["task_id"],
|
self._execute_one,
|
||||||
item["account_info"],
|
item["task_id"],
|
||||||
config_info,
|
item["account_info"],
|
||||||
total,
|
config_info,
|
||||||
))
|
total,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
for future in as_completed(futures):
|
for future in as_completed(futures):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -20,8 +20,10 @@ from typing import TYPE_CHECKING
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .huya_runner import HuyaBatchRunner
|
from .huya_runner import HuyaBatchRunner
|
||||||
|
|
||||||
|
|
||||||
class HuyaBatchRunnerCore:
|
class HuyaBatchRunnerCore:
|
||||||
"""虎牙任务执行器公共基础:批次状态、日志、任务落库。"""
|
"""虎牙任务执行器公共基础:批次状态、日志、任务落库。"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
db: Session,
|
db: Session,
|
||||||
@@ -156,7 +158,8 @@ class HuyaBatchRegistry:
|
|||||||
expired = [
|
expired = [
|
||||||
batch_id
|
batch_id
|
||||||
for batch_id, batch in self._batches.items()
|
for batch_id, batch in self._batches.items()
|
||||||
if batch.get("finished") and now - float(batch.get("finished_at") or now) > ttl_seconds
|
if batch.get("finished")
|
||||||
|
and now - float(batch.get("finished_at") or now) > ttl_seconds
|
||||||
]
|
]
|
||||||
for batch_id in expired:
|
for batch_id in expired:
|
||||||
self._batches.pop(batch_id, None)
|
self._batches.pop(batch_id, None)
|
||||||
@@ -206,4 +209,3 @@ class HuyaBatchRegistry:
|
|||||||
|
|
||||||
|
|
||||||
huya_batch_registry = HuyaBatchRegistry()
|
huya_batch_registry = HuyaBatchRegistry()
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,10 @@ def apply_huya_config_defaults(config: HuyaConfig) -> bool:
|
|||||||
"""补齐虎牙配置默认值,返回是否发生变更。"""
|
"""补齐虎牙配置默认值,返回是否发生变更。"""
|
||||||
changed = False
|
changed = False
|
||||||
for field in HUYA_CONFIG_FIELDS:
|
for field in HUYA_CONFIG_FIELDS:
|
||||||
if field == "bind_act_id" and str(getattr(config, field, "") or "").strip() == "17096":
|
if (
|
||||||
|
field == "bind_act_id"
|
||||||
|
and str(getattr(config, field, "") or "").strip() == "17096"
|
||||||
|
):
|
||||||
setattr(config, field, HUYA_CONFIG_DEFAULTS[field])
|
setattr(config, field, HUYA_CONFIG_DEFAULTS[field])
|
||||||
changed = True
|
changed = True
|
||||||
continue
|
continue
|
||||||
@@ -160,7 +163,9 @@ def split_huya_password_line(line: str) -> HuyaPasswordLine | None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def import_huya_password_accounts(db: Session, text: str, tag: str = "") -> tuple[int, int]:
|
def import_huya_password_accounts(
|
||||||
|
db: Session, text: str, tag: str = ""
|
||||||
|
) -> tuple[int, int]:
|
||||||
"""导入虎牙账号密码,返回 (导入/更新数, 跳过数)。"""
|
"""导入虎牙账号密码,返回 (导入/更新数, 跳过数)。"""
|
||||||
created_or_updated = 0
|
created_or_updated = 0
|
||||||
skipped = 0
|
skipped = 0
|
||||||
@@ -207,13 +212,17 @@ def import_huya_password_accounts(db: Session, text: str, tag: str = "") -> tupl
|
|||||||
return created_or_updated, skipped
|
return created_or_updated, skipped
|
||||||
|
|
||||||
|
|
||||||
def _upsert_huya_account(db: Session, parsed: dict, tag: str = "", status: str | None = None) -> HuyaAccount:
|
def _upsert_huya_account(
|
||||||
|
db: Session, parsed: dict, tag: str = "", status: str | None = None
|
||||||
|
) -> HuyaAccount:
|
||||||
"""按 uid/yyuid 新增或更新虎牙账号。"""
|
"""按 uid/yyuid 新增或更新虎牙账号。"""
|
||||||
account = None
|
account = None
|
||||||
if parsed["uid"]:
|
if parsed["uid"]:
|
||||||
account = db.query(HuyaAccount).filter(HuyaAccount.uid == parsed["uid"]).first()
|
account = db.query(HuyaAccount).filter(HuyaAccount.uid == parsed["uid"]).first()
|
||||||
if account is None and parsed["yyuid"]:
|
if account is None and parsed["yyuid"]:
|
||||||
account = db.query(HuyaAccount).filter(HuyaAccount.yyuid == parsed["yyuid"]).first()
|
account = (
|
||||||
|
db.query(HuyaAccount).filter(HuyaAccount.yyuid == parsed["yyuid"]).first()
|
||||||
|
)
|
||||||
|
|
||||||
if account is None:
|
if account is None:
|
||||||
account = HuyaAccount(
|
account = HuyaAccount(
|
||||||
@@ -273,13 +282,17 @@ def save_huya_login_cookie_to_account(
|
|||||||
return account
|
return account
|
||||||
|
|
||||||
|
|
||||||
def upsert_huya_cookie(db: Session, cookie: str, tag: str = "", username_hint: str = "") -> HuyaAccount:
|
def upsert_huya_cookie(
|
||||||
|
db: Session, cookie: str, tag: str = "", username_hint: str = ""
|
||||||
|
) -> HuyaAccount:
|
||||||
"""保存单条登录得到的虎牙 Cookie。"""
|
"""保存单条登录得到的虎牙 Cookie。"""
|
||||||
line = f"{username_hint}----{cookie}" if username_hint else cookie
|
line = f"{username_hint}----{cookie}" if username_hint else cookie
|
||||||
parsed = parse_huya_cookie_line(line)
|
parsed = parse_huya_cookie_line(line)
|
||||||
if not parsed:
|
if not parsed:
|
||||||
raise ValueError("登录成功但 Cookie 中没有识别到虎牙 uid")
|
raise ValueError("登录成功但 Cookie 中没有识别到虎牙 uid")
|
||||||
account = _upsert_huya_account(db, parsed, tag=(tag or "").strip(), status="login_success")
|
account = _upsert_huya_account(
|
||||||
|
db, parsed, tag=(tag or "").strip(), status="login_success"
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(account)
|
db.refresh(account)
|
||||||
return account
|
return account
|
||||||
@@ -368,18 +381,24 @@ def create_planned_tasks(
|
|||||||
batch_id = uuid.uuid4().hex[:12]
|
batch_id = uuid.uuid4().hex[:12]
|
||||||
payload = payload or {}
|
payload = payload or {}
|
||||||
accounts = db.query(HuyaAccount).filter(HuyaAccount.id.in_(account_ids)).all()
|
accounts = db.query(HuyaAccount).filter(HuyaAccount.id.in_(account_ids)).all()
|
||||||
if task_type in {"refresh_goods", "refresh_recharge_goods", "create_recharge_order"} and accounts:
|
if (
|
||||||
|
task_type
|
||||||
|
in {"refresh_goods", "refresh_recharge_goods", "create_recharge_order"}
|
||||||
|
and accounts
|
||||||
|
):
|
||||||
# 全局快照和单笔支付二维码使用一个选中的 CK 即可;兑换商品需要保留多账号批量任务。
|
# 全局快照和单笔支付二维码使用一个选中的 CK 即可;兑换商品需要保留多账号批量任务。
|
||||||
accounts = accounts[:1]
|
accounts = accounts[:1]
|
||||||
for account in accounts:
|
for account in accounts:
|
||||||
db.add(HuyaTask(
|
db.add(
|
||||||
batch_id=batch_id,
|
HuyaTask(
|
||||||
account_id=account.id,
|
batch_id=batch_id,
|
||||||
task_type=task_type,
|
account_id=account.id,
|
||||||
status="planned",
|
task_type=task_type,
|
||||||
message="任务已创建,等待执行",
|
status="planned",
|
||||||
result={"payload": payload} if payload else None,
|
message="任务已创建,等待执行",
|
||||||
created_by=created_by,
|
result={"payload": payload} if payload else None,
|
||||||
))
|
created_by=created_by,
|
||||||
|
)
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return batch_id, len(accounts)
|
return batch_id, len(accounts)
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ from typing import Optional
|
|||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from core.douyu.proxy import resolve_working_proxy, verify_proxy_url, parse_proxy_response
|
from core.douyu.proxy import (
|
||||||
|
resolve_working_proxy,
|
||||||
|
verify_proxy_url,
|
||||||
|
parse_proxy_response,
|
||||||
|
)
|
||||||
from core.douyu.proxy_platforms import create_adapter, get_platform_labels
|
from core.douyu.proxy_platforms import create_adapter, get_platform_labels
|
||||||
from core.douyu.proxy_platforms.base import _get_local_exit_ip
|
from core.douyu.proxy_platforms.base import _get_local_exit_ip
|
||||||
from ..models import ProxyConfig as ProxyConfigModel, AuditLog
|
from ..models import ProxyConfig as ProxyConfigModel, AuditLog
|
||||||
@@ -20,8 +24,8 @@ def _build_whitelist_params(cfg: ProxyConfigModel) -> dict:
|
|||||||
Returns:
|
Returns:
|
||||||
{"whitelist_platform": str, "whitelist_credentials": dict|None}
|
{"whitelist_platform": str, "whitelist_credentials": dict|None}
|
||||||
"""
|
"""
|
||||||
platform = getattr(cfg, 'whitelist_platform', None) or "xiequ"
|
platform = getattr(cfg, "whitelist_platform", None) or "xiequ"
|
||||||
credentials = getattr(cfg, 'whitelist_credentials', None)
|
credentials = getattr(cfg, "whitelist_credentials", None)
|
||||||
|
|
||||||
# 向后兼容:旧字段有值但新字段为空时,自动迁移
|
# 向后兼容:旧字段有值但新字段为空时,自动迁移
|
||||||
if not credentials and cfg.whitelist_uid and cfg.whitelist_ukey:
|
if not credentials and cfg.whitelist_uid and cfg.whitelist_ukey:
|
||||||
@@ -57,7 +61,10 @@ class ProxyService:
|
|||||||
# 应用层自动迁移:旧字段有值但新字段为空时,填充新字段
|
# 应用层自动迁移:旧字段有值但新字段为空时,填充新字段
|
||||||
if cfg.whitelist_uid and cfg.whitelist_ukey and not cfg.whitelist_credentials:
|
if cfg.whitelist_uid and cfg.whitelist_ukey and not cfg.whitelist_credentials:
|
||||||
cfg.whitelist_platform = "xiequ"
|
cfg.whitelist_platform = "xiequ"
|
||||||
cfg.whitelist_credentials = {"uid": cfg.whitelist_uid, "ukey": cfg.whitelist_ukey}
|
cfg.whitelist_credentials = {
|
||||||
|
"uid": cfg.whitelist_uid,
|
||||||
|
"ukey": cfg.whitelist_ukey,
|
||||||
|
}
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return cfg
|
return cfg
|
||||||
@@ -65,7 +72,10 @@ class ProxyService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def update_config(
|
def update_config(
|
||||||
db: Session,
|
db: Session,
|
||||||
enabled, api_url, http, https,
|
enabled,
|
||||||
|
api_url,
|
||||||
|
http,
|
||||||
|
https,
|
||||||
whitelist_enabled,
|
whitelist_enabled,
|
||||||
whitelist_platform="xiequ",
|
whitelist_platform="xiequ",
|
||||||
whitelist_credentials=None,
|
whitelist_credentials=None,
|
||||||
@@ -96,12 +106,14 @@ class ProxyService:
|
|||||||
db.refresh(cfg)
|
db.refresh(cfg)
|
||||||
|
|
||||||
if current_user:
|
if current_user:
|
||||||
db.add(AuditLog(
|
db.add(
|
||||||
user_id=current_user.id,
|
AuditLog(
|
||||||
username=current_user.username,
|
user_id=current_user.id,
|
||||||
action="proxy:update",
|
username=current_user.username,
|
||||||
target="proxy_config",
|
action="proxy:update",
|
||||||
))
|
target="proxy_config",
|
||||||
|
)
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
@@ -258,18 +270,21 @@ class ProxyService:
|
|||||||
|
|
||||||
# 3. 检查并同步白名单
|
# 3. 检查并同步白名单
|
||||||
records = adapter.get_whitelist()
|
records = adapter.get_whitelist()
|
||||||
in_list = any(r.get('ip') == local_ip for r in records)
|
in_list = any(r.get("ip") == local_ip for r in records)
|
||||||
|
|
||||||
if records:
|
if records:
|
||||||
push("info", f"白名单共 {len(records)} 条记录")
|
push("info", f"白名单共 {len(records)} 条记录")
|
||||||
|
|
||||||
if in_list:
|
if in_list:
|
||||||
record = next((r for r in records if r.get('ip') == local_ip), {})
|
record = next((r for r in records if r.get("ip") == local_ip), {})
|
||||||
memo = record.get('memo', '')
|
memo = record.get("memo", "")
|
||||||
if memo == adapter.memo:
|
if memo == adapter.memo:
|
||||||
push("success", f"本机IP {local_ip} 已在白名单中 (备注正确)")
|
push("success", f"本机IP {local_ip} 已在白名单中 (备注正确)")
|
||||||
else:
|
else:
|
||||||
push("warning", f'本机IP {local_ip} 备注不匹配 (当前: "{memo}"),更新中...')
|
push(
|
||||||
|
"warning",
|
||||||
|
f'本机IP {local_ip} 备注不匹配 (当前: "{memo}"),更新中...',
|
||||||
|
)
|
||||||
sync_ok, sync_msg = adapter.sync_ip(local_ip)
|
sync_ok, sync_msg = adapter.sync_ip(local_ip)
|
||||||
push("success" if sync_ok else "error", f"白名单更新: {sync_msg}")
|
push("success" if sync_ok else "error", f"白名单更新: {sync_msg}")
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -32,9 +32,22 @@ def _as_utc(value: datetime | None) -> datetime | None:
|
|||||||
|
|
||||||
|
|
||||||
def cleanup_orphan_yyb_tasks(db: Session, message: str) -> int:
|
def cleanup_orphan_yyb_tasks(db: Session, message: str) -> int:
|
||||||
rows = db.query(YybRechargeTask).filter(
|
rows = (
|
||||||
YybRechargeTask.status.in_(["created", "waiting_login", "ready", "running", "ordering", "waiting_payment"])
|
db.query(YybRechargeTask)
|
||||||
).all()
|
.filter(
|
||||||
|
YybRechargeTask.status.in_(
|
||||||
|
[
|
||||||
|
"created",
|
||||||
|
"waiting_login",
|
||||||
|
"ready",
|
||||||
|
"running",
|
||||||
|
"ordering",
|
||||||
|
"waiting_payment",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
for task in rows:
|
for task in rows:
|
||||||
if task.status == "ordering":
|
if task.status == "ordering":
|
||||||
task.status = "waiting_payment"
|
task.status = "waiting_payment"
|
||||||
@@ -50,7 +63,9 @@ def cleanup_orphan_yyb_tasks(db: Session, message: str) -> int:
|
|||||||
return len(rows)
|
return len(rows)
|
||||||
|
|
||||||
|
|
||||||
def sync_task(db: Session, task: YybRechargeTask, worker: YybWorkerClient) -> YybRechargeTask:
|
def sync_task(
|
||||||
|
db: Session, task: YybRechargeTask, worker: YybWorkerClient
|
||||||
|
) -> YybRechargeTask:
|
||||||
data = worker.get_job(task.worker_job_id)
|
data = worker.get_job(task.worker_job_id)
|
||||||
task.status = str(data.get("status", task.status))
|
task.status = str(data.get("status", task.status))
|
||||||
task.phase = str(data.get("phase", task.phase))
|
task.phase = str(data.get("phase", task.phase))
|
||||||
@@ -59,10 +74,16 @@ def sync_task(db: Session, task: YybRechargeTask, worker: YybWorkerClient) -> Yy
|
|||||||
task.provider = str(data["provider"])
|
task.provider = str(data["provider"])
|
||||||
if data.get("qr_data"):
|
if data.get("qr_data"):
|
||||||
task.login_qr_data = str(data["qr_data"])
|
task.login_qr_data = str(data["qr_data"])
|
||||||
task.result = {**(task.result or {}), "login_qr_mime_type": data.get("qr_mime_type", "image/jpeg")}
|
task.result = {
|
||||||
|
**(task.result or {}),
|
||||||
|
"login_qr_mime_type": data.get("qr_mime_type", "image/jpeg"),
|
||||||
|
}
|
||||||
if data.get("payment_qr_data"):
|
if data.get("payment_qr_data"):
|
||||||
task.payment_qr_data = str(data["payment_qr_data"])
|
task.payment_qr_data = str(data["payment_qr_data"])
|
||||||
task.result = {**(task.result or {}), "payment_qr_mime_type": data.get("payment_qr_mime_type", "image/png")}
|
task.result = {
|
||||||
|
**(task.result or {}),
|
||||||
|
"payment_qr_mime_type": data.get("payment_qr_mime_type", "image/png"),
|
||||||
|
}
|
||||||
task.result = {
|
task.result = {
|
||||||
**(task.result or {}),
|
**(task.result or {}),
|
||||||
"logs": data.get("logs", []),
|
"logs": data.get("logs", []),
|
||||||
@@ -76,32 +97,53 @@ def sync_task(db: Session, task: YybRechargeTask, worker: YybWorkerClient) -> Yy
|
|||||||
task.payment_last_checked_at = last_checked_at
|
task.payment_last_checked_at = last_checked_at
|
||||||
if task.status in {"success", "failed"} and task.finished_at is None:
|
if task.status in {"success", "failed"} and task.finished_at is None:
|
||||||
task.finished_at = _utcnow()
|
task.finished_at = _utcnow()
|
||||||
if task.status not in {"success", "failed", "stopped"} and task.finished_at is not None:
|
if (
|
||||||
|
task.status not in {"success", "failed", "stopped"}
|
||||||
|
and task.finished_at is not None
|
||||||
|
):
|
||||||
task.finished_at = None
|
task.finished_at = None
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(task)
|
db.refresh(task)
|
||||||
return task
|
return task
|
||||||
|
|
||||||
|
|
||||||
def public_task(task: YybRechargeTask, include_qr: bool = True,
|
def public_task(
|
||||||
include_payment_qr: bool | None = None,
|
task: YybRechargeTask,
|
||||||
creator_username: str = "") -> dict[str, Any]:
|
include_qr: bool = True,
|
||||||
|
include_payment_qr: bool | None = None,
|
||||||
|
creator_username: str = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
result: dict[str, Any] = {
|
result: dict[str, Any] = {
|
||||||
"id": task.id, "task_id": task.task_id,
|
"id": task.id,
|
||||||
"provider": task.provider, "platform": task.platform, "points": task.points,
|
"task_id": task.task_id,
|
||||||
|
"provider": task.provider,
|
||||||
|
"platform": task.platform,
|
||||||
|
"points": task.points,
|
||||||
"price_fen": task.price_fen,
|
"price_fen": task.price_fen,
|
||||||
"product_id": task.product_id, "zone_id": task.zone_id, "zone_name": task.zone_name,
|
"product_id": task.product_id,
|
||||||
"role_id": task.role_id, "role_name": task.role_name, "status": task.status,
|
"zone_id": task.zone_id,
|
||||||
"phase": task.phase, "message": task.message, "result": task.result,
|
"zone_name": task.zone_name,
|
||||||
"created_by": task.created_by, "created_by_username": creator_username,
|
"role_id": task.role_id,
|
||||||
"created_at": _as_utc(task.created_at), "finished_at": _as_utc(task.finished_at),
|
"role_name": task.role_name,
|
||||||
|
"status": task.status,
|
||||||
|
"phase": task.phase,
|
||||||
|
"message": task.message,
|
||||||
|
"result": task.result,
|
||||||
|
"created_by": task.created_by,
|
||||||
|
"created_by_username": creator_username,
|
||||||
|
"created_at": _as_utc(task.created_at),
|
||||||
|
"finished_at": _as_utc(task.finished_at),
|
||||||
"payment_started_at": _as_utc(task.payment_started_at),
|
"payment_started_at": _as_utc(task.payment_started_at),
|
||||||
"payment_qr_created_at": _as_utc(task.payment_qr_created_at),
|
"payment_qr_created_at": _as_utc(task.payment_qr_created_at),
|
||||||
"payment_last_checked_at": _as_utc(task.payment_last_checked_at),
|
"payment_last_checked_at": _as_utc(task.payment_last_checked_at),
|
||||||
}
|
}
|
||||||
if task.result:
|
if task.result:
|
||||||
result["login_qr_mime_type"] = task.result.get("login_qr_mime_type", "image/jpeg")
|
result["login_qr_mime_type"] = task.result.get(
|
||||||
result["payment_qr_mime_type"] = task.result.get("payment_qr_mime_type", "image/png")
|
"login_qr_mime_type", "image/jpeg"
|
||||||
|
)
|
||||||
|
result["payment_qr_mime_type"] = task.result.get(
|
||||||
|
"payment_qr_mime_type", "image/png"
|
||||||
|
)
|
||||||
if include_payment_qr is None:
|
if include_payment_qr is None:
|
||||||
include_payment_qr = include_qr
|
include_payment_qr = include_qr
|
||||||
if include_qr:
|
if include_qr:
|
||||||
|
|||||||
@@ -18,13 +18,20 @@ class YybWorkerClient:
|
|||||||
self.key = os.getenv("YYB_WORKER_KEY", "")
|
self.key = os.getenv("YYB_WORKER_KEY", "")
|
||||||
self.timeout = float(os.getenv("YYB_WORKER_TIMEOUT", "30"))
|
self.timeout = float(os.getenv("YYB_WORKER_TIMEOUT", "30"))
|
||||||
|
|
||||||
def _request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
def _request(
|
||||||
|
self, method: str, path: str, payload: dict[str, Any] | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
headers = {"Accept": "application/json"}
|
headers = {"Accept": "application/json"}
|
||||||
if self.key:
|
if self.key:
|
||||||
headers["Authorization"] = f"Bearer {self.key}"
|
headers["Authorization"] = f"Bearer {self.key}"
|
||||||
try:
|
try:
|
||||||
response = requests.request(method, self.base_url + path, json=payload,
|
response = requests.request(
|
||||||
headers=headers, timeout=self.timeout)
|
method,
|
||||||
|
self.base_url + path,
|
||||||
|
json=payload,
|
||||||
|
headers=headers,
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
data = response.json()
|
data = response.json()
|
||||||
except (requests.RequestException, ValueError) as exc:
|
except (requests.RequestException, ValueError) as exc:
|
||||||
raise YybWorkerError(f"应用宝 Worker 不可用: {exc}") from exc
|
raise YybWorkerError(f"应用宝 Worker 不可用: {exc}") from exc
|
||||||
@@ -35,19 +42,34 @@ class YybWorkerClient:
|
|||||||
def create_job(self) -> dict[str, Any]:
|
def create_job(self) -> dict[str, Any]:
|
||||||
return self._request("POST", "/v1/jobs")
|
return self._request("POST", "/v1/jobs")
|
||||||
|
|
||||||
def login(self, worker_job_id: str, provider: str, timeout: int = 600) -> dict[str, Any]:
|
def login(
|
||||||
return self._request("POST", f"/v1/jobs/{worker_job_id}/login",
|
self, worker_job_id: str, provider: str, timeout: int = 600
|
||||||
{"provider": provider, "timeout": timeout})
|
) -> dict[str, Any]:
|
||||||
|
return self._request(
|
||||||
|
"POST",
|
||||||
|
f"/v1/jobs/{worker_job_id}/login",
|
||||||
|
{"provider": provider, "timeout": timeout},
|
||||||
|
)
|
||||||
|
|
||||||
def get_job(self, worker_job_id: str) -> dict[str, Any]:
|
def get_job(self, worker_job_id: str) -> dict[str, Any]:
|
||||||
return self._request("GET", f"/v1/jobs/{worker_job_id}")
|
return self._request("GET", f"/v1/jobs/{worker_job_id}")
|
||||||
|
|
||||||
def selection_options(self, worker_job_id: str, platform: str,
|
def selection_options(
|
||||||
points: int | None = None, zone_id: str | None = None) -> dict[str, Any]:
|
self,
|
||||||
return self._request("POST", f"/v1/jobs/{worker_job_id}/selection-options",
|
worker_job_id: str,
|
||||||
{"platform": platform, "points": points, "zone_id": zone_id})
|
platform: str,
|
||||||
|
points: int | None = None,
|
||||||
|
zone_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return self._request(
|
||||||
|
"POST",
|
||||||
|
f"/v1/jobs/{worker_job_id}/selection-options",
|
||||||
|
{"platform": platform, "points": points, "zone_id": zone_id},
|
||||||
|
)
|
||||||
|
|
||||||
def selection(self, worker_job_id: str, selection: dict[str, Any]) -> dict[str, Any]:
|
def selection(
|
||||||
|
self, worker_job_id: str, selection: dict[str, Any]
|
||||||
|
) -> dict[str, Any]:
|
||||||
return self._request("POST", f"/v1/jobs/{worker_job_id}/selection", selection)
|
return self._request("POST", f"/v1/jobs/{worker_job_id}/selection", selection)
|
||||||
|
|
||||||
def payment(self, worker_job_id: str) -> dict[str, Any]:
|
def payment(self, worker_job_id: str) -> dict[str, Any]:
|
||||||
|
|||||||
Reference in New Issue
Block a user