style: 统一 Ruff 代码格式
This commit is contained in:
@@ -6,7 +6,12 @@ from .login_api_wgapi import WgapiLoginAPI
|
||||
from .login_api_iframe import IframeLoginAPI
|
||||
from .email_verifier import EmailVerifier
|
||||
from .activity_client import DouyuActivityClient, DouyuActivityError
|
||||
from .recharge_api import FishFinRechargeClient, FishFinRechargeConfig, FishFinRechargeConfigError, FishFinRechargeError
|
||||
from .recharge_api import (
|
||||
FishFinRechargeClient,
|
||||
FishFinRechargeConfig,
|
||||
FishFinRechargeConfigError,
|
||||
FishFinRechargeError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DouyuLogin",
|
||||
|
||||
@@ -15,7 +15,9 @@ class CookieEnricher:
|
||||
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"
|
||||
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)
|
||||
RETRIES = 3
|
||||
|
||||
@@ -47,25 +49,27 @@ class CookieEnricher:
|
||||
logger.warning(f"补CK失败 {attempt}/{max_attempts}: {e}")
|
||||
if attempt < max_attempts:
|
||||
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:
|
||||
"""访问 generateCsrf 接口,从 Set-Cookie 中同步 cvl_csrf_token。"""
|
||||
logger.info("补齐CSRF Cookie...")
|
||||
headers = {
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.6,en;q=0.5',
|
||||
'Origin': 'https://www.douyu.com',
|
||||
'Referer': self.CSRF_REFERER,
|
||||
'Sec-Fetch-Dest': 'empty',
|
||||
'Sec-Fetch-Mode': 'cors',
|
||||
'Sec-Fetch-Site': 'same-origin',
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en-US;q=0.6,en;q=0.5",
|
||||
"Origin": "https://www.douyu.com",
|
||||
"Referer": self.CSRF_REFERER,
|
||||
"Sec-Fetch-Dest": "empty",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"Sec-Fetch-Site": "same-origin",
|
||||
# 覆盖登录接口的默认表单头,尽量贴近浏览器抓包。
|
||||
'Content-Type': None,
|
||||
'X-Requested-With': None,
|
||||
"Content-Type": None,
|
||||
"X-Requested-With": None,
|
||||
}
|
||||
response = self.request(
|
||||
'post',
|
||||
"post",
|
||||
self.CSRF_API,
|
||||
headers=headers,
|
||||
timeout=self.TIMEOUT,
|
||||
@@ -82,11 +86,11 @@ class CookieEnricher:
|
||||
preview = body[:200].replace("\n", "\\n")
|
||||
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', '未知错误')}")
|
||||
|
||||
cookies = self.session.cookies.get_dict()
|
||||
csrf_token = cookies.get('cvl_csrf_token', '')
|
||||
csrf_token = cookies.get("cvl_csrf_token", "")
|
||||
if not csrf_token:
|
||||
raise ValueError("生成CSRF失败: 响应没有 cvl_csrf_token")
|
||||
|
||||
@@ -97,25 +101,25 @@ class CookieEnricher:
|
||||
"""访问 getCsrfCookie 接口,从 Set-Cookie 中同步 acf_ccn。"""
|
||||
logger.info("补齐 acf_ccn Cookie...")
|
||||
headers = {
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Pragma': 'no-cache',
|
||||
'Priority': 'u=1, i',
|
||||
'Referer': self.ACF_CCN_REFERER,
|
||||
'Sec-CH-UA': '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
|
||||
'Sec-CH-UA-Mobile': '?0',
|
||||
'Sec-CH-UA-Platform': '"macOS"',
|
||||
'Sec-Fetch-Dest': 'empty',
|
||||
'Sec-Fetch-Mode': 'cors',
|
||||
'Sec-Fetch-Site': 'same-origin',
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||
"Cache-Control": "no-cache",
|
||||
"Pragma": "no-cache",
|
||||
"Priority": "u=1, i",
|
||||
"Referer": self.ACF_CCN_REFERER,
|
||||
"Sec-CH-UA": '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
|
||||
"Sec-CH-UA-Mobile": "?0",
|
||||
"Sec-CH-UA-Platform": '"macOS"',
|
||||
"Sec-Fetch-Dest": "empty",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"Sec-Fetch-Site": "same-origin",
|
||||
# 该接口抓包没有 Origin、表单 Content-Type 和 X-Requested-With。
|
||||
'Origin': None,
|
||||
'Content-Type': None,
|
||||
'X-Requested-With': None,
|
||||
"Origin": None,
|
||||
"Content-Type": None,
|
||||
"X-Requested-With": None,
|
||||
}
|
||||
response = self.request(
|
||||
'get',
|
||||
"get",
|
||||
self.ACF_CCN_API,
|
||||
headers=headers,
|
||||
timeout=self.TIMEOUT,
|
||||
@@ -123,10 +127,10 @@ class CookieEnricher:
|
||||
response.raise_for_status()
|
||||
|
||||
cookies = self.session.cookies.get_dict()
|
||||
acf_ccn = cookies.get('acf_ccn', '') or response.cookies.get('acf_ccn', '')
|
||||
if acf_ccn and not 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"):
|
||||
# 极少数情况下响应 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:
|
||||
raise ValueError("补齐 acf_ccn 失败: 响应没有 acf_ccn")
|
||||
|
||||
@@ -25,21 +25,21 @@ DOUYU_RC4_KEY = "7TkbRSEWvVWebXbr"
|
||||
|
||||
def md5(text: str) -> str:
|
||||
"""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:
|
||||
"""RSA加密"""
|
||||
key = RSA.import_key(public_key)
|
||||
cipher = PKCS1_v1_5.new(key)
|
||||
encrypted = cipher.encrypt(text.encode('utf-8'))
|
||||
return base64.b64encode(encrypted).decode('utf-8')
|
||||
encrypted = cipher.encrypt(text.encode("utf-8"))
|
||||
return base64.b64encode(encrypted).decode("utf-8")
|
||||
|
||||
|
||||
def aes_encrypt(text: str, key: str = DOUYU_AES_KEY) -> str:
|
||||
"""AES加密"""
|
||||
key_bytes = key.encode('utf-8')
|
||||
text_bytes = text.encode('utf-8')
|
||||
key_bytes = key.encode("utf-8")
|
||||
text_bytes = text.encode("utf-8")
|
||||
|
||||
# 填充到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)
|
||||
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:
|
||||
@@ -62,7 +62,7 @@ def encrypt_password(password: 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)
|
||||
encrypted = cipher.encrypt(text.encode('utf-8'))
|
||||
return base64.b64encode(encrypted).decode('utf-8')
|
||||
encrypted = cipher.encrypt(text.encode("utf-8"))
|
||||
return base64.b64encode(encrypted).decode("utf-8")
|
||||
|
||||
+20
-18
@@ -47,7 +47,9 @@ class LoginAPIStrategy(ABC):
|
||||
...
|
||||
|
||||
@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:
|
||||
(gt, challenge, code_token)
|
||||
"""
|
||||
geetest_data = payload.get('data', {}).get('geetest', {})
|
||||
code_data = geetest_data.get('code_data', {})
|
||||
gt = code_data.get('gt', '')
|
||||
challenge = code_data.get('challenge', '')
|
||||
code_token = geetest_data.get('code_token', '')
|
||||
geetest_data = payload.get("data", {}).get("geetest", {})
|
||||
code_data = geetest_data.get("code_data", {})
|
||||
gt = code_data.get("gt", "")
|
||||
challenge = code_data.get("challenge", "")
|
||||
code_token = geetest_data.get("code_token", "")
|
||||
return gt, challenge, code_token
|
||||
|
||||
def extract_remote_code(self, payload: dict) -> str:
|
||||
@@ -101,7 +103,7 @@ class LoginAPIStrategy(ABC):
|
||||
Returns:
|
||||
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:
|
||||
"""从验证码响应提取登录 URL
|
||||
@@ -112,27 +114,27 @@ class LoginAPIStrategy(ABC):
|
||||
Returns:
|
||||
登录回调 URL
|
||||
"""
|
||||
data = payload.get('data', {})
|
||||
data = payload.get("data", {})
|
||||
if not isinstance(data, dict):
|
||||
return ''
|
||||
return ""
|
||||
|
||||
# `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)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return ''
|
||||
return ""
|
||||
|
||||
def extract_mobile_bind_unique_key(self, payload: dict) -> str:
|
||||
"""提取服务端要求绑定手机号时返回的一次性继续登录标识。"""
|
||||
data = payload.get('data', {})
|
||||
data = payload.get("data", {})
|
||||
if not isinstance(data, dict):
|
||||
return ''
|
||||
return ""
|
||||
|
||||
def find_unique_key(value: object) -> str:
|
||||
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:
|
||||
return unique_key
|
||||
for child in value.values():
|
||||
@@ -144,14 +146,14 @@ class LoginAPIStrategy(ABC):
|
||||
result = find_unique_key(child)
|
||||
if result:
|
||||
return result
|
||||
return ''
|
||||
return ""
|
||||
|
||||
return find_unique_key(data)
|
||||
|
||||
def build_skip_mobile_bind_data(self, unique_key: str) -> dict:
|
||||
"""构建跳过手机号绑定、继续完成网页登录的请求参数。"""
|
||||
return {
|
||||
'type': '3',
|
||||
'uniqueKey': unique_key,
|
||||
'biz_type': '1',
|
||||
"type": "3",
|
||||
"uniqueKey": unique_key,
|
||||
"biz_type": "1",
|
||||
}
|
||||
|
||||
@@ -42,9 +42,11 @@ class IframeLoginAPI(LoginAPIStrategy):
|
||||
def encrypt_username(self, username: str) -> str:
|
||||
"""RC4 加密后 URL 编码"""
|
||||
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 编码)。
|
||||
@@ -52,17 +54,17 @@ class IframeLoginAPI(LoginAPIStrategy):
|
||||
# 第一次登录用 wgapi 接口,需要 base64 编码的用户名
|
||||
encrypted_username = encrypt_nickname_or_phone(username)
|
||||
return {
|
||||
'type': '1',
|
||||
'nicknameOrPhoneEncrypt': encrypted_username,
|
||||
'password': encrypt_password(password),
|
||||
'biz_type': '1',
|
||||
'room_id': '0',
|
||||
'redirect_url': referer,
|
||||
't': str(int(time.time() * 1000)),
|
||||
'client_id': '1',
|
||||
'did': '',
|
||||
'lang': '',
|
||||
'isMultiAccount': '0',
|
||||
"type": "1",
|
||||
"nicknameOrPhoneEncrypt": encrypted_username,
|
||||
"password": encrypt_password(password),
|
||||
"biz_type": "1",
|
||||
"room_id": "0",
|
||||
"redirect_url": referer,
|
||||
"t": str(int(time.time() * 1000)),
|
||||
"client_id": "1",
|
||||
"did": "",
|
||||
"lang": "",
|
||||
"isMultiAccount": "0",
|
||||
}
|
||||
|
||||
def build_second_login_data(
|
||||
@@ -81,46 +83,46 @@ class IframeLoginAPI(LoginAPIStrategy):
|
||||
登录使用 wgapi 接口,参数格式与 wgapi 策略一致。
|
||||
"""
|
||||
# seccode 格式: validate|jordan(requests 会自动 URL 编码)
|
||||
seccode_value = validate + '|jordan'
|
||||
seccode_value = validate + "|jordan"
|
||||
|
||||
# 使用 base64 编码(wgapi 接口要求),而不是 URL 编码
|
||||
encrypted_username = encrypt_nickname_or_phone(username)
|
||||
|
||||
return {
|
||||
'type': '1',
|
||||
'nicknameOrPhoneEncrypt': encrypted_username,
|
||||
'password': encrypt_password(password),
|
||||
'room_id': '0',
|
||||
'code_type': '1',
|
||||
'code_token': code_token,
|
||||
'gt_version': 'v3',
|
||||
'geetest_challenge': challenge,
|
||||
'geetest_validate': validate,
|
||||
'geetest_seccode': seccode_value,
|
||||
'code_data[geetest_challenge]': challenge,
|
||||
'code_data[geetest_validate]': validate,
|
||||
'code_data[geetest_seccode]': seccode_value,
|
||||
'code_data[gt_version]': 'v3',
|
||||
'code_data[code]': '',
|
||||
'redirect_url': referer,
|
||||
't': str(int(time.time() * 1000)),
|
||||
'client_id': '1',
|
||||
'did': '',
|
||||
'lang': '',
|
||||
'isMultiAccount': '0',
|
||||
'biz_type': '1',
|
||||
"type": "1",
|
||||
"nicknameOrPhoneEncrypt": encrypted_username,
|
||||
"password": encrypt_password(password),
|
||||
"room_id": "0",
|
||||
"code_type": "1",
|
||||
"code_token": code_token,
|
||||
"gt_version": "v3",
|
||||
"geetest_challenge": challenge,
|
||||
"geetest_validate": validate,
|
||||
"geetest_seccode": seccode_value,
|
||||
"code_data[geetest_challenge]": challenge,
|
||||
"code_data[geetest_validate]": validate,
|
||||
"code_data[geetest_seccode]": seccode_value,
|
||||
"code_data[gt_version]": "v3",
|
||||
"code_data[code]": "",
|
||||
"redirect_url": referer,
|
||||
"t": str(int(time.time() * 1000)),
|
||||
"client_id": "1",
|
||||
"did": "",
|
||||
"lang": "",
|
||||
"isMultiAccount": "0",
|
||||
"biz_type": "1",
|
||||
}
|
||||
|
||||
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:
|
||||
"""构建提交验证码参数"""
|
||||
return {
|
||||
'veify_type': '2', # e 语言原文如此,斗鱼接口拼写
|
||||
'phoneCaptcha': captcha,
|
||||
'client_id': '1',
|
||||
'isMultiAccount': '0',
|
||||
'cacheId': code,
|
||||
"veify_type": "2", # e 语言原文如此,斗鱼接口拼写
|
||||
"phoneCaptcha": captcha,
|
||||
"client_id": "1",
|
||||
"isMultiAccount": "0",
|
||||
"cacheId": code,
|
||||
}
|
||||
|
||||
@@ -36,20 +36,22 @@ class WgapiLoginAPI(LoginAPIStrategy):
|
||||
"""RC4 加密后 base64 编码"""
|
||||
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 {
|
||||
'type': '1',
|
||||
'nicknameOrPhoneEncrypt': self.encrypt_username(username),
|
||||
'password': encrypt_password(password),
|
||||
'biz_type': '1',
|
||||
'room_id': '0',
|
||||
'redirect_url': referer,
|
||||
't': str(int(time.time() * 1000)),
|
||||
'client_id': '1',
|
||||
'did': '',
|
||||
'lang': '',
|
||||
'isMultiAccount': '0',
|
||||
"type": "1",
|
||||
"nicknameOrPhoneEncrypt": self.encrypt_username(username),
|
||||
"password": encrypt_password(password),
|
||||
"biz_type": "1",
|
||||
"room_id": "0",
|
||||
"redirect_url": referer,
|
||||
"t": str(int(time.time() * 1000)),
|
||||
"client_id": "1",
|
||||
"did": "",
|
||||
"lang": "",
|
||||
"isMultiAccount": "0",
|
||||
}
|
||||
|
||||
def build_second_login_data(
|
||||
@@ -65,44 +67,44 @@ class WgapiLoginAPI(LoginAPIStrategy):
|
||||
) -> dict:
|
||||
"""构建第二次登录参数(带极验验证)"""
|
||||
return {
|
||||
'type': '1',
|
||||
'nicknameOrPhoneEncrypt': self.encrypt_username(username),
|
||||
'password': encrypt_password(password),
|
||||
'room_id': '0',
|
||||
'code_type': '1',
|
||||
'code_token': code_token,
|
||||
'gt_version': 'v3',
|
||||
'geetest_challenge': challenge,
|
||||
'geetest_validate': validate,
|
||||
'geetest_seccode': seccode,
|
||||
'code_data[geetest_challenge]': challenge,
|
||||
'code_data[geetest_validate]': validate,
|
||||
'code_data[geetest_seccode]': seccode,
|
||||
'code_data[gt_version]': 'v3',
|
||||
'code_data[code]': '',
|
||||
'redirect_url': referer,
|
||||
't': str(int(time.time() * 1000)),
|
||||
'client_id': '1',
|
||||
'did': '',
|
||||
'lang': '',
|
||||
'isMultiAccount': '0',
|
||||
'biz_type': '1',
|
||||
"type": "1",
|
||||
"nicknameOrPhoneEncrypt": self.encrypt_username(username),
|
||||
"password": encrypt_password(password),
|
||||
"room_id": "0",
|
||||
"code_type": "1",
|
||||
"code_token": code_token,
|
||||
"gt_version": "v3",
|
||||
"geetest_challenge": challenge,
|
||||
"geetest_validate": validate,
|
||||
"geetest_seccode": seccode,
|
||||
"code_data[geetest_challenge]": challenge,
|
||||
"code_data[geetest_validate]": validate,
|
||||
"code_data[geetest_seccode]": seccode,
|
||||
"code_data[gt_version]": "v3",
|
||||
"code_data[code]": "",
|
||||
"redirect_url": referer,
|
||||
"t": str(int(time.time() * 1000)),
|
||||
"client_id": "1",
|
||||
"did": "",
|
||||
"lang": "",
|
||||
"isMultiAccount": "0",
|
||||
"biz_type": "1",
|
||||
}
|
||||
|
||||
def build_send_email_data(self, code: str) -> dict:
|
||||
"""构建发送邮箱验证参数"""
|
||||
return {
|
||||
'code': code,
|
||||
'client_id': '1',
|
||||
"code": code,
|
||||
"client_id": "1",
|
||||
}
|
||||
|
||||
def build_verify_data(self, code: str, captcha: str) -> dict:
|
||||
"""构建提交验证码参数"""
|
||||
return {
|
||||
'verify_type': '2',
|
||||
'captcha': captcha,
|
||||
'isMultiAccount': '0',
|
||||
'code': code,
|
||||
'client_id': '1',
|
||||
'redirect_url': '//www.douyu.com/api/passport/login',
|
||||
"verify_type": "2",
|
||||
"captcha": captcha,
|
||||
"isMultiAccount": "0",
|
||||
"code": code,
|
||||
"client_id": "1",
|
||||
"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")
|
||||
msg = data.get("msg", "") or ""
|
||||
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:
|
||||
return [], ip_match.group(1)
|
||||
|
||||
@@ -46,12 +46,12 @@ def parse_proxy_response(text: str) -> tuple[list[str], Optional[str]]:
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
if '添加白名单' in text or '白名单' in text:
|
||||
ip_match = re.search(r'(\d+\.\d+\.\d+\.\d+)', text)
|
||||
if "添加白名单" in text or "白名单" in text:
|
||||
ip_match = re.search(r"(\d+\.\d+\.\d+\.\d+)", text)
|
||||
if ip_match:
|
||||
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]
|
||||
if proxies:
|
||||
return proxies, None
|
||||
|
||||
@@ -67,7 +67,10 @@ class XiequAdapter(BaseWhitelistAdapter):
|
||||
|
||||
# 映射到统一格式
|
||||
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
|
||||
if r.get("IP") or r.get("ip")
|
||||
]
|
||||
@@ -97,7 +100,7 @@ class XiequAdapter(BaseWhitelistAdapter):
|
||||
# 频率限制,等待后重试一次
|
||||
if retry and ("频率过快" in text or "稍后" in text):
|
||||
wait = 5
|
||||
match = re.search(r'(\d+)\s*秒', text)
|
||||
match = re.search(r"(\d+)\s*秒", text)
|
||||
if match:
|
||||
wait = int(match.group(1))
|
||||
logger.info(f"白名单添加被限流,等待 {wait} 秒后重试...")
|
||||
@@ -147,9 +150,13 @@ class XiequAdapter(BaseWhitelistAdapter):
|
||||
try:
|
||||
records = self.get_whitelist()
|
||||
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)
|
||||
return True, msg
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ class XkdailiAdapter(BaseWhitelistAdapter):
|
||||
}
|
||||
base_params.update(params)
|
||||
from urllib.parse import urlencode
|
||||
|
||||
return f"{self.BASE_URL}?{urlencode(base_params)}"
|
||||
|
||||
def _parse_response(self, text: str) -> tuple[bool, str]:
|
||||
|
||||
@@ -17,11 +17,9 @@ LogFunc = Callable[[str, str], None]
|
||||
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:
|
||||
@@ -62,14 +60,16 @@ class ProxyResolver:
|
||||
return
|
||||
log_method = getattr(
|
||||
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,
|
||||
)
|
||||
log_method(message)
|
||||
|
||||
def _sync_ip(self, ip: str) -> tuple[bool, str]:
|
||||
if not self.whitelist_syncer:
|
||||
return False, '未配置白名单 UID/UKEY'
|
||||
return False, "未配置白名单 UID/UKEY"
|
||||
ok, sync_msg = self.whitelist_syncer.sync_ip(ip)
|
||||
if ok:
|
||||
self._last_synced_ip = ip
|
||||
@@ -82,14 +82,14 @@ class ProxyResolver:
|
||||
|
||||
local_ip = self.whitelist_syncer.get_local_exit_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)
|
||||
if ok:
|
||||
self._log('info', f"白名单同步成功: {sync_msg}")
|
||||
self._log("info", f"白名单同步成功: {sync_msg}")
|
||||
else:
|
||||
self._log('warning', f"白名单同步失败: {sync_msg}")
|
||||
self._log("warning", f"白名单同步失败: {sync_msg}")
|
||||
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(
|
||||
self,
|
||||
@@ -107,18 +107,18 @@ class ProxyResolver:
|
||||
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
if self._is_stopped():
|
||||
return None, '任务已停止'
|
||||
return None, "任务已停止"
|
||||
if attempt > 1:
|
||||
delay = min(attempt - 1, 2)
|
||||
if self.log_func:
|
||||
self._log('info', f'等待 {delay}s 后重试...')
|
||||
self._log("info", f"等待 {delay}s 后重试...")
|
||||
if self._wait_or_stopped(delay):
|
||||
return None, '任务已停止'
|
||||
return None, "任务已停止"
|
||||
|
||||
self._sync_local_exit_ip_if_needed(attempt)
|
||||
if self._is_stopped():
|
||||
return None, '任务已停止'
|
||||
self._log('info', f'代理预检 {attempt}/{max_attempts}: 正在获取代理')
|
||||
return None, "任务已停止"
|
||||
self._log("info", f"代理预检 {attempt}/{max_attempts}: 正在获取代理")
|
||||
|
||||
try:
|
||||
response = requests.get(self.api_url, timeout=10)
|
||||
@@ -128,38 +128,49 @@ class ProxyResolver:
|
||||
proxy_urls, whitelist_ip = parse_proxy_response(text)
|
||||
|
||||
if proxy_urls:
|
||||
self._log('info', f'获取到 {len(proxy_urls)} 个代理,并发验证')
|
||||
available, msg = verify_proxies_concurrent(proxy_urls, return_all=return_all)
|
||||
self._log("info", f"获取到 {len(proxy_urls)} 个代理,并发验证")
|
||||
available, msg = verify_proxies_concurrent(
|
||||
proxy_urls, return_all=return_all
|
||||
)
|
||||
if available:
|
||||
if not return_all:
|
||||
self._log('success', f'代理预检成功: {available}')
|
||||
self._log("success", f"代理预检成功: {available}")
|
||||
return available, msg
|
||||
last_error = msg
|
||||
self._log('warning', f"代理预检 {attempt}/{max_attempts}: {last_error}")
|
||||
self._log(
|
||||
"warning", f"代理预检 {attempt}/{max_attempts}: {last_error}"
|
||||
)
|
||||
continue
|
||||
|
||||
if whitelist_ip and self.whitelist_syncer:
|
||||
if self.sync_whitelist_once and self._has_synced_whitelist:
|
||||
last_error = f'白名单已同步但代理API仍返回白名单错误: {whitelist_ip}'
|
||||
self._log('warning', last_error)
|
||||
last_error = (
|
||||
f"白名单已同步但代理API仍返回白名单错误: {whitelist_ip}"
|
||||
)
|
||||
self._log("warning", last_error)
|
||||
continue
|
||||
|
||||
self._log('warning', f'代理需要白名单IP: {whitelist_ip},自动同步...')
|
||||
self._log(
|
||||
"warning", f"代理需要白名单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:
|
||||
self._log('info', '白名单已更新,立即重试...')
|
||||
self._log("info", "白名单已更新,立即重试...")
|
||||
continue
|
||||
return None, f'白名单同步失败: {sync_msg}'
|
||||
return None, f"白名单同步失败: {sync_msg}"
|
||||
|
||||
last_error = '代理API响应无法解析'
|
||||
self._log('warning', f"代理预检 {attempt}/{max_attempts}: {last_error}: {text[:80]}")
|
||||
last_error = "代理API响应无法解析"
|
||||
self._log(
|
||||
"warning",
|
||||
f"代理预检 {attempt}/{max_attempts}: {last_error}: {text[:80]}",
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
last_error = f'代理API请求失败: {exc}'
|
||||
self._log('warning', f"代理预检 {attempt}/{max_attempts}: {last_error}")
|
||||
last_error = f"代理API请求失败: {exc}"
|
||||
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(
|
||||
|
||||
@@ -16,24 +16,24 @@ def verify_proxy_url(proxy_url: str, timeout: tuple = (3, 5)) -> tuple[bool, str
|
||||
Returns:
|
||||
(是否可用, 消息)
|
||||
"""
|
||||
proxies = {'http': proxy_url, 'https': proxy_url}
|
||||
proxies = {"http": proxy_url, "https": proxy_url}
|
||||
|
||||
# ── 斗鱼主站可达性验证 ──
|
||||
try:
|
||||
response = requests.get(
|
||||
'https://www.douyu.com',
|
||||
"https://www.douyu.com",
|
||||
proxies=proxies,
|
||||
timeout=timeout,
|
||||
headers={'User-Agent': 'Mozilla/5.0'},
|
||||
headers={"User-Agent": "Mozilla/5.0"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return True, '代理可用 → 斗鱼可达'
|
||||
return True, "代理可用 → 斗鱼可达"
|
||||
except Exception as exc:
|
||||
err_msg = str(exc)
|
||||
if 'Tunnel connection failed' in err_msg or '503' in err_msg:
|
||||
detail = '代理拒绝连接(白名单可能未生效)'
|
||||
elif 'timed out' in err_msg.lower():
|
||||
detail = '连接超时'
|
||||
if "Tunnel connection failed" in err_msg or "503" in err_msg:
|
||||
detail = "代理拒绝连接(白名单可能未生效)"
|
||||
elif "timed out" in err_msg.lower():
|
||||
detail = "连接超时"
|
||||
else:
|
||||
detail = type(exc).__name__
|
||||
logger.debug(f"代理验证失败 [{proxy_url}]: 斗鱼主站不可达: {detail}")
|
||||
@@ -54,7 +54,7 @@ def verify_proxies_concurrent(
|
||||
return_all=True: (可用代理 URL 列表或 None, 消息)
|
||||
"""
|
||||
if not proxy_urls:
|
||||
return None, '无代理可验证'
|
||||
return None, "无代理可验证"
|
||||
|
||||
if len(proxy_urls) == 1:
|
||||
ok, msg = verify_proxy_url(proxy_urls[0], timeout)
|
||||
@@ -64,7 +64,9 @@ def verify_proxies_concurrent(
|
||||
|
||||
if return_all:
|
||||
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 = {
|
||||
executor.submit(verify_proxy_url, proxy, timeout): proxy
|
||||
for proxy in proxy_urls
|
||||
@@ -77,9 +79,11 @@ def verify_proxies_concurrent(
|
||||
except Exception:
|
||||
continue
|
||||
if available:
|
||||
logger.success(f"并发验证找到 {len(available)}/{len(proxy_urls)} 个可用代理")
|
||||
return available, f'找到 {len(available)} 个可用代理'
|
||||
return None, f'共 {len(proxy_urls)} 个代理均不可用'
|
||||
logger.success(
|
||||
f"并发验证找到 {len(available)}/{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:
|
||||
future_map = {
|
||||
@@ -99,4 +103,4 @@ def verify_proxies_concurrent(
|
||||
except Exception:
|
||||
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(),
|
||||
notify_url=os.getenv("FISH_FIN_RECHARGE_NOTIFY_URL", "").strip(),
|
||||
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:
|
||||
@@ -86,7 +87,9 @@ class FishFinRechargeClient:
|
||||
def _sign_value(value: Any) -> str:
|
||||
"""将参数转为待签名文本;对象按稳定紧凑 JSON 表示。"""
|
||||
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):
|
||||
return "true" if value else "false"
|
||||
return str(value)
|
||||
@@ -143,7 +146,9 @@ class FishFinRechargeClient:
|
||||
received = str(payload.get("sign") or "").strip().lower()
|
||||
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 请求。"""
|
||||
request_params: dict[str, Any] = {
|
||||
"app_id": self.config.app_id,
|
||||
@@ -167,27 +172,37 @@ class FishFinRechargeClient:
|
||||
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:
|
||||
sign_params = self.normalized_params(request_params)
|
||||
sign_query = "&".join(f"{key}={sign_params[key]}" for key in sorted(sign_params))
|
||||
trace_event.update({
|
||||
"url": url,
|
||||
"content_type": "application/json",
|
||||
"json_body": self._debug_payload(request_params),
|
||||
"sign_params": sign_params,
|
||||
# 不把可重放签名原文或 AppSecret 写入日志,只记录待签名串摘要。
|
||||
"sign_source_digest": hashlib.sha256(
|
||||
f"{sign_query}{method.upper()}".encode("utf-8")
|
||||
).hexdigest()[:12],
|
||||
})
|
||||
sign_query = "&".join(
|
||||
f"{key}={sign_params[key]}" for key in sorted(sign_params)
|
||||
)
|
||||
trace_event.update(
|
||||
{
|
||||
"url": url,
|
||||
"content_type": "application/json",
|
||||
"json_body": self._debug_payload(request_params),
|
||||
"sign_params": sign_params,
|
||||
# 不把可重放签名原文或 AppSecret 写入日志,只记录待签名串摘要。
|
||||
"sign_source_digest": hashlib.sha256(
|
||||
f"{sign_query}{method.upper()}".encode("utf-8")
|
||||
).hexdigest()[:12],
|
||||
}
|
||||
)
|
||||
self.trace(trace_event)
|
||||
try:
|
||||
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:
|
||||
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()
|
||||
payload = response.json()
|
||||
except requests.RequestException as exc:
|
||||
@@ -205,18 +220,22 @@ class FishFinRechargeClient:
|
||||
"message": payload.get("msg") or payload.get("message") or "",
|
||||
"out_order_id": self._response_value(payload, "out_order_id", "outOrderId"),
|
||||
"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"),
|
||||
}
|
||||
if self.config.debug:
|
||||
trace_event.update({
|
||||
"response_headers": {
|
||||
key: value
|
||||
for key, value in response.headers.items()
|
||||
if key.lower() in {"content-type", "x-request-id", "request-id"}
|
||||
},
|
||||
"response_body": self._debug_payload(payload),
|
||||
})
|
||||
trace_event.update(
|
||||
{
|
||||
"response_headers": {
|
||||
key: value
|
||||
for key, value in response.headers.items()
|
||||
if key.lower() in {"content-type", "x-request-id", "request-id"}
|
||||
},
|
||||
"response_body": self._debug_payload(payload),
|
||||
}
|
||||
)
|
||||
self.trace(trace_event)
|
||||
return payload
|
||||
|
||||
@@ -263,7 +282,11 @@ class FishFinRechargeClient:
|
||||
raise ValueError("product_id 不能为空")
|
||||
if not isinstance(buy_num, int) or isinstance(buy_num, bool) or 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 的整数")
|
||||
if not isinstance(recharge_arg, list) or not recharge_arg:
|
||||
raise ValueError("recharge_arg 必须是非空数组")
|
||||
@@ -287,7 +310,9 @@ class FishFinRechargeClient:
|
||||
out_order_id = str(out_order_id).strip()
|
||||
if not 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]:
|
||||
"""查询商户账户信息。"""
|
||||
|
||||
+193
-52
@@ -4,46 +4,78 @@ from typing import Any
|
||||
from Crypto.PublicKey import RSA
|
||||
from Crypto.Cipher import PKCS1_v1_5, AES
|
||||
|
||||
|
||||
# 随机产生4个字符组成的字符串
|
||||
def four_random_chart() -> str:
|
||||
return hex(int(65536 * (1 + random.random())))[2:][1:]
|
||||
|
||||
|
||||
# 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
|
||||
DV = 1 << DB
|
||||
t = n_obj['t']
|
||||
t = n_obj["t"]
|
||||
|
||||
result = 0
|
||||
for i in range(t):
|
||||
result += n_obj[i] * (DV ** i)
|
||||
result += n_obj[i] * (DV**i)
|
||||
|
||||
return result
|
||||
|
||||
def encrypt_data(plaintext:str) -> str:
|
||||
|
||||
def encrypt_data(plaintext: str) -> str:
|
||||
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()
|
||||
if len(hex_result) % 2 == 1:
|
||||
hex_result = '0' + hex_result
|
||||
hex_result = "0" + hex_result
|
||||
return hex_result
|
||||
|
||||
def RSA_jiami_r(str_16:str) -> str:
|
||||
|
||||
def RSA_jiami_r(str_16: str) -> str:
|
||||
global public_key
|
||||
# 你的数据
|
||||
n_data = {
|
||||
0: 134982529, 1: 254232810, 2: 164556709, 3: 234907349,
|
||||
4: 134685994, 5: 35463984, 6: 258277946, 7: 12518857,
|
||||
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,
|
||||
0: 134982529,
|
||||
1: 254232810,
|
||||
2: 164556709,
|
||||
3: 234907349,
|
||||
4: 134685994,
|
||||
5: 35463984,
|
||||
6: 258277946,
|
||||
7: 12518857,
|
||||
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,
|
||||
't': 37, 's': 0
|
||||
"t": 37,
|
||||
"s": 0,
|
||||
}
|
||||
|
||||
e = 65537
|
||||
@@ -54,11 +86,12 @@ def RSA_jiami_r(str_16:str) -> str:
|
||||
encrypted = encrypt_data(str_16)
|
||||
return encrypted
|
||||
|
||||
|
||||
# AES加密
|
||||
# 加密模式: AES-CBC
|
||||
# 密钥长度: 128位
|
||||
# IV: 固定为 "0000000000000000"
|
||||
def parse_string_to_wordarray(text:str) -> list[int]:
|
||||
def parse_string_to_wordarray(text: str) -> list[int]:
|
||||
"""将字符串转换为 WordArray 格式"""
|
||||
length = len(text)
|
||||
words = []
|
||||
@@ -81,13 +114,15 @@ def parse_string_to_wordarray(text:str) -> list[int]:
|
||||
words[word_index] |= char_code << shift
|
||||
|
||||
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 = 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 = b'0000' * 4 # "0000000000000000"
|
||||
iv = b"0000" * 4 # "0000000000000000"
|
||||
|
||||
# 填充(PKCS7)
|
||||
pad_len = 16 - len(plaintext) % 16
|
||||
@@ -100,13 +135,14 @@ def AES_O(plaintext:str, str_16:str) -> list[int]:
|
||||
# 结果是字节数组
|
||||
return list(ciphertext)
|
||||
|
||||
|
||||
# 自定义base64编码
|
||||
def geetest_base64_encode(data:list[int]) -> dict[str, Any]:
|
||||
def geetest_base64_encode(data: list[int]) -> dict[str, Any]:
|
||||
"""极验自定义Base64编码"""
|
||||
|
||||
# 配置
|
||||
charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789()'
|
||||
pad_char = '.'
|
||||
charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789()"
|
||||
pad_char = "."
|
||||
|
||||
# 位掩码 (这些是打乱的)
|
||||
masks = [7274496, 9483264, 19220, 235]
|
||||
@@ -159,13 +195,10 @@ def geetest_base64_encode(data:list[int]) -> dict[str, Any]:
|
||||
|
||||
break
|
||||
|
||||
return {
|
||||
"res": encoded,
|
||||
"end": padding
|
||||
}
|
||||
return {"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实现
|
||||
|
||||
@@ -185,7 +218,7 @@ def encrypt_string(e:str, t:list[int], n:str) -> str:
|
||||
|
||||
# 每次读取2个字符(十六进制)
|
||||
while o < len(n):
|
||||
r = n[o:o + 2] # 取2个字符
|
||||
r = n[o : o + 2] # 取2个字符
|
||||
if len(r) < 2:
|
||||
break
|
||||
o += 2
|
||||
@@ -205,7 +238,7 @@ def encrypt_string(e:str, t:list[int], n:str) -> str:
|
||||
return i
|
||||
|
||||
|
||||
def simple_md5(message:str) -> str:
|
||||
def simple_md5(message: str) -> str:
|
||||
"""
|
||||
简化版MD5实现,结构更清晰
|
||||
"""
|
||||
@@ -216,31 +249,139 @@ def simple_md5(message:str) -> str:
|
||||
|
||||
# 轮移位常量
|
||||
shifts = [
|
||||
7, 12, 17, 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
|
||||
7,
|
||||
12,
|
||||
17,
|
||||
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 = [
|
||||
0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee,
|
||||
0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501,
|
||||
0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be,
|
||||
0x6b901122, 0xfd987193, 0xa679438e, 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
|
||||
0xD76AA478,
|
||||
0xE8C7B756,
|
||||
0x242070DB,
|
||||
0xC1BDCEEE,
|
||||
0xF57C0FAF,
|
||||
0x4787C62A,
|
||||
0xA8304613,
|
||||
0xFD469501,
|
||||
0x698098D8,
|
||||
0x8B44F7AF,
|
||||
0xFFFF5BB1,
|
||||
0x895CD7BE,
|
||||
0x6B901122,
|
||||
0xFD987193,
|
||||
0xA679438E,
|
||||
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,
|
||||
]
|
||||
|
||||
# 实际实现...
|
||||
return verify_result(message)
|
||||
return verify_result(message)
|
||||
|
||||
@@ -11,6 +11,7 @@ REQUEST_TIMEOUT = (3.05, 12)
|
||||
def pilImgToCv2(img: Image.Image, flag=cv2.COLOR_RGB2BGR):
|
||||
return cv2.cvtColor(np.asarray(img), flag)
|
||||
|
||||
|
||||
# 识别图片缺口返回滑块距离
|
||||
def shibie(img: Image.Image, slice: Image.Image):
|
||||
# 通过 pilImgToCv2 将图片置灰
|
||||
@@ -38,20 +39,70 @@ def shibie(img: Image.Image, slice: Image.Image):
|
||||
# showImg(resultBg) # 可以通过它来看处理后的图片效果
|
||||
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 = [
|
||||
39, 38, 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
|
||||
39,
|
||||
38,
|
||||
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)
|
||||
new_img = Image.new("RGB", (260,160))
|
||||
new_img = Image.new("RGB", (260, 160))
|
||||
r = 160
|
||||
for _ in range(len(Ut)):
|
||||
a = r / 2
|
||||
@@ -63,11 +114,12 @@ def restore_geetest_image(input_path:str, output_path:str) -> None:
|
||||
new_img.save(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):
|
||||
if i == 0:
|
||||
url = "https://static.geetest.com/"+bg
|
||||
url = "https://static.geetest.com/" + bg
|
||||
response = requests.get(url, timeout=REQUEST_TIMEOUT)
|
||||
response.raise_for_status()
|
||||
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()
|
||||
with open("slice.jpg", "wb") as f:
|
||||
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:
|
||||
"""解析极验 JSONP 响应。"""
|
||||
response.raise_for_status()
|
||||
match = re.search(r'\((.*)\)$', response.text)
|
||||
match = re.search(r"\((.*)\)$", response.text)
|
||||
if not match:
|
||||
raise ValueError(f"{source} 无法解析 JSONP 响应")
|
||||
|
||||
data = json.loads(match.group(1))
|
||||
if data.get('status') == 'error':
|
||||
raise ValueError(f"{source} 失败: {data.get('user_error', data.get('error', '未知错误'))}")
|
||||
if data.get("status") == "error":
|
||||
raise ValueError(
|
||||
f"{source} 失败: {data.get('user_error', data.get('error', '未知错误'))}"
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
@@ -55,26 +57,26 @@ def _parse_json_response(response: requests.Response, source: str) -> dict:
|
||||
|
||||
def get_challenge_gt_bak() -> Tuple[str, str]:
|
||||
headers = {
|
||||
'accept': 'application/json, text/javascript, */*; q=0.01',
|
||||
'accept-language': 'zh-CN,zh;q=0.9',
|
||||
'priority': 'u=1, i',
|
||||
'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-mobile': '?0',
|
||||
'sec-ch-ua-platform': '"Windows"',
|
||||
'sec-fetch-dest': 'empty',
|
||||
'sec-fetch-mode': 'cors',
|
||||
'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',
|
||||
'x-requested-with': 'XMLHttpRequest',
|
||||
"accept": "application/json, text/javascript, */*; q=0.01",
|
||||
"accept-language": "zh-CN,zh;q=0.9",
|
||||
"priority": "u=1, i",
|
||||
"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-mobile": "?0",
|
||||
"sec-ch-ua-platform": '"Windows"',
|
||||
"sec-fetch-dest": "empty",
|
||||
"sec-fetch-mode": "cors",
|
||||
"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",
|
||||
"x-requested-with": "XMLHttpRequest",
|
||||
}
|
||||
|
||||
params = {
|
||||
't': str(int(round(time.time() * 1000))),
|
||||
"t": str(int(round(time.time() * 1000))),
|
||||
}
|
||||
|
||||
response = requests.get(
|
||||
'https://demos.geetest.com/gt/register-slide',
|
||||
"https://demos.geetest.com/gt/register-slide",
|
||||
params=params,
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
@@ -85,21 +87,21 @@ def get_challenge_gt_bak() -> Tuple[str, str]:
|
||||
|
||||
def get_challenge_gt() -> Tuple[str, str]:
|
||||
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',
|
||||
'Accept': 'application/json, text/javascript, */*; q=0.01',
|
||||
'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',
|
||||
"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",
|
||||
"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",
|
||||
}
|
||||
|
||||
data = {
|
||||
'type': '1',
|
||||
'nicknameOrPhoneEncrypt': '03ILaBwtmmCm0A==',
|
||||
'password': '57219dddec71c31b7647683fa5306103',
|
||||
'biz_type': '1',
|
||||
"type": "1",
|
||||
"nicknameOrPhoneEncrypt": "03ILaBwtmmCm0A==",
|
||||
"password": "57219dddec71c31b7647683fa5306103",
|
||||
"biz_type": "1",
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
'https://passport.douyu.com/wgapi/member/passport/login',
|
||||
"https://passport.douyu.com/wgapi/member/passport/login",
|
||||
headers=headers,
|
||||
data=data,
|
||||
timeout=10,
|
||||
@@ -114,28 +116,27 @@ def get_challenge_gt() -> Tuple[str, str]:
|
||||
raise ValueError(f"斗鱼登录接口返回中缺少极验参数: {preview}") from exc
|
||||
|
||||
|
||||
|
||||
def get_js_address(gt: str, proxies: Optional[Mapping[str, str]] = None) -> dict:
|
||||
headers = {
|
||||
'accept': '*/*',
|
||||
'accept-language': 'zh-CN,zh;q=0.9',
|
||||
'referer': PASSPORT_REFERER,
|
||||
'sec-ch-ua': '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
|
||||
'sec-ch-ua-mobile': '?0',
|
||||
'sec-ch-ua-platform': '"macOS"',
|
||||
'sec-fetch-dest': 'script',
|
||||
'sec-fetch-mode': 'no-cors',
|
||||
'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',
|
||||
"accept": "*/*",
|
||||
"accept-language": "zh-CN,zh;q=0.9",
|
||||
"referer": PASSPORT_REFERER,
|
||||
"sec-ch-ua": '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"sec-ch-ua-platform": '"macOS"',
|
||||
"sec-fetch-dest": "script",
|
||||
"sec-fetch-mode": "no-cors",
|
||||
"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",
|
||||
}
|
||||
|
||||
params = {
|
||||
'gt': gt,
|
||||
'callback': 'geetest_' + str(int(round(time.time() * 1000))),
|
||||
"gt": gt,
|
||||
"callback": "geetest_" + str(int(round(time.time() * 1000))),
|
||||
}
|
||||
|
||||
response = _get(
|
||||
'https://api.geetest.com/gettype.php',
|
||||
"https://api.geetest.com/gettype.php",
|
||||
params=params,
|
||||
headers=headers,
|
||||
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")
|
||||
|
||||
|
||||
def get_c_s(
|
||||
gt: str,
|
||||
challenge: str,
|
||||
@@ -150,26 +152,32 @@ def get_c_s(
|
||||
proxies: Optional[Mapping[str, str]] = None,
|
||||
) -> Tuple[list[int], str]:
|
||||
headers = {
|
||||
'accept': '*/*',
|
||||
'accept-language': 'zh-CN,zh;q=0.9',
|
||||
'referer': PASSPORT_REFERER,
|
||||
'sec-ch-ua': '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
|
||||
'sec-ch-ua-mobile': '?0',
|
||||
'sec-ch-ua-platform': '"macOS"',
|
||||
'sec-fetch-dest': 'script',
|
||||
'sec-fetch-mode': 'no-cors',
|
||||
'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',
|
||||
"accept": "*/*",
|
||||
"accept-language": "zh-CN,zh;q=0.9",
|
||||
"referer": PASSPORT_REFERER,
|
||||
"sec-ch-ua": '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"sec-ch-ua-platform": '"macOS"',
|
||||
"sec-fetch-dest": "script",
|
||||
"sec-fetch-mode": "no-cors",
|
||||
"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",
|
||||
}
|
||||
|
||||
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(
|
||||
int(round(time.time() * 1000))),
|
||||
"https://api.geetest.com/get.php?gt="
|
||||
+ gt
|
||||
+ "&challenge="
|
||||
+ challenge
|
||||
+ "&lang=zh-cn&pt=0&client_type=web&w="
|
||||
+ w
|
||||
+ "&callback=geetest_"
|
||||
+ str(int(round(time.time() * 1000))),
|
||||
headers=headers,
|
||||
proxies=proxies,
|
||||
)
|
||||
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(
|
||||
@@ -180,159 +188,181 @@ def req_fullpage_validate(
|
||||
) -> dict:
|
||||
"""HAR 中的 fullpage 最终校验,成功后直接返回 validate。"""
|
||||
headers = {
|
||||
'Accept': '*/*',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||
'Connection': 'keep-alive',
|
||||
'Referer': PASSPORT_REFERER,
|
||||
'Sec-Fetch-Dest': 'script',
|
||||
'Sec-Fetch-Mode': 'no-cors',
|
||||
'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',
|
||||
'sec-ch-ua': '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
|
||||
'sec-ch-ua-mobile': '?0',
|
||||
'sec-ch-ua-platform': '"macOS"',
|
||||
"Accept": "*/*",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||
"Connection": "keep-alive",
|
||||
"Referer": PASSPORT_REFERER,
|
||||
"Sec-Fetch-Dest": "script",
|
||||
"Sec-Fetch-Mode": "no-cors",
|
||||
"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",
|
||||
"sec-ch-ua": '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"sec-ch-ua-platform": '"macOS"',
|
||||
}
|
||||
|
||||
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(
|
||||
int(round(time.time() * 1000))),
|
||||
"https://api.geetest.com/ajax.php?gt="
|
||||
+ gt
|
||||
+ "&challenge="
|
||||
+ challenge
|
||||
+ "&lang=zh-cn&pt=0&client_type=web&w="
|
||||
+ w
|
||||
+ "&callback=geetest_"
|
||||
+ str(int(round(time.time() * 1000))),
|
||||
headers=headers,
|
||||
proxies=proxies,
|
||||
)
|
||||
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 = {
|
||||
'Accept': '*/*',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||
'Connection': 'keep-alive',
|
||||
'Referer': 'https://demos.geetest.com/',
|
||||
'Sec-Fetch-Dest': 'script',
|
||||
'Sec-Fetch-Mode': 'no-cors',
|
||||
'Sec-Fetch-Site': 'cross-site',
|
||||
'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',
|
||||
'sec-ch-ua': '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"',
|
||||
'sec-ch-ua-mobile': '?0',
|
||||
'sec-ch-ua-platform': '"Windows"',
|
||||
"Accept": "*/*",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||
"Connection": "keep-alive",
|
||||
"Referer": "https://demos.geetest.com/",
|
||||
"Sec-Fetch-Dest": "script",
|
||||
"Sec-Fetch-Mode": "no-cors",
|
||||
"Sec-Fetch-Site": "cross-site",
|
||||
"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",
|
||||
"sec-ch-ua": '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"',
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"sec-ch-ua-platform": '"Windows"',
|
||||
}
|
||||
|
||||
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(
|
||||
int(round(time.time() * 1000))),
|
||||
"https://api.geevisit.com/ajax.php?gt="
|
||||
+ gt
|
||||
+ "&challenge="
|
||||
+ challenge
|
||||
+ "&lang=zh-cn&pt=0&client_type=web&w="
|
||||
+ w2
|
||||
+ "&callback=geetest_"
|
||||
+ str(int(round(time.time() * 1000))),
|
||||
headers=headers,
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
)
|
||||
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 = {
|
||||
'Accept': '*/*',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||
'Connection': 'keep-alive',
|
||||
'Referer': 'https://demos.geetest.com/',
|
||||
'Sec-Fetch-Dest': 'script',
|
||||
'Sec-Fetch-Mode': 'no-cors',
|
||||
'Sec-Fetch-Site': 'cross-site',
|
||||
'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',
|
||||
'sec-ch-ua': '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"',
|
||||
'sec-ch-ua-mobile': '?0',
|
||||
'sec-ch-ua-platform': '"Windows"',
|
||||
"Accept": "*/*",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||
"Connection": "keep-alive",
|
||||
"Referer": "https://demos.geetest.com/",
|
||||
"Sec-Fetch-Dest": "script",
|
||||
"Sec-Fetch-Mode": "no-cors",
|
||||
"Sec-Fetch-Site": "cross-site",
|
||||
"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",
|
||||
"sec-ch-ua": '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"',
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"sec-ch-ua-platform": '"Windows"',
|
||||
}
|
||||
params = {
|
||||
'is_next': 'true',
|
||||
'type': 'slide3',
|
||||
'gt': gt,
|
||||
'challenge': challenge,
|
||||
'lang': 'zh-cn',
|
||||
'https': 'true',
|
||||
'protocol': 'https://',
|
||||
'offline': 'false',
|
||||
'product': 'embed',
|
||||
'api_server': 'api.geevisit.com',
|
||||
'isPC': 'true',
|
||||
'autoReset': 'true',
|
||||
'width': '100%',
|
||||
'callback': 'geetest_'+str(int(round(time.time() * 1000))),
|
||||
"is_next": "true",
|
||||
"type": "slide3",
|
||||
"gt": gt,
|
||||
"challenge": challenge,
|
||||
"lang": "zh-cn",
|
||||
"https": "true",
|
||||
"protocol": "https://",
|
||||
"offline": "false",
|
||||
"product": "embed",
|
||||
"api_server": "api.geevisit.com",
|
||||
"isPC": "true",
|
||||
"autoReset": "true",
|
||||
"width": "100%",
|
||||
"callback": "geetest_" + str(int(round(time.time() * 1000))),
|
||||
}
|
||||
|
||||
response = requests.get(
|
||||
'https://api.geevisit.com/get.php',
|
||||
"https://api.geevisit.com/get.php",
|
||||
params=params,
|
||||
headers=headers,
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
logger.debug("极验 get.php 原始响应: {}", response.text[:500])
|
||||
match = re.search(r'\((.*)\)$', response.text)
|
||||
match = re.search(r"\((.*)\)$", response.text)
|
||||
if match:
|
||||
json_str = match.group(1)
|
||||
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 (
|
||||
inner_data.get('bg', ''),
|
||||
inner_data.get('fullbg', ''),
|
||||
inner_data.get('c', []),
|
||||
inner_data.get('s', ''),
|
||||
inner_data.get('slice', ''),
|
||||
inner_data.get('challenge', '')
|
||||
inner_data.get("bg", ""),
|
||||
inner_data.get("fullbg", ""),
|
||||
inner_data.get("c", []),
|
||||
inner_data.get("s", ""),
|
||||
inner_data.get("slice", ""),
|
||||
inner_data.get("challenge", ""),
|
||||
)
|
||||
else:
|
||||
# 旧版极验格式
|
||||
return (
|
||||
data.get('bg', ''),
|
||||
data.get('fullbg', ''),
|
||||
data.get('c', []),
|
||||
data.get('s', ''),
|
||||
data.get('slice', ''),
|
||||
data.get('challenge', '')
|
||||
data.get("bg", ""),
|
||||
data.get("fullbg", ""),
|
||||
data.get("c", []),
|
||||
data.get("s", ""),
|
||||
data.get("slice", ""),
|
||||
data.get("challenge", ""),
|
||||
)
|
||||
else:
|
||||
raise ValueError("无法解析 JSONP 响应")
|
||||
|
||||
def req_end(gt:str, challenge:str, w:str) -> dict:
|
||||
|
||||
def req_end(gt: str, challenge: str, w: str) -> dict:
|
||||
|
||||
headers = {
|
||||
'Accept': '*/*',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||
'Connection': 'keep-alive',
|
||||
'Referer': 'https://demos.geetest.com/',
|
||||
'Sec-Fetch-Dest': 'script',
|
||||
'Sec-Fetch-Mode': 'no-cors',
|
||||
'Sec-Fetch-Site': 'cross-site',
|
||||
'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',
|
||||
'sec-ch-ua': '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"',
|
||||
'sec-ch-ua-mobile': '?0',
|
||||
'sec-ch-ua-platform': '"Windows"',
|
||||
"Accept": "*/*",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||
"Connection": "keep-alive",
|
||||
"Referer": "https://demos.geetest.com/",
|
||||
"Sec-Fetch-Dest": "script",
|
||||
"Sec-Fetch-Mode": "no-cors",
|
||||
"Sec-Fetch-Site": "cross-site",
|
||||
"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",
|
||||
"sec-ch-ua": '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"',
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"sec-ch-ua-platform": '"Windows"',
|
||||
}
|
||||
|
||||
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,
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
logger.debug("极验 ajax.php 原始响应: {}", response.text[:500])
|
||||
match = re.search(r'\((.*)\)$', response.text)
|
||||
match = re.search(r"\((.*)\)$", response.text)
|
||||
if match:
|
||||
json_str = match.group(1)
|
||||
data = json.loads(json_str)
|
||||
logger.debug("极验验证响应: {}", data)
|
||||
|
||||
# 检查验证是否成功
|
||||
if data.get('success') == 1:
|
||||
if data.get("success") == 1:
|
||||
return data
|
||||
else:
|
||||
# 如果验证失败,返回错误信息
|
||||
return {
|
||||
'success': 0,
|
||||
'message': data.get('message', '验证失败'),
|
||||
'validate': ''
|
||||
"success": 0,
|
||||
"message": data.get("message", "验证失败"),
|
||||
"validate": "",
|
||||
}
|
||||
else:
|
||||
raise ValueError("无法解析 JSONP 响应")
|
||||
|
||||
@@ -18,63 +18,70 @@ def generate_fake_performance_timing(base_time: Optional[int] = None) -> dict[st
|
||||
|
||||
# 定义合理的时间间隔范围(毫秒)
|
||||
intervals = {
|
||||
'fetch': random.randint(1, 2),
|
||||
'domain_lookup_start': random.randint(3, 5),
|
||||
'domain_lookup': random.randint(5, 15),
|
||||
'connect': random.randint(50, 150),
|
||||
'ssl_offset': random.randint(30, 50),
|
||||
'request': random.randint(1, 5),
|
||||
'response': random.randint(20, 100),
|
||||
'response_end': random.randint(1, 3),
|
||||
'unload_start': random.randint(1, 3),
|
||||
'unload': random.randint(1, 5),
|
||||
'dom_loading': random.randint(1, 3),
|
||||
'dom_interactive': random.randint(50, 200),
|
||||
'dom_content_loaded': random.randint(1, 3),
|
||||
'load_event': random.randint(0, 5)
|
||||
"fetch": random.randint(1, 2),
|
||||
"domain_lookup_start": random.randint(3, 5),
|
||||
"domain_lookup": random.randint(5, 15),
|
||||
"connect": random.randint(50, 150),
|
||||
"ssl_offset": random.randint(30, 50),
|
||||
"request": random.randint(1, 5),
|
||||
"response": random.randint(20, 100),
|
||||
"response_end": random.randint(1, 3),
|
||||
"unload_start": random.randint(1, 3),
|
||||
"unload": random.randint(1, 5),
|
||||
"dom_loading": random.randint(1, 3),
|
||||
"dom_interactive": random.randint(50, 200),
|
||||
"dom_content_loaded": random.randint(1, 3),
|
||||
"load_event": random.randint(0, 5),
|
||||
}
|
||||
|
||||
timing = {}
|
||||
|
||||
# 按照时间顺序构建
|
||||
timing['navigationStart'] = base_time
|
||||
timing['fetchStart'] = timing['navigationStart'] + intervals['fetch']
|
||||
timing['domainLookupStart'] = timing['fetchStart'] + intervals['domain_lookup_start']
|
||||
timing['domainLookupEnd'] = timing['domainLookupStart'] + intervals['domain_lookup']
|
||||
timing["navigationStart"] = base_time
|
||||
timing["fetchStart"] = timing["navigationStart"] + intervals["fetch"]
|
||||
timing["domainLookupStart"] = (
|
||||
timing["fetchStart"] + intervals["domain_lookup_start"]
|
||||
)
|
||||
timing["domainLookupEnd"] = timing["domainLookupStart"] + intervals["domain_lookup"]
|
||||
|
||||
timing['connectStart'] = timing['domainLookupEnd']
|
||||
timing['secureConnectionStart'] = timing['connectStart'] + intervals['ssl_offset']
|
||||
timing['connectEnd'] = timing['connectStart'] + intervals['connect']
|
||||
timing["connectStart"] = timing["domainLookupEnd"]
|
||||
timing["secureConnectionStart"] = timing["connectStart"] + intervals["ssl_offset"]
|
||||
timing["connectEnd"] = timing["connectStart"] + intervals["connect"]
|
||||
|
||||
timing['requestStart'] = timing['connectEnd'] + intervals['request']
|
||||
timing['responseStart'] = timing['requestStart'] + intervals['response']
|
||||
timing['responseEnd'] = timing['responseStart'] + intervals['response_end']
|
||||
timing["requestStart"] = timing["connectEnd"] + intervals["request"]
|
||||
timing["responseStart"] = timing["requestStart"] + intervals["response"]
|
||||
timing["responseEnd"] = timing["responseStart"] + intervals["response_end"]
|
||||
|
||||
timing['unloadEventStart'] = timing['responseEnd'] + intervals['unload_start']
|
||||
timing['unloadEventEnd'] = timing['unloadEventStart'] + intervals['unload']
|
||||
timing["unloadEventStart"] = timing["responseEnd"] + intervals["unload_start"]
|
||||
timing["unloadEventEnd"] = timing["unloadEventStart"] + intervals["unload"]
|
||||
|
||||
timing['domLoading'] = timing['unloadEventEnd'] + intervals['dom_loading']
|
||||
timing['domInteractive'] = timing['domLoading'] + intervals['dom_interactive']
|
||||
timing['domContentLoadedEventStart'] = timing['domInteractive']
|
||||
timing['domContentLoadedEventEnd'] = timing['domInteractive'] + intervals['dom_content_loaded']
|
||||
timing['domComplete'] = timing['domContentLoadedEventEnd']
|
||||
timing['loadEventStart'] = timing['domComplete']
|
||||
timing['loadEventEnd'] = timing['loadEventStart'] + intervals['load_event']
|
||||
timing["domLoading"] = timing["unloadEventEnd"] + intervals["dom_loading"]
|
||||
timing["domInteractive"] = timing["domLoading"] + intervals["dom_interactive"]
|
||||
timing["domContentLoadedEventStart"] = timing["domInteractive"]
|
||||
timing["domContentLoadedEventEnd"] = (
|
||||
timing["domInteractive"] + intervals["dom_content_loaded"]
|
||||
)
|
||||
timing["domComplete"] = timing["domContentLoadedEventEnd"]
|
||||
timing["loadEventStart"] = timing["domComplete"]
|
||||
timing["loadEventEnd"] = timing["loadEventStart"] + intervals["load_event"]
|
||||
|
||||
# 无重定向的情况
|
||||
timing['redirectStart'] = 0
|
||||
timing['redirectEnd'] = 0
|
||||
timing["redirectStart"] = 0
|
||||
timing["redirectEnd"] = 0
|
||||
|
||||
return timing
|
||||
|
||||
|
||||
def __ease_out_expo(sep):
|
||||
'''
|
||||
轨迹相关操作
|
||||
'''
|
||||
"""
|
||||
轨迹相关操作
|
||||
"""
|
||||
if sep == 1:
|
||||
return 1
|
||||
else:
|
||||
return 1 - pow(2, -10 * sep)
|
||||
|
||||
|
||||
def get_slide_track(distance):
|
||||
"""
|
||||
根据滑动距离生成滑动轨迹
|
||||
@@ -86,7 +93,9 @@ def get_slide_track(distance):
|
||||
"""
|
||||
|
||||
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 = [
|
||||
[random.randint(-50, -10), random.randint(-50, -10), 0],
|
||||
@@ -110,4 +119,4 @@ def get_slide_track(distance):
|
||||
slide_track.append([x, _y, t])
|
||||
_x = x
|
||||
slide_track.append(slide_track[-1])
|
||||
return slide_track, slide_track[-1][2]
|
||||
return slide_track, slide_track[-1][2]
|
||||
|
||||
@@ -2,25 +2,60 @@ import time
|
||||
import random
|
||||
import json
|
||||
from loguru import logger
|
||||
from core.geetest.common.trajectory import generate_realistic_trajectory, 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.trajectory import (
|
||||
generate_realistic_trajectory,
|
||||
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.network import 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
|
||||
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.performance import (
|
||||
generate_fake_performance_timing,
|
||||
get_slide_track,
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
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)
|
||||
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()
|
||||
# 映射
|
||||
@@ -45,7 +80,7 @@ def get_w2(gt:str, challenge:str, c:list[int], s:str,str_16:str) -> str:
|
||||
"r": fake_timing["domContentLoadedEventEnd"],
|
||||
"s": fake_timing["domComplete"],
|
||||
"t": fake_timing["loadEventStart"],
|
||||
"u": fake_timing["loadEventEnd"]
|
||||
"u": fake_timing["loadEventEnd"],
|
||||
}
|
||||
|
||||
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),
|
||||
end_x=853,
|
||||
end_y=288,
|
||||
start_time=first_time
|
||||
start_time=first_time,
|
||||
)
|
||||
|
||||
|
||||
trajectory = process_mouse_trajectory(guiji_yuanshu_shuzu)["data"]
|
||||
compressed = compress_trajectory(trajectory)
|
||||
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)
|
||||
|
||||
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))
|
||||
|
||||
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()
|
||||
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"],
|
||||
"s": fake_timing["domComplete"],
|
||||
"t": fake_timing["loadEventStart"],
|
||||
"u": fake_timing["loadEventEnd"]
|
||||
"u": fake_timing["loadEventEnd"],
|
||||
}
|
||||
|
||||
|
||||
|
||||
trajectory = get_slide_track(hkjl)[0]
|
||||
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])
|
||||
|
||||
|
||||
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:
|
||||
# 16位字符串
|
||||
@@ -154,7 +214,7 @@ def run_solver() -> None:
|
||||
hkjl = download_picture(bg, fullbg, slice)
|
||||
|
||||
# 获取第三个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)
|
||||
|
||||
+29
-13
@@ -26,6 +26,7 @@
|
||||
python -m core.huya.account_env <账号> <密码> --new-device # 抛弃旧环境, 换新设备
|
||||
python -m core.huya.account_env <账号> --show # 只查看该账号绑定
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
@@ -35,10 +36,10 @@ import sys
|
||||
import time
|
||||
|
||||
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,
|
||||
_save_db,
|
||||
get_profile, # 幂等画像: 同账号永远复用同一套 (data/huya_device_profiles.json)
|
||||
get_profile, # 幂等画像: 同账号永远复用同一套 (data/huya_device_profiles.json)
|
||||
)
|
||||
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")
|
||||
changed = True
|
||||
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
|
||||
if changed:
|
||||
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,
|
||||
force_new_device: bool = False, proxies: dict | None = None) -> dict:
|
||||
def bind_and_login(
|
||||
account: str,
|
||||
password: str,
|
||||
force_new_device: bool = False,
|
||||
proxies: dict | None = None,
|
||||
) -> dict:
|
||||
"""账号 ↔ 环境绑定并登录。
|
||||
|
||||
注册链在 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)。
|
||||
"""
|
||||
env = get_or_create_env(account, force_new=force_new_device)
|
||||
print(f"[env] {account} ↔ {env.get('vendor')}/{env.get('model')} "
|
||||
f"fp40={env.get('fingerprint', '')[:12]}... guid32={env.get('guid32', '')[:12]}... "
|
||||
f"t1.t0={env.get('hdid', '')[:12]}...")
|
||||
print(
|
||||
f"[env] {account} ↔ {env.get('vendor')}/{env.get('model')} "
|
||||
f"fp40={env.get('fingerprint', '')[:12]}... guid32={env.get('guid32', '')[:12]}... "
|
||||
f"t1.t0={env.get('hdid', '')[:12]}..."
|
||||
)
|
||||
|
||||
# device_info 显式传入本环境 (否则 HuyaAppPasswordLogin 内部会再走一次
|
||||
# get_profile —— 结果相同, 但显式传入让"环境→注册→登录"的数据流向可读);
|
||||
@@ -108,13 +117,16 @@ def bind_and_login(account: str, password: str,
|
||||
login_env.setdefault("device_id", env.get("device_id"))
|
||||
|
||||
result = HuyaAppPasswordLogin(
|
||||
account, password, proxies=proxies, device_info=login_env,
|
||||
account,
|
||||
password,
|
||||
proxies=proxies,
|
||||
device_info=login_env,
|
||||
).login()
|
||||
|
||||
# ---- 绑定元数据回写 (令牌不入库: t2/t5 每次登录实时签发, 环境才是长期身份) ----
|
||||
db = _load_db()
|
||||
record = dict(db.get(account) or env)
|
||||
record.setdefault("bound_at", int(time.time())) # 首次绑定时间
|
||||
record.setdefault("bound_at", int(time.time())) # 首次绑定时间
|
||||
record["last_login"] = {
|
||||
"ok": result.success,
|
||||
"msg": result.message[:120],
|
||||
@@ -125,9 +137,13 @@ def bind_and_login(account: str, password: str,
|
||||
|
||||
return {
|
||||
"account": account,
|
||||
"env": {"model": env.get("model"), "vendor": env.get("vendor"),
|
||||
"fingerprint": env.get("fingerprint"), "guid32": env.get("guid32"),
|
||||
"hdid_t1t0": env.get("hdid")},
|
||||
"env": {
|
||||
"model": env.get("model"),
|
||||
"vendor": env.get("vendor"),
|
||||
"fingerprint": env.get("fingerprint"),
|
||||
"guid32": env.get("guid32"),
|
||||
"hdid_t1t0": env.get("hdid"),
|
||||
},
|
||||
"login_success": result.success,
|
||||
"login_message": result.message,
|
||||
"code": getattr(result, "code", None),
|
||||
|
||||
@@ -245,7 +245,9 @@ class ScoreExchangePrizeResp(TafStruct):
|
||||
self.msg = ins.read_string(1, default=self.msg)
|
||||
self.orderId = ins.read_string(3, default=self.orderId)
|
||||
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):
|
||||
os.write_int32(0, self.status)
|
||||
@@ -512,7 +514,9 @@ class ActTaskDetailItem(TafStruct):
|
||||
|
||||
@property
|
||||
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:
|
||||
return {
|
||||
|
||||
@@ -172,7 +172,11 @@ def register_huya_with_sms_line(
|
||||
if login_result.success and login_result.cookie:
|
||||
cookie = login_result.cookie
|
||||
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:
|
||||
return HuyaAutoRegisterResult(
|
||||
phone=phone,
|
||||
@@ -207,7 +211,9 @@ def register_huya_with_sms_line(
|
||||
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(
|
||||
uid=uid,
|
||||
cookie=cookie,
|
||||
@@ -220,7 +226,9 @@ def register_huya_with_sms_line(
|
||||
stop_event=stop_event,
|
||||
)
|
||||
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(
|
||||
phone=phone,
|
||||
provider=item.provider,
|
||||
@@ -230,7 +238,9 @@ def register_huya_with_sms_line(
|
||||
cookie=cookie,
|
||||
code=poll_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,
|
||||
username=username,
|
||||
uid=uid,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
证书格式:base64( [0x0c][key_idx][AES-128-ECB(key16, zeropad(P1))] )
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
@@ -48,7 +49,7 @@ def parse_p1(data: bytes) -> dict:
|
||||
|
||||
def tk(n: int) -> bytes:
|
||||
nonlocal o
|
||||
b = data[o:o + n]
|
||||
b = data[o : o + n]
|
||||
o += n
|
||||
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:
|
||||
"""账号专属指纹状态目录 (持久化, 保证同一账号多次登录是同一台'设备')。"""
|
||||
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
|
||||
|
||||
|
||||
DEFAULT_TIMEOUT = (8, 40)
|
||||
|
||||
|
||||
@@ -52,7 +55,9 @@ class HuyaSdidResult:
|
||||
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)。
|
||||
|
||||
若 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():
|
||||
line = line.strip()
|
||||
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:
|
||||
hdid = line[len(HDID_PREFIX):]
|
||||
hdid = line[len(HDID_PREFIX) :]
|
||||
if sdid:
|
||||
return sdid, hdid
|
||||
stderr_tail = (proc.stderr or "").strip().splitlines()
|
||||
@@ -168,8 +173,9 @@ def get_huya_sdid(
|
||||
try:
|
||||
sdid, hdid = _run_node_runner(state_dir, app_id, timeout)
|
||||
if sdid:
|
||||
logger.debug("虎牙设备指纹成功(node): sdid={}... hdid={}...",
|
||||
sdid[:24], hdid[:10])
|
||||
logger.debug(
|
||||
"虎牙设备指纹成功(node): sdid={}... hdid={}...", sdid[:24], hdid[:10]
|
||||
)
|
||||
return HuyaSdidResult(sdid=sdid, hdid=hdid, source="fingerprint")
|
||||
except HuyaFingerprintError as exc:
|
||||
logger.warning("虎牙 hydevice 指纹失败: {}", exc)
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
- 固定: hdid(32hex 硬锚,全账号同一) / app_version / sdk_version;
|
||||
- 动态签发: safedeviceid、登录帧 device_id。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
@@ -85,6 +86,7 @@ def record_login(account: str, ok: bool, message: str = "") -> None:
|
||||
供 app_login 登录流程调用, GUI 设备绑定页读取展示。
|
||||
"""
|
||||
import time as _time
|
||||
|
||||
db = _load_db()
|
||||
rec = db.get(account)
|
||||
if rec is None:
|
||||
@@ -112,7 +114,9 @@ def _load_db() -> dict:
|
||||
def _save_db(db: dict) -> None:
|
||||
try:
|
||||
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:
|
||||
pass
|
||||
|
||||
@@ -130,8 +134,12 @@ def _enrich_profile(profile: dict) -> tuple[dict, bool]:
|
||||
out["guid32"] = hashlib.sha256(os.urandom(16) + b"guid").hexdigest()
|
||||
changed = True
|
||||
if len(out.get("hebe") or {}) < 5:
|
||||
out["hebe"] = {f"Hebe_D{i}": hashlib.sha256(os.urandom(16) + f"hebe{i}".encode()).hexdigest()
|
||||
for i in range(1, 6)}
|
||||
out["hebe"] = {
|
||||
f"Hebe_D{i}": hashlib.sha256(
|
||||
os.urandom(16) + f"hebe{i}".encode()
|
||||
).hexdigest()
|
||||
for i in range(1, 6)
|
||||
}
|
||||
changed = True
|
||||
return out, changed
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
基于 XXTEA 算法与 uid + k1 派生密钥。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
@@ -27,13 +28,23 @@ def _xxtea_encrypt_words(v: list[int], k: list[int]) -> list[int]:
|
||||
p = 0
|
||||
while p < n - 1:
|
||||
y = v[p + 1]
|
||||
z = (v[p] + ((((z >> 5) ^ (y << 2)) + ((y >> 3) ^ (z << 4)))
|
||||
^ ((s ^ y) + (k[(p & 3) ^ e] ^ z)))) & 0xFFFFFFFF
|
||||
z = (
|
||||
v[p]
|
||||
+ (
|
||||
(((z >> 5) ^ (y << 2)) + ((y >> 3) ^ (z << 4)))
|
||||
^ ((s ^ y) + (k[(p & 3) ^ e] ^ z))
|
||||
)
|
||||
) & 0xFFFFFFFF
|
||||
v[p] = z
|
||||
p += 1
|
||||
y = v[0]
|
||||
z = (v[n - 1] + ((((z >> 5) ^ (y << 2)) + ((y >> 3) ^ (z << 4)))
|
||||
^ ((s ^ y) + (k[((n - 1) & 3) ^ e] ^ z)))) & 0xFFFFFFFF
|
||||
z = (
|
||||
v[n - 1]
|
||||
+ (
|
||||
(((z >> 5) ^ (y << 2)) + ((y >> 3) ^ (z << 4)))
|
||||
^ ((s ^ y) + (k[((n - 1) & 3) ^ e] ^ z))
|
||||
)
|
||||
) & 0xFFFFFFFF
|
||||
v[n - 1] = z
|
||||
q -= 1
|
||||
return v
|
||||
@@ -45,9 +56,9 @@ def xxtea_encrypt(data: bytes, key16: bytes) -> bytes:
|
||||
nwords = (n // 4) + 1
|
||||
v = [0] * nwords
|
||||
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
|
||||
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)
|
||||
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
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Optional
|
||||
from .taf_protocol import TafOutputStream, TafInputStream, TafStruct, TafType
|
||||
|
||||
@@ -78,17 +79,19 @@ def _skip_to_struct_end(ins: TafInputStream):
|
||||
# 基础结构
|
||||
# ============================================================
|
||||
|
||||
|
||||
class UserId(TafStruct):
|
||||
"""用户标识(cookie 在这里)"""
|
||||
|
||||
def __init__(self):
|
||||
self.lUid: int = 0 # tag 0
|
||||
self.sGuid: str = "" # tag 1
|
||||
self.sToken: str = "" # tag 2
|
||||
self.sHuYaUA: str = "" # tag 3 "webh5&0.0.1&websocket&&diypc_52775"
|
||||
self.sCookie: str = "" # tag 4 完整 cookie
|
||||
self.iTokenType: int = 0 # tag 5
|
||||
self.sDeviceInfo: str = "" # tag 6
|
||||
self.sQIMEI: str = "" # tag 7
|
||||
self.lUid: int = 0 # tag 0
|
||||
self.sGuid: str = "" # tag 1
|
||||
self.sToken: str = "" # tag 2
|
||||
self.sHuYaUA: str = "" # tag 3 "webh5&0.0.1&websocket&&diypc_52775"
|
||||
self.sCookie: str = "" # tag 4 完整 cookie
|
||||
self.iTokenType: int = 0 # tag 5
|
||||
self.sDeviceInfo: str = "" # tag 6
|
||||
self.sQIMEI: str = "" # tag 7
|
||||
|
||||
def write_to(self, os: TafOutputStream):
|
||||
# HAR2 实证: 浏览器不优化空串/0值, 全写字段
|
||||
@@ -114,11 +117,12 @@ class UserId(TafStruct):
|
||||
|
||||
class ShopAppInfo(TafStruct):
|
||||
"""应用信息(HAR2 实证字段顺序: tag0 sAppId, tag1 sBizType, tag4 scene, tag5 sourceId)"""
|
||||
|
||||
def __init__(self):
|
||||
self.sAppId: str = "huya" # tag 0
|
||||
self.sBizType: str = "" # tag 1
|
||||
self.scene: int = 0 # tag 4
|
||||
self.sourceId: str = "" # tag 5
|
||||
self.sAppId: str = "huya" # tag 0
|
||||
self.sBizType: str = "" # tag 1
|
||||
self.scene: int = 0 # tag 4
|
||||
self.sourceId: str = "" # tag 5
|
||||
|
||||
def write_to(self, os: TafOutputStream):
|
||||
# HAR2 实证: 全写字段(含空串/0)
|
||||
@@ -138,14 +142,16 @@ class ShopAppInfo(TafStruct):
|
||||
# wsLaunch 初始化
|
||||
# ============================================================
|
||||
|
||||
|
||||
class WsLaunchSubStruct(TafStruct):
|
||||
"""wsLaunch tag4 子结构(5个空字符串字段,浏览器强制写)"""
|
||||
|
||||
def __init__(self):
|
||||
self.s0: str = "" # tag 0
|
||||
self.s1: str = "" # tag 1
|
||||
self.s2: str = "" # tag 2
|
||||
self.s3: str = "" # tag 3
|
||||
self.s4: str = "" # tag 4
|
||||
self.s0: str = "" # tag 0
|
||||
self.s1: str = "" # tag 1
|
||||
self.s2: str = "" # tag 2
|
||||
self.s3: str = "" # tag 3
|
||||
self.s4: str = "" # tag 4
|
||||
|
||||
def write_to(self, os: TafOutputStream):
|
||||
# 浏览器写空字符串(STRING1 length0),这里强制写以精确匹配
|
||||
@@ -169,11 +175,12 @@ class WsLaunchReq(TafStruct):
|
||||
tag3: appSrc "HUYA&ZH&2052"
|
||||
tag4: 子struct (5个空字符串)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.lUid: int = 0 # tag 0
|
||||
self.s1: str = "" # tag 1
|
||||
self.lUid: int = 0 # tag 0
|
||||
self.s1: str = "" # tag 1
|
||||
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
|
||||
|
||||
def write_to(self, os: TafOutputStream):
|
||||
@@ -192,18 +199,20 @@ class WsLaunchReq(TafStruct):
|
||||
# 商品查询
|
||||
# ============================================================
|
||||
|
||||
|
||||
class GetGoodsInfoReqV5(TafStruct):
|
||||
"""商品查询请求 (shopMiddleUI.getGoodsInfoV5)"""
|
||||
|
||||
def __init__(self):
|
||||
self.userId = UserId() # tag 0
|
||||
self.userId = UserId() # tag 0
|
||||
self.shopAppInfo = ShopAppInfo() # tag 1
|
||||
self.pid: int = 0 # tag 2
|
||||
self.gameId: str = "" # tag 3
|
||||
self.spuId: str = "" # tag 4
|
||||
self.channelStockCode: str = "" # tag 5
|
||||
self.skuId: int = 0 # tag 6
|
||||
self.inviterUid: int = 0 # tag 7
|
||||
self.userModifyPriceId: int = 0 # tag 8
|
||||
self.pid: int = 0 # tag 2
|
||||
self.gameId: str = "" # tag 3
|
||||
self.spuId: str = "" # tag 4
|
||||
self.channelStockCode: str = "" # tag 5
|
||||
self.skuId: int = 0 # tag 6
|
||||
self.inviterUid: int = 0 # tag 7
|
||||
self.userModifyPriceId: int = 0 # tag 8
|
||||
|
||||
def write_to(self, os: TafOutputStream):
|
||||
# HAR2 实证: 全写字段
|
||||
@@ -224,13 +233,14 @@ class GetGoodsInfoReqV5(TafStruct):
|
||||
|
||||
class GoodsInfoRsp(TafStruct):
|
||||
"""商品查询响应。"""
|
||||
|
||||
def __init__(self):
|
||||
self.code: int = 0 # tag 0
|
||||
self.message: str = "" # tag 1
|
||||
self.goodsInfo = None # tag 2
|
||||
self.selfGoods: int = 0 # tag 3
|
||||
self.marketStatus: int = 0 # tag 5
|
||||
self.timestamp: int = 0 # tag 6
|
||||
self.code: int = 0 # tag 0
|
||||
self.message: str = "" # tag 1
|
||||
self.goodsInfo = None # tag 2
|
||||
self.selfGoods: int = 0 # tag 3
|
||||
self.marketStatus: int = 0 # tag 5
|
||||
self.timestamp: int = 0 # tag 6
|
||||
|
||||
def read_from(self, ins: TafInputStream):
|
||||
self.code = ins.read_int32(0, default=self.code)
|
||||
@@ -293,6 +303,7 @@ class GoodsInfoRsp(TafStruct):
|
||||
|
||||
class GoodsBaseInfo(TafStruct):
|
||||
"""商品基础信息(getGoodsInfoV5 tag2.tag0)。"""
|
||||
|
||||
def __init__(self):
|
||||
self.spuId: str = ""
|
||||
self.appId: str = ""
|
||||
@@ -328,6 +339,7 @@ class GoodsBaseInfo(TafStruct):
|
||||
|
||||
class GoodsSkuItem(TafStruct):
|
||||
"""商品 SKU 信息(getGoodsInfoV5 tag2.tag4.tag3 map value)。"""
|
||||
|
||||
def __init__(self):
|
||||
self.skuId: int = 0
|
||||
self.spuId: str = ""
|
||||
@@ -370,6 +382,7 @@ class GoodsSkuItem(TafStruct):
|
||||
|
||||
class GoodsPriceInfo(TafStruct):
|
||||
"""商品价格与 SKU 信息(getGoodsInfoV5 tag2.tag4)。"""
|
||||
|
||||
def __init__(self):
|
||||
self.spuId: str = ""
|
||||
self.minPrice: int = 0
|
||||
@@ -435,6 +448,7 @@ class GoodsPriceInfo(TafStruct):
|
||||
|
||||
class GoodsInfoDetail(TafStruct):
|
||||
"""getGoodsInfoV5 响应里的 goodsInfo 主体。"""
|
||||
|
||||
def __init__(self):
|
||||
self.baseInfo = GoodsBaseInfo()
|
||||
self.priceInfo = GoodsPriceInfo()
|
||||
@@ -457,8 +471,10 @@ class GoodsInfoDetail(TafStruct):
|
||||
# 订单历史
|
||||
# ============================================================
|
||||
|
||||
|
||||
class OrderListShopInfo(TafStruct):
|
||||
"""订单明细里的店铺信息(只取展示需要的字段)"""
|
||||
|
||||
def __init__(self):
|
||||
self.shopName: str = "" # tag 0
|
||||
|
||||
@@ -477,14 +493,15 @@ class OrderListShopInfo(TafStruct):
|
||||
|
||||
class OrderListGoodsDetail(TafStruct):
|
||||
"""订单明细(queryUserOrderList 响应 tag16)"""
|
||||
|
||||
def __init__(self):
|
||||
self.spuId: str = "" # tag 4
|
||||
self.skuId: int = 0 # tag 16
|
||||
self.buyerUid: int = 0 # tag 18
|
||||
self.virtualType: int = 0 # tag 19
|
||||
self.quantity: int = 0 # tag 20
|
||||
self.spuId: str = "" # tag 4
|
||||
self.skuId: int = 0 # tag 16
|
||||
self.buyerUid: int = 0 # tag 18
|
||||
self.virtualType: int = 0 # tag 19
|
||||
self.quantity: int = 0 # tag 20
|
||||
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):
|
||||
self.spuId = ins.read_string(4, default=self.spuId)
|
||||
@@ -513,19 +530,20 @@ class OrderListGoodsDetail(TafStruct):
|
||||
|
||||
class OrderListItem(TafStruct):
|
||||
"""订单列表项(queryUserOrderList 响应 tag3 的 list 元素)"""
|
||||
|
||||
def __init__(self):
|
||||
self.bizOrderId: str = "" # tag 0 shop10148750
|
||||
self.appId: str = "" # tag 1 shop
|
||||
self.orderId: str = "" # tag 2
|
||||
self.pid: int = 0 # tag 3
|
||||
self.shopName: str = "" # tag 4
|
||||
self.orderStatus: int = 0 # tag 5
|
||||
self.itemName: str = "" # tag 8
|
||||
self.unitPrice: int = 0 # tag 9 分
|
||||
self.quantity: int = 0 # tag 10
|
||||
self.totalPrice: int = 0 # tag 12 分
|
||||
self.createTime: int = 0 # tag 14 毫秒时间戳
|
||||
self.payTime: int = 0 # tag 15 毫秒时间戳
|
||||
self.bizOrderId: str = "" # tag 0 shop10148750
|
||||
self.appId: str = "" # tag 1 shop
|
||||
self.orderId: str = "" # tag 2
|
||||
self.pid: int = 0 # tag 3
|
||||
self.shopName: str = "" # tag 4
|
||||
self.orderStatus: int = 0 # tag 5
|
||||
self.itemName: str = "" # tag 8
|
||||
self.unitPrice: int = 0 # tag 9 分
|
||||
self.quantity: int = 0 # tag 10
|
||||
self.totalPrice: int = 0 # tag 12 分
|
||||
self.createTime: int = 0 # tag 14 毫秒时间戳
|
||||
self.payTime: int = 0 # tag 15 毫秒时间戳
|
||||
self.goodsDetail: Optional[OrderListGoodsDetail] = None # tag 16
|
||||
|
||||
def read_from(self, ins: TafInputStream):
|
||||
@@ -567,12 +585,13 @@ class OrderListItem(TafStruct):
|
||||
|
||||
class QueryUserOrderListReq(TafStruct):
|
||||
"""查询购买历史订单 (revenueWebUI.queryUserOrderList)"""
|
||||
|
||||
def __init__(self):
|
||||
self.userId = UserId() # tag 0
|
||||
self.offset: int = 0 # tag 1
|
||||
self.orderType: int = 1 # tag 2
|
||||
self.pageSize: int = 10 # tag 3
|
||||
self.status: int = 0 # tag 4
|
||||
self.userId = UserId() # tag 0
|
||||
self.offset: int = 0 # tag 1
|
||||
self.orderType: int = 1 # tag 2
|
||||
self.pageSize: int = 10 # tag 3
|
||||
self.status: int = 0 # tag 4
|
||||
|
||||
def write_to(self, os: TafOutputStream):
|
||||
os.write_struct(0, self.userId)
|
||||
@@ -587,6 +606,7 @@ class QueryUserOrderListReq(TafStruct):
|
||||
|
||||
class QueryUserOrderListRsp(TafStruct):
|
||||
"""购买历史订单响应"""
|
||||
|
||||
def __init__(self):
|
||||
self.code: int = 0
|
||||
self.message: str = ""
|
||||
@@ -624,17 +644,18 @@ class QueryUserOrderListRsp(TafStruct):
|
||||
# 下单
|
||||
# ============================================================
|
||||
|
||||
|
||||
class CreateOrderExtraParam(TafStruct):
|
||||
def __init__(self):
|
||||
self.freight: int = 0 # tag 0
|
||||
self.channelStockType: str = "" # tag 1
|
||||
self.channelStockCode: str = "" # tag 2
|
||||
self.relatedBizId: str = "" # tag 3
|
||||
self.bizParams: str = "" # tag 4
|
||||
self.popupTraceId: str = "" # tag 5
|
||||
self.supplierUid: int = 0 # tag 6
|
||||
self.categoryId: str = "" # tag 7
|
||||
self.ext: str = "" # tag 8
|
||||
self.freight: int = 0 # tag 0
|
||||
self.channelStockType: str = "" # tag 1
|
||||
self.channelStockCode: str = "" # tag 2
|
||||
self.relatedBizId: str = "" # tag 3
|
||||
self.bizParams: str = "" # tag 4
|
||||
self.popupTraceId: str = "" # tag 5
|
||||
self.supplierUid: int = 0 # tag 6
|
||||
self.categoryId: str = "" # tag 7
|
||||
self.ext: str = "" # tag 8
|
||||
|
||||
def write_to(self, os: TafOutputStream):
|
||||
# HAR 实证:下单 extraParam 会强制写默认 0/空串字段
|
||||
@@ -655,9 +676,9 @@ class CreateOrderExtraParam(TafStruct):
|
||||
|
||||
class CreateOrderPromotionParam(TafStruct):
|
||||
def __init__(self):
|
||||
self.yxjDeductPrice: int = 0 # tag 0
|
||||
self.userModifyPriceId: int = 0 # tag 1
|
||||
self.enablePromotion: int = 1 # tag 2
|
||||
self.yxjDeductPrice: int = 0 # tag 0
|
||||
self.userModifyPriceId: int = 0 # tag 1
|
||||
self.enablePromotion: int = 1 # tag 2
|
||||
|
||||
def write_to(self, os: TafOutputStream):
|
||||
# HAR 实证:tag0/tag1 为 0,tag2 为 1
|
||||
@@ -671,11 +692,11 @@ class CreateOrderPromotionParam(TafStruct):
|
||||
|
||||
class CreateOrderAccountParam(TafStruct):
|
||||
def __init__(self):
|
||||
self.payoutTypeList: List[int] = [] # tag 0 Vector<INT32>
|
||||
self.payoutChargeAmount: int = 0 # tag 1
|
||||
self.cancelPayoutTypeList: List[int] = [] # tag 2
|
||||
self.recycleSupplierId: int = 0 # tag 3
|
||||
self.claimPrice: int = 0 # tag 4
|
||||
self.payoutTypeList: List[int] = [] # tag 0 Vector<INT32>
|
||||
self.payoutChargeAmount: int = 0 # tag 1
|
||||
self.cancelPayoutTypeList: List[int] = [] # tag 2
|
||||
self.recycleSupplierId: int = 0 # tag 3
|
||||
self.claimPrice: int = 0 # tag 4
|
||||
|
||||
def write_to(self, os: TafOutputStream):
|
||||
# HAR 实证:空 list/0 值也会写出
|
||||
@@ -691,8 +712,8 @@ class CreateOrderAccountParam(TafStruct):
|
||||
|
||||
class PromotionItem(TafStruct):
|
||||
def __init__(self):
|
||||
self.promotionId: int = 0 # tag 0
|
||||
self.promotionType: int = 0 # tag 1
|
||||
self.promotionId: int = 0 # tag 0
|
||||
self.promotionType: int = 0 # tag 1
|
||||
|
||||
def write_to(self, os: TafOutputStream):
|
||||
_opt_int(os, 0, self.promotionId)
|
||||
@@ -705,40 +726,41 @@ class PromotionItem(TafStruct):
|
||||
|
||||
class CreateOrderReqV5(TafStruct):
|
||||
"""下单请求 (shopMiddleUI.createOrderV5)"""
|
||||
|
||||
def __init__(self):
|
||||
self.userId = UserId() # tag 0
|
||||
self.shopAppInfo = ShopAppInfo() # tag 1
|
||||
self.receiveId: int = 0 # tag 2
|
||||
self.pid: int = 0 # tag 3
|
||||
self.skuId: int = 0 # tag 4
|
||||
self.itemCount: int = 1 # tag 5
|
||||
self.remark: str = "" # tag 6
|
||||
self.spuId: str = "" # tag 7
|
||||
self.gameId: str = "" # tag 8
|
||||
self.orderId: int = 0 # tag 9
|
||||
self.src: int = 0 # tag 10
|
||||
self.couponUserIds: List[int] = [] # tag 11 Vector<INT64>
|
||||
self.orderType: int = 0 # tag 12
|
||||
self.userId = UserId() # tag 0
|
||||
self.shopAppInfo = ShopAppInfo() # tag 1
|
||||
self.receiveId: int = 0 # tag 2
|
||||
self.pid: int = 0 # tag 3
|
||||
self.skuId: int = 0 # tag 4
|
||||
self.itemCount: int = 1 # tag 5
|
||||
self.remark: str = "" # tag 6
|
||||
self.spuId: str = "" # tag 7
|
||||
self.gameId: str = "" # tag 8
|
||||
self.orderId: int = 0 # tag 9
|
||||
self.src: int = 0 # tag 10
|
||||
self.couponUserIds: List[int] = [] # tag 11 Vector<INT64>
|
||||
self.orderType: int = 0 # tag 12
|
||||
self.extraParam: Optional[CreateOrderExtraParam] = None # tag 13
|
||||
self.scene: int = 0 # tag 14
|
||||
self.promotionItems: List = [] # tag 15 Vector<PromotionItem>
|
||||
self.sourceId: str = "" # tag 16
|
||||
self.env: Dict[str, str] = {} # tag 17 Map<STRING,STRING>
|
||||
self.orderScene: int = 0 # tag 18
|
||||
self.watchWord: str = "" # tag 19
|
||||
self.marketingChannel: str = "" # tag 20
|
||||
self.scene: int = 0 # tag 14
|
||||
self.promotionItems: List = [] # tag 15 Vector<PromotionItem>
|
||||
self.sourceId: str = "" # tag 16
|
||||
self.env: Dict[str, str] = {} # tag 17 Map<STRING,STRING>
|
||||
self.orderScene: int = 0 # tag 18
|
||||
self.watchWord: str = "" # tag 19
|
||||
self.marketingChannel: str = "" # tag 20
|
||||
self.promotionParam: Optional[CreateOrderPromotionParam] = None # tag 21
|
||||
self.externalTraceKey: str = "" # tag 22
|
||||
self.kefuUid: int = 0 # tag 23
|
||||
self.accountParam: Optional[CreateOrderAccountParam] = None # tag 24
|
||||
self.parentOrderId: int = 0 # tag 25
|
||||
self.vendorAccountType: str = "" # tag 26
|
||||
self.vendorAccountVal: str = "" # tag 27
|
||||
self.vendorSubAccountVal: str = "" # tag 28
|
||||
self.vendorSubAccountType: str = "" # tag 29
|
||||
self.bizType: int = 0 # tag 30
|
||||
self.gameCategoryId: int = 0 # tag 31
|
||||
self.ext: str = "" # tag 32
|
||||
self.externalTraceKey: str = "" # tag 22
|
||||
self.kefuUid: int = 0 # tag 23
|
||||
self.accountParam: Optional[CreateOrderAccountParam] = None # tag 24
|
||||
self.parentOrderId: int = 0 # tag 25
|
||||
self.vendorAccountType: str = "" # tag 26
|
||||
self.vendorAccountVal: str = "" # tag 27
|
||||
self.vendorSubAccountVal: str = "" # tag 28
|
||||
self.vendorSubAccountType: str = "" # tag 29
|
||||
self.bizType: int = 0 # tag 30
|
||||
self.gameCategoryId: int = 0 # tag 31
|
||||
self.ext: str = "" # tag 32
|
||||
|
||||
def write_to(self, os: TafOutputStream):
|
||||
# HAR 实证:createOrderV5 会写出完整字段,即使值为 0/空串/空 list
|
||||
@@ -782,13 +804,14 @@ class CreateOrderReqV5(TafStruct):
|
||||
|
||||
class CreateOrderRsp(TafStruct):
|
||||
"""下单响应 (shopMiddleUI.createOrderV5)"""
|
||||
|
||||
def __init__(self):
|
||||
self.code: int = 0 # tag 0
|
||||
self.message: str = "" # tag 1
|
||||
self.orderId: int = 0 # tag 2 虎牙订单号
|
||||
self.subOrderId: int = 0 # tag 4
|
||||
self.orderStatus: int = 0 # tag 5
|
||||
self.riskUrl: str = "" # tag 8 风控跳转URL(code!=200时可能有)
|
||||
self.code: int = 0 # tag 0
|
||||
self.message: str = "" # tag 1
|
||||
self.orderId: int = 0 # tag 2 虎牙订单号
|
||||
self.subOrderId: int = 0 # tag 4
|
||||
self.orderStatus: int = 0 # tag 5
|
||||
self.riskUrl: str = "" # tag 8 风控跳转URL(code!=200时可能有)
|
||||
|
||||
def read_from(self, ins: TafInputStream):
|
||||
self.code = ins.read_int32(0, default=self.code)
|
||||
@@ -806,19 +829,21 @@ class CreateOrderRsp(TafStruct):
|
||||
# 支付(payOrderSubmitV5)
|
||||
# ============================================================
|
||||
|
||||
|
||||
class PayOrderRes(TafStruct):
|
||||
"""
|
||||
发起支付响应 (shopMiddleUI.payOrderSubmitV5)
|
||||
结构从 state_shop_ts.js 的 payOrderRes 推断,tag 顺序按出现顺序
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.code: int = 0 # tag 0
|
||||
self.message: str = "" # tag 1
|
||||
self.orderId: int = 0 # tag 2 虎牙订单号
|
||||
self.appOrderId: str = "" # tag 3
|
||||
self.payOrderId: str = "" # tag 4 支付宝 payOrderId
|
||||
self.payUrl: str = "" # tag 5 支付宝下单URL(含sign)
|
||||
self.amount: int = 0 # tag 6
|
||||
self.code: int = 0 # tag 0
|
||||
self.message: str = "" # tag 1
|
||||
self.orderId: int = 0 # tag 2 虎牙订单号
|
||||
self.appOrderId: str = "" # tag 3
|
||||
self.payOrderId: str = "" # tag 4 支付宝 payOrderId
|
||||
self.payUrl: str = "" # tag 5 支付宝下单URL(含sign)
|
||||
self.amount: int = 0 # tag 6
|
||||
|
||||
def read_from(self, ins: TafInputStream):
|
||||
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
|
||||
0x0c ZERO 0x0d SIMPLE_LIST
|
||||
"""
|
||||
|
||||
import struct
|
||||
import io
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
@@ -24,16 +25,17 @@ class TafType:
|
||||
STRING4 = 0x07
|
||||
MAP = 0x08
|
||||
LIST = 0x09
|
||||
STRUCT_BEGIN = 0x0a
|
||||
STRUCT_END = 0x0b
|
||||
ZERO = 0x0c
|
||||
SIMPLE_LIST = 0x0d
|
||||
STRUCT_BEGIN = 0x0A
|
||||
STRUCT_END = 0x0B
|
||||
ZERO = 0x0C
|
||||
SIMPLE_LIST = 0x0D
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 输出流(编码)
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TafOutputStream:
|
||||
"""TAF 编码输出流"""
|
||||
|
||||
@@ -46,9 +48,9 @@ class TafOutputStream:
|
||||
# ---- head ----
|
||||
def write_head(self, tag: int, data_type: int):
|
||||
if tag < 15:
|
||||
self.buf.write(struct.pack('B', (tag << 4) | data_type))
|
||||
self.buf.write(struct.pack("B", (tag << 4) | data_type))
|
||||
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):
|
||||
@@ -56,28 +58,28 @@ class TafOutputStream:
|
||||
self.write_head(tag, TafType.ZERO)
|
||||
else:
|
||||
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):
|
||||
if -128 <= value <= 127:
|
||||
self.write_int8(tag, value)
|
||||
else:
|
||||
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):
|
||||
if -32768 <= value <= 32767:
|
||||
self.write_int16(tag, value)
|
||||
else:
|
||||
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):
|
||||
if -2147483648 <= value <= 2147483647:
|
||||
self.write_int32(tag, value)
|
||||
else:
|
||||
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):
|
||||
"""uint64:超过 int32 范围用 INT64"""
|
||||
@@ -85,27 +87,27 @@ class TafOutputStream:
|
||||
self.write_int32(tag, value)
|
||||
else:
|
||||
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):
|
||||
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):
|
||||
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):
|
||||
encoded = value.encode('utf-8')
|
||||
encoded = value.encode("utf-8")
|
||||
length = len(encoded)
|
||||
if length > 255:
|
||||
self.write_head(tag, TafType.STRING4)
|
||||
self.buf.write(struct.pack('>I', length))
|
||||
self.buf.write(struct.pack(">I", length))
|
||||
else:
|
||||
self.write_head(tag, TafType.STRING1)
|
||||
self.buf.write(struct.pack('B', length))
|
||||
self.buf.write(struct.pack("B", length))
|
||||
self.buf.write(encoded)
|
||||
|
||||
# ---- 字节数组 ----
|
||||
@@ -133,8 +135,9 @@ class TafOutputStream:
|
||||
self.write_struct_end()
|
||||
|
||||
# ---- Map ----
|
||||
def write_map(self, tag: int, value: Dict[Any, Any],
|
||||
key_writer=None, val_writer=None):
|
||||
def write_map(
|
||||
self, tag: int, value: Dict[Any, Any], key_writer=None, val_writer=None
|
||||
):
|
||||
self.write_head(tag, TafType.MAP)
|
||||
self.write_int32(0, len(value))
|
||||
for k, v in value.items():
|
||||
@@ -172,7 +175,7 @@ class TafOutputStream:
|
||||
self.write_map(tag, value)
|
||||
elif isinstance(value, (list, tuple)):
|
||||
self.write_list(tag, list(value))
|
||||
elif hasattr(value, 'write_to'):
|
||||
elif hasattr(value, "write_to"):
|
||||
self.write_struct(tag, value)
|
||||
else:
|
||||
raise TypeError(f"不支持的类型: {type(value)}")
|
||||
@@ -182,6 +185,7 @@ class TafOutputStream:
|
||||
# 输入流(解码)—— 完整实现,支持所有类型
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TafInputStream:
|
||||
"""TAF 解码输入流"""
|
||||
|
||||
@@ -201,14 +205,14 @@ class TafInputStream:
|
||||
data = self.buf.read(1)
|
||||
if not data:
|
||||
raise EOFError("读取到文件末尾")
|
||||
b = struct.unpack('B', data)[0]
|
||||
b = struct.unpack("B", data)[0]
|
||||
tag = (b >> 4) & 0x0F
|
||||
data_type = b & 0x0F
|
||||
if tag == 15:
|
||||
data = self.buf.read(1)
|
||||
if not data:
|
||||
raise EOFError("读取 tag 扩展字节失败")
|
||||
tag = struct.unpack('B', data)[0]
|
||||
tag = struct.unpack("B", data)[0]
|
||||
return tag, data_type
|
||||
|
||||
# ---- 跳过 ----
|
||||
@@ -228,10 +232,10 @@ class TafInputStream:
|
||||
elif data_type == TafType.DOUBLE:
|
||||
self.buf.read(8)
|
||||
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)
|
||||
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)
|
||||
elif data_type == TafType.MAP:
|
||||
self._skip_map()
|
||||
@@ -258,13 +262,13 @@ class TafInputStream:
|
||||
if dtype == TafType.ZERO:
|
||||
return 0
|
||||
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:
|
||||
return struct.unpack('>h', self.buf.read(2))[0]
|
||||
return struct.unpack(">h", self.buf.read(2))[0]
|
||||
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:
|
||||
return struct.unpack('>q', self.buf.read(8))[0]
|
||||
return struct.unpack(">q", self.buf.read(8))[0]
|
||||
raise ValueError(f"期望整数, 实际 0x{dtype:02x}")
|
||||
|
||||
def _skip_struct(self):
|
||||
@@ -335,19 +339,23 @@ class TafInputStream:
|
||||
if dtype == TafType.ZERO:
|
||||
return 0
|
||||
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:
|
||||
return struct.unpack('>H', self.buf.read(2))[0]
|
||||
return struct.unpack(">H", self.buf.read(2))[0]
|
||||
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:
|
||||
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}")
|
||||
|
||||
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))
|
||||
|
||||
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)
|
||||
if not found:
|
||||
return default
|
||||
@@ -355,12 +363,14 @@ class TafInputStream:
|
||||
if dtype == TafType.ZERO:
|
||||
return 0.0
|
||||
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:
|
||||
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))
|
||||
|
||||
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)
|
||||
|
||||
def read_string(self, tag: int, required: bool = False, default: str = "") -> str:
|
||||
@@ -369,14 +379,16 @@ class TafInputStream:
|
||||
return default
|
||||
dtype = found[1]
|
||||
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:
|
||||
length = struct.unpack('>I', self.buf.read(4))[0]
|
||||
length = struct.unpack(">I", self.buf.read(4))[0]
|
||||
else:
|
||||
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)
|
||||
if not found:
|
||||
return default
|
||||
@@ -388,8 +400,9 @@ class TafInputStream:
|
||||
return self.buf.read(length)
|
||||
|
||||
# ---- 复合类型 ----
|
||||
def read_map(self, tag: int, required: bool = False,
|
||||
key_reader=None, val_reader=None) -> Dict:
|
||||
def read_map(
|
||||
self, tag: int, required: bool = False, key_reader=None, val_reader=None
|
||||
) -> Dict:
|
||||
found = self._find_tag(tag, required)
|
||||
if not found:
|
||||
return {}
|
||||
@@ -405,8 +418,7 @@ class TafInputStream:
|
||||
result[k] = v
|
||||
return result
|
||||
|
||||
def read_list(self, tag: int, required: bool = False,
|
||||
item_reader=None) -> List:
|
||||
def read_list(self, tag: int, required: bool = False, item_reader=None) -> List:
|
||||
found = self._find_tag(tag, required)
|
||||
if not found:
|
||||
return []
|
||||
@@ -439,13 +451,18 @@ class TafInputStream:
|
||||
return reader(self, 0)
|
||||
# 自动推断
|
||||
if dtype == TafType.STRING1:
|
||||
length = struct.unpack('B', self.buf.read(1))[0]
|
||||
return self.buf.read(length).decode('utf-8', errors='replace')
|
||||
length = struct.unpack("B", self.buf.read(1))[0]
|
||||
return self.buf.read(length).decode("utf-8", errors="replace")
|
||||
if dtype == TafType.STRING4:
|
||||
length = struct.unpack('>I', self.buf.read(4))[0]
|
||||
return self.buf.read(length).decode('utf-8', errors='replace')
|
||||
if dtype in (TafType.ZERO, TafType.INT8, TafType.INT16,
|
||||
TafType.INT32, TafType.INT64):
|
||||
length = struct.unpack(">I", self.buf.read(4))[0]
|
||||
return self.buf.read(length).decode("utf-8", errors="replace")
|
||||
if dtype in (
|
||||
TafType.ZERO,
|
||||
TafType.INT8,
|
||||
TafType.INT16,
|
||||
TafType.INT32,
|
||||
TafType.INT64,
|
||||
):
|
||||
return self._read_int_value(dtype)
|
||||
if dtype == TafType.STRUCT_BEGIN:
|
||||
# 未知 struct,跳过
|
||||
@@ -459,6 +476,7 @@ class TafInputStream:
|
||||
# 结构体基类
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TafStruct:
|
||||
"""TAF 结构体基类:子类实现 write_to / read_from"""
|
||||
|
||||
@@ -470,8 +488,7 @@ class TafStruct:
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""调试用:转字典"""
|
||||
return {k: v for k, v in self.__dict__.items()
|
||||
if not k.startswith('_')}
|
||||
return {k: v for k, v in self.__dict__.items() if not k.startswith("_")}
|
||||
|
||||
def __repr__(self):
|
||||
return f"{self.__class__.__name__}({self.to_dict()})"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
基于 AES-128-ECB 与 0 填充。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
+37
-13
@@ -134,12 +134,18 @@ def make_user_action(now_ms: int | None = None) -> str:
|
||||
"longitude": "-1.0",
|
||||
"ssid": "",
|
||||
"user_action": [
|
||||
{"id": "24", "time": str(t1),
|
||||
"x": str(random.randint(150, 900)),
|
||||
"y": str(random.randint(800, 1600))},
|
||||
{"id": "11", "time": str(t2),
|
||||
"x": str(random.randint(150, 900)),
|
||||
"y": str(random.randint(800, 1600))},
|
||||
{
|
||||
"id": "24",
|
||||
"time": str(t1),
|
||||
"x": str(random.randint(150, 900)),
|
||||
"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,
|
||||
@@ -152,10 +158,17 @@ def make_trace_id(pid: int = 0) -> str:
|
||||
return f"{random.getrandbits(64):016x}-{pid}-{int(_time.time() * 1000)}"
|
||||
|
||||
|
||||
def _build_wup_data(w: _Writer, uid_str: str, sha1_password: str,
|
||||
safedeviceid: str, hdid: str, session: int,
|
||||
trace_id: str, user_action_json: str,
|
||||
device_info: Dict[str, str]) -> None:
|
||||
def _build_wup_data(
|
||||
w: _Writer,
|
||||
uid_str: str,
|
||||
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。"""
|
||||
meta_json = _build_meta_json(session, trace_id)
|
||||
name = _make_name(uid_str)
|
||||
@@ -179,7 +192,9 @@ def _build_wup_data(w: _Writer, uid_str: str, sha1_password: str,
|
||||
# -- t1: 设备信息 struct --
|
||||
di = device_info
|
||||
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(2, di.get("sdk_version", "1.0.80138"))
|
||||
w.string(3, "")
|
||||
@@ -224,8 +239,17 @@ def build_password_login_wup(
|
||||
) -> bytes:
|
||||
"""构造密码登录的 WUP TAF 请求体。"""
|
||||
wd = _Writer()
|
||||
_build_wup_data(wd, uid_str, sha1_password, safedeviceid, hdid,
|
||||
session, trace_id, user_action_json, device_info)
|
||||
_build_wup_data(
|
||||
wd,
|
||||
uid_str,
|
||||
sha1_password,
|
||||
safedeviceid,
|
||||
hdid,
|
||||
session,
|
||||
trace_id,
|
||||
user_action_json,
|
||||
device_info,
|
||||
)
|
||||
wup_data = wd.get()
|
||||
|
||||
req = _Writer()
|
||||
|
||||
+26
-20
@@ -9,6 +9,7 @@ Wup 包结构:
|
||||
tag7:sBuffer(bytes), tag8:iTimeout, tag9:context(map), tag10:status(map)
|
||||
sBuffer = Map<"tReq", 编码后的请求结构体>
|
||||
"""
|
||||
|
||||
import struct
|
||||
from typing import Any, Dict, Optional
|
||||
from .taf_protocol import TafOutputStream, TafInputStream, TafType, TafStruct
|
||||
@@ -18,16 +19,16 @@ class WupRequest:
|
||||
"""Wup 请求对象"""
|
||||
|
||||
def __init__(self):
|
||||
self.iVersion: int = 3 # tag 1
|
||||
self.cPacketType: int = 0 # tag 2
|
||||
self.iMessageType: int = 0 # tag 3
|
||||
self.iRequestId: int = 0 # tag 4
|
||||
self.sServantName: str = "" # tag 5
|
||||
self.sFuncName: str = "" # tag 6
|
||||
self.sBuffer: bytes = b'' # tag 7
|
||||
self.iTimeout: int = 3000 # tag 8
|
||||
self.context: Dict[str, str] = {} # tag 9
|
||||
self.status: Dict[str, str] = {} # tag 10
|
||||
self.iVersion: int = 3 # tag 1
|
||||
self.cPacketType: int = 0 # tag 2
|
||||
self.iMessageType: int = 0 # tag 3
|
||||
self.iRequestId: int = 0 # tag 4
|
||||
self.sServantName: str = "" # tag 5
|
||||
self.sFuncName: str = "" # tag 6
|
||||
self.sBuffer: bytes = b"" # tag 7
|
||||
self.iTimeout: int = 3000 # tag 8
|
||||
self.context: Dict[str, str] = {} # tag 9
|
||||
self.status: Dict[str, str] = {} # tag 10
|
||||
self.newdata: Dict[str, bytes] = {}
|
||||
|
||||
def setServant(self, name: str):
|
||||
@@ -49,7 +50,7 @@ class WupRequest:
|
||||
"""
|
||||
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
|
||||
os.write_struct(0, struct_data)
|
||||
elif isinstance(struct_data, dict):
|
||||
@@ -83,7 +84,7 @@ class WupRequest:
|
||||
os.write_map(tag, value)
|
||||
elif isinstance(value, (list, tuple)):
|
||||
os.write_list(tag, list(value))
|
||||
elif hasattr(value, 'write_to'):
|
||||
elif hasattr(value, "write_to"):
|
||||
os.write_struct(tag, value)
|
||||
else:
|
||||
raise TypeError(f"不支持的字段类型: {type(value)}")
|
||||
@@ -115,14 +116,14 @@ class WupRequest:
|
||||
|
||||
# 3. 长度前缀
|
||||
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:
|
||||
"""去掉可选的 4 字节 WUP 长度前缀,返回裸 WUP body"""
|
||||
if len(data) < 4:
|
||||
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):
|
||||
return data[4:]
|
||||
return data
|
||||
@@ -138,7 +139,7 @@ class WupResponse:
|
||||
self.iRequestId: int = 0
|
||||
self.sServantName: str = ""
|
||||
self.sFuncName: str = ""
|
||||
self.sBuffer: bytes = b''
|
||||
self.sBuffer: bytes = b""
|
||||
self.iTimeout: int = 0
|
||||
self.context: Dict[str, str] = {}
|
||||
self.status: Dict[str, str] = {}
|
||||
@@ -249,19 +250,20 @@ class WupResponse:
|
||||
# 辅助:按已知 dtype 读取值
|
||||
# ============================================================
|
||||
|
||||
|
||||
def _read_string_value(ins: TafInputStream, dtype: int) -> str:
|
||||
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:
|
||||
length = struct.unpack('>I', ins.buf.read(4))[0]
|
||||
length = struct.unpack(">I", ins.buf.read(4))[0]
|
||||
else:
|
||||
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:
|
||||
if dtype != TafType.SIMPLE_LIST:
|
||||
return b''
|
||||
return b""
|
||||
ins.read_head() # 元素类型 INT8
|
||||
length = ins._read_int_len()
|
||||
return ins.buf.read(length)
|
||||
@@ -276,6 +278,10 @@ def _read_map_value(ins: TafInputStream, dtype: int) -> Dict:
|
||||
_, kt = ins.read_head()
|
||||
k = _read_string_value(ins, kt)
|
||||
_, 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
|
||||
return result
|
||||
|
||||
+35
-15
@@ -58,12 +58,14 @@ def parse_sms_lines(text: str) -> list[SmsLine]:
|
||||
continue
|
||||
if not phone or not url:
|
||||
continue
|
||||
rows.append(SmsLine(
|
||||
phone=phone,
|
||||
url=url,
|
||||
provider=detect_provider(url),
|
||||
raw=line,
|
||||
))
|
||||
rows.append(
|
||||
SmsLine(
|
||||
phone=phone,
|
||||
url=url,
|
||||
provider=detect_provider(url),
|
||||
raw=line,
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
@@ -133,32 +135,50 @@ class SmsProviderClient:
|
||||
if low.startswith("yes|"):
|
||||
code = extract_sms_code(body)
|
||||
if code:
|
||||
return SmsPollResult(status="code", code=code, message="收到验证码", raw=body)
|
||||
return SmsPollResult(status="error", message=f"已收到短信但未识别验证码: {body}", raw=body)
|
||||
return SmsPollResult(
|
||||
status="code", code=code, message="收到验证码", raw=body
|
||||
)
|
||||
return SmsPollResult(
|
||||
status="error", message=f"已收到短信但未识别验证码: {body}", raw=body
|
||||
)
|
||||
code = extract_sms_code(body)
|
||||
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)
|
||||
|
||||
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:
|
||||
payload = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
code = extract_sms_code(body)
|
||||
if code:
|
||||
return SmsPollResult(status="code", code=code, message="收到验证码", raw=body)
|
||||
return SmsPollResult(status="error", message=f"短信平台返回非 JSON: {body[:120]}", raw=body)
|
||||
return SmsPollResult(
|
||||
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 {}
|
||||
msg = str(payload.get("msg") or "")
|
||||
code_text = str(data.get("code") 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):
|
||||
return SmsPollResult(status="waiting", message="验证码时间早于本次发码", raw=body)
|
||||
return SmsPollResult(
|
||||
status="waiting", message="验证码时间早于本次发码", raw=body
|
||||
)
|
||||
|
||||
code = extract_sms_code(code_text)
|
||||
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:
|
||||
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)
|
||||
|
||||
+6
-3
@@ -12,11 +12,14 @@
|
||||
```python
|
||||
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"])
|
||||
s = d.attach(pid)
|
||||
from bypass_loader import load_bypass # scripts/ 在 sys.path (同目录运行即可)
|
||||
load_bypass(s) # ← 唯一的绕过步骤, 可传参
|
||||
from bypass_loader import load_bypass # scripts/ 在 sys.path (同目录运行即可)
|
||||
|
||||
load_bypass(s) # ← 唯一的绕过步骤, 可传参
|
||||
d.resume(pid)
|
||||
|
||||
# 之后挂业务钩子 (挂业务 JS 的时机不再敏感 — patch_guard 已内置延迟)
|
||||
|
||||
@@ -73,10 +73,14 @@ def _normalize_value(value: object, target_column) -> object:
|
||||
try:
|
||||
return json.loads(value)
|
||||
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]
|
||||
missing_columns = [column for column in columns if column not in source_table.c]
|
||||
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)
|
||||
statement = select(*(source_table.c[name] for name in 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():
|
||||
yield {
|
||||
@@ -101,12 +107,12 @@ def _ensure_target_is_empty(target_engine: Engine, target_tables: list[Table]) -
|
||||
"""拒绝向已有业务数据的 MySQL 写入,防止误覆盖。"""
|
||||
with target_engine.connect() as connection:
|
||||
occupied = [
|
||||
table.name
|
||||
for table in target_tables
|
||||
if _count_rows(connection, table) > 0
|
||||
table.name for table in target_tables if _count_rows(connection, table) > 0
|
||||
]
|
||||
if occupied:
|
||||
raise RuntimeError(f"目标 MySQL 已存在业务数据,拒绝迁移:{', '.join(occupied)}")
|
||||
raise RuntimeError(
|
||||
f"目标 MySQL 已存在业务数据,拒绝迁移:{', '.join(occupied)}"
|
||||
)
|
||||
|
||||
|
||||
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["DATABASE_URL"] = f"sqlite:///{source_path}"
|
||||
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,
|
||||
env=source_env,
|
||||
check=True,
|
||||
@@ -141,7 +151,9 @@ def main() -> int:
|
||||
|
||||
target_url = target_url or DATABASE_URL
|
||||
if not target_url.startswith("mysql+"):
|
||||
raise ValueError("目标库必须是 MySQL;可传 --target-url,或设置 DB_HOST/DB_* 环境变量")
|
||||
raise ValueError(
|
||||
"目标库必须是 MySQL;可传 --target-url,或设置 DB_HOST/DB_* 环境变量"
|
||||
)
|
||||
|
||||
print("正在初始化目标 MySQL 表结构...")
|
||||
run_migrations()
|
||||
@@ -153,7 +165,9 @@ def main() -> int:
|
||||
|
||||
try:
|
||||
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:
|
||||
raise RuntimeError(
|
||||
"源 SQLite 存在当前程序无法识别的表:"
|
||||
@@ -171,7 +185,10 @@ def main() -> int:
|
||||
|
||||
_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:
|
||||
source_table = source_metadata.tables[target_table.name]
|
||||
source_count = _count_rows(source_connection, source_table)
|
||||
@@ -184,7 +201,10 @@ def main() -> int:
|
||||
for batch in _chunks(rows, args.batch_size):
|
||||
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:
|
||||
source_table = source_metadata.tables[target_table.name]
|
||||
source_count = _count_rows(source_connection, source_table)
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
python3 main.py sample-session # 用迁移的 live 捕获生成示例会话态
|
||||
python3 main.py sample-order # 用迁移的 order7 生成示例订单
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
@@ -50,14 +51,31 @@ from pyvm.protocol import ( # noqa: E402
|
||||
REPLAY = ROOT / "replay"
|
||||
DEFAULT_APPID = PAY_APPID
|
||||
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",
|
||||
"pay_method", "buy_quantity", "mb_pwd", "pay_id", "auth_key",
|
||||
"card_value", "accounttype", "provide_uin", "extend", "ts",
|
||||
"from_h5", "webversion"]
|
||||
ORDER_FIELDS = [
|
||||
"token_id",
|
||||
"openid",
|
||||
"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:
|
||||
"""写入任务协议证据并限制为当前用户可读。"""
|
||||
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")
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
|
||||
def _load_cap(path: Path) -> dict:
|
||||
"""加载 deepCap 捕获 JSON,兼容三种存档格式,返回顶层 dict(含 C 键)。
|
||||
|
||||
@@ -113,32 +132,47 @@ def parse_plaintext(path: str | Path) -> dict:
|
||||
|
||||
# ---------------------------------------------------------------- 命令
|
||||
|
||||
|
||||
def cmd_verify(args=None) -> int:
|
||||
"""校验当前受版本控制的 goods 协议材料,不依赖已删除的历史抓包。"""
|
||||
args_path = REPLAY / "live" / "default" / "args-template.json"
|
||||
args_template = json.loads(args_path.read_text(encoding="utf-8"))
|
||||
validate_goods_materials(args_template)
|
||||
required = ("bytecode.json", "constants.json", "xmidasops.json", "e2e/multi-1.json",
|
||||
"e2e/ws-args.json", "deepcaps2/cap-69667.json")
|
||||
required = (
|
||||
"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()]
|
||||
if missing:
|
||||
print(f"❌ goods 协议材料缺失: {', '.join(missing)}")
|
||||
return 1
|
||||
print("✅ goods 协议材料校验通过(18 槽模板、Te/S-box、VM 文件均完整)")
|
||||
print(" 历史逐字节黄金向量未随仓库保留;如需恢复,可放入 replay/golden 后由 CI 自动执行。")
|
||||
print(
|
||||
" 历史逐字节黄金向量未随仓库保留;如需恢复,可放入 replay/golden 后由 CI 自动执行。"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _gen_with_session(st: SessionState, order: dict) -> str:
|
||||
"""用会话态 + 订单参数生成 encrypt_msg(args_template 会话绑定,必须用捕获值)。"""
|
||||
from pyvm.algorithm import decode_d
|
||||
|
||||
params = {k: order.get(k, "") for k in ORDER_FIELDS}
|
||||
args_tpl = decode_d(st.args_template_d)
|
||||
return generate_encrypt_msg_offline(
|
||||
params, order.get("fk_extend", ""), order.get("ts", ""), order.get("_rand", ""),
|
||||
xmidas=st.xmidas_ops, xmidas_token=st.xmidas_token,
|
||||
params,
|
||||
order.get("fk_extend", ""),
|
||||
order.get("ts", ""),
|
||||
order.get("_rand", ""),
|
||||
xmidas=st.xmidas_ops,
|
||||
xmidas_token=st.xmidas_token,
|
||||
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
|
||||
cookie_str = "; ".join(f"{k}={v}" for k, v in st.cookies.items())
|
||||
req = urllib.request.Request(
|
||||
url, data=body.encode("utf-8"),
|
||||
url,
|
||||
data=body.encode("utf-8"),
|
||||
headers={
|
||||
"User-Agent": GOODS_USER_AGENT,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
@@ -221,6 +256,7 @@ def cmd_submit(args) -> int:
|
||||
def cmd_sample_session(args) -> int:
|
||||
"""用迁移的 live 捕获生成示例会话态(key16/key1/xmidas_ops)。"""
|
||||
from pyvm.algorithm import decode_d
|
||||
|
||||
web_args = decode_d(_load_cap(REPLAY / "live/caps7/cap-85091.json")["C"][2])
|
||||
cap7 = _load_cap(REPLAY / "live/caps7/cap-85091.json")
|
||||
st = SessionState(
|
||||
@@ -228,7 +264,9 @@ def cmd_sample_session(args) -> int:
|
||||
key16=list(web_args[6][0]),
|
||||
key1=list(web_args[0][0]),
|
||||
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={},
|
||||
source="live/order7 (归档示例,cookies 为空)",
|
||||
)
|
||||
@@ -252,6 +290,7 @@ def cmd_sample_order(args) -> int:
|
||||
|
||||
# ---------------------------------------------------------------- mall 命令
|
||||
|
||||
|
||||
def cmd_mall_verify(args=None) -> int:
|
||||
"""校验当前受版本控制的 mall VM 和固定槽模板。"""
|
||||
fixed_path = REPLAY / "mall" / "transform-fixed.json"
|
||||
@@ -282,7 +321,9 @@ def cmd_mall_gen(args) -> int:
|
||||
|
||||
def cmd_mall_sample(args) -> int:
|
||||
"""用归档同会话黄金对生成示例 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"])
|
||||
out = Path(args.output) if args.output else ROOT / "config" / "mall-session.json"
|
||||
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:
|
||||
cookie_str += "; midas_openkey=" + cookies["accesstoken"]
|
||||
req = urllib.request.Request(
|
||||
url, data=new_body.encode("utf-8"),
|
||||
url,
|
||||
data=new_body.encode("utf-8"),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
@@ -346,7 +388,11 @@ def cmd_mall_submit(args) -> int:
|
||||
print(f"[submit] 网络错误: {e!r}")
|
||||
return 3
|
||||
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
|
||||
ret = None
|
||||
try:
|
||||
@@ -370,7 +416,9 @@ def cmd_mall_submit(args) -> int:
|
||||
print(f" 失败响应 → {fail_out}(保留上次成功响应)")
|
||||
if ret in ("1018", 1018):
|
||||
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
|
||||
|
||||
|
||||
@@ -383,11 +431,15 @@ def cmd_mall_capture(args) -> 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)
|
||||
|
||||
# ---- 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.add_parser("verify", help="校验当前 goods 协议材料")
|
||||
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("--output", default=None)
|
||||
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("--order-template", default=str(ROOT / "config" / "mall-order-template.json"))
|
||||
p.add_argument(
|
||||
"--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 = 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("--order-template", default=str(ROOT / "config" / "mall-order-template.json"))
|
||||
p.add_argument("--product-id", default=None, help="和平精英点券商品 ID;默认使用模板当前商品")
|
||||
p.add_argument(
|
||||
"--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("--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-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-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("--pf", default=None, help="支付平台标识;由角色选择器按 Android/iOS 写入")
|
||||
p.add_argument(
|
||||
"--pf", default=None, help="支付平台标识;由角色选择器按 Android/iOS 写入"
|
||||
)
|
||||
p.add_argument("--output", default=None)
|
||||
p = msub.add_parser("pay", help="mall 下单 -> goods web_save -> 微信支付二维码(纯 Python)")
|
||||
p.add_argument("--mall-response", default=str(ROOT / "config" / "mall-order-response.json"),
|
||||
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 = msub.add_parser(
|
||||
"pay", help="mall 下单 -> goods web_save -> 微信支付二维码(纯 Python)"
|
||||
)
|
||||
p.add_argument(
|
||||
"--mall-response",
|
||||
default=str(ROOT / "config" / "mall-order-response.json"),
|
||||
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("--output", default=None, help="二维码 PNG 输出路径")
|
||||
msub.add_parser("capture", help="用浏览器捕获 mall 会话态(Node 脚本)")
|
||||
@@ -447,20 +533,29 @@ def main() -> int:
|
||||
args = ap.parse_args()
|
||||
try:
|
||||
if args.module == "goods":
|
||||
return {"verify": cmd_verify, "gen": cmd_gen, "submit": cmd_submit,
|
||||
"sample-session": cmd_sample_session, "sample-order": cmd_sample_order}[args.cmd](args)
|
||||
return {
|
||||
"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":
|
||||
return {"verify": cmd_mall_verify, "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 {
|
||||
"verify": cmd_mall_verify,
|
||||
"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
|
||||
except (FileNotFoundError, ValueError) as e:
|
||||
print(f"错误: {e}")
|
||||
return 2
|
||||
|
||||
|
||||
|
||||
|
||||
def build_mall_transform(fixed: dict) -> list:
|
||||
"""仅CK全自动:固定槽模板 + 随机 key16/槽6/明文缓冲 构造 transform_input。
|
||||
|
||||
@@ -487,6 +582,7 @@ def mall_getpaytoken(cookies: dict) -> tuple[list, str]:
|
||||
证据: evidence/getpaytoken-pure-http.json
|
||||
"""
|
||||
import urllib.request
|
||||
|
||||
login = midas_login_params(cookies)
|
||||
login["offer_id"] = "800001492"
|
||||
body = {
|
||||
@@ -494,8 +590,12 @@ def mall_getpaytoken(cookies: dict) -> tuple[list, str]:
|
||||
"call_param": {
|
||||
"call_func": "GetPayToken",
|
||||
"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",
|
||||
"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")]:
|
||||
if k not in cookies and v in cookies:
|
||||
cookie_str += f"; {k}=" + cookies[v]
|
||||
req = urllib.request.Request(url, data=json.dumps(body).encode("utf-8"), headers={
|
||||
"Content-Type": "application/json", "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")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps(body).encode("utf-8"),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"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:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
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:
|
||||
raise ValueError("--quantity 必须是正整数")
|
||||
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]")
|
||||
product = products[0]
|
||||
product_id = getattr(args, "product_id", None)
|
||||
@@ -617,7 +728,9 @@ def cmd_mall_auto(args) -> int:
|
||||
return 1
|
||||
print(f"[auto] arrays {len(arrays)} | pay_token {pay_token[:16]}...")
|
||||
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)
|
||||
ti = build_mall_transform(fixed)
|
||||
print("[auto] ③ 纯 Python 生成 encrypt_msg...")
|
||||
@@ -639,18 +752,29 @@ def cmd_mall_auto(args) -> int:
|
||||
if k not in cookies and v in cookies:
|
||||
cookie_str += f"; {k}=" + cookies[v]
|
||||
url = MALL_API_URL + "?t=" + str(int(time.time() * 1000))
|
||||
req = urllib.request.Request(url, data=new_body.encode("utf-8"), headers={
|
||||
"Content-Type": "application/json", "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")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=new_body.encode("utf-8"),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"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:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
raw = r.read().decode("utf-8", "replace")
|
||||
except urllib.error.HTTPError as e:
|
||||
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)
|
||||
js = json.loads(raw)
|
||||
ret = js.get("result_code")
|
||||
@@ -669,7 +793,9 @@ def cmd_mall_auto(args) -> int:
|
||||
return 0
|
||||
detail = js.get("result_info", "") or call_reply.get("result_info", "") or ""
|
||||
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}")
|
||||
return 1
|
||||
|
||||
@@ -714,10 +840,13 @@ def cmd_mall_pay(args) -> int:
|
||||
cr = json.loads(resp["data"]["call_reply"])
|
||||
up = cr["data"]["url_params"]
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
q = parse_qs(urlparse(up).query)
|
||||
mall_tid = q.get("token_id", [""])[0]
|
||||
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 订单为准继续")
|
||||
except Exception as e: # noqa: BLE001
|
||||
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"))
|
||||
xmidas = json.loads((gd / "xmidasops.json").read_text(encoding="utf-8"))
|
||||
from pyvm.algorithm import decode_d, recover_plaintext_from_buffer
|
||||
|
||||
at_file = gd / "args-template.json"
|
||||
if at_file.exists():
|
||||
# 新版采集:decoded 18参直接读取(capture-goods-session.mjs J 转储)
|
||||
@@ -740,8 +870,10 @@ def cmd_mall_pay(args) -> int:
|
||||
if not web_token:
|
||||
m = re.search(r"web_token=([0-9A-F]+)", body_tpl)
|
||||
web_token = m.group(1) if m else ""
|
||||
print(f"[pay] goods 会话态: xmidasops={len(xmidas)} key16={'有' if keys.get('key16') else '无'} "
|
||||
f"web_token={web_token[:12]}...")
|
||||
print(
|
||||
f"[pay] goods 会话态: xmidasops={len(xmidas)} key16={'有' if keys.get('key16') else '无'} "
|
||||
f"web_token={web_token[:12]}..."
|
||||
)
|
||||
|
||||
# 3. 恢复页面真实明文(ts/fk_extend/_rand),刷新 ts 为当前一致值
|
||||
try:
|
||||
@@ -761,22 +893,37 @@ def cmd_mall_pay(args) -> int:
|
||||
now_ms = int(time.time() * 1000)
|
||||
params["ts"] = str(now_ms // 1000)
|
||||
if rec_ts:
|
||||
print(f"[pay] 页面真实明文: ts={rec_ts}(捕获) -> 使用当前 ts={params['ts']} "
|
||||
f"(E3:ts 与 body t 一致即可,不必等于页面值)")
|
||||
print(
|
||||
f"[pay] 页面真实明文: ts={rec_ts}(捕获) -> 使用当前 ts={params['ts']} "
|
||||
f"(E3:ts 与 body t 一致即可,不必等于页面值)"
|
||||
)
|
||||
print("[pay] 生成 goods encrypt_msg(纯 Python)...")
|
||||
hex_msg = generate_encrypt_msg_offline(
|
||||
params, fk_extend, params["ts"], rand_val,
|
||||
xmidas=xmidas, args_template=web_args,
|
||||
key16=keys.get("key16"), key1=keys.get("key1"),
|
||||
params,
|
||||
fk_extend,
|
||||
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]}...")
|
||||
|
||||
# 4. 构造 web_save body(会话订单 token + 当前动态值 + 纯 py encrypt_msg)
|
||||
body = body_tpl
|
||||
for k, v in [("token_id", token_id), ("transaction_id", transaction_id),
|
||||
("out_trade_no", out_trade_no), ("offer_type", offer_type)]:
|
||||
for k, v in [
|
||||
("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(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"&t=[0-9]+", "&t=" + str(int(time.time() * 1000)), 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
|
||||
url = f"https://api.unipay.qq.com/v1/r/{args.appid}/web_save"
|
||||
req = urllib.request.Request(
|
||||
url, data=body.encode("utf-8"),
|
||||
url,
|
||||
data=body.encode("utf-8"),
|
||||
headers={
|
||||
"User-Agent": MALL_USER_AGENT,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
@@ -855,5 +1003,6 @@ def cmd_mall_pay(args) -> int:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
- mall.py: mall 侧高层 API(MallSession + generate_encrypt_msg)
|
||||
- session.py: 会话态模型
|
||||
"""
|
||||
|
||||
from .algorithm import (
|
||||
build_plaintext,
|
||||
decode_d,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@
|
||||
→ webSave(33 块变换)
|
||||
→ encrypt_msg(1056 hex)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -16,17 +17,39 @@ from typing import Any
|
||||
from .algorithm import generate_encrypt_msg_offline
|
||||
|
||||
REPLAY = Path(__file__).resolve().parent.parent / "replay"
|
||||
ORDER_FIELDS = ["token_id", "openid", "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"]
|
||||
ORDER_FIELDS = [
|
||||
"token_id",
|
||||
"openid",
|
||||
"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:
|
||||
"""goods 会话态:xMidasOps(59640,服务端生成)+ key16/key1 + args_template。"""
|
||||
|
||||
def __init__(self, xmidas_ops: list, key16: list, key1: list,
|
||||
args_template_d: str = "", xmidas_token: str = ""):
|
||||
def __init__(
|
||||
self,
|
||||
xmidas_ops: list,
|
||||
key16: list,
|
||||
key1: list,
|
||||
args_template_d: str = "",
|
||||
xmidas_token: str = "",
|
||||
):
|
||||
self.xmidas_ops = xmidas_ops
|
||||
self.key16 = key16
|
||||
self.key1 = key1
|
||||
@@ -44,8 +67,13 @@ class GoodsSession:
|
||||
def from_session_state(cls, path: str | Path) -> "GoodsSession":
|
||||
"""从 scripts/capture-session.mjs 生成的 session-state.json 加载。"""
|
||||
d = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
return cls(d["xmidas_ops"], d["key16"], d["key1"],
|
||||
d.get("args_template_d", ""), d.get("xmidas_token", ""))
|
||||
return cls(
|
||||
d["xmidas_ops"],
|
||||
d["key16"],
|
||||
d["key1"],
|
||||
d.get("args_template_d", ""),
|
||||
d.get("xmidas_token", ""),
|
||||
)
|
||||
|
||||
def to_json(self) -> dict:
|
||||
return {
|
||||
@@ -58,17 +86,29 @@ class GoodsSession:
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, d: dict) -> "GoodsSession":
|
||||
return cls(d["xmidas_ops"], d["key16"], d["key1"],
|
||||
d.get("args_template_d", ""), d.get("xmidas_token", ""))
|
||||
return cls(
|
||||
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:
|
||||
"""用会话态 + 订单参数生成 goods encrypt_msg(1056 hex)。"""
|
||||
from .algorithm import decode_d
|
||||
|
||||
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
|
||||
return generate_encrypt_msg_offline(
|
||||
params, order.get("fk_extend", ""), 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,
|
||||
params,
|
||||
order.get("fk_extend", ""),
|
||||
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."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
WECHAT_APPID = "wxd44977328b36e647"
|
||||
|
||||
@@ -12,6 +12,7 @@ mall 加密链路(E3 已验证,U-2022 闭环):
|
||||
注意:xMidasOps 是 mall 详情页页面级数据表(59620 长度,服务端生成),
|
||||
必须从浏览器捕获(与 goods 的 59640 不同)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
@@ -24,9 +25,28 @@ from .algorithm import UNDEF, JSObject, Window
|
||||
from .pagedoo_vm import PagedooVM
|
||||
|
||||
REPLAY = Path(__file__).resolve().parent.parent / "replay" / "mall"
|
||||
GLOBALS = [UNDEF, None, True, False, 4294967295, 3995986053, 2103143698, 1622111212,
|
||||
4263108271, 3162892160, 1960464030, 2867129963, 3224029870, 3514649446,
|
||||
1382846327, 1898428403, 1268470028, 1457769175, 1595352606, 1100935262]
|
||||
GLOBALS = [
|
||||
UNDEF,
|
||||
None,
|
||||
True,
|
||||
False,
|
||||
4294967295,
|
||||
3995986053,
|
||||
2103143698,
|
||||
1622111212,
|
||||
4263108271,
|
||||
3162892160,
|
||||
1960464030,
|
||||
2867129963,
|
||||
3224029870,
|
||||
3514649446,
|
||||
1382846327,
|
||||
1898428403,
|
||||
1268470028,
|
||||
1457769175,
|
||||
1595352606,
|
||||
1100935262,
|
||||
]
|
||||
|
||||
|
||||
class MallSession:
|
||||
@@ -45,9 +65,13 @@ class MallSession:
|
||||
|
||||
def validate(self) -> None:
|
||||
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:
|
||||
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]
|
||||
if isinstance(mid, list) and mid and isinstance(mid[0], list):
|
||||
if len(mid[0]) != 624:
|
||||
@@ -61,7 +85,11 @@ class MallSession:
|
||||
- J|...|e377650|{JSON} e377650 创建参数(transform_input)
|
||||
- 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
|
||||
xmidas = None
|
||||
# xMidasOps:取 mall 详情页(z.iwan / pagedoo)那条
|
||||
@@ -85,7 +113,9 @@ class MallSession:
|
||||
transform_input = json.loads(l.split("|e377650|", 1)[1])
|
||||
break
|
||||
if transform_input is None:
|
||||
raise ValueError("frames.jsonl 中未找到 e377650 J 转储(需 mall 详情页购买触发加密)")
|
||||
raise ValueError(
|
||||
"frames.jsonl 中未找到 e377650 J 转储(需 mall 详情页购买触发加密)"
|
||||
)
|
||||
if xmidas is None:
|
||||
raise ValueError("frames.jsonl 中未找到 59620 长度 xMidasOps")
|
||||
return cls(transform_input, xmidas)
|
||||
@@ -112,8 +142,18 @@ def _mk_window(xmidas: list) -> Window:
|
||||
w.set("sessionStorage", JSObject())
|
||||
w.set("screen", JSObject())
|
||||
w.set("history", JSObject())
|
||||
w.set("XMLHttpRequest", type("XHR", (), {
|
||||
"open": lambda *a: None, "send": lambda *a: None, "setRequestHeader": lambda *a: None}))
|
||||
w.set(
|
||||
"XMLHttpRequest",
|
||||
type(
|
||||
"XHR",
|
||||
(),
|
||||
{
|
||||
"open": lambda *a: None,
|
||||
"send": lambda *a: None,
|
||||
"setRequestHeader": lambda *a: None,
|
||||
},
|
||||
),
|
||||
)
|
||||
w.set("fetch", lambda *a: None)
|
||||
w.set("xMidasOps", xmidas)
|
||||
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]
|
||||
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)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Read YYB's official order list after a payment is completed."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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."""
|
||||
for item in document.get("list", []):
|
||||
if not isinstance(item, dict):
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
"""YYB 下单和付款失败响应的统一归类。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
这里仅收敛已由成功请求验证的固定值,不负责推断或修改协议字段。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
@@ -47,21 +48,38 @@ def file_fingerprint(path: Path) -> str:
|
||||
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 所需静态表结构,发现升级时尽早失败。"""
|
||||
if not isinstance(args_template, list) or len(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):
|
||||
if not isinstance(args_template[index], list) or not args_template[index]:
|
||||
raise ValueError(f"goods args-template 槽 {index} 缺失")
|
||||
expected_lengths = {0: 16, 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}
|
||||
expected_lengths = {
|
||||
0: 16,
|
||||
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():
|
||||
value = args_template[index][0]
|
||||
if not isinstance(value, list) or len(value) != expected:
|
||||
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:
|
||||
raise ValueError(f"goods xMidasOps 长度异常: {len(xmidas)} != 59640")
|
||||
|
||||
@@ -70,7 +88,9 @@ def validate_mall_materials(transform_fixed: dict[str, Any]) -> None:
|
||||
"""校验 mall 固定槽模板;动态槽仍由当前会话的 GetPayToken 填充。"""
|
||||
if not isinstance(transform_fixed, dict):
|
||||
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))
|
||||
if 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} 不是数组")
|
||||
|
||||
|
||||
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)
|
||||
replay = root / "replay"
|
||||
|
||||
@@ -6,6 +6,7 @@ F-2049/F-2051(E3):encrypt_msg 与当前会话绑定——
|
||||
- key1: 诱饵密钥(点击级,捕获即可)
|
||||
服务端能验证 key 派生状态(随机 key 变体 ret:1099),因此新订单必须先捕获会话态。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -31,7 +32,9 @@ class SessionState:
|
||||
key16: list[int] = field(default_factory=list)
|
||||
key1: list[int] = field(default_factory=list)
|
||||
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)
|
||||
openid: str = ""
|
||||
openkey: str = ""
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
付款码只在服务端 ``web_save`` 返回 ``ret=0`` 后渲染。付款后通过商城官方订单
|
||||
列表确认本次新出现的完成订单;该检查不触发付款或确认操作。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
@@ -26,7 +27,11 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
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.payment_errors import describe_payment_failure # 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:
|
||||
write_private_text(path, json.dumps(value, ensure_ascii=False, indent=2) + "\n")
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict:
|
||||
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"]
|
||||
except (KeyError, TypeError, json.JSONDecodeError) as exc:
|
||||
raise ValueError("mall 响应缺少 data.call_reply.data.url_params") from exc
|
||||
return {key: values[-1] for key, values in urllib.parse.parse_qs(
|
||||
urllib.parse.urlparse(url_params).query, keep_blank_values=True).items()}
|
||||
return {
|
||||
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:
|
||||
@@ -96,8 +107,9 @@ def request_bytes(url: str, cookies: dict[str, str], body: bytes | None = None)
|
||||
if body is not None:
|
||||
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
||||
headers["Origin"] = "https://pay.qq.com"
|
||||
request = urllib.request.Request(url, data=body, headers=headers,
|
||||
method="POST" if body is not None else "GET")
|
||||
request = urllib.request.Request(
|
||||
url, data=body, headers=headers, method="POST" if body is not None else "GET"
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
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")
|
||||
|
||||
|
||||
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")
|
||||
openkey = cookies.get("accesstoken") or cookies.get("midas_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)
|
||||
anti = re.search(r'id="antiAutoScriptToken"\s+value="([0-9A-Fa-f]+)"', html)
|
||||
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]
|
||||
if len(xmidas) != 59640:
|
||||
raise ValueError(f"goods xMidasOps 长度异常: {len(xmidas)}")
|
||||
@@ -147,16 +163,25 @@ def load_template_args() -> list:
|
||||
runtime_args = runtime_template / "args-template.json"
|
||||
if runtime_args.exists():
|
||||
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:
|
||||
if path.exists():
|
||||
return load_json(path)
|
||||
raise FileNotFoundError("缺少归档 goods args-template.json")
|
||||
|
||||
|
||||
def build_save_fields(order: 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]:
|
||||
def build_save_fields(
|
||||
order: 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 表单,不能使用脱敏空模板。"""
|
||||
if amount_fen <= 0:
|
||||
raise ValueError("充值金额必须为正数")
|
||||
@@ -176,7 +201,9 @@ def build_save_fields(order: dict[str, str], cookies: dict[str, str], web_token:
|
||||
"openkey": cookies.get("accesstoken", ""),
|
||||
"session_id": login["session_id"],
|
||||
"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,
|
||||
"zoneid": zone_id,
|
||||
"buy_quantity": "1",
|
||||
@@ -207,17 +234,38 @@ def build_save_fields(order: dict[str, str], cookies: dict[str, str], web_token:
|
||||
return fields
|
||||
|
||||
|
||||
def build_save_body(order: dict[str, str], cookies: dict[str, str], web_token: str,
|
||||
anti_token: str, encrypt_msg: str, zone_id: str, pf: str,
|
||||
amount_fen: int) -> str:
|
||||
def build_save_body(
|
||||
order: dict[str, 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(
|
||||
order, cookies, web_token, anti_token, encrypt_msg, zone_id, pf, amount_fen,
|
||||
))
|
||||
return urllib.parse.urlencode(
|
||||
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,
|
||||
zone_id: str, pf: str) -> str:
|
||||
def build_page_info_body(
|
||||
order: dict[str, str],
|
||||
cookies: dict[str, str],
|
||||
anti_token: str,
|
||||
zone_id: str,
|
||||
pf: str,
|
||||
) -> str:
|
||||
"""构造网页端 fp-behv 后的 web_page_info 表单。"""
|
||||
login = midas_login_params(cookies)
|
||||
fields = {
|
||||
@@ -235,7 +283,9 @@ def build_page_info_body(order: dict[str, str], cookies: dict[str, str], anti_to
|
||||
"openkey": cookies.get("accesstoken", ""),
|
||||
"session_id": login["session_id"],
|
||||
"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,
|
||||
"isusempaymode": "1",
|
||||
"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)
|
||||
|
||||
|
||||
def make_encrypt_rand(params: dict[str, str], fk_extend: str, ts: str,
|
||||
is_qq_login: bool) -> str:
|
||||
def make_encrypt_rand(
|
||||
params: dict[str, str], fk_extend: str, ts: str, is_qq_login: bool
|
||||
) -> str:
|
||||
"""按登录渠道生成页面已验证形态的 _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 位于加密明文中,
|
||||
# 末控制字节是协议内容,不能为了对齐擅自替换为 QQ 使用的 \x03。
|
||||
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)
|
||||
|
||||
|
||||
def save_web_save_request_meta(out_dir: Path, fields: dict[str, str], device_fp_length: int,
|
||||
plaintext_length: int) -> None:
|
||||
def save_web_save_request_meta(
|
||||
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",
|
||||
"sck", "anti_auto_script_token_id", "web_token", "encrypt_msg")
|
||||
sensitive = (
|
||||
"token_id",
|
||||
"transaction_id",
|
||||
"out_trade_no",
|
||||
"openid",
|
||||
"openkey",
|
||||
"sck",
|
||||
"anti_auto_script_token_id",
|
||||
"web_token",
|
||||
"encrypt_msg",
|
||||
)
|
||||
fingerprints = {
|
||||
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
|
||||
}
|
||||
@@ -290,7 +355,9 @@ def make_qr(sign: str, output: Path) -> None:
|
||||
try:
|
||||
import segno
|
||||
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)
|
||||
segno.make(sign).save(str(output), scale=6, border=2)
|
||||
os.chmod(output, 0o600)
|
||||
@@ -303,7 +370,9 @@ def node_environment() -> dict[str, str]:
|
||||
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."""
|
||||
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)
|
||||
|
||||
|
||||
def save_payment_meta(path: Path, order: dict[str, str], baseline: dict[str, bool],
|
||||
document: dict, portal_serial_no: str = "") -> None:
|
||||
def save_payment_meta(
|
||||
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.
|
||||
|
||||
``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)
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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:
|
||||
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("--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(
|
||||
"--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("--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("--pf", default="", help="所选 Android/iOS 支付平台标识")
|
||||
parser.add_argument("--amount-fen", type=int, default=0, help="所选点券的价格,单位分(check-only 模式不需要)")
|
||||
parser.add_argument("--dry-run", 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),不创建订单")
|
||||
parser.add_argument(
|
||||
"--amount-fen",
|
||||
type=int,
|
||||
default=0,
|
||||
help="所选点券的价格,单位分(check-only 模式不需要)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
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()
|
||||
if args.payment_timeout <= 0 or args.payment_interval <= 0:
|
||||
raise ValueError("--payment-timeout 和 --payment-interval 必须为正数")
|
||||
@@ -421,7 +526,9 @@ def main() -> int:
|
||||
session_path = Path(args.session).resolve()
|
||||
response_path = Path(args.mall_response).resolve()
|
||||
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:
|
||||
make_private_directory(out_dir)
|
||||
return cmd_check_only(session_path, out_dir)
|
||||
@@ -444,7 +551,9 @@ def main() -> int:
|
||||
raise ValueError("mall 响应无法解析 url_params") from exc
|
||||
|
||||
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)
|
||||
url = goods_page_url(cookies, order, args.zone_id, args.pf)
|
||||
print(f"[jsdom-pay] 订单: {order['token_id'][:20]}...")
|
||||
@@ -457,9 +566,18 @@ def main() -> int:
|
||||
|
||||
fp_path = out_dir / "device-fp.json"
|
||||
command = [
|
||||
"node", str(ROOT / "scripts/generate-devicefp-jsdom.mjs"),
|
||||
"--html", str(html_path), "--goods-url", url, "--cookies", str(session_path),
|
||||
"--output", str(fp_path), "--wait", str(args.wait * 1000),
|
||||
"node",
|
||||
str(ROOT / "scripts/generate-devicefp-jsdom.mjs"),
|
||||
"--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...")
|
||||
subprocess.run(command, check=True, cwd=ROOT, env=node_environment())
|
||||
@@ -469,36 +587,53 @@ def main() -> int:
|
||||
return 0
|
||||
|
||||
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)
|
||||
try:
|
||||
fp_json = json.loads(fp_response)
|
||||
except json.JSONDecodeError:
|
||||
fp_json = {}
|
||||
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
|
||||
|
||||
# QQ 成功 HAR 包含该前置;微信历史成功链路没有,不能将 QQ 状态机混入微信请求。
|
||||
if midas_login_params(cookies)["qq_appid"]:
|
||||
print("[jsdom-pay] 拉取 QQ web_page_info...")
|
||||
page_info = request_bytes(PAGE_INFO_URL, cookies, build_page_info_body(
|
||||
order, cookies, anti_token, args.zone_id, payment_pf,
|
||||
).encode())
|
||||
page_info = request_bytes(
|
||||
PAGE_INFO_URL,
|
||||
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)
|
||||
try:
|
||||
page_info_json = json.loads(page_info)
|
||||
except json.JSONDecodeError:
|
||||
page_info_json = {}
|
||||
if page_info_json.get("ret") != 0:
|
||||
print(f"[jsdom-pay] QQ web_page_info 失败: ret={page_info_json.get('ret')} "
|
||||
f"{page_info_json.get('msg', '')}")
|
||||
print(
|
||||
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}")
|
||||
return 1
|
||||
|
||||
web_args = load_template_args()
|
||||
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] 协议材料校验通过")
|
||||
# key 派生已破解(F-2052,2026-08-12):key16 可随机生成,key1 由反解器
|
||||
# derive_key1_from_key16 求解(key16=Sbox[Te 链(key1)]),满足服务端派生校验,
|
||||
@@ -529,18 +664,38 @@ def main() -> int:
|
||||
now_seconds = str(int(time.time()))
|
||||
fk_extend = "tdrc_session%3D" + fp["session_id"]
|
||||
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(
|
||||
params, fk_extend, now_seconds, random_suffix,
|
||||
key16=key16, key1=key1, args_template=web_args, xmidas=xmidas, xmidas_token=web_token,
|
||||
params,
|
||||
fk_extend,
|
||||
now_seconds,
|
||||
random_suffix,
|
||||
key16=key16,
|
||||
key1=key1,
|
||||
args_template=web_args,
|
||||
xmidas=xmidas,
|
||||
xmidas_token=web_token,
|
||||
)
|
||||
fields = build_save_fields(
|
||||
order, cookies, web_token, anti_token, encrypt_msg,
|
||||
args.zone_id, payment_pf, args.amount_fen,
|
||||
order,
|
||||
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)
|
||||
from pyvm.order_status import order_completion_states, get_official_orders
|
||||
|
||||
@@ -561,10 +716,14 @@ def main() -> int:
|
||||
except json.JSONDecodeError:
|
||||
response_json = {}
|
||||
if response_json.get("ret") != 0:
|
||||
detail = f"ret={response_json.get('ret')} err_code={response_json.get('err_code', '')} " \
|
||||
f"{response_json.get('msg', '')}"
|
||||
detail = (
|
||||
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] {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}")
|
||||
return 1
|
||||
sign = response_json.get("info", {}).get("channel_info", {}).get("sign", "")
|
||||
@@ -588,7 +747,13 @@ def main() -> int:
|
||||
baseline_document = {"list": []}
|
||||
baseline_states = {}
|
||||
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:
|
||||
print("[jsdom-pay] 已保存基线;后台将单独执行只读到账检测")
|
||||
return 0
|
||||
@@ -596,17 +761,23 @@ def main() -> int:
|
||||
print("[jsdom-pay] 记录付款前订单列表,等待微信付款完成...")
|
||||
status_path = out_dir / "payment-status.json"
|
||||
save_payment_status(status_path, baseline_document, baseline_states)
|
||||
identifiers = {key: value for key, value in {
|
||||
"token_id": order.get("token_id", ""),
|
||||
"transaction_id": order.get("transaction_id", ""),
|
||||
"out_trade_no": order.get("out_trade_no", ""),
|
||||
}.items() if value}
|
||||
identifiers = {
|
||||
key: value
|
||||
for key, value in {
|
||||
"token_id": order.get("token_id", ""),
|
||||
"transaction_id": order.get("transaction_id", ""),
|
||||
"out_trade_no": order.get("out_trade_no", ""),
|
||||
}.items()
|
||||
if value
|
||||
}
|
||||
deadline = time.monotonic() + args.payment_timeout
|
||||
while time.monotonic() < deadline:
|
||||
time.sleep(args.payment_interval)
|
||||
document = get_official_orders(cookies)
|
||||
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)
|
||||
if completed:
|
||||
print("[jsdom-pay] 微信付款成功,商城订单已完成。")
|
||||
@@ -620,6 +791,11 @@ def main() -> int:
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
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)
|
||||
raise SystemExit(2) from exc
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""应用宝 QQ 二维码登录(纯 Python,无浏览器自动化)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
@@ -15,7 +16,12 @@ from http.cookiejar import CookieJar
|
||||
from pathlib import Path
|
||||
from urllib.error import HTTPError
|
||||
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
|
||||
OPEN_APPID = "102033112"
|
||||
@@ -60,8 +66,15 @@ class Client:
|
||||
self.jar = CookieJar()
|
||||
self.opener = build_opener(NoRedirect, HTTPCookieProcessor(self.jar))
|
||||
|
||||
def request(self, url: str, *, method: str = "GET", body: bytes | None = None,
|
||||
referer: str = "", headers: dict[str, str] | None = None) -> Response:
|
||||
def request(
|
||||
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": "*/*"}
|
||||
if 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_updated_at"] = int(time.time())
|
||||
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:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
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:
|
||||
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("--timeout", type=float, default=300, help="二维码等待秒数")
|
||||
parser.add_argument("--interval", type=float, default=3, help="轮询间隔秒数")
|
||||
@@ -183,8 +200,12 @@ def main() -> int:
|
||||
client = Client()
|
||||
state = secrets.token_urlsafe(14)
|
||||
show_query = {
|
||||
"which": "Login", "display": "pc", "response_type": "code", "client_id": OPEN_APPID,
|
||||
"redirect_uri": CALLBACK, "state": state,
|
||||
"which": "Login",
|
||||
"display": "pc",
|
||||
"response_type": "code",
|
||||
"client_id": OPEN_APPID,
|
||||
"redirect_uri": CALLBACK,
|
||||
"state": state,
|
||||
}
|
||||
show_url = f"{GRAPH_SHOW}?{urlencode(show_query)}"
|
||||
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}")
|
||||
|
||||
xlogin_query = {
|
||||
"appid": PT_APPID, "daid": PT_DAID, "style": "33", "login_text": "登录",
|
||||
"hide_title_bar": "1", "hide_border": "1", "target": "self", "s_url": LOGIN_JUMP,
|
||||
"appid": PT_APPID,
|
||||
"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_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 = client.request(xlogin_url, referer=show_url)
|
||||
@@ -207,9 +235,16 @@ def main() -> int:
|
||||
raise RuntimeError("QQ 登录页未写入 pt_login_sig")
|
||||
|
||||
qr_query = {
|
||||
"appid": PT_APPID, "e": "2", "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,
|
||||
"appid": PT_APPID,
|
||||
"e": "2",
|
||||
"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)}"
|
||||
image = client.request(qr_url, referer=xlogin_url)
|
||||
@@ -228,10 +263,23 @@ def main() -> int:
|
||||
o1v_id = secrets.token_hex(16)
|
||||
while time.monotonic() < deadline:
|
||||
poll_query = {
|
||||
"u1": LOGIN_JUMP, "ptqrtoken": str(ptqr_token(qrsig)), "ptredirect": "0", "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,
|
||||
"u1": LOGIN_JUMP,
|
||||
"ptqrtoken": str(ptqr_token(qrsig)),
|
||||
"ptredirect": "0",
|
||||
"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",
|
||||
}
|
||||
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)
|
||||
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")
|
||||
jump = client.request(login_jump, referer=callback)
|
||||
if jump.status != 200:
|
||||
@@ -262,8 +312,10 @@ def main() -> int:
|
||||
if not p_skey:
|
||||
raise RuntimeError("QQ check_sig 未写入 p_skey")
|
||||
authorize = client.request(
|
||||
"https://graph.qq.com/oauth2.0/authorize", method="POST",
|
||||
body=urlencode(authorize_params(state, p_skey)).encode("utf-8"), referer=login_jump,
|
||||
"https://graph.qq.com/oauth2.0/authorize",
|
||||
method="POST",
|
||||
body=urlencode(authorize_params(state, p_skey)).encode("utf-8"),
|
||||
referer=login_jump,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
oauth_callback = authorize.location()
|
||||
@@ -276,12 +328,16 @@ def main() -> int:
|
||||
if not cookies.get("openid") or not cookies.get("accesstoken"):
|
||||
raise RuntimeError("YYB QQ OAuth 回调未写入 openid/accesstoken")
|
||||
login_type = cookies.get("logintype", "QC")
|
||||
info = client.request(USER_INFO, headers={
|
||||
"Ual-Access-Login-Type": login_type_header(login_type),
|
||||
"Ual-Access-Access-Token": cookies["accesstoken"],
|
||||
"Ual-Access-Openid": cookies["openid"],
|
||||
"Origin": "https://m.yyb.qq.com", "Referer": "https://m.yyb.qq.com/",
|
||||
})
|
||||
info = client.request(
|
||||
USER_INFO,
|
||||
headers={
|
||||
"Ual-Access-Login-Type": login_type_header(login_type),
|
||||
"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:
|
||||
raise RuntimeError(f"YYB QQ 登录态校验失败: HTTP {info.status}")
|
||||
try:
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
二维码由微信 OAuth 生成,用户用微信扫码确认;本脚本轮询授权结果,完成
|
||||
YYB OAuth 回调后将动态 cookies 合并到 mall-session.json,供后续纯 CK 流程使用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
@@ -18,7 +19,12 @@ from http.cookiejar import CookieJar
|
||||
from pathlib import Path
|
||||
from urllib.error import HTTPError
|
||||
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
|
||||
OPEN_APPID = "wxd44977328b36e647"
|
||||
@@ -58,7 +64,9 @@ class Client:
|
||||
self.jar = CookieJar()
|
||||
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": "*/*"}
|
||||
if 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_updated_at"] = int(time.time())
|
||||
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:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
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:
|
||||
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("--timeout", type=float, default=300, help="二维码等待秒数")
|
||||
parser.add_argument("--interval", type=float, default=2, help="轮询间隔秒数")
|
||||
@@ -158,7 +170,9 @@ def main() -> int:
|
||||
if page.status != 200:
|
||||
raise RuntimeError(f"微信授权页请求失败: HTTP {page.status}")
|
||||
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:
|
||||
raise RuntimeError(f"微信二维码请求失败: HTTP {image.status}")
|
||||
args.qr.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -173,7 +187,9 @@ def main() -> int:
|
||||
poll_query = {"uuid": uuid}
|
||||
if 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)
|
||||
if errcode == 405 and code:
|
||||
break
|
||||
@@ -202,13 +218,16 @@ def main() -> int:
|
||||
login_type = cookies.get("logintype", "WX")
|
||||
if not openid or not access_token:
|
||||
raise RuntimeError("YYB OAuth 回调未写入 openid/accesstoken")
|
||||
info = client.request(USER_INFO, headers={
|
||||
"Ual-Access-Login-Type": login_type_header(login_type),
|
||||
"Ual-Access-Access-Token": access_token,
|
||||
"Ual-Access-Openid": openid,
|
||||
"Origin": "https://m.yyb.qq.com",
|
||||
"Referer": "https://m.yyb.qq.com/",
|
||||
})
|
||||
info = client.request(
|
||||
USER_INFO,
|
||||
headers={
|
||||
"Ual-Access-Login-Type": login_type_header(login_type),
|
||||
"Ual-Access-Access-Token": access_token,
|
||||
"Ual-Access-Openid": openid,
|
||||
"Origin": "https://m.yyb.qq.com",
|
||||
"Referer": "https://m.yyb.qq.com/",
|
||||
},
|
||||
)
|
||||
if info.status != 200:
|
||||
raise RuntimeError(f"YYB 登录态校验失败: HTTP {info.status}")
|
||||
try:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""用已登录的 YYB CK 选择和平精英点券档位、区服和角色。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
@@ -45,26 +46,43 @@ UA = (
|
||||
|
||||
def load_cookies(path: Path) -> dict[str, str]:
|
||||
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"):
|
||||
raise ValueError("会话缺少 openid/accesstoken,请先执行 login-wechat.py")
|
||||
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(
|
||||
PRODUCTS_URL,
|
||||
json={"platform": PLATFORMS[platform]["query_platform"], "source_id": SOURCE_ID,
|
||||
"yyb_app_id": YYB_APP_ID},
|
||||
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,
|
||||
json={
|
||||
"platform": PLATFORMS[platform]["query_platform"],
|
||||
"source_id": SOURCE_ID,
|
||||
"yyb_app_id": YYB_APP_ID,
|
||||
},
|
||||
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:
|
||||
raise RuntimeError(f"点券商品查询失败: HTTP {response.status_code}")
|
||||
document = response.json()
|
||||
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", [])
|
||||
result: list[dict[str, str | int]] = []
|
||||
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", "")))
|
||||
if not match or str(product.get("status")) != "20":
|
||||
continue
|
||||
result.append({
|
||||
"points": int(match.group(1)),
|
||||
"product_id": str(product.get("product_id", "")),
|
||||
"price_fen": int(product.get("price", 0)),
|
||||
"offer_id": str(product.get("res_offer_id", "")),
|
||||
"name": str(product.get("product_name", "")),
|
||||
})
|
||||
result.append(
|
||||
{
|
||||
"points": int(match.group(1)),
|
||||
"product_id": str(product.get("product_id", "")),
|
||||
"price_fen": int(product.get("price", 0)),
|
||||
"offer_id": str(product.get("res_offer_id", "")),
|
||||
"name": str(product.get("product_name", "")),
|
||||
}
|
||||
)
|
||||
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")
|
||||
return result
|
||||
|
||||
@@ -95,30 +117,48 @@ class Cmall:
|
||||
def query(self, cmd: str, **extra: str) -> dict:
|
||||
login = midas_login_params(self.cookies)
|
||||
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,
|
||||
"pfkey": "pfkey", "webversion": "", **extra,
|
||||
"pfkey": "pfkey",
|
||||
"webversion": "",
|
||||
**extra,
|
||||
**login,
|
||||
}
|
||||
# 当前商城页面将查询参数放在 URL 上,但请求方法为 POST 且没有 body。
|
||||
response = requests.post(
|
||||
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/",
|
||||
"User-Agent": UA},
|
||||
cookies=self.cookies, impersonate="chrome", timeout=30,
|
||||
headers={
|
||||
"Origin": "https://z.iwan.yyb.qq.com",
|
||||
"Referer": "https://z.iwan.yyb.qq.com/",
|
||||
"User-Agent": UA,
|
||||
},
|
||||
cookies=self.cookies,
|
||||
impersonate="chrome",
|
||||
timeout=30,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise RuntimeError(f"游戏数据查询失败: HTTP {response.status_code}")
|
||||
document = response.json()
|
||||
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
|
||||
|
||||
def zones(self) -> list[dict[str, str]]:
|
||||
response = self.query("14", use_currency_offerid="1")
|
||||
zones = response.get("zone_list", [])
|
||||
return [{"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")]
|
||||
return [
|
||||
{
|
||||
"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]]:
|
||||
response = self.query("15", use_currency_offerid="1", zoneid=zone_id)
|
||||
@@ -126,14 +166,18 @@ class Cmall:
|
||||
# QQ 平台和平精英的 area 与 zoneid 不同(如 area=2, zoneid=1);
|
||||
# PlaceOrder 校验角色时需要该分区信息,随角色一并返回。
|
||||
partition = response.get("partition_info") or {}
|
||||
return [{
|
||||
"role_id": str(item.get("role_id", "")),
|
||||
"name": unquote(str(item.get("role_name", ""))),
|
||||
"ban_status": str(item.get("ban_status", "")),
|
||||
"area": str(partition.get("area", "")),
|
||||
"partition": str(partition.get("partition", "")),
|
||||
"platid": str(partition.get("platid", "")),
|
||||
} for item in roles if isinstance(item, dict) and item.get("role_id")]
|
||||
return [
|
||||
{
|
||||
"role_id": str(item.get("role_id", "")),
|
||||
"name": unquote(str(item.get("role_name", ""))),
|
||||
"ban_status": str(item.get("ban_status", "")),
|
||||
"area": str(partition.get("area", "")),
|
||||
"partition": str(partition.get("partition", "")),
|
||||
"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:
|
||||
@@ -151,18 +195,31 @@ def choose(label: str, options: list[dict], display) -> dict:
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="和平精英点券和角色选择(纯 CK 查询)")
|
||||
parser.add_argument("--session", type=Path, default=ROOT / "config/mall-session.json")
|
||||
parser.add_argument("--output", type=Path, default=ROOT / "config/peace-elite-selection.json")
|
||||
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="仅列出当前所有点券档位")
|
||||
parser.add_argument(
|
||||
"--session", type=Path, default=ROOT / "config/mall-session.json"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output", type=Path, default=ROOT / "config/peace-elite-selection.json"
|
||||
)
|
||||
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()
|
||||
|
||||
cookies = load_cookies(args.session)
|
||||
if args.platform is None:
|
||||
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"],
|
||||
)
|
||||
platform = str(selected_platform["id"])
|
||||
@@ -172,32 +229,59 @@ def main() -> int:
|
||||
products = product_options(cookies, platform)
|
||||
if args.list_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
|
||||
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:
|
||||
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:
|
||||
available = ", ".join(str(item["points"]) for item in products)
|
||||
raise ValueError(f"不支持 {args.points} 点券;当前可选: {available}")
|
||||
print(f"已选择: {product['points']}点券({product['price_fen'] / 100:g}元)")
|
||||
|
||||
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"])
|
||||
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":
|
||||
raise RuntimeError("所选角色已被封禁,不能充值")
|
||||
|
||||
selection = {"platform": platform, "order_pf": PLATFORMS[platform]["order_pf"],
|
||||
"points": product["points"], "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"]}
|
||||
selection = {
|
||||
"platform": platform,
|
||||
"order_pf": PLATFORMS[platform]["order_pf"],
|
||||
"points": product["points"],
|
||||
"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.write_text(json.dumps(selection, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"\n已选择 {selection['points']}点券 / {selection['zone_name']} / {selection['role_name']}")
|
||||
args.output.write_text(
|
||||
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}")
|
||||
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
|
||||
payment links never leave the worker API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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"HOLD_[A-Za-z0-9_-]+", "[订单已隐藏]", 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(r"(?:pay_token|web_token|anti_token|session_id|sessionid)\s*[:= ]\s*\S+",
|
||||
"[敏感字段已隐藏]", clean, flags=re.I)
|
||||
clean = re.sub(
|
||||
r"(?:token|openid|openkey|cookie)=\S+", "[敏感字段已隐藏]", 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")
|
||||
with _lock:
|
||||
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.
|
||||
|
||||
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()
|
||||
_safe_log(job, f"[{phase}] 开始执行")
|
||||
try:
|
||||
process = subprocess.Popen(command, cwd=ROOT, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, text=True,
|
||||
bufsize=1, env=environment)
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
cwd=ROOT,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
env=environment,
|
||||
)
|
||||
with _lock:
|
||||
job["process_pid"] = process.pid
|
||||
assert process.stdout is not None
|
||||
for line in process.stdout:
|
||||
_safe_log(job, line)
|
||||
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:
|
||||
job["process_pid"] = None
|
||||
if code != 0:
|
||||
@@ -134,7 +151,9 @@ def _run_process(job_id: str, command: list[str], phase: str, mark_success: bool
|
||||
job["message"] = "付款流程已完成"
|
||||
return code
|
||||
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:
|
||||
job["status"] = "failed"
|
||||
job["message"] = str(exc)
|
||||
@@ -146,42 +165,74 @@ def _start_login(job_id: str, provider: str, timeout: int) -> None:
|
||||
directory = _job_dir(job_id)
|
||||
session = directory / "mall-session.json"
|
||||
qr = directory / ("qq-login.jpg" if provider == "qq" else "wechat-login.jpg")
|
||||
command = [sys.executable, f"scripts/login-{provider}.py", "--session", str(session),
|
||||
"--qr", str(qr), "--timeout", str(timeout)]
|
||||
command = [
|
||||
sys.executable,
|
||||
f"scripts/login-{provider}.py",
|
||||
"--session",
|
||||
str(session),
|
||||
"--qr",
|
||||
str(qr),
|
||||
"--timeout",
|
||||
str(timeout),
|
||||
]
|
||||
with _lock:
|
||||
job["provider"] = provider
|
||||
job["qr_path"] = str(qr)
|
||||
job["session_path"] = str(session)
|
||||
job["status"] = "waiting_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:
|
||||
raise ValueError("任务不存在")
|
||||
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", {})
|
||||
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("当前登录态不支持该点券档位")
|
||||
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:
|
||||
product = products[0]
|
||||
cmall = selector.Cmall(cookies, str(product["offer_id"]), platform)
|
||||
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:
|
||||
raise ValueError("区服不存在")
|
||||
selected_zone = selected_zone or (zones[0] if zones else None)
|
||||
roles = cmall.roles(selected_zone["zone_id"]) if selected_zone else []
|
||||
return {"products": products, "zones": zones, "roles": roles,
|
||||
"default_product": product, "default_zone": selected_zone}
|
||||
return {
|
||||
"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,
|
||||
failed_message: str) -> bool:
|
||||
def _payment_stage(
|
||||
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."""
|
||||
job = _jobs[job_id]
|
||||
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."""
|
||||
job = _jobs[job_id]
|
||||
directory = _job_dir(job_id)
|
||||
command = [sys.executable, "scripts/jsdom-pay.py", "--check-only",
|
||||
"--session", str(directory / "mall-session.json"),
|
||||
"--out-dir", str(directory / "jsdom-order")]
|
||||
command = [
|
||||
sys.executable,
|
||||
"scripts/jsdom-pay.py",
|
||||
"--check-only",
|
||||
"--session",
|
||||
str(directory / "mall-session.json"),
|
||||
"--out-dir",
|
||||
str(directory / "jsdom-order"),
|
||||
]
|
||||
environment = os.environ.copy()
|
||||
environment.pop("NODE_OPTIONS", None)
|
||||
check_started = time.monotonic()
|
||||
_safe_log(job, "[到账检测] 开始执行")
|
||||
try:
|
||||
result = subprocess.run(command, cwd=ROOT, capture_output=True, text=True,
|
||||
env=environment, timeout=90)
|
||||
result = subprocess.run(
|
||||
command,
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=environment,
|
||||
timeout=90,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
_safe_log(job, f"[到账检测] 执行超时,耗时 {time.monotonic() - check_started:.1f} 秒")
|
||||
_safe_log(
|
||||
job, f"[到账检测] 执行超时,耗时 {time.monotonic() - check_started:.1f} 秒"
|
||||
)
|
||||
with _lock:
|
||||
job["payment_last_checked_at"] = int(time.time())
|
||||
return 2
|
||||
@@ -229,7 +294,9 @@ def _check_payment_once(job_id: str) -> int:
|
||||
_safe_log(job, line)
|
||||
for line in (result.stderr or "").splitlines():
|
||||
_safe_log(job, line)
|
||||
_safe_log(job, f"[到账检测] 执行结束,耗时 {time.monotonic() - check_started:.1f} 秒")
|
||||
_safe_log(
|
||||
job, f"[到账检测] 执行结束,耗时 {time.monotonic() - check_started:.1f} 秒"
|
||||
)
|
||||
with _lock:
|
||||
job["payment_last_checked_at"] = int(time.time())
|
||||
return result.returncode
|
||||
@@ -275,28 +342,62 @@ def _payment_flow(job_id: str, selection: dict) -> None:
|
||||
job["status"] = "ordering"
|
||||
job["phase"] = "payment"
|
||||
job["message"] = "正在创建商城订单"
|
||||
order_cmd = [sys.executable, "main.py", "mall", "auto", "--session", 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)]
|
||||
order_cmd = [
|
||||
sys.executable,
|
||||
"main.py",
|
||||
"mall",
|
||||
"auto",
|
||||
"--session",
|
||||
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"):
|
||||
order_cmd.extend(["--area", str(selection["area"])])
|
||||
if selection.get("partition"):
|
||||
order_cmd.extend(["--partition", str(selection["partition"])])
|
||||
if selection.get("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
|
||||
pay_cmd = [sys.executable, "scripts/jsdom-pay.py", "--session", str(session),
|
||||
"--mall-response", 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", "正在生成微信付款码", "生成付款码失败"):
|
||||
pay_cmd = [
|
||||
sys.executable,
|
||||
"scripts/jsdom-pay.py",
|
||||
"--session",
|
||||
str(session),
|
||||
"--mall-response",
|
||||
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
|
||||
meta_path = output / "payment-meta.json"
|
||||
try:
|
||||
@@ -318,7 +419,9 @@ def _start_payment(job_id: str, selection: dict) -> None:
|
||||
_jobs[job_id]["status"] = "ordering"
|
||||
_jobs[job_id]["phase"] = "payment"
|
||||
_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:
|
||||
@@ -344,13 +447,18 @@ def _stop_job(job_id: str) -> None:
|
||||
|
||||
def _public_job(job_id: str) -> dict:
|
||||
job = _jobs[job_id]
|
||||
result = {key: value for key, value in job.items()
|
||||
if key not in {"directory", "session_path", "process_pid"}}
|
||||
result = {
|
||||
key: value
|
||||
for key, value in job.items()
|
||||
if key not in {"directory", "session_path", "process_pid"}
|
||||
}
|
||||
qr_path = job.get("qr_path", "")
|
||||
if qr_path and Path(qr_path).exists():
|
||||
qr_bytes = Path(qr_path).read_bytes()
|
||||
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"
|
||||
for name in ("wechat-pay.png", "payment-status.json"):
|
||||
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)
|
||||
if path and name.endswith(".png"):
|
||||
payment_qr_bytes = path.read_bytes()
|
||||
result["payment_qr_data"] = base64.b64encode(payment_qr_bytes).decode("ascii")
|
||||
result["payment_qr_mime_type"] = "image/jpeg" if payment_qr_bytes.startswith(b"\xff\xd8\xff") else "image/png"
|
||||
result["payment_qr_data"] = base64.b64encode(payment_qr_bytes).decode(
|
||||
"ascii"
|
||||
)
|
||||
result["payment_qr_mime_type"] = (
|
||||
"image/jpeg"
|
||||
if payment_qr_bytes.startswith(b"\xff\xd8\xff")
|
||||
else "image/png"
|
||||
)
|
||||
elif path:
|
||||
try:
|
||||
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"] = {
|
||||
"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": {
|
||||
"is_finished": matched.get("is_finished"),
|
||||
"status": matched.get("status"),
|
||||
} if isinstance(matched, dict) else None,
|
||||
}
|
||||
if isinstance(matched, dict)
|
||||
else None,
|
||||
}
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
@@ -411,8 +533,14 @@ class Handler(BaseHTTPRequestHandler):
|
||||
directory = DEFAULT_DATA / job_id
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(directory, 0o700)
|
||||
_jobs[job_id] = {"job_id": job_id, "status": "created", "phase": "login",
|
||||
"logs": [], "directory": str(directory), "created_at": int(time.time())}
|
||||
_jobs[job_id] = {
|
||||
"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))
|
||||
if len(path) == 4 and path[:2] == ["v1", "jobs"] and path[3] == "login":
|
||||
job_id = path[2]
|
||||
@@ -421,44 +549,87 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return self._json(400, {"detail": "无效任务或登录方式"})
|
||||
_start_login(job_id, body["provider"], int(body.get("timeout", 600)))
|
||||
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]
|
||||
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
|
||||
return self._json(200, options)
|
||||
if len(path) == 4 and path[:2] == ["v1", "jobs"] and path[3] == "selection":
|
||||
job_id = path[2]
|
||||
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):
|
||||
return self._json(400, {"detail": "选择参数不完整"})
|
||||
selector = _load_selector()
|
||||
if body["platform"] not in selector.PLATFORMS:
|
||||
return self._json(400, {"detail": "不支持的平台"})
|
||||
session_path = _job_dir(job_id) / "mall-session.json"
|
||||
cookies = json.loads(session_path.read_text(encoding="utf-8")).get("cookies", {})
|
||||
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)
|
||||
cookies = json.loads(session_path.read_text(encoding="utf-8")).get(
|
||||
"cookies", {}
|
||||
)
|
||||
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:
|
||||
return self._json(400, {"detail": "商品已失效,请重新选择"})
|
||||
cmall = selector.Cmall(cookies, str(product["offer_id"]), body["platform"])
|
||||
zone = next((item for item in cmall.zones()
|
||||
if str(item["zone_id"]) == str(body["zone_id"])), None)
|
||||
cmall = selector.Cmall(
|
||||
cookies, str(product["offer_id"]), body["platform"]
|
||||
)
|
||||
zone = next(
|
||||
(
|
||||
item
|
||||
for item in cmall.zones()
|
||||
if str(item["zone_id"]) == str(body["zone_id"])
|
||||
),
|
||||
None,
|
||||
)
|
||||
if zone is None:
|
||||
return self._json(400, {"detail": "区服已失效,请重新选择"})
|
||||
role = next((item for item in cmall.roles(zone["zone_id"])
|
||||
if str(item["role_id"]) == str(body["role_id"])), None)
|
||||
role = next(
|
||||
(
|
||||
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":
|
||||
return self._json(400, {"detail": "角色不可充值,请重新选择"})
|
||||
_jobs[job_id]["selection"] = {
|
||||
"platform": body["platform"], "points": product["points"],
|
||||
"platform": body["platform"],
|
||||
"points": product["points"],
|
||||
"price_fen": product["price_fen"],
|
||||
"product_id": product["product_id"], "offer_id": product["offer_id"],
|
||||
"zone_id": zone["zone_id"], "zone_name": zone["name"],
|
||||
"role_id": role["role_id"], "role_name": role["name"],
|
||||
"area": role.get("area", ""), "partition": role.get("partition", ""),
|
||||
"product_id": product["product_id"],
|
||||
"offer_id": product["offer_id"],
|
||||
"zone_id": zone["zone_id"],
|
||||
"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"],
|
||||
}
|
||||
_jobs[job_id]["phase"] = "payment"
|
||||
@@ -473,14 +644,23 @@ class Handler(BaseHTTPRequestHandler):
|
||||
with _lock:
|
||||
if job.get("status") == "stopped":
|
||||
return self._json(400, {"detail": "任务已停止,不能生成付款码"})
|
||||
if job.get("status") in {"ordering", "waiting_payment", "payment_timeout"} \
|
||||
or job.get("process_pid"):
|
||||
return self._json(400, {"detail": "已有进行中的支付流程,请勿重复操作"})
|
||||
if job.get("status") in {
|
||||
"ordering",
|
||||
"waiting_payment",
|
||||
"payment_timeout",
|
||||
} or job.get("process_pid"):
|
||||
return self._json(
|
||||
400, {"detail": "已有进行中的支付流程,请勿重复操作"}
|
||||
)
|
||||
if not job.get("selection"):
|
||||
return self._json(400, {"detail": "请先完成角色选择"})
|
||||
_start_payment(job_id, job["selection"])
|
||||
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]
|
||||
if job_id not in _jobs:
|
||||
return self._json(400, {"detail": "任务不存在"})
|
||||
@@ -541,7 +721,9 @@ def _restore_jobs(data_dir: Path) -> int:
|
||||
continue
|
||||
if not meta_path.exists():
|
||||
_jobs[job_id] = {
|
||||
"job_id": job_id, "status": "ready", "phase": "selection",
|
||||
"job_id": job_id,
|
||||
"status": "ready",
|
||||
"phase": "selection",
|
||||
"logs": ["服务重启,已从任务目录恢复登录会话"],
|
||||
"directory": str(directory),
|
||||
"created_at": int(time.time()),
|
||||
@@ -569,7 +751,9 @@ def _restore_jobs(data_dir: Path) -> int:
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
_jobs[job_id] = {
|
||||
"job_id": job_id, "status": status, "phase": phase,
|
||||
"job_id": job_id,
|
||||
"status": status,
|
||||
"phase": phase,
|
||||
"logs": ["服务重启,已从任务目录恢复本任务"],
|
||||
"directory": str(directory),
|
||||
"created_at": int(time.time()),
|
||||
@@ -587,9 +771,15 @@ def main() -> int:
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=8810)
|
||||
parser.add_argument("--data-dir", type=Path, default=DEFAULT_DATA)
|
||||
parser.add_argument("--evidence-ttl-hours", type=int, default=DEFAULT_EVIDENCE_TTL_HOURS,
|
||||
help="任务原始证据保留时长;0 表示不自动清理")
|
||||
parser.add_argument("--key", default=None, help="HTTP Bearer 鉴权密钥;默认读取 YYB_WORKER_KEY")
|
||||
parser.add_argument(
|
||||
"--evidence-ttl-hours",
|
||||
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()
|
||||
# 强制绝对路径:子进程以 ROOT 为 cwd,相对路径会让任务文件写到错误位置。
|
||||
DEFAULT_DATA = Path(args.data_dir).resolve()
|
||||
@@ -603,7 +793,10 @@ def main() -> int:
|
||||
os.chmod(DEFAULT_DATA, 0o700)
|
||||
removed = _cleanup_expired_jobs(DEFAULT_DATA, args.evidence_ttl_hours)
|
||||
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)
|
||||
if restored:
|
||||
print(f"已从任务目录恢复 {restored} 个支付任务", flush=True)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""YYB 付款表单的离线回归测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
@@ -23,14 +24,24 @@ from pyvm.protocol import goods_material_diagnostics, validate_goods_materials
|
||||
class TestJsdomPay(unittest.TestCase):
|
||||
def test_build_save_body_has_current_payment_context(self):
|
||||
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"},
|
||||
"web-token", "anti-token", "cipher", "2",
|
||||
"mds_myappjp-__mds_myappjp_PC_aW9zd2hpdGVsaX0-iap", 600,
|
||||
"web-token",
|
||||
"anti-token",
|
||||
"cipher",
|
||||
"2",
|
||||
"mds_myappjp-__mds_myappjp_PC_aW9zd2hpdGVsaX0-iap",
|
||||
600,
|
||||
)
|
||||
fields = parse_qs(body, keep_blank_values=True)
|
||||
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["pay_method"], ["wechat"])
|
||||
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):
|
||||
body = _MODULE.build_save_body(
|
||||
{"token_id": "token", "transaction_id": "transaction", "out_trade_no": "trade"},
|
||||
{"openid": "openid", "accesstoken": "access-token", "logintype": "QC", "appid": "102033112"},
|
||||
"web-token", "anti-token", "cipher", "1", "pf", 100,
|
||||
{
|
||||
"token_id": "token",
|
||||
"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)
|
||||
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):
|
||||
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"},
|
||||
"web-token", "anti-token", "cipher", "1", "pf", 100,
|
||||
"web-token",
|
||||
"anti-token",
|
||||
"cipher",
|
||||
"1",
|
||||
"pf",
|
||||
100,
|
||||
)
|
||||
fields = parse_qs(body, keep_blank_values=True)
|
||||
self.assertNotIn("offerid_for_qq_appid", fields)
|
||||
|
||||
def test_qq_page_info_matches_the_web_flow(self):
|
||||
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"},
|
||||
"anti-token", "1", "pf",
|
||||
{
|
||||
"token_id": "token",
|
||||
"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)
|
||||
self.assertEqual(fields["isusempaymode"], ["1"])
|
||||
@@ -71,13 +116,17 @@ class TestJsdomPay(unittest.TestCase):
|
||||
self.assertNotIn("pay_method", fields)
|
||||
|
||||
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)
|
||||
|
||||
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.assertNotIn("风控", message)
|
||||
|
||||
@@ -88,17 +137,30 @@ class TestJsdomPay(unittest.TestCase):
|
||||
|
||||
def test_qq_encrypt_rand_aligns_plaintext_to_aes_block(self):
|
||||
params = {
|
||||
"token_id": "t", "openid": "o", "openkey": "k", "session_id": "openid",
|
||||
"session_type": "kp_accesstoken", "zoneid": "1", "pay_method": "wechat",
|
||||
"buy_quantity": "1", "from_h5": "1", "webversion": "minipayv2",
|
||||
"token_id": "t",
|
||||
"openid": "o",
|
||||
"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)
|
||||
plaintext = _MODULE.build_plaintext(params, "tdrc_session%3Dpay-test", "1775990000", rand_value)
|
||||
rand_value = _MODULE.make_encrypt_rand(
|
||||
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.assertGreaterEqual(len(rand_value), 8)
|
||||
|
||||
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(rand_value[-1], "\x01")
|
||||
|
||||
@@ -124,12 +186,26 @@ class TestJsdomPay(unittest.TestCase):
|
||||
import json
|
||||
|
||||
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 = {
|
||||
"token_id": "token", "openid": "openid", "openkey": "key", "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",
|
||||
"token_id": "token",
|
||||
"openid": "openid",
|
||||
"openkey": "key",
|
||||
"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",
|
||||
}
|
||||
timestamp = "1775990000"
|
||||
@@ -137,16 +213,27 @@ class TestJsdomPay(unittest.TestCase):
|
||||
for length in range(512):
|
||||
params["extend"] = "x" * length
|
||||
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:
|
||||
break
|
||||
else:
|
||||
self.fail("无法构造 528B goods 测试明文")
|
||||
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(
|
||||
params, fk_extend, timestamp, rand_value, key16=key16, key1=key1,
|
||||
args_template=template, xmidas=xmidas, xmidas_token="A" * 96,
|
||||
params,
|
||||
fk_extend,
|
||||
timestamp,
|
||||
rand_value,
|
||||
key16=key16,
|
||||
key1=key1,
|
||||
args_template=template,
|
||||
xmidas=xmidas,
|
||||
xmidas_token="A" * 96,
|
||||
)
|
||||
self.assertEqual(len(ciphertext), 1056)
|
||||
|
||||
|
||||
+24
-8
@@ -67,8 +67,12 @@ class _SensitiveDataFilter(logging.Filter):
|
||||
|
||||
@classmethod
|
||||
def redact(cls, message: str) -> str:
|
||||
message = cls._HEADER_RE.sub(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._HEADER_RE.sub(
|
||||
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)
|
||||
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)
|
||||
|
||||
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
|
||||
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")
|
||||
sequence = 1
|
||||
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
|
||||
with source.open("rb") as raw, gzip.open(archive, "wb") as 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)
|
||||
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()
|
||||
|
||||
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.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"))
|
||||
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.setFormatter(formatter)
|
||||
file_handler.addFilter(redaction_filter)
|
||||
@@ -199,7 +213,9 @@ def _configure_standard_logging(level: str, log_path: Path) -> logging.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`` 可指定完整文件名。环境变量
|
||||
|
||||
@@ -91,7 +91,7 @@ def encrypt_value(value: str | None) -> str | None:
|
||||
|
||||
|
||||
def _decrypt_with_candidate(value: str, candidate: _KeyCandidate) -> str:
|
||||
raw = _b64d(value[len(_PREFIX):])
|
||||
raw = _b64d(value[len(_PREFIX) :])
|
||||
if len(raw) < 28:
|
||||
raise ValueError("密文字段长度无效")
|
||||
nonce = raw[:12]
|
||||
@@ -112,7 +112,9 @@ def decrypt_value(value: str | None) -> str | None:
|
||||
return _decrypt_with_candidate(value, candidate)
|
||||
except Exception as 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]:
|
||||
@@ -125,7 +127,9 @@ def decrypt_value_with_key_name(value: str) -> tuple[str, str]:
|
||||
return _decrypt_with_candidate(value, candidate), candidate.name
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
raise ValueError("敏感字段解密失败,请确认 APP_ENCRYPTION_KEY 是否正确") from last_error
|
||||
raise ValueError(
|
||||
"敏感字段解密失败,请确认 APP_ENCRYPTION_KEY 是否正确"
|
||||
) from last_error
|
||||
|
||||
|
||||
class EncryptedText(TypeDecorator):
|
||||
@@ -184,9 +188,15 @@ def encrypt_existing_sensitive_data(engine: Engine) -> int:
|
||||
for table_name, column_name in _SENSITIVE_COLUMNS:
|
||||
if not _table_exists(engine, table_name):
|
||||
continue
|
||||
rows = conn.execute(
|
||||
text(f"SELECT id, {column_name} FROM {table_name} WHERE {column_name} IS NOT NULL")
|
||||
).mappings().all()
|
||||
rows = (
|
||||
conn.execute(
|
||||
text(
|
||||
f"SELECT id, {column_name} FROM {table_name} WHERE {column_name} IS NOT NULL"
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
for row in rows:
|
||||
raw_value = row[column_name]
|
||||
if raw_value is None or raw_value == "":
|
||||
@@ -204,7 +214,9 @@ def encrypt_existing_sensitive_data(engine: Engine) -> int:
|
||||
continue
|
||||
encrypted = encrypt_value(plain_value)
|
||||
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"]},
|
||||
)
|
||||
changed += 1
|
||||
|
||||
@@ -32,7 +32,9 @@ def _get_database_url() -> str:
|
||||
db_user = os.getenv("DB_USER", "douyu_login").strip()
|
||||
db_password = os.getenv("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 (
|
||||
f"mysql+pymysql://{quote_plus(db_user)}:{quote_plus(db_password)}"
|
||||
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"):
|
||||
|
||||
@event.listens_for(engine, "connect")
|
||||
def _set_sqlite_pragmas(dbapi_connection, connection_record):
|
||||
"""提升 SQLite 并发写入稳定性。"""
|
||||
@@ -78,6 +81,7 @@ if DATABASE_URL.startswith("sqlite"):
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.close()
|
||||
|
||||
|
||||
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
|
||||
Base = declarative_base()
|
||||
|
||||
@@ -104,7 +108,9 @@ def run_migrations():
|
||||
from alembic.config import Config
|
||||
|
||||
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)
|
||||
# 标记为应用内嵌调用,env.py 据此跳过 fileConfig,避免覆盖 uvicorn 日志配置。
|
||||
os.environ["ALEMBIC_EMBEDDED"] = "1"
|
||||
|
||||
+23
-3
@@ -12,7 +12,20 @@ from fastapi.responses import FileResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
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 .version import get_app_version
|
||||
from utils import setup_logger
|
||||
@@ -22,7 +35,9 @@ from utils import setup_logger
|
||||
async def lifespan(app: FastAPI):
|
||||
# 本地 dev.sh 和 Docker 均显式设置 LOG_DIR;直接运行时回退到项目 logs/。
|
||||
_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))
|
||||
|
||||
init_db()
|
||||
@@ -46,6 +61,7 @@ async def lifespan(app: FastAPI):
|
||||
if cleaned_relogin:
|
||||
logger.info(f"启动清理 CK 重登残留任务: {cleaned_relogin} 条")
|
||||
from .services.yyb_service import cleanup_orphan_yyb_tasks
|
||||
|
||||
cleaned_yyb = cleanup_orphan_yyb_tasks(db, message="任务已中断(服务重启)")
|
||||
if cleaned_yyb:
|
||||
logger.info(f"启动清理应用宝残留任务: {cleaned_yyb} 条")
|
||||
@@ -65,7 +81,11 @@ _cors_env = os.getenv("CORS_ORIGINS", "")
|
||||
if _cors_env:
|
||||
_cors_origins = [o.strip() for o in _cors_env.split(",") if o.strip()]
|
||||
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(
|
||||
CORSMiddleware,
|
||||
|
||||
@@ -39,7 +39,9 @@ def _add_column_if_missing(bind, table_name: str, column: sa.Column) -> bool:
|
||||
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):
|
||||
op.create_index(name, table_name, columns, unique=unique)
|
||||
|
||||
@@ -62,8 +64,12 @@ def upgrade() -> None:
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
else:
|
||||
_add_column_if_missing(bind, "users", sa.Column("custom_permissions", sa.JSON(), nullable=True))
|
||||
_create_index_if_missing(bind, "ix_users_username", "users", ["username"], unique=True)
|
||||
_add_column_if_missing(
|
||||
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"):
|
||||
op.create_table(
|
||||
@@ -84,8 +90,14 @@ def upgrade() -> None:
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
else:
|
||||
_add_column_if_missing(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")))
|
||||
_add_column_if_missing(
|
||||
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:
|
||||
op.execute(
|
||||
"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 "
|
||||
"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"):
|
||||
op.create_table(
|
||||
@@ -127,7 +141,9 @@ def upgrade() -> None:
|
||||
sa.ForeignKeyConstraint(["created_by"], ["users.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"):
|
||||
op.create_table(
|
||||
|
||||
@@ -19,16 +19,30 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("accounts") as batch:
|
||||
batch.alter_column("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())
|
||||
batch.alter_column(
|
||||
"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:
|
||||
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("https", existing_type=sa.String(length=256), 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())
|
||||
batch.alter_column(
|
||||
"https", existing_type=sa.String(length=256), 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:
|
||||
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())
|
||||
|
||||
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("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(
|
||||
"whitelist_ukey", existing_type=sa.Text(), type_=sa.String(length=128)
|
||||
)
|
||||
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("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:
|
||||
batch.alter_column("email_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))
|
||||
batch.alter_column(
|
||||
"email_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(
|
||||
sa.Column("whitelist_platform", sa.String(32), server_default="xiequ")
|
||||
)
|
||||
batch.add_column(
|
||||
sa.Column("whitelist_credentials", sa.JSON(), nullable=True)
|
||||
)
|
||||
batch.add_column(sa.Column("whitelist_credentials", sa.JSON(), nullable=True))
|
||||
|
||||
|
||||
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)}
|
||||
|
||||
|
||||
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):
|
||||
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_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"):
|
||||
op.create_table(
|
||||
@@ -79,7 +83,9 @@ def upgrade() -> None:
|
||||
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_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"):
|
||||
op.create_table(
|
||||
@@ -106,13 +112,17 @@ def upgrade() -> None:
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=True),
|
||||
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:
|
||||
bind = op.get_bind()
|
||||
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")
|
||||
if _has_table(bind, "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)}
|
||||
|
||||
|
||||
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):
|
||||
op.create_index(name, table_name, columns, unique=unique)
|
||||
|
||||
@@ -71,7 +73,13 @@ def downgrade() -> None:
|
||||
if _has_table(bind, "huya_recharge_goods_snapshot"):
|
||||
indexes = _indexes(bind, "huya_recharge_goods_snapshot")
|
||||
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:
|
||||
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")
|
||||
|
||||
@@ -59,9 +59,17 @@ def upgrade() -> None:
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("batch_id"),
|
||||
)
|
||||
op.create_index("ix_huya_register_batches_batch_id", "huya_register_batches", ["batch_id"])
|
||||
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"])
|
||||
op.create_index(
|
||||
"ix_huya_register_batches_batch_id", "huya_register_batches", ["batch_id"]
|
||||
)
|
||||
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"):
|
||||
op.create_table(
|
||||
@@ -91,10 +99,18 @@ def upgrade() -> None:
|
||||
sa.ForeignKeyConstraint(["batch_db_id"], ["huya_register_batches.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_huya_register_items_batch_db_id", "huya_register_items", ["batch_db_id"])
|
||||
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"])
|
||||
op.create_index(
|
||||
"ix_huya_register_items_batch_db_id", "huya_register_items", ["batch_db_id"]
|
||||
)
|
||||
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"):
|
||||
op.create_table(
|
||||
@@ -116,10 +132,24 @@ def upgrade() -> None:
|
||||
sa.ForeignKeyConstraint(["created_by"], ["users.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_huya_register_success_logs_batch_id", "huya_register_success_logs", ["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"])
|
||||
op.create_index(
|
||||
"ix_huya_register_success_logs_batch_id",
|
||||
"huya_register_success_logs",
|
||||
["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:
|
||||
|
||||
@@ -38,7 +38,9 @@ def _add_column_if_missing(bind, table_name: str, column: sa.Column) -> None:
|
||||
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):
|
||||
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:
|
||||
bind = op.get_bind()
|
||||
|
||||
_add_column_if_missing(bind, "accounts", sa.Column("uid", sa.String(length=32), nullable=True))
|
||||
_add_column_if_missing(bind, "accounts", sa.Column("nickname", sa.String(length=128), 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(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))
|
||||
_add_column_if_missing(
|
||||
bind, "accounts", sa.Column("uid", sa.String(length=32), nullable=True)
|
||||
)
|
||||
_add_column_if_missing(
|
||||
bind, "accounts", sa.Column("nickname", sa.String(length=128), 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(
|
||||
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"])
|
||||
|
||||
if not _has_table(bind, "douyu_tasks"):
|
||||
@@ -75,8 +101,12 @@ def upgrade() -> None:
|
||||
sa.ForeignKeyConstraint(["created_by"], ["users.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
_create_index_if_missing(bind, "ix_douyu_tasks_batch_id", "douyu_tasks", ["batch_id"])
|
||||
_create_index_if_missing(bind, "ix_douyu_tasks_task_type", "douyu_tasks", ["task_type"])
|
||||
_create_index_if_missing(
|
||||
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"):
|
||||
op.create_table(
|
||||
@@ -121,7 +151,10 @@ def downgrade() -> None:
|
||||
if _has_table(bind, "douyu_goods_snapshot"):
|
||||
indexes = _indexes(bind, "douyu_goods_snapshot")
|
||||
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")
|
||||
if _has_table(bind, "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)}
|
||||
|
||||
|
||||
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):
|
||||
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_created_by_id", "huya_tasks", ["created_by", "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()
|
||||
columns = {column["name"] for column in sa.inspect(bind).get_columns("accounts")}
|
||||
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:
|
||||
|
||||
@@ -23,7 +23,9 @@ def upgrade() -> None:
|
||||
return
|
||||
columns = {column["name"] for column in sa.inspect(bind).get_columns("accounts")}
|
||||
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:
|
||||
|
||||
@@ -56,10 +56,8 @@ def upgrade() -> None:
|
||||
return
|
||||
|
||||
for index in range(0, len(delete_ids), 500):
|
||||
chunk = delete_ids[index:index + 500]
|
||||
bind.execute(
|
||||
task_table.delete().where(task_table.c.id.in_(chunk))
|
||||
)
|
||||
chunk = delete_ids[index : index + 500]
|
||||
bind.execute(task_table.delete().where(task_table.c.id.in_(chunk)))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -18,7 +18,9 @@ def upgrade() -> None:
|
||||
"yyb_recharge_tasks",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
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("provider", sa.String(16), nullable=False, server_default=""),
|
||||
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("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("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(
|
||||
"uq_yyb_recharge_tasks_task_id", "yyb_recharge_tasks", ["task_id"], unique=True
|
||||
)
|
||||
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"])
|
||||
|
||||
|
||||
|
||||
@@ -18,8 +18,12 @@ def _is_mysql() -> bool:
|
||||
|
||||
def upgrade() -> None:
|
||||
if _is_mysql():
|
||||
op.execute("ALTER TABLE yyb_recharge_tasks MODIFY login_qr_data MEDIUMTEXT NULL")
|
||||
op.execute("ALTER TABLE yyb_recharge_tasks MODIFY payment_qr_data MEDIUMTEXT NULL")
|
||||
op.execute(
|
||||
"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:
|
||||
|
||||
@@ -13,10 +13,21 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("yyb_recharge_tasks", sa.Column("price_fen", sa.Integer(), 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))
|
||||
op.add_column(
|
||||
"yyb_recharge_tasks", sa.Column("price_fen", sa.Integer(), 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:
|
||||
|
||||
@@ -23,7 +23,9 @@ def upgrade() -> None:
|
||||
op.add_column("users", sa.Column("deleted_at", sa.DateTime(), nullable=True))
|
||||
op.create_index("ix_users_deleted_at", "users", ["deleted_at"])
|
||||
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:
|
||||
|
||||
@@ -26,35 +26,71 @@ def upgrade() -> None:
|
||||
if "handbook_scope" not in _columns(bind, "douyu_tasks"):
|
||||
op.add_column(
|
||||
"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"):
|
||||
op.create_table(
|
||||
"douyu_workbench_accounts",
|
||||
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("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.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"):
|
||||
op.create_table(
|
||||
"douyu_workbenches",
|
||||
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("updated_at", sa.DateTime(), nullable=True),
|
||||
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_handbook_scope", "douyu_workbenches", ["handbook_scope"])
|
||||
op.create_index(
|
||||
"ix_douyu_workbenches_user_id", "douyu_workbenches", ["user_id"]
|
||||
)
|
||||
op.create_index(
|
||||
"ix_douyu_workbenches_handbook_scope",
|
||||
"douyu_workbenches",
|
||||
["handbook_scope"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -30,7 +30,9 @@ def upgrade() -> None:
|
||||
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_handbook_scope", "douyu_workbenches", ["handbook_scope"])
|
||||
op.create_index(
|
||||
"ix_douyu_workbenches_handbook_scope", "douyu_workbenches", ["handbook_scope"]
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -27,9 +27,33 @@ def upgrade() -> None:
|
||||
return
|
||||
columns = _columns(bind)
|
||||
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_api_account_template_name", sa.Column("gold_api_account_template_name", sa.String(length=64), nullable=True, server_default="斗鱼UID")),
|
||||
(
|
||||
"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_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:
|
||||
if name not in columns:
|
||||
@@ -41,6 +65,10 @@ def downgrade() -> None:
|
||||
if not sa.inspect(bind).has_table("douyu_config"):
|
||||
return
|
||||
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:
|
||||
op.drop_column("douyu_config", name)
|
||||
|
||||
@@ -24,7 +24,10 @@ def upgrade() -> None:
|
||||
return
|
||||
columns = {column["name"] for column in inspector.get_columns("douyu_tasks")}
|
||||
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(
|
||||
"ix_douyu_tasks_supplier_out_order_id",
|
||||
"douyu_tasks",
|
||||
|
||||
@@ -25,10 +25,17 @@ def upgrade() -> None:
|
||||
return
|
||||
columns = {column["name"] for column in inspector.get_columns("huya_accounts")}
|
||||
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")}
|
||||
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:
|
||||
|
||||
@@ -27,8 +27,16 @@ INDEXES = (
|
||||
["handbook_scope", "task_type", "id"],
|
||||
),
|
||||
# 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 = (
|
||||
db.query(DouyuTask, Account)
|
||||
.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())
|
||||
.limit(100)
|
||||
.all()
|
||||
@@ -56,14 +59,19 @@ def _audit_log_out(db: Session, row: AuditLog) -> dict:
|
||||
batch_id = row.target.removeprefix("douyu_batch:")
|
||||
task = (
|
||||
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())
|
||||
.first()
|
||||
)
|
||||
task_result = task.result if task and isinstance(task.result, dict) else {}
|
||||
recharge_channel = str(task_result.get("recharge_channel") or "wechat_qr")
|
||||
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):
|
||||
detail_text = json.dumps(detail, ensure_ascii=False, separators=(",", ":"))
|
||||
return {
|
||||
@@ -99,7 +107,9 @@ def list_audit_logs(
|
||||
query = query.filter(AuditLog.action == action.strip())
|
||||
if keyword:
|
||||
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:
|
||||
query = query.filter(AuditLog.success == success)
|
||||
if start_time:
|
||||
@@ -107,7 +117,12 @@ def list_audit_logs(
|
||||
if end_time:
|
||||
query = query.filter(AuditLog.created_at <= end_time)
|
||||
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 {
|
||||
"items": [_audit_log_out(db, row) for row in rows],
|
||||
"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()
|
||||
|
||||
return TokenResponse(
|
||||
@@ -66,8 +68,19 @@ def me(current_user: User = Depends(get_current_user)):
|
||||
|
||||
|
||||
@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="/")
|
||||
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()
|
||||
return {"message": "已登出"}
|
||||
|
||||
@@ -44,7 +44,9 @@ def _empty_task_summary() -> dict:
|
||||
|
||||
def _can_view_huya_all(user: User) -> bool:
|
||||
"""兼容旧 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")
|
||||
@@ -65,12 +67,17 @@ def dashboard_summary(
|
||||
)
|
||||
|
||||
login_tasks = _empty_task_summary()
|
||||
if any(user_has_permission(current, permission) for permission in (
|
||||
"login:batch",
|
||||
"login:view_all",
|
||||
"login:view_assigned",
|
||||
)):
|
||||
login_query = db.query(LoginTask).join(Account, LoginTask.account_id == Account.id)
|
||||
if any(
|
||||
user_has_permission(current, permission)
|
||||
for permission in (
|
||||
"login:batch",
|
||||
"login:view_all",
|
||||
"login:view_assigned",
|
||||
)
|
||||
):
|
||||
login_query = db.query(LoginTask).join(
|
||||
Account, LoginTask.account_id == Account.id
|
||||
)
|
||||
if not user_has_permission(current, "login:view_all"):
|
||||
login_query = login_query.filter(Account.assigned_to == current.id)
|
||||
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
|
||||
|
||||
huya_accounts = 0
|
||||
can_view_huya_accounts = any(user_has_permission(current, permission) for permission in (
|
||||
"huya:account",
|
||||
"huya:view_all",
|
||||
"huya:view_assigned",
|
||||
))
|
||||
can_view_huya_accounts = any(
|
||||
user_has_permission(current, permission)
|
||||
for permission in (
|
||||
"huya:account",
|
||||
"huya:view_all",
|
||||
"huya:view_assigned",
|
||||
)
|
||||
)
|
||||
if can_view_huya_accounts:
|
||||
huya_account_query = db.query(HuyaAccount)
|
||||
if not _can_view_huya_all(current):
|
||||
huya_account_query = huya_account_query.filter(HuyaAccount.assigned_to == current.id)
|
||||
huya_accounts = huya_account_query.with_entities(func.count(HuyaAccount.id)).scalar() or 0
|
||||
huya_account_query = huya_account_query.filter(
|
||||
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_goods = 0
|
||||
huya_recharge_goods = 0
|
||||
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):
|
||||
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_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 {
|
||||
"douyu": {
|
||||
|
||||
@@ -106,7 +106,11 @@ def list_tasks(
|
||||
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"):
|
||||
@@ -130,12 +134,20 @@ def list_tasks(
|
||||
|
||||
result = []
|
||||
for t in rows:
|
||||
result.append(LoginTaskOut(
|
||||
id=t.id, batch_id=t.batch_id, 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,
|
||||
))
|
||||
result.append(
|
||||
LoginTaskOut(
|
||||
id=t.id,
|
||||
batch_id=t.batch_id,
|
||||
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
|
||||
|
||||
|
||||
@@ -178,7 +190,11 @@ def delete_tasks(
|
||||
ids = [int(x) for x in task_ids.split(",") if x.strip().isdigit()]
|
||||
if not ids:
|
||||
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()
|
||||
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 ..services.proxy_service import proxy_service
|
||||
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=["代理与白名单"])
|
||||
@@ -37,7 +39,9 @@ def update_proxy_config(
|
||||
api_url=req.api_url if req.api_url is not None else "",
|
||||
http=req.http if req.http 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_credentials=req.whitelist_credentials,
|
||||
whitelist_uid=req.whitelist_uid,
|
||||
@@ -53,16 +57,19 @@ def list_platforms():
|
||||
result = []
|
||||
for name in get_platform_names():
|
||||
fields = get_credential_fields(name)
|
||||
result.append(PlatformInfo(
|
||||
name=name,
|
||||
label=labels.get(name, name),
|
||||
credential_fields=[PlatformFieldDef(**f) for f in fields],
|
||||
))
|
||||
result.append(
|
||||
PlatformInfo(
|
||||
name=name,
|
||||
label=labels.get(name, name),
|
||||
credential_fields=[PlatformFieldDef(**f) for f in fields],
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# ---- WebSocket 日志推送 ----
|
||||
|
||||
|
||||
@router.websocket("/ws/test/{test_id}")
|
||||
async def ws_test_logs(websocket: WebSocket, test_id: str):
|
||||
"""WebSocket 推送代理/白名单测试实时日志(需认证)。"""
|
||||
@@ -100,6 +107,7 @@ async def ws_test_logs(websocket: WebSocket, test_id: str):
|
||||
|
||||
# ---- 异步测试 API 端点 ----
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
async def test_proxy(
|
||||
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()
|
||||
result = []
|
||||
for u in users:
|
||||
result.append(UserInfo(
|
||||
id=u.id,
|
||||
username=u.username,
|
||||
role=u.role,
|
||||
is_active=u.is_active,
|
||||
remark=u.remark or "",
|
||||
created_at=u.created_at,
|
||||
permissions=get_user_permissions(u),
|
||||
custom_permissions=u.custom_permissions,
|
||||
))
|
||||
result.append(
|
||||
UserInfo(
|
||||
id=u.id,
|
||||
username=u.username,
|
||||
role=u.role,
|
||||
is_active=u.is_active,
|
||||
remark=u.remark or "",
|
||||
created_at=u.created_at,
|
||||
permissions=get_user_permissions(u),
|
||||
custom_permissions=u.custom_permissions,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@@ -58,13 +60,22 @@ def create_user(
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
db.add(AuditLog(user_id=current.id, username=current.username,
|
||||
action="user:create", target=user.username))
|
||||
db.add(
|
||||
AuditLog(
|
||||
user_id=current.id,
|
||||
username=current.username,
|
||||
action="user:create",
|
||||
target=user.username,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return UserInfo(
|
||||
id=user.id, username=user.username, role=user.role,
|
||||
is_active=user.is_active, remark=user.remark or "",
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
remark=user.remark or "",
|
||||
permissions=get_user_permissions(user),
|
||||
custom_permissions=user.custom_permissions,
|
||||
)
|
||||
@@ -97,13 +108,22 @@ def update_user(
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
db.add(AuditLog(user_id=current.id, username=current.username,
|
||||
action="user:edit", target=user.username))
|
||||
db.add(
|
||||
AuditLog(
|
||||
user_id=current.id,
|
||||
username=current.username,
|
||||
action="user:edit",
|
||||
target=user.username,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return UserInfo(
|
||||
id=user.id, username=user.username, role=user.role,
|
||||
is_active=user.is_active, remark=user.remark or "",
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
remark=user.remark or "",
|
||||
permissions=get_user_permissions(user),
|
||||
custom_permissions=user.custom_permissions,
|
||||
)
|
||||
@@ -126,21 +146,27 @@ def rename_user(
|
||||
raise HTTPException(status_code=400, detail="用户名至少 2 个字符")
|
||||
if username == user.username:
|
||||
return UserInfo(
|
||||
id=user.id, username=user.username, role=user.role,
|
||||
is_active=user.is_active, remark=user.remark or "",
|
||||
permissions=get_user_permissions(user), custom_permissions=user.custom_permissions,
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
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():
|
||||
raise HTTPException(status_code=400, detail="用户名已存在")
|
||||
|
||||
old_username = user.username
|
||||
user.username = username
|
||||
db.add(AuditLog(
|
||||
user_id=current.id,
|
||||
username=current.username,
|
||||
action="user:rename",
|
||||
target=f"{old_username} -> {username}",
|
||||
))
|
||||
db.add(
|
||||
AuditLog(
|
||||
user_id=current.id,
|
||||
username=current.username,
|
||||
action="user:rename",
|
||||
target=f"{old_username} -> {username}",
|
||||
)
|
||||
)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError as exc:
|
||||
@@ -148,9 +174,13 @@ def rename_user(
|
||||
raise HTTPException(status_code=400, detail="用户名已存在") from exc
|
||||
db.refresh(user)
|
||||
return UserInfo(
|
||||
id=user.id, username=user.username, role=user.role,
|
||||
is_active=user.is_active, remark=user.remark or "",
|
||||
permissions=get_user_permissions(user), custom_permissions=user.custom_permissions,
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
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)
|
||||
)
|
||||
deleted_username = user.username
|
||||
db.add(AuditLog(user_id=current.id, username=current.username,
|
||||
action="user:delete", target=deleted_username))
|
||||
db.add(
|
||||
AuditLog(
|
||||
user_id=current.id,
|
||||
username=current.username,
|
||||
action="user:delete",
|
||||
target=deleted_username,
|
||||
)
|
||||
)
|
||||
user.is_active = False
|
||||
user.deleted_at = datetime.now(timezone.utc)
|
||||
user.deleted_username = deleted_username
|
||||
@@ -202,6 +238,7 @@ def delete_user(
|
||||
def list_permissions(_: User = Depends(require_permission("user:assign_permissions"))):
|
||||
"""返回所有可用权限点及角色默认权限映射。"""
|
||||
from ..permissions import ROLE_PERMISSIONS
|
||||
|
||||
return {
|
||||
"permissions": PERMISSIONS,
|
||||
"role_permissions": ROLE_PERMISSIONS,
|
||||
|
||||
+172
-45
@@ -20,7 +20,9 @@ from ..services.audit_service import record_audit
|
||||
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。
|
||||
@@ -53,14 +55,26 @@ def _creator_username(db: Session, task: YybRechargeTask) -> str:
|
||||
|
||||
|
||||
@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)
|
||||
task = YybRechargeTask(task_id=uuid.uuid4().hex[:16], created_by=current.id,
|
||||
worker_job_id=str(data["job_id"]), status=str(data.get("status", "created")),
|
||||
phase="login", message="请选择登录方式")
|
||||
task = YybRechargeTask(
|
||||
task_id=uuid.uuid4().hex[:16],
|
||||
created_by=current.id,
|
||||
worker_job_id=str(data["job_id"]),
|
||||
status=str(data.get("status", "created")),
|
||||
phase="login",
|
||||
message="请选择登录方式",
|
||||
)
|
||||
db.add(task)
|
||||
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},
|
||||
)
|
||||
db.commit()
|
||||
@@ -69,9 +83,18 @@ def create_task(payload: YybTaskCreateRequest, db: Session = Depends(get_db), cu
|
||||
|
||||
|
||||
@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)
|
||||
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.status = str(data.get("status", "waiting_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"):
|
||||
task.login_qr_data = data["qr_data"]
|
||||
record_audit(
|
||||
db, 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,
|
||||
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()
|
||||
return public_task(task, creator_username=_creator_username(db, task))
|
||||
|
||||
|
||||
@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)
|
||||
try:
|
||||
sync_task(db, task, YybWorkerClient())
|
||||
except YybWorkerError:
|
||||
# Worker 暂时重启时仍返回最近一次持久化状态。
|
||||
pass
|
||||
return public_task(task, include_qr=user_has_permission(current, "yyb:session"),
|
||||
include_payment_qr=user_has_permission(current, "yyb:recharge"),
|
||||
creator_username=_creator_username(db, task))
|
||||
return public_task(
|
||||
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")
|
||||
def list_tasks(scope: str = Query("mine", pattern="^(mine|all)$"),
|
||||
status: str | None = Query(None, max_length=32),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: Session = Depends(get_db), current: User = Depends(require_permission("yyb:session"))):
|
||||
def list_tasks(
|
||||
scope: str = Query("mine", pattern="^(mine|all)$"),
|
||||
status: str | None = Query(None, max_length=32),
|
||||
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"):
|
||||
raise HTTPException(403, "无权查看全部充值任务")
|
||||
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()
|
||||
usernames = {
|
||||
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, ""))
|
||||
for task in rows]
|
||||
return [
|
||||
public_task(
|
||||
task, include_qr=False, creator_username=usernames.get(task.created_by, "")
|
||||
)
|
||||
for task in rows
|
||||
]
|
||||
|
||||
|
||||
@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)
|
||||
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
|
||||
db.commit()
|
||||
return data
|
||||
|
||||
|
||||
@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)
|
||||
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())
|
||||
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])
|
||||
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(
|
||||
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={
|
||||
"task_id": task.task_id, "product_id": task.product_id, "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,
|
||||
"task_id": task.task_id,
|
||||
"product_id": task.product_id,
|
||||
"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()
|
||||
@@ -151,10 +235,19 @@ def selection(task_id: int, payload: YybSelectionRequest, db: Session = Depends(
|
||||
|
||||
|
||||
@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;锁行做原子状态迁移,防止并发双击重复下单。
|
||||
_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:
|
||||
raise HTTPException(404, "充值任务不存在")
|
||||
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.payment_started_at = None
|
||||
record_audit(
|
||||
db, current, action="recharge:yyb:payment", target=f"yyb_task:{task.task_id}",
|
||||
detail="生成付款码失败", success=False,
|
||||
db,
|
||||
current,
|
||||
action="recharge:yyb:payment",
|
||||
target=f"yyb_task:{task.task_id}",
|
||||
detail="生成付款码失败",
|
||||
success=False,
|
||||
)
|
||||
db.commit()
|
||||
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.message = str(data.get("message", task.message))
|
||||
record_audit(
|
||||
db, current, 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,
|
||||
current,
|
||||
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()
|
||||
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")
|
||||
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)
|
||||
if task.status not in {"waiting_payment", "payment_timeout"}:
|
||||
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":
|
||||
task.phase = "completed"
|
||||
record_audit(
|
||||
db, 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},
|
||||
db,
|
||||
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",
|
||||
)
|
||||
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")
|
||||
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)
|
||||
data = _worker_call(lambda: YybWorkerClient().stop(task.worker_job_id))
|
||||
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.finished_at = _utcnow()
|
||||
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},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
@@ -8,27 +8,31 @@ from sqlalchemy.orm import Session
|
||||
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]:
|
||||
"""拆分一行账号文本,支持 |、tab、逗号、空格分隔。"""
|
||||
if '|' in line:
|
||||
return line.split('|')
|
||||
if '\t' in line:
|
||||
return line.split('\t')
|
||||
if ',' in line:
|
||||
if "|" in line:
|
||||
return line.split("|")
|
||||
if "\t" in line:
|
||||
return line.split("\t")
|
||||
if "," in line:
|
||||
return next(csv.reader([line]))
|
||||
return line.split()
|
||||
|
||||
|
||||
def cookie_account_ids_query(db: Session):
|
||||
"""返回有成功登录记录(cookie非空)的账号ID子查询。"""
|
||||
return db.query(LoginTask.account_id).filter(
|
||||
LoginTask.status == 'success',
|
||||
LoginTask.cookie != '',
|
||||
LoginTask.cookie.isnot(None),
|
||||
).distinct()
|
||||
return (
|
||||
db.query(LoginTask.account_id)
|
||||
.filter(
|
||||
LoginTask.status == "success",
|
||||
LoginTask.cookie != "",
|
||||
LoginTask.cookie.isnot(None),
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
|
||||
def parse_and_build_accounts(
|
||||
@@ -61,9 +65,9 @@ def parse_and_build_accounts(
|
||||
skipped = 0
|
||||
duplicated = 0
|
||||
seen_in_batch: set[str] = set()
|
||||
for line in text.strip().split('\n'):
|
||||
for line in text.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
parts = split_account_line(line)
|
||||
if len(parts) < 4:
|
||||
@@ -88,14 +92,16 @@ def parse_and_build_accounts(
|
||||
seen_in_batch.add(username_key)
|
||||
|
||||
email_cfg = get_email_config_for_account(email)
|
||||
accounts.append(Account(
|
||||
username=username,
|
||||
password=password,
|
||||
email=email,
|
||||
email_password=email_password,
|
||||
email_imap_server=email_cfg['server'],
|
||||
email_imap_port=email_cfg.get('port', 993),
|
||||
email_imap_ssl=email_cfg.get('ssl', True),
|
||||
tag=tag,
|
||||
))
|
||||
accounts.append(
|
||||
Account(
|
||||
username=username,
|
||||
password=password,
|
||||
email=email,
|
||||
email_password=email_password,
|
||||
email_imap_server=email_cfg["server"],
|
||||
email_imap_port=email_cfg.get("port", 993),
|
||||
email_imap_ssl=email_cfg.get("ssl", True),
|
||||
tag=tag,
|
||||
)
|
||||
)
|
||||
return accounts, skipped, duplicated
|
||||
|
||||
@@ -12,8 +12,17 @@ from ..models import AuditLog, User
|
||||
|
||||
|
||||
_SENSITIVE_KEY_PARTS = (
|
||||
"cookie", "token", "password", "passwd", "secret", "signature", "sign",
|
||||
"qr_data", "authorization", "credential", "private_key",
|
||||
"cookie",
|
||||
"token",
|
||||
"password",
|
||||
"passwd",
|
||||
"secret",
|
||||
"signature",
|
||||
"sign",
|
||||
"qr_data",
|
||||
"authorization",
|
||||
"credential",
|
||||
"private_key",
|
||||
)
|
||||
|
||||
|
||||
@@ -42,7 +51,9 @@ def record_audit(
|
||||
) -> AuditLog:
|
||||
"""加入一条审计记录;调用方负责与业务变更一起提交事务。"""
|
||||
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:
|
||||
detail_text = ""
|
||||
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。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
@@ -246,7 +248,9 @@ def load_batch_snapshot(batch_id: str, *, recover_interrupted: bool = True) -> d
|
||||
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 = live_batch_ids or set()
|
||||
db = SessionLocal()
|
||||
@@ -269,42 +273,51 @@ def list_batch_summaries(limit: int = 50, live_batch_ids: set[str] | None = None
|
||||
if status == "running":
|
||||
running_count = max(
|
||||
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({
|
||||
"batch_id": row.batch_id,
|
||||
"status": status,
|
||||
"message": message,
|
||||
"tag": row.tag or "",
|
||||
"created_by": row.created_by,
|
||||
"concurrency": int(row.concurrency or 1),
|
||||
"wait_seconds": float(row.wait_seconds or 180),
|
||||
"poll_interval": float(row.poll_interval or 5),
|
||||
"password_prefix": row.password_prefix or "hy",
|
||||
"use_proxy": bool(row.use_proxy),
|
||||
"total": int(row.total or 0),
|
||||
"success_count": int(row.success_count or 0),
|
||||
"failed_count": int(row.failed_count or 0),
|
||||
"stopped_count": int(row.stopped_count or 0),
|
||||
"running_count": running_count,
|
||||
"created_at": row.created_at,
|
||||
"started_at": row.started_at,
|
||||
"finished_at": row.finished_at,
|
||||
"items": [],
|
||||
})
|
||||
result.append(
|
||||
{
|
||||
"batch_id": row.batch_id,
|
||||
"status": status,
|
||||
"message": message,
|
||||
"tag": row.tag or "",
|
||||
"created_by": row.created_by,
|
||||
"concurrency": int(row.concurrency or 1),
|
||||
"wait_seconds": float(row.wait_seconds or 180),
|
||||
"poll_interval": float(row.poll_interval or 5),
|
||||
"password_prefix": row.password_prefix or "hy",
|
||||
"use_proxy": bool(row.use_proxy),
|
||||
"total": int(row.total or 0),
|
||||
"success_count": int(row.success_count or 0),
|
||||
"failed_count": int(row.failed_count or 0),
|
||||
"stopped_count": int(row.stopped_count or 0),
|
||||
"running_count": running_count,
|
||||
"created_at": row.created_at,
|
||||
"started_at": row.started_at,
|
||||
"finished_at": row.finished_at,
|
||||
"items": [],
|
||||
}
|
||||
)
|
||||
return result
|
||||
finally:
|
||||
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.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.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()
|
||||
return f"{account}----{password or ''}----{phone or ''}----{sms_url or ''}"
|
||||
@@ -319,7 +332,9 @@ def export_success_logs_text(
|
||||
"""从成功流水表导出 txt。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
query = db.query(HuyaRegisterSuccessLog).order_by(HuyaRegisterSuccessLog.id.asc())
|
||||
query = db.query(HuyaRegisterSuccessLog).order_by(
|
||||
HuyaRegisterSuccessLog.id.asc()
|
||||
)
|
||||
if batch_id:
|
||||
query = query.filter(HuyaRegisterSuccessLog.batch_id == batch_id)
|
||||
if tag:
|
||||
@@ -350,7 +365,9 @@ def list_success_logs(
|
||||
"""列出成功流水(含密码,供管理端展示/导出)。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
query = db.query(HuyaRegisterSuccessLog).order_by(HuyaRegisterSuccessLog.id.desc())
|
||||
query = db.query(HuyaRegisterSuccessLog).order_by(
|
||||
HuyaRegisterSuccessLog.id.desc()
|
||||
)
|
||||
if batch_id:
|
||||
query = query.filter(HuyaRegisterSuccessLog.batch_id == batch_id)
|
||||
if tag:
|
||||
@@ -406,7 +423,11 @@ class HuyaRegisterRunner:
|
||||
|
||||
def _create_proxy_fetcher(self) -> ProxyFetcher | None:
|
||||
"""按需创建 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
|
||||
if not self.proxy_config.api_url:
|
||||
return None
|
||||
@@ -414,9 +435,15 @@ class HuyaRegisterRunner:
|
||||
wl_platform = "xiequ"
|
||||
wl_credentials = None
|
||||
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)
|
||||
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 = {
|
||||
"uid": self.proxy_config.whitelist_uid,
|
||||
"ukey": self.proxy_config.whitelist_ukey,
|
||||
@@ -446,7 +473,11 @@ class HuyaRegisterRunner:
|
||||
return
|
||||
db = SessionLocal()
|
||||
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:
|
||||
return
|
||||
row.status = self.batch.status
|
||||
@@ -485,7 +516,11 @@ class HuyaRegisterRunner:
|
||||
return
|
||||
db = SessionLocal()
|
||||
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:
|
||||
return
|
||||
row.status = item.status
|
||||
@@ -518,12 +553,16 @@ class HuyaRegisterRunner:
|
||||
setattr(item, key, value)
|
||||
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()
|
||||
try:
|
||||
# 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 ""
|
||||
if result.username:
|
||||
account.username = result.username
|
||||
@@ -537,23 +576,29 @@ class HuyaRegisterRunner:
|
||||
account.updated_at = _now()
|
||||
|
||||
item = self.batch.items[index]
|
||||
db.add(HuyaRegisterSuccessLog(
|
||||
batch_id=self.batch.batch_id,
|
||||
item_id=item.db_id,
|
||||
account_id=account.id,
|
||||
phone=result.phone or item.phone,
|
||||
username=result.username or account.username or "",
|
||||
uid=result.uid or account.uid or account.yyuid or "",
|
||||
password=result.password or "",
|
||||
sms_url=sms_url,
|
||||
tag=self.batch.tag,
|
||||
provider=result.provider or item.provider,
|
||||
created_by=self.batch.created_by,
|
||||
created_at=_now(),
|
||||
))
|
||||
db.add(
|
||||
HuyaRegisterSuccessLog(
|
||||
batch_id=self.batch.batch_id,
|
||||
item_id=item.db_id,
|
||||
account_id=account.id,
|
||||
phone=result.phone or item.phone,
|
||||
username=result.username or account.username or "",
|
||||
uid=result.uid or account.uid or account.yyuid or "",
|
||||
password=result.password or "",
|
||||
sms_url=sms_url,
|
||||
tag=self.batch.tag,
|
||||
provider=result.provider or item.provider,
|
||||
created_by=self.batch.created_by,
|
||||
created_at=_now(),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
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:
|
||||
db.close()
|
||||
|
||||
@@ -578,12 +623,16 @@ class HuyaRegisterRunner:
|
||||
|
||||
def _run_one(self, index: int, item: SmsLine):
|
||||
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
|
||||
|
||||
proxies, proxy_error = self._resolve_proxy()
|
||||
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
|
||||
|
||||
self._set_item(
|
||||
@@ -714,7 +763,12 @@ class HuyaRegisterRunner:
|
||||
futures = []
|
||||
for index in indices:
|
||||
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
|
||||
item = self.sms_lines[index]
|
||||
futures.append(executor.submit(self._run_one, index, item))
|
||||
@@ -841,7 +895,11 @@ class HuyaRegisterRegistry:
|
||||
# DB 同步为 running,避免返回 pending 导致前端误判
|
||||
db = SessionLocal()
|
||||
try:
|
||||
row = db.query(HuyaRegisterBatchModel).filter(HuyaRegisterBatchModel.id == db_id).first()
|
||||
row = (
|
||||
db.query(HuyaRegisterBatchModel)
|
||||
.filter(HuyaRegisterBatchModel.id == db_id)
|
||||
.first()
|
||||
)
|
||||
if row:
|
||||
row.status = "running"
|
||||
row.message = "批次运行中"
|
||||
@@ -850,7 +908,9 @@ class HuyaRegisterRegistry:
|
||||
finally:
|
||||
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:
|
||||
self._runners[batch_id] = runner
|
||||
return runner
|
||||
@@ -930,7 +990,9 @@ class HuyaRegisterRegistry:
|
||||
if poll_interval is not None:
|
||||
batch_row.poll_interval = int(max(1.0, float(poll_interval)))
|
||||
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:
|
||||
batch_row.fixed_password = (fixed_password or "").strip()
|
||||
if use_proxy is not None:
|
||||
@@ -965,7 +1027,12 @@ class HuyaRegisterRegistry:
|
||||
|
||||
batch = _batch_from_db(batch_row, item_rows)
|
||||
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
|
||||
]
|
||||
finally:
|
||||
|
||||
@@ -19,15 +19,24 @@ from .huya_runner_recharge import RechargeMixin
|
||||
|
||||
|
||||
class HuyaBatchRunner(
|
||||
HuyaBatchRunnerCore, BindMixin, GoodsMixin, RechargeMixin,
|
||||
HuyaBatchRunnerCore,
|
||||
BindMixin,
|
||||
GoodsMixin,
|
||||
RechargeMixin,
|
||||
):
|
||||
"""批量执行虎牙任务(功能域 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()
|
||||
try:
|
||||
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:
|
||||
return
|
||||
|
||||
@@ -59,28 +68,48 @@ class HuyaBatchRunner(
|
||||
"create_recharge_order",
|
||||
}:
|
||||
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
|
||||
|
||||
try:
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
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)
|
||||
if task.status == "success":
|
||||
self._push_log("success", f"[{current}] {name} {task.message}")
|
||||
@@ -124,17 +153,19 @@ class HuyaBatchRunner(
|
||||
task.status = "pending"
|
||||
task.message = "等待执行"
|
||||
task.finished_at = None
|
||||
task_infos.append({
|
||||
"task_id": task.id,
|
||||
"account_info": {
|
||||
"account_id": account.id,
|
||||
"uid": account.uid or "",
|
||||
"yyuid": account.yyuid or "",
|
||||
"username": account.username or "",
|
||||
"nickname": account.nickname or "",
|
||||
"cookie": normalize_huya_cookie(account.cookie or ""),
|
||||
},
|
||||
})
|
||||
task_infos.append(
|
||||
{
|
||||
"task_id": task.id,
|
||||
"account_info": {
|
||||
"account_id": account.id,
|
||||
"uid": account.uid or "",
|
||||
"yyuid": account.yyuid or "",
|
||||
"username": account.username or "",
|
||||
"nickname": account.nickname or "",
|
||||
"cookie": normalize_huya_cookie(account.cookie or ""),
|
||||
},
|
||||
}
|
||||
)
|
||||
self.db.commit()
|
||||
|
||||
total = len(task_infos)
|
||||
@@ -149,13 +180,15 @@ class HuyaBatchRunner(
|
||||
if self._stop.is_set():
|
||||
self._push_log("warning", "任务已停止,跳过剩余账号")
|
||||
break
|
||||
futures.append(executor.submit(
|
||||
self._execute_one,
|
||||
item["task_id"],
|
||||
item["account_info"],
|
||||
config_info,
|
||||
total,
|
||||
))
|
||||
futures.append(
|
||||
executor.submit(
|
||||
self._execute_one,
|
||||
item["task_id"],
|
||||
item["account_info"],
|
||||
config_info,
|
||||
total,
|
||||
)
|
||||
)
|
||||
|
||||
for future in as_completed(futures):
|
||||
try:
|
||||
|
||||
@@ -20,8 +20,10 @@ from typing import TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from .huya_runner import HuyaBatchRunner
|
||||
|
||||
|
||||
class HuyaBatchRunnerCore:
|
||||
"""虎牙任务执行器公共基础:批次状态、日志、任务落库。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: Session,
|
||||
@@ -156,7 +158,8 @@ class HuyaBatchRegistry:
|
||||
expired = [
|
||||
batch_id
|
||||
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:
|
||||
self._batches.pop(batch_id, None)
|
||||
@@ -206,4 +209,3 @@ class HuyaBatchRegistry:
|
||||
|
||||
|
||||
huya_batch_registry = HuyaBatchRegistry()
|
||||
|
||||
|
||||
@@ -37,7 +37,10 @@ def apply_huya_config_defaults(config: HuyaConfig) -> bool:
|
||||
"""补齐虎牙配置默认值,返回是否发生变更。"""
|
||||
changed = False
|
||||
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])
|
||||
changed = True
|
||||
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
|
||||
skipped = 0
|
||||
@@ -207,13 +212,17 @@ def import_huya_password_accounts(db: Session, text: str, tag: str = "") -> tupl
|
||||
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 新增或更新虎牙账号。"""
|
||||
account = None
|
||||
if parsed["uid"]:
|
||||
account = db.query(HuyaAccount).filter(HuyaAccount.uid == parsed["uid"]).first()
|
||||
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:
|
||||
account = HuyaAccount(
|
||||
@@ -273,13 +282,17 @@ def save_huya_login_cookie_to_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。"""
|
||||
line = f"{username_hint}----{cookie}" if username_hint else cookie
|
||||
parsed = parse_huya_cookie_line(line)
|
||||
if not parsed:
|
||||
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.refresh(account)
|
||||
return account
|
||||
@@ -368,18 +381,24 @@ def create_planned_tasks(
|
||||
batch_id = uuid.uuid4().hex[:12]
|
||||
payload = payload or {}
|
||||
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 即可;兑换商品需要保留多账号批量任务。
|
||||
accounts = accounts[:1]
|
||||
for account in accounts:
|
||||
db.add(HuyaTask(
|
||||
batch_id=batch_id,
|
||||
account_id=account.id,
|
||||
task_type=task_type,
|
||||
status="planned",
|
||||
message="任务已创建,等待执行",
|
||||
result={"payload": payload} if payload else None,
|
||||
created_by=created_by,
|
||||
))
|
||||
db.add(
|
||||
HuyaTask(
|
||||
batch_id=batch_id,
|
||||
account_id=account.id,
|
||||
task_type=task_type,
|
||||
status="planned",
|
||||
message="任务已创建,等待执行",
|
||||
result={"payload": payload} if payload else None,
|
||||
created_by=created_by,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return batch_id, len(accounts)
|
||||
|
||||
@@ -8,7 +8,11 @@ from typing import Optional
|
||||
|
||||
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.base import _get_local_exit_ip
|
||||
from ..models import ProxyConfig as ProxyConfigModel, AuditLog
|
||||
@@ -20,8 +24,8 @@ def _build_whitelist_params(cfg: ProxyConfigModel) -> dict:
|
||||
Returns:
|
||||
{"whitelist_platform": str, "whitelist_credentials": dict|None}
|
||||
"""
|
||||
platform = getattr(cfg, 'whitelist_platform', None) or "xiequ"
|
||||
credentials = getattr(cfg, 'whitelist_credentials', None)
|
||||
platform = getattr(cfg, "whitelist_platform", None) or "xiequ"
|
||||
credentials = getattr(cfg, "whitelist_credentials", None)
|
||||
|
||||
# 向后兼容:旧字段有值但新字段为空时,自动迁移
|
||||
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:
|
||||
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()
|
||||
|
||||
return cfg
|
||||
@@ -65,7 +72,10 @@ class ProxyService:
|
||||
@staticmethod
|
||||
def update_config(
|
||||
db: Session,
|
||||
enabled, api_url, http, https,
|
||||
enabled,
|
||||
api_url,
|
||||
http,
|
||||
https,
|
||||
whitelist_enabled,
|
||||
whitelist_platform="xiequ",
|
||||
whitelist_credentials=None,
|
||||
@@ -96,12 +106,14 @@ class ProxyService:
|
||||
db.refresh(cfg)
|
||||
|
||||
if current_user:
|
||||
db.add(AuditLog(
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action="proxy:update",
|
||||
target="proxy_config",
|
||||
))
|
||||
db.add(
|
||||
AuditLog(
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action="proxy:update",
|
||||
target="proxy_config",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return cfg
|
||||
|
||||
@@ -258,18 +270,21 @@ class ProxyService:
|
||||
|
||||
# 3. 检查并同步白名单
|
||||
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:
|
||||
push("info", f"白名单共 {len(records)} 条记录")
|
||||
|
||||
if in_list:
|
||||
record = next((r for r in records if r.get('ip') == local_ip), {})
|
||||
memo = record.get('memo', '')
|
||||
record = next((r for r in records if r.get("ip") == local_ip), {})
|
||||
memo = record.get("memo", "")
|
||||
if memo == adapter.memo:
|
||||
push("success", f"本机IP {local_ip} 已在白名单中 (备注正确)")
|
||||
else:
|
||||
push("warning", f'本机IP {local_ip} 备注不匹配 (当前: "{memo}"),更新中...')
|
||||
push(
|
||||
"warning",
|
||||
f'本机IP {local_ip} 备注不匹配 (当前: "{memo}"),更新中...',
|
||||
)
|
||||
sync_ok, sync_msg = adapter.sync_ip(local_ip)
|
||||
push("success" if sync_ok else "error", f"白名单更新: {sync_msg}")
|
||||
else:
|
||||
|
||||
@@ -32,9 +32,22 @@ def _as_utc(value: datetime | None) -> datetime | None:
|
||||
|
||||
|
||||
def cleanup_orphan_yyb_tasks(db: Session, message: str) -> int:
|
||||
rows = db.query(YybRechargeTask).filter(
|
||||
YybRechargeTask.status.in_(["created", "waiting_login", "ready", "running", "ordering", "waiting_payment"])
|
||||
).all()
|
||||
rows = (
|
||||
db.query(YybRechargeTask)
|
||||
.filter(
|
||||
YybRechargeTask.status.in_(
|
||||
[
|
||||
"created",
|
||||
"waiting_login",
|
||||
"ready",
|
||||
"running",
|
||||
"ordering",
|
||||
"waiting_payment",
|
||||
]
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for task in rows:
|
||||
if task.status == "ordering":
|
||||
task.status = "waiting_payment"
|
||||
@@ -50,7 +63,9 @@ def cleanup_orphan_yyb_tasks(db: Session, message: str) -> int:
|
||||
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)
|
||||
task.status = str(data.get("status", task.status))
|
||||
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"])
|
||||
if data.get("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"):
|
||||
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 or {}),
|
||||
"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
|
||||
if task.status in {"success", "failed"} and task.finished_at is None:
|
||||
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
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
return task
|
||||
|
||||
|
||||
def public_task(task: YybRechargeTask, include_qr: bool = True,
|
||||
include_payment_qr: bool | None = None,
|
||||
creator_username: str = "") -> dict[str, Any]:
|
||||
def public_task(
|
||||
task: YybRechargeTask,
|
||||
include_qr: bool = True,
|
||||
include_payment_qr: bool | None = None,
|
||||
creator_username: str = "",
|
||||
) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {
|
||||
"id": task.id, "task_id": task.task_id,
|
||||
"provider": task.provider, "platform": task.platform, "points": task.points,
|
||||
"id": task.id,
|
||||
"task_id": task.task_id,
|
||||
"provider": task.provider,
|
||||
"platform": task.platform,
|
||||
"points": task.points,
|
||||
"price_fen": task.price_fen,
|
||||
"product_id": task.product_id, "zone_id": task.zone_id, "zone_name": task.zone_name,
|
||||
"role_id": task.role_id, "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),
|
||||
"product_id": task.product_id,
|
||||
"zone_id": task.zone_id,
|
||||
"zone_name": task.zone_name,
|
||||
"role_id": task.role_id,
|
||||
"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_qr_created_at": _as_utc(task.payment_qr_created_at),
|
||||
"payment_last_checked_at": _as_utc(task.payment_last_checked_at),
|
||||
}
|
||||
if task.result:
|
||||
result["login_qr_mime_type"] = task.result.get("login_qr_mime_type", "image/jpeg")
|
||||
result["payment_qr_mime_type"] = task.result.get("payment_qr_mime_type", "image/png")
|
||||
result["login_qr_mime_type"] = task.result.get(
|
||||
"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:
|
||||
include_payment_qr = include_qr
|
||||
if include_qr:
|
||||
|
||||
@@ -18,13 +18,20 @@ class YybWorkerClient:
|
||||
self.key = os.getenv("YYB_WORKER_KEY", "")
|
||||
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"}
|
||||
if self.key:
|
||||
headers["Authorization"] = f"Bearer {self.key}"
|
||||
try:
|
||||
response = requests.request(method, self.base_url + path, json=payload,
|
||||
headers=headers, timeout=self.timeout)
|
||||
response = requests.request(
|
||||
method,
|
||||
self.base_url + path,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
data = response.json()
|
||||
except (requests.RequestException, ValueError) as exc:
|
||||
raise YybWorkerError(f"应用宝 Worker 不可用: {exc}") from exc
|
||||
@@ -35,19 +42,34 @@ class YybWorkerClient:
|
||||
def create_job(self) -> dict[str, Any]:
|
||||
return self._request("POST", "/v1/jobs")
|
||||
|
||||
def login(self, worker_job_id: str, provider: str, timeout: int = 600) -> dict[str, Any]:
|
||||
return self._request("POST", f"/v1/jobs/{worker_job_id}/login",
|
||||
{"provider": provider, "timeout": timeout})
|
||||
def login(
|
||||
self, worker_job_id: str, provider: str, timeout: int = 600
|
||||
) -> 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]:
|
||||
return self._request("GET", f"/v1/jobs/{worker_job_id}")
|
||||
|
||||
def selection_options(self, worker_job_id: str, 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_options(
|
||||
self,
|
||||
worker_job_id: str,
|
||||
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)
|
||||
|
||||
def payment(self, worker_job_id: str) -> dict[str, Any]:
|
||||
|
||||
Reference in New Issue
Block a user